Przeglądaj źródła

增加与ai大模型对话

wwh 3 tygodni temu
rodzic
commit
222fef0534
14 zmienionych plików z 762 dodań i 1 usunięć
  1. 44 0
      huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/AgentController.java
  2. 69 0
      huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/AiProxyController.java
  3. 10 0
      huimv-employment/fe-api/src/main/resources/application.yml
  4. 2 0
      huimv-employment/fe-api/src/test/resources/application-test.yml
  5. 6 0
      huimv-employment/fe-common/src/main/java/com/huimv/employment/common/exception/ErrorCode.java
  6. 2 1
      huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/config/IntegrationAutoConfiguration.java
  7. 130 0
      huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbApiProperties.java
  8. 23 0
      huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbAutoConfiguration.java
  9. 20 0
      huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyService.java
  10. 192 0
      huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyServiceImpl.java
  11. 57 0
      huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/SseStreamSupport.java
  12. 114 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/agent/AgentChatService.java
  13. 42 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/agent/dto/AgentMessageRequest.java
  14. 51 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/agent/dto/AgentMessageResponse.java

+ 44 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/AgentController.java

@@ -0,0 +1,44 @@
1
+package com.huimv.employment.controller.mp;
2
+
3
+import com.huimv.employment.common.web.R;
4
+import com.huimv.employment.security.LoginUser;
5
+import com.huimv.employment.security.LoginUserHolder;
6
+import com.huimv.employment.service.agent.AgentChatService;
7
+import com.huimv.employment.service.agent.dto.AgentMessageRequest;
8
+import com.huimv.employment.service.agent.dto.AgentMessageResponse;
9
+import io.swagger.v3.oas.annotations.Operation;
10
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
11
+import io.swagger.v3.oas.annotations.tags.Tag;
12
+import org.springframework.validation.annotation.Validated;
13
+import org.springframework.web.bind.annotation.PostMapping;
14
+import org.springframework.web.bind.annotation.RequestBody;
15
+import org.springframework.web.bind.annotation.RequestMapping;
16
+import org.springframework.web.bind.annotation.RestController;
17
+
18
+/**
19
+ * 小程序智能体对话接口。
20
+ * <p>
21
+ * 封装大模型 Chat Completions 调用,返回统一 {@link R} 格式;底层复用 {@code fe-integration} 的 KB 代理。
22
+ * </p>
23
+ */
24
+@RestController
25
+@RequestMapping("/api/v1/mp/agent")
26
+@Tag(name = "智能体", description = "AI 对话,需 JWT 鉴权")
27
+@SecurityRequirement(name = "Authorization")
28
+public class AgentController {
29
+
30
+    private final AgentChatService agentChatService;
31
+
32
+    public AgentController(AgentChatService agentChatService) {
33
+        this.agentChatService = agentChatService;
34
+    }
35
+
36
+    @PostMapping("/messages")
37
+    @Operation(summary = "发送消息并获取 AI 回复")
38
+    public R<AgentMessageResponse> sendMessage(@Validated @RequestBody AgentMessageRequest request) {
39
+        LoginUser loginUser = LoginUserHolder.require();
40
+        AgentMessageResponse response = agentChatService.chat(
41
+                loginUser.getUserId(), loginUser.getUserType(), request);
42
+        return R.ok(response);
43
+    }
44
+}

+ 69 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/AiProxyController.java

@@ -0,0 +1,69 @@
1
+package com.huimv.employment.controller.mp;
2
+
3
+import com.huimv.employment.integration.kb.KbOpenAiProxyService;
4
+import com.huimv.employment.integration.kb.SseStreamSupport;
5
+import io.swagger.v3.oas.annotations.Operation;
6
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import org.springframework.http.MediaType;
9
+import org.springframework.http.ResponseEntity;
10
+import org.springframework.util.StreamUtils;
11
+import org.springframework.web.bind.annotation.GetMapping;
12
+import org.springframework.web.bind.annotation.PostMapping;
13
+import org.springframework.web.bind.annotation.RequestMapping;
14
+import org.springframework.web.bind.annotation.RestController;
15
+
16
+import javax.servlet.http.HttpServletRequest;
17
+import javax.servlet.http.HttpServletResponse;
18
+import java.io.IOException;
19
+import java.nio.charset.StandardCharsets;
20
+
21
+/**
22
+ * 大模型 OpenAI 兼容接口代理(小程序 JWT 鉴权,上游由服务端 fe.kb.api-key 鉴权)。
23
+ * <p>
24
+ * 支持 {@code stream=true} 的 SSE 流式响应,请求/响应体与 OpenAI Chat Completions 规范一致。
25
+ * </p>
26
+ */
27
+@RestController
28
+@RequestMapping("/api/v1/mp/ai")
29
+@Tag(name = "AI 对话", description = "OpenAI 兼容代理,需 JWT 鉴权")
30
+@SecurityRequirement(name = "Authorization")
31
+public class AiProxyController {
32
+
33
+    private final KbOpenAiProxyService kbOpenAiProxyService;
34
+
35
+    public AiProxyController(KbOpenAiProxyService kbOpenAiProxyService) {
36
+        this.kbOpenAiProxyService = kbOpenAiProxyService;
37
+    }
38
+
39
+    @GetMapping("/models")
40
+    @Operation(summary = "获取可用模型列表(OpenAI 兼容)")
41
+    public ResponseEntity<byte[]> models() {
42
+        return kbOpenAiProxyService.listModels();
43
+    }
44
+
45
+    @PostMapping(value = "/chat/completions", consumes = MediaType.APPLICATION_JSON_VALUE)
46
+    @Operation(summary = "对话补全(OpenAI 兼容,支持 SSE 流式)")
47
+    public void chatCompletions(HttpServletRequest request, HttpServletResponse response) throws IOException {
48
+        byte[] body = StreamUtils.copyToByteArray(request.getInputStream());
49
+        if (kbOpenAiProxyService.isStreamRequest(body)) {
50
+            SseStreamSupport.prepareSseResponse(response);
51
+            kbOpenAiProxyService.streamChatCompletions(body, response.getOutputStream());
52
+            response.flushBuffer();
53
+            return;
54
+        }
55
+        ResponseEntity<byte[]> upstream = kbOpenAiProxyService.chatCompletions(body);
56
+        response.setStatus(upstream.getStatusCodeValue());
57
+        MediaType contentType = upstream.getHeaders().getContentType();
58
+        if (contentType != null) {
59
+            response.setContentType(contentType.toString());
60
+        } else {
61
+            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
62
+            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
63
+        }
64
+        byte[] respBody = upstream.getBody();
65
+        if (respBody != null && respBody.length > 0) {
66
+            response.getOutputStream().write(respBody);
67
+        }
68
+    }
69
+}

+ 10 - 0
huimv-employment/fe-api/src/main/resources/application.yml

@@ -41,3 +41,13 @@ fe:
41 41
       template-code: ${ALIYUN_SMS_TEMPLATE_CODE:}
42 42
       template-param-name: code
43 43
       endpoint: dysmsapi.aliyuncs.com
44
+  kb:
45
+    enabled: true
46
+    scheme: http
47
+    host: harrison1.iask.in
48
+    port: 9107
49
+    api-key: ${FE_KB_API_KEY:}
50
+    default-model: default
51
+    connect-timeout-ms: 15000
52
+    read-timeout-ms: 120000
53
+    stream-read-timeout-ms: 300000

+ 2 - 0
huimv-employment/fe-api/src/test/resources/application-test.yml

@@ -9,6 +9,8 @@ spring:
9 9
 fe:
10 10
   sms:
11 11
     mock-enabled: true
12
+  kb:
13
+    enabled: false
12 14
 
13 15
 springdoc:
14 16
   api-docs:

+ 6 - 0
huimv-employment/fe-common/src/main/java/com/huimv/employment/common/exception/ErrorCode.java

@@ -51,6 +51,12 @@ public enum ErrorCode {
51 51
     /** 企业:仅 enterprise 身份可访问 */
52 52
     ENTERPRISE_ONLY(40303, "仅企业用户可访问"),
53 53
 
54
+    /** AI:大模型网关已关闭 */
55
+    AI_SERVICE_DISABLED(50301, "AI 服务未启用"),
56
+
57
+    /** AI:调用上游大模型失败 */
58
+    AI_SERVICE_FAILED(50003, "AI 服务调用失败,请稍后再试"),
59
+
54 60
     /** 通用:未预期的系统异常 */
55 61
     INTERNAL_ERROR(500, "系统繁忙,请稍后再试");
56 62
 

+ 2 - 1
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/config/IntegrationAutoConfiguration.java

@@ -1,5 +1,6 @@
1 1
 package com.huimv.employment.integration.config;
2 2
 
3
+import com.huimv.employment.integration.kb.KbApiProperties;
3 4
 import com.huimv.employment.integration.sms.SmsProperties;
4 5
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
5 6
 import org.springframework.context.annotation.Configuration;
@@ -8,6 +9,6 @@ import org.springframework.context.annotation.Configuration;
8 9
  * fe-integration 模块自动配置:注册第三方集成相关的配置属性类。
9 10
  */
10 11
 @Configuration
11
-@EnableConfigurationProperties(SmsProperties.class)
12
+@EnableConfigurationProperties({SmsProperties.class, KbApiProperties.class})
12 13
 public class IntegrationAutoConfiguration {
13 14
 }

+ 130 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbApiProperties.java

@@ -0,0 +1,130 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import org.springframework.boot.context.properties.ConfigurationProperties;
4
+
5
+/**
6
+ * 大模型网关 HTTP 对接(OpenAI 兼容 {@code /v1/models}、{@code /v1/chat/completions}),绑定 {@code fe.kb.*}。
7
+ */
8
+@ConfigurationProperties(prefix = "fe.kb")
9
+public class KbApiProperties {
10
+
11
+    /** 是否启用远程调用(为 false 时接口返回业务错误,便于无对端环境) */
12
+    private boolean enabled = true;
13
+
14
+    private String scheme = "http";
15
+
16
+    private String host = "localhost";
17
+
18
+    private int port = 9107;
19
+
20
+    /** 访问令牌(配置原始 key,请求头自动加 Bearer 前缀) */
21
+    private String apiKey = "";
22
+
23
+    private String authHeaderName = "Authorization";
24
+
25
+    /** agent/messages 等非透传场景默认模型 */
26
+    private String defaultModel = "default";
27
+
28
+    private int connectTimeoutMs = 15000;
29
+
30
+    private int readTimeoutMs = 120000;
31
+
32
+    /** 对话流式 SSE(stream=true)读超时 */
33
+    private int streamReadTimeoutMs = 300000;
34
+
35
+    public boolean isEnabled() {
36
+        return enabled;
37
+    }
38
+
39
+    public void setEnabled(boolean enabled) {
40
+        this.enabled = enabled;
41
+    }
42
+
43
+    public String getScheme() {
44
+        return scheme;
45
+    }
46
+
47
+    public void setScheme(String scheme) {
48
+        this.scheme = scheme;
49
+    }
50
+
51
+    public String getHost() {
52
+        return host;
53
+    }
54
+
55
+    public void setHost(String host) {
56
+        this.host = host;
57
+    }
58
+
59
+    public int getPort() {
60
+        return port;
61
+    }
62
+
63
+    public void setPort(int port) {
64
+        this.port = port;
65
+    }
66
+
67
+    public String getApiKey() {
68
+        return apiKey;
69
+    }
70
+
71
+    public void setApiKey(String apiKey) {
72
+        this.apiKey = apiKey;
73
+    }
74
+
75
+    public String getAuthHeaderName() {
76
+        return authHeaderName;
77
+    }
78
+
79
+    public void setAuthHeaderName(String authHeaderName) {
80
+        this.authHeaderName = authHeaderName;
81
+    }
82
+
83
+    public String getDefaultModel() {
84
+        return defaultModel;
85
+    }
86
+
87
+    public void setDefaultModel(String defaultModel) {
88
+        this.defaultModel = defaultModel;
89
+    }
90
+
91
+    public int getConnectTimeoutMs() {
92
+        return connectTimeoutMs;
93
+    }
94
+
95
+    public void setConnectTimeoutMs(int connectTimeoutMs) {
96
+        this.connectTimeoutMs = connectTimeoutMs;
97
+    }
98
+
99
+    public int getReadTimeoutMs() {
100
+        return readTimeoutMs;
101
+    }
102
+
103
+    public void setReadTimeoutMs(int readTimeoutMs) {
104
+        this.readTimeoutMs = readTimeoutMs;
105
+    }
106
+
107
+    public int getStreamReadTimeoutMs() {
108
+        return streamReadTimeoutMs;
109
+    }
110
+
111
+    public void setStreamReadTimeoutMs(int streamReadTimeoutMs) {
112
+        this.streamReadTimeoutMs = streamReadTimeoutMs;
113
+    }
114
+
115
+    /** 请求头鉴权值,默认 {@code Bearer {apiKey}}。 */
116
+    public String authHeaderValue() {
117
+        if (apiKey == null || apiKey.isEmpty()) {
118
+            return "";
119
+        }
120
+        String k = apiKey.trim();
121
+        if (k.regionMatches(true, 0, "Bearer ", 0, 7)) {
122
+            return k;
123
+        }
124
+        return "Bearer " + k;
125
+    }
126
+
127
+    public String baseUrl() {
128
+        return scheme + "://" + host + ":" + port;
129
+    }
130
+}

+ 23 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbAutoConfiguration.java

@@ -0,0 +1,23 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
4
+import org.springframework.context.annotation.Bean;
5
+import org.springframework.context.annotation.Configuration;
6
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
7
+import org.springframework.web.client.RestTemplate;
8
+
9
+/**
10
+ * 大模型网关 HTTP 客户端自动配置。
11
+ */
12
+@Configuration
13
+@EnableConfigurationProperties(KbApiProperties.class)
14
+public class KbAutoConfiguration {
15
+
16
+    @Bean("kbRestTemplate")
17
+    public RestTemplate kbRestTemplate(KbApiProperties properties) {
18
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
19
+        factory.setConnectTimeout(properties.getConnectTimeoutMs());
20
+        factory.setReadTimeout(properties.getReadTimeoutMs());
21
+        return new RestTemplate(factory);
22
+    }
23
+}

+ 20 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyService.java

@@ -0,0 +1,20 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import org.springframework.http.ResponseEntity;
4
+
5
+import java.io.IOException;
6
+import java.io.OutputStream;
7
+
8
+/**
9
+ * 大模型 OpenAI 兼容接口转发({@code /v1/models}、{@code /v1/chat/completions})。
10
+ */
11
+public interface KbOpenAiProxyService {
12
+
13
+    ResponseEntity<byte[]> listModels();
14
+
15
+    ResponseEntity<byte[]> chatCompletions(byte[] requestBody);
16
+
17
+    boolean isStreamRequest(byte[] requestBody);
18
+
19
+    void streamChatCompletions(byte[] requestBody, OutputStream output) throws IOException;
20
+}

+ 192 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyServiceImpl.java

@@ -0,0 +1,192 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.huimv.employment.common.exception.BizException;
6
+import com.huimv.employment.common.exception.ErrorCode;
7
+import org.springframework.beans.factory.annotation.Qualifier;
8
+import org.springframework.http.HttpEntity;
9
+import org.springframework.http.HttpHeaders;
10
+import org.springframework.http.HttpMethod;
11
+import org.springframework.http.MediaType;
12
+import org.springframework.http.ResponseEntity;
13
+import org.springframework.stereotype.Service;
14
+import org.springframework.util.StreamUtils;
15
+import org.springframework.util.StringUtils;
16
+import org.springframework.web.client.HttpStatusCodeException;
17
+import org.springframework.web.client.RestTemplate;
18
+
19
+import java.io.IOException;
20
+import java.io.InputStream;
21
+import java.io.OutputStream;
22
+import java.net.HttpURLConnection;
23
+import java.net.URL;
24
+import java.nio.charset.StandardCharsets;
25
+
26
+/**
27
+ * 将 OpenAI 兼容对话/模型列表接口转发至上游大模型网关。
28
+ */
29
+@Service
30
+public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
31
+
32
+    private static final String PATH_MODELS = "/v1/models";
33
+    private static final String PATH_CHAT = "/v1/chat/completions";
34
+
35
+    private final KbApiProperties properties;
36
+    private final RestTemplate restTemplate;
37
+    private final ObjectMapper objectMapper;
38
+
39
+    public KbOpenAiProxyServiceImpl(KbApiProperties properties,
40
+                                    @Qualifier("kbRestTemplate") RestTemplate restTemplate,
41
+                                    ObjectMapper objectMapper) {
42
+        this.properties = properties;
43
+        this.restTemplate = restTemplate;
44
+        this.objectMapper = objectMapper;
45
+    }
46
+
47
+    @Override
48
+    public ResponseEntity<byte[]> listModels() {
49
+        ensureReady();
50
+        try {
51
+            ResponseEntity<byte[]> upstream = restTemplate.exchange(
52
+                    properties.baseUrl() + PATH_MODELS,
53
+                    HttpMethod.GET,
54
+                    new HttpEntity<>(upstreamAuthHeaders()),
55
+                    byte[].class);
56
+            return forward(upstream);
57
+        } catch (HttpStatusCodeException ex) {
58
+            return forwardError(ex);
59
+        }
60
+    }
61
+
62
+    @Override
63
+    public ResponseEntity<byte[]> chatCompletions(byte[] requestBody) {
64
+        ensureReady();
65
+        if (requestBody == null || requestBody.length == 0) {
66
+            throw new BizException(ErrorCode.BAD_REQUEST, "请求体不能为空");
67
+        }
68
+        try {
69
+            HttpHeaders headers = upstreamAuthHeaders();
70
+            headers.setContentType(MediaType.APPLICATION_JSON);
71
+            ResponseEntity<byte[]> upstream = restTemplate.exchange(
72
+                    properties.baseUrl() + PATH_CHAT,
73
+                    HttpMethod.POST,
74
+                    new HttpEntity<>(requestBody, headers),
75
+                    byte[].class);
76
+            return forward(upstream);
77
+        } catch (HttpStatusCodeException ex) {
78
+            return forwardError(ex);
79
+        }
80
+    }
81
+
82
+    @Override
83
+    public void streamChatCompletions(byte[] requestBody, OutputStream output) throws IOException {
84
+        ensureReady();
85
+        if (requestBody == null || requestBody.length == 0) {
86
+            throw new BizException(ErrorCode.BAD_REQUEST, "请求体不能为空");
87
+        }
88
+        HttpURLConnection conn = null;
89
+        InputStream input = null;
90
+        try {
91
+            conn = openUpstreamConnection(requestBody.length);
92
+            conn.setRequestProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
93
+            conn.setRequestProperty(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE);
94
+            conn.setRequestProperty(properties.getAuthHeaderName(), properties.authHeaderValue());
95
+            conn.setDoOutput(true);
96
+            try (OutputStream upstreamOut = conn.getOutputStream()) {
97
+                upstreamOut.write(requestBody);
98
+            }
99
+            int status = conn.getResponseCode();
100
+            input = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
101
+            if (input != null) {
102
+                if (status >= 400) {
103
+                    byte[] errBytes = StreamUtils.copyToByteArray(input);
104
+                    if (errBytes.length > 0) {
105
+                        String errText = new String(errBytes, StandardCharsets.UTF_8).trim();
106
+                        if (errText.startsWith("data:") || errText.contains("\n\n")) {
107
+                            output.write(errBytes);
108
+                            output.flush();
109
+                        } else {
110
+                            SseStreamSupport.writeDataEvent(output, errText);
111
+                        }
112
+                    }
113
+                } else {
114
+                    SseStreamSupport.pipeWithFlush(input, output);
115
+                }
116
+            }
117
+        } finally {
118
+            if (input != null) {
119
+                try {
120
+                    input.close();
121
+                } catch (IOException ignored) {
122
+                    // ignore
123
+                }
124
+            }
125
+            if (conn != null) {
126
+                conn.disconnect();
127
+            }
128
+        }
129
+    }
130
+
131
+    @Override
132
+    public boolean isStreamRequest(byte[] requestBody) {
133
+        if (requestBody == null || requestBody.length == 0) {
134
+            return false;
135
+        }
136
+        try {
137
+            JsonNode root = objectMapper.readTree(requestBody);
138
+            JsonNode stream = root.get("stream");
139
+            return stream != null && stream.asBoolean(false);
140
+        } catch (Exception ex) {
141
+            return false;
142
+        }
143
+    }
144
+
145
+    private HttpURLConnection openUpstreamConnection(int contentLength) throws IOException {
146
+        URL url = new URL(properties.baseUrl() + PATH_CHAT);
147
+        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
148
+        conn.setRequestMethod("POST");
149
+        conn.setConnectTimeout(properties.getConnectTimeoutMs());
150
+        conn.setReadTimeout(properties.getStreamReadTimeoutMs());
151
+        conn.setFixedLengthStreamingMode(contentLength);
152
+        return conn;
153
+    }
154
+
155
+    private void ensureReady() {
156
+        if (!properties.isEnabled()) {
157
+            throw new BizException(ErrorCode.AI_SERVICE_DISABLED);
158
+        }
159
+        if (!StringUtils.hasText(properties.getApiKey())) {
160
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "未配置大模型访问密钥 fe.kb.api-key");
161
+        }
162
+    }
163
+
164
+    private HttpHeaders upstreamAuthHeaders() {
165
+        HttpHeaders headers = new HttpHeaders();
166
+        headers.add(properties.getAuthHeaderName(), properties.authHeaderValue());
167
+        return headers;
168
+    }
169
+
170
+    private ResponseEntity<byte[]> forward(ResponseEntity<byte[]> upstream) {
171
+        HttpHeaders headers = new HttpHeaders();
172
+        MediaType contentType = upstream.getHeaders().getContentType();
173
+        if (contentType != null) {
174
+            headers.setContentType(contentType);
175
+        } else {
176
+            headers.setContentType(MediaType.APPLICATION_JSON);
177
+        }
178
+        return new ResponseEntity<>(upstream.getBody(), headers, upstream.getStatusCode());
179
+    }
180
+
181
+    private ResponseEntity<byte[]> forwardError(HttpStatusCodeException ex) {
182
+        HttpHeaders headers = new HttpHeaders();
183
+        MediaType contentType = ex.getResponseHeaders() != null ? ex.getResponseHeaders().getContentType() : null;
184
+        if (contentType != null) {
185
+            headers.setContentType(contentType);
186
+        } else {
187
+            headers.setContentType(MediaType.APPLICATION_JSON);
188
+        }
189
+        byte[] body = ex.getResponseBodyAsByteArray();
190
+        return new ResponseEntity<>(body, headers, ex.getStatusCode());
191
+    }
192
+}

+ 57 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/SseStreamSupport.java

@@ -0,0 +1,57 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import org.springframework.http.HttpHeaders;
4
+import org.springframework.http.MediaType;
5
+
6
+import javax.servlet.http.HttpServletResponse;
7
+import java.io.IOException;
8
+import java.io.InputStream;
9
+import java.io.OutputStream;
10
+import java.nio.charset.StandardCharsets;
11
+
12
+/**
13
+ * Server-Sent Events(SSE)响应头与流式写出工具。
14
+ */
15
+public final class SseStreamSupport {
16
+
17
+    private static final int PIPE_BUFFER_SIZE = 4096;
18
+
19
+    private SseStreamSupport() {
20
+    }
21
+
22
+    public static void prepareSseResponse(HttpServletResponse response) {
23
+        response.setStatus(HttpServletResponse.SC_OK);
24
+        response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
25
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
26
+        response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
27
+        response.setHeader(HttpHeaders.PRAGMA, "no-cache");
28
+        response.setHeader(HttpHeaders.EXPIRES, "0");
29
+        response.setHeader(HttpHeaders.CONNECTION, "keep-alive");
30
+        response.setHeader("X-Accel-Buffering", "no");
31
+    }
32
+
33
+    public static void pipeWithFlush(InputStream input, OutputStream output) throws IOException {
34
+        if (input == null) {
35
+            return;
36
+        }
37
+        byte[] buffer = new byte[PIPE_BUFFER_SIZE];
38
+        int read;
39
+        while ((read = input.read(buffer)) != -1) {
40
+            output.write(buffer, 0, read);
41
+            output.flush();
42
+        }
43
+    }
44
+
45
+    public static void writeDataEvent(OutputStream output, String dataPayload) throws IOException {
46
+        if (dataPayload == null) {
47
+            return;
48
+        }
49
+        String line = "data: " + dataPayload + "\n\n";
50
+        output.write(line.getBytes(StandardCharsets.UTF_8));
51
+        output.flush();
52
+    }
53
+
54
+    public static void writeDoneEvent(OutputStream output) throws IOException {
55
+        writeDataEvent(output, "[DONE]");
56
+    }
57
+}

+ 114 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/agent/AgentChatService.java

@@ -0,0 +1,114 @@
1
+package com.huimv.employment.service.agent;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.fasterxml.jackson.databind.node.ArrayNode;
6
+import com.fasterxml.jackson.databind.node.ObjectNode;
7
+import com.huimv.employment.common.exception.BizException;
8
+import com.huimv.employment.common.exception.ErrorCode;
9
+import com.huimv.employment.integration.kb.KbApiProperties;
10
+import com.huimv.employment.integration.kb.KbOpenAiProxyService;
11
+import com.huimv.employment.service.agent.dto.AgentMessageRequest;
12
+import com.huimv.employment.service.agent.dto.AgentMessageResponse;
13
+import org.springframework.http.HttpStatus;
14
+import org.springframework.http.ResponseEntity;
15
+import org.springframework.stereotype.Service;
16
+import org.springframework.util.StringUtils;
17
+
18
+import java.nio.charset.StandardCharsets;
19
+import java.util.UUID;
20
+
21
+/**
22
+ * 小程序智能体对话:封装 OpenAI Chat Completions 调用。
23
+ */
24
+@Service
25
+public class AgentChatService {
26
+
27
+    private static final String SYSTEM_PROMPT =
28
+            "你是灵活用工智能助手,帮助企业主解答用工规范、成本测算、登记流程等问题。回答应简洁、专业、可操作。";
29
+
30
+    private final KbOpenAiProxyService kbOpenAiProxyService;
31
+    private final KbApiProperties kbApiProperties;
32
+    private final ObjectMapper objectMapper;
33
+
34
+    public AgentChatService(KbOpenAiProxyService kbOpenAiProxyService,
35
+                            KbApiProperties kbApiProperties,
36
+                            ObjectMapper objectMapper) {
37
+        this.kbOpenAiProxyService = kbOpenAiProxyService;
38
+        this.kbApiProperties = kbApiProperties;
39
+        this.objectMapper = objectMapper;
40
+    }
41
+
42
+    public AgentMessageResponse chat(Long userId, String userType, AgentMessageRequest request) {
43
+        String conversationId = StringUtils.hasText(request.getConversationId())
44
+                ? request.getConversationId()
45
+                : UUID.randomUUID().toString().replace("-", "");
46
+
47
+        byte[] body = buildChatRequest(request.getMessage());
48
+        ResponseEntity<byte[]> upstream = kbOpenAiProxyService.chatCompletions(body);
49
+        if (!upstream.getStatusCode().is2xxSuccessful()) {
50
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, extractUpstreamError(upstream));
51
+        }
52
+
53
+        String reply = parseReply(upstream.getBody());
54
+        AgentMessageResponse response = new AgentMessageResponse();
55
+        response.setReply(reply);
56
+        response.setConversationId(conversationId);
57
+        return response;
58
+    }
59
+
60
+    private byte[] buildChatRequest(String userMessage) {
61
+        try {
62
+            ObjectNode root = objectMapper.createObjectNode();
63
+            root.put("model", kbApiProperties.getDefaultModel());
64
+            root.put("stream", false);
65
+
66
+            ArrayNode messages = root.putArray("messages");
67
+            ObjectNode system = messages.addObject();
68
+            system.put("role", "system");
69
+            system.put("content", SYSTEM_PROMPT);
70
+
71
+            ObjectNode user = messages.addObject();
72
+            user.put("role", "user");
73
+            user.put("content", userMessage);
74
+
75
+            return objectMapper.writeValueAsBytes(root);
76
+        } catch (Exception ex) {
77
+            throw new BizException(ErrorCode.INTERNAL_ERROR);
78
+        }
79
+    }
80
+
81
+    private String parseReply(byte[] body) {
82
+        if (body == null || body.length == 0) {
83
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型返回空响应");
84
+        }
85
+        try {
86
+            JsonNode root = objectMapper.readTree(body);
87
+            JsonNode choices = root.get("choices");
88
+            if (choices == null || !choices.isArray() || choices.isEmpty()) {
89
+                throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型响应格式异常");
90
+            }
91
+            JsonNode content = choices.get(0).path("message").path("content");
92
+            if (content.isMissingNode() || !StringUtils.hasText(content.asText())) {
93
+                throw new BizException(ErrorCode.AI_SERVICE_FAILED, "大模型未返回有效内容");
94
+            }
95
+            return content.asText();
96
+        } catch (BizException ex) {
97
+            throw ex;
98
+        } catch (Exception ex) {
99
+            throw new BizException(ErrorCode.AI_SERVICE_FAILED, "解析大模型响应失败");
100
+        }
101
+    }
102
+
103
+    private String extractUpstreamError(ResponseEntity<byte[]> upstream) {
104
+        byte[] body = upstream.getBody();
105
+        if (body == null || body.length == 0) {
106
+            return "上游返回 HTTP " + upstream.getStatusCode().value();
107
+        }
108
+        String text = new String(body, StandardCharsets.UTF_8);
109
+        if (upstream.getStatusCode() == HttpStatus.UNAUTHORIZED) {
110
+            return "大模型鉴权失败,请检查 fe.kb.api-key";
111
+        }
112
+        return text.length() > 200 ? text.substring(0, 200) : text;
113
+    }
114
+}

+ 42 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/agent/dto/AgentMessageRequest.java

@@ -0,0 +1,42 @@
1
+package com.huimv.employment.service.agent.dto;
2
+
3
+import javax.validation.constraints.NotBlank;
4
+
5
+/**
6
+ * 小程序智能体对话请求。
7
+ */
8
+public class AgentMessageRequest {
9
+
10
+    /** 企业 ID(可选,预留多企业场景) */
11
+    private Long enterpriseId;
12
+
13
+    /** 会话 ID,不传则自动生成 */
14
+    private String conversationId;
15
+
16
+    @NotBlank(message = "消息内容不能为空")
17
+    private String message;
18
+
19
+    public Long getEnterpriseId() {
20
+        return enterpriseId;
21
+    }
22
+
23
+    public void setEnterpriseId(Long enterpriseId) {
24
+        this.enterpriseId = enterpriseId;
25
+    }
26
+
27
+    public String getConversationId() {
28
+        return conversationId;
29
+    }
30
+
31
+    public void setConversationId(String conversationId) {
32
+        this.conversationId = conversationId;
33
+    }
34
+
35
+    public String getMessage() {
36
+        return message;
37
+    }
38
+
39
+    public void setMessage(String message) {
40
+        this.message = message;
41
+    }
42
+}

+ 51 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/agent/dto/AgentMessageResponse.java

@@ -0,0 +1,51 @@
1
+package com.huimv.employment.service.agent.dto;
2
+
3
+/**
4
+ * 小程序智能体对话响应。
5
+ */
6
+public class AgentMessageResponse {
7
+
8
+    /** AI 回复文本 */
9
+    private String reply;
10
+
11
+    /** 会话 ID */
12
+    private String conversationId;
13
+
14
+    /** 下一步动作(如 show_employment_draft),一期可为空 */
15
+    private String nextAction;
16
+
17
+    /** 关联草稿 ID,一期可为空 */
18
+    private String draftId;
19
+
20
+    public String getReply() {
21
+        return reply;
22
+    }
23
+
24
+    public void setReply(String reply) {
25
+        this.reply = reply;
26
+    }
27
+
28
+    public String getConversationId() {
29
+        return conversationId;
30
+    }
31
+
32
+    public void setConversationId(String conversationId) {
33
+        this.conversationId = conversationId;
34
+    }
35
+
36
+    public String getNextAction() {
37
+        return nextAction;
38
+    }
39
+
40
+    public void setNextAction(String nextAction) {
41
+        this.nextAction = nextAction;
42
+    }
43
+
44
+    public String getDraftId() {
45
+        return draftId;
46
+    }
47
+
48
+    public void setDraftId(String draftId) {
49
+        this.draftId = draftId;
50
+    }
51
+}