瀏覽代碼

增加与ai大模型对话

wwh 3 周之前
父節點
當前提交
ba08096189

+ 35 - 4
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/ConversationController.java

@@ -3,22 +3,30 @@ package com.huimv.employment.controller.mp;
3 3
 import com.huimv.employment.common.exception.BizException;
4 4
 import com.huimv.employment.common.exception.ErrorCode;
5 5
 import com.huimv.employment.common.web.R;
6
+import com.huimv.employment.integration.kb.SseStreamSupport;
6 7
 import com.huimv.employment.security.LoginUser;
7 8
 import com.huimv.employment.security.LoginUserHolder;
9
+import com.huimv.employment.service.conversation.ConversationChatService;
8 10
 import com.huimv.employment.service.conversation.ConversationService;
11
+import com.huimv.employment.service.conversation.dto.ConversationChatRequest;
9 12
 import com.huimv.employment.service.conversation.dto.ConversationCreateRequest;
10 13
 import com.huimv.employment.service.conversation.dto.ConversationSummaryResponse;
11 14
 import io.swagger.v3.oas.annotations.Operation;
12 15
 import io.swagger.v3.oas.annotations.security.SecurityRequirement;
13 16
 import io.swagger.v3.oas.annotations.tags.Tag;
17
+import org.springframework.http.MediaType;
14 18
 import org.springframework.validation.annotation.Validated;
15 19
 import org.springframework.web.bind.annotation.GetMapping;
20
+import org.springframework.web.bind.annotation.PathVariable;
16 21
 import org.springframework.web.bind.annotation.PostMapping;
17 22
 import org.springframework.web.bind.annotation.RequestBody;
23
+import org.springframework.web.bind.annotation.RequestHeader;
18 24
 import org.springframework.web.bind.annotation.RequestMapping;
19 25
 import org.springframework.web.bind.annotation.RequestParam;
20 26
 import org.springframework.web.bind.annotation.RestController;
21 27
 
28
+import javax.servlet.http.HttpServletResponse;
29
+import java.io.IOException;
22 30
 import java.util.List;
23 31
 
24 32
 /**
@@ -28,8 +36,8 @@ import java.util.List;
28 36
  * 从 JWT 解析 {@code userId},仅返回当前用户自己的会话,不支持跨用户查询。
29 37
  * </p>
30 38
  * <p>
31
- * 典型用法:点击「新对话」调用 {@code POST /conversations} 获取 {@code conversationNo},
32
- * 再作为 {@code session_id} 调用 {@code POST /ai/console/chat} 进行多轮对话
39
+ * 推荐流程:{@code POST /conversations} 创建会话 → {@code POST /conversations/{id}/chat} 多轮对话(服务端落库)。
40
+ * 如需直连上游网关,仍可使用 {@code POST /ai/console/chat} 纯透传接口
33 41
  * </p>
34 42
  */
35 43
 @RestController
@@ -39,11 +47,15 @@ import java.util.List;
39 47
 public class ConversationController {
40 48
 
41 49
     private static final String USER_TYPE_ENTERPRISE = "enterprise";
50
+    private static final String HEADER_AGENT_ID = "X-Agent-Id";
42 51
 
43 52
     private final ConversationService conversationService;
53
+    private final ConversationChatService conversationChatService;
44 54
 
45
-    public ConversationController(ConversationService conversationService) {
55
+    public ConversationController(ConversationService conversationService,
56
+                                  ConversationChatService conversationChatService) {
46 57
         this.conversationService = conversationService;
58
+        this.conversationChatService = conversationChatService;
47 59
     }
48 60
 
49 61
     /**
@@ -63,7 +75,7 @@ public class ConversationController {
63 75
 
64 76
     /**
65 77
      * 新建 AI 对话会话。
66
-     * <p>返回的 {@code conversationNo} 即后续 AI 对话的 {@code session_id}。</p>
78
+     * <p>返回的 {@code conversationNo} 即上游 AI 网关的 {@code session_id}。</p>
67 79
      */
68 80
     @PostMapping
69 81
     @Operation(summary = "新建 AI 对话会话",
@@ -74,6 +86,25 @@ public class ConversationController {
74 86
         return R.ok(conversationService.create(loginUser.getUserId(), request));
75 87
     }
76 88
 
89
+    /**
90
+     * 在已有会话内发起 AI 对话(SSE 流式,流结束后落库)。
91
+     */
92
+    @PostMapping(value = "/{id}/chat", consumes = MediaType.APPLICATION_JSON_VALUE)
93
+    @Operation(summary = "会话内 AI 对话(SSE 流式 + 落库)",
94
+            description = "需先 POST /conversations 创建会话。请求体仅需 message 字段;"
95
+                    + "服务端组装上游 console/chat 请求,流式开始前写入 user 消息,"
96
+                    + "流式结束后写入 assistant 消息。可选请求头 X-Agent-Id。")
97
+    public void chat(@PathVariable("id") Long conversationId,
98
+                     @Validated @RequestBody ConversationChatRequest request,
99
+                     HttpServletResponse response,
100
+                     @RequestHeader(value = HEADER_AGENT_ID, required = false) String agentId) throws IOException {
101
+        LoginUser loginUser = requireEnterprise();
102
+        SseStreamSupport.prepareSseResponse(response);
103
+        conversationChatService.chat(
104
+                loginUser.getUserId(), conversationId, request, agentId, response.getOutputStream());
105
+        response.flushBuffer();
106
+    }
107
+
77 108
     private static LoginUser requireEnterprise() {
78 109
         LoginUser loginUser = LoginUserHolder.require();
79 110
         if (!USER_TYPE_ENTERPRISE.equals(loginUser.getUserType())) {

+ 127 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/entity/FeConversationMessage.java

@@ -0,0 +1,127 @@
1
+package com.huimv.employment.dao.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.IdType;
4
+import com.baomidou.mybatisplus.annotation.TableId;
5
+import com.baomidou.mybatisplus.annotation.TableName;
6
+
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * AI 对话消息 {@code fe_conversation_message} 实体。
11
+ */
12
+@TableName("fe_conversation_message")
13
+public class FeConversationMessage {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    private Long conversationId;
19
+
20
+    /** user / assistant / system */
21
+    private String role;
22
+
23
+    private String content;
24
+
25
+    /** text / card_draft / card_cost / card_order */
26
+    private String contentType;
27
+
28
+    private String cardPayload;
29
+
30
+    private String agentModule;
31
+
32
+    private String nextAction;
33
+
34
+    private Long relatedDraftId;
35
+
36
+    private String tokenUsage;
37
+
38
+    private LocalDateTime createTime;
39
+
40
+    public Long getId() {
41
+        return id;
42
+    }
43
+
44
+    public void setId(Long id) {
45
+        this.id = id;
46
+    }
47
+
48
+    public Long getConversationId() {
49
+        return conversationId;
50
+    }
51
+
52
+    public void setConversationId(Long conversationId) {
53
+        this.conversationId = conversationId;
54
+    }
55
+
56
+    public String getRole() {
57
+        return role;
58
+    }
59
+
60
+    public void setRole(String role) {
61
+        this.role = role;
62
+    }
63
+
64
+    public String getContent() {
65
+        return content;
66
+    }
67
+
68
+    public void setContent(String content) {
69
+        this.content = content;
70
+    }
71
+
72
+    public String getContentType() {
73
+        return contentType;
74
+    }
75
+
76
+    public void setContentType(String contentType) {
77
+        this.contentType = contentType;
78
+    }
79
+
80
+    public String getCardPayload() {
81
+        return cardPayload;
82
+    }
83
+
84
+    public void setCardPayload(String cardPayload) {
85
+        this.cardPayload = cardPayload;
86
+    }
87
+
88
+    public String getAgentModule() {
89
+        return agentModule;
90
+    }
91
+
92
+    public void setAgentModule(String agentModule) {
93
+        this.agentModule = agentModule;
94
+    }
95
+
96
+    public String getNextAction() {
97
+        return nextAction;
98
+    }
99
+
100
+    public void setNextAction(String nextAction) {
101
+        this.nextAction = nextAction;
102
+    }
103
+
104
+    public Long getRelatedDraftId() {
105
+        return relatedDraftId;
106
+    }
107
+
108
+    public void setRelatedDraftId(Long relatedDraftId) {
109
+        this.relatedDraftId = relatedDraftId;
110
+    }
111
+
112
+    public String getTokenUsage() {
113
+        return tokenUsage;
114
+    }
115
+
116
+    public void setTokenUsage(String tokenUsage) {
117
+        this.tokenUsage = tokenUsage;
118
+    }
119
+
120
+    public LocalDateTime getCreateTime() {
121
+        return createTime;
122
+    }
123
+
124
+    public void setCreateTime(LocalDateTime createTime) {
125
+        this.createTime = createTime;
126
+    }
127
+}

+ 10 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/mapper/FeConversationMessageMapper.java

@@ -0,0 +1,10 @@
1
+package com.huimv.employment.dao.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.huimv.employment.dao.entity.FeConversationMessage;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/** {@code fe_conversation_message} MyBatis-Plus Mapper。 */
8
+@Mapper
9
+public interface FeConversationMessageMapper extends BaseMapper<FeConversationMessage> {
10
+}

+ 91 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/ConsoleChatSseAggregator.java

@@ -0,0 +1,91 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+
6
+import java.nio.charset.StandardCharsets;
7
+
8
+/**
9
+ * 从 {@code /api/console/chat} SSE 字节流中增量提取 assistant 文本。
10
+ */
11
+public class ConsoleChatSseAggregator {
12
+
13
+    private final ObjectMapper objectMapper;
14
+    private final StringBuilder lineBuffer = new StringBuilder();
15
+    private final StringBuilder assistantText = new StringBuilder();
16
+
17
+    public ConsoleChatSseAggregator(ObjectMapper objectMapper) {
18
+        this.objectMapper = objectMapper;
19
+    }
20
+
21
+    public void appendBytes(byte[] buffer, int offset, int length) {
22
+        if (buffer == null || length <= 0) {
23
+            return;
24
+        }
25
+        lineBuffer.append(new String(buffer, offset, length, StandardCharsets.UTF_8));
26
+        drainCompleteLines();
27
+    }
28
+
29
+    public String getAssistantText() {
30
+        return assistantText.toString();
31
+    }
32
+
33
+    private void drainCompleteLines() {
34
+        int newlineIndex;
35
+        while ((newlineIndex = indexOfLineBreak(lineBuffer)) >= 0) {
36
+            String line = lineBuffer.substring(0, newlineIndex).trim();
37
+            removeThroughLineBreak(lineBuffer, newlineIndex);
38
+            consumeSseLine(line);
39
+        }
40
+    }
41
+
42
+    private void consumeSseLine(String line) {
43
+        if (!line.startsWith("data:")) {
44
+            return;
45
+        }
46
+        String payload = line.substring(5).trim();
47
+        if (payload.isEmpty() || "[DONE]".equals(payload)) {
48
+            return;
49
+        }
50
+        try {
51
+            JsonNode data = objectMapper.readTree(payload);
52
+            JsonNode output = data.get("output");
53
+            if (output == null || !output.isArray()) {
54
+                return;
55
+            }
56
+            for (JsonNode item : output) {
57
+                if (!"assistant".equals(item.path("role").asText())) {
58
+                    continue;
59
+                }
60
+                JsonNode contents = item.get("content");
61
+                if (contents == null || !contents.isArray()) {
62
+                    continue;
63
+                }
64
+                for (JsonNode content : contents) {
65
+                    if ("text".equals(content.path("type").asText()) && content.has("text")) {
66
+                        assistantText.append(content.get("text").asText(""));
67
+                    }
68
+                }
69
+            }
70
+        } catch (Exception ignored) {
71
+            // 跳过无法解析的 SSE 片段,不影响流式转发
72
+        }
73
+    }
74
+
75
+    private static int indexOfLineBreak(StringBuilder sb) {
76
+        for (int i = 0; i < sb.length(); i++) {
77
+            if (sb.charAt(i) == '\n') {
78
+                return i;
79
+            }
80
+        }
81
+        return -1;
82
+    }
83
+
84
+    private static void removeThroughLineBreak(StringBuilder sb, int newlineIndex) {
85
+        int removeEnd = newlineIndex + 1;
86
+        if (removeEnd < sb.length() && sb.charAt(removeEnd) == '\r') {
87
+            removeEnd++;
88
+        }
89
+        sb.delete(0, removeEnd);
90
+    }
91
+}

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

@@ -1,5 +1,7 @@
1 1
 package com.huimv.employment.integration.kb;
2 2
 
3
+import com.fasterxml.jackson.databind.ObjectMapper;
4
+
3 5
 import org.springframework.http.ResponseEntity;
4 6
 
5 7
 import java.io.IOException;
@@ -13,4 +15,10 @@ public interface KbOpenAiProxyService {
13 15
     ResponseEntity<byte[]> getVersion();
14 16
 
15 17
     void streamConsoleChat(byte[] requestBody, String agentId, OutputStream output) throws IOException;
18
+
19
+    /**
20
+     * 流式转发 SSE 并聚合 assistant 文本(供会话落库场景使用,不影响纯透传接口)。
21
+     */
22
+    String streamConsoleChatCollecting(byte[] requestBody, String agentId, OutputStream output,
23
+                                       ObjectMapper objectMapper) throws IOException;
16 24
 }

+ 15 - 1
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyServiceImpl.java

@@ -1,5 +1,6 @@
1 1
 package com.huimv.employment.integration.kb;
2 2
 
3
+import com.fasterxml.jackson.databind.ObjectMapper;
3 4
 import com.huimv.employment.common.exception.BizException;
4 5
 import com.huimv.employment.common.exception.ErrorCode;
5 6
 import org.springframework.beans.factory.annotation.Qualifier;
@@ -57,6 +58,19 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
57 58
 
58 59
     @Override
59 60
     public void streamConsoleChat(byte[] requestBody, String agentId, OutputStream output) throws IOException {
61
+        streamConsoleChatInternal(requestBody, agentId, output, null);
62
+    }
63
+
64
+    @Override
65
+    public String streamConsoleChatCollecting(byte[] requestBody, String agentId, OutputStream output,
66
+                                              ObjectMapper objectMapper) throws IOException {
67
+        ConsoleChatSseAggregator aggregator = new ConsoleChatSseAggregator(objectMapper);
68
+        streamConsoleChatInternal(requestBody, agentId, output, aggregator);
69
+        return aggregator.getAssistantText();
70
+    }
71
+
72
+    private void streamConsoleChatInternal(byte[] requestBody, String agentId, OutputStream output,
73
+                                           ConsoleChatSseAggregator aggregator) throws IOException {
60 74
         ensureEnabled();
61 75
         validateRequestBody(requestBody);
62 76
         HttpURLConnection conn = null;
@@ -76,7 +90,7 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
76 90
                 if (status >= 400) {
77 91
                     writeUpstreamError(output, input);
78 92
                 } else {
79
-                    SseStreamSupport.pipeWithFlush(input, output);
93
+                    SseStreamSupport.pipeWithFlush(input, output, aggregator);
80 94
                 }
81 95
             }
82 96
         } finally {

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

@@ -31,6 +31,14 @@ public final class SseStreamSupport {
31 31
     }
32 32
 
33 33
     public static void pipeWithFlush(InputStream input, OutputStream output) throws IOException {
34
+        pipeWithFlush(input, output, null);
35
+    }
36
+
37
+    /**
38
+     * 转发 SSE 并在转发过程中收集字节(供 assistant 文本解析)。
39
+     */
40
+    public static void pipeWithFlush(InputStream input, OutputStream output, ConsoleChatSseAggregator aggregator)
41
+            throws IOException {
34 42
         if (input == null) {
35 43
             return;
36 44
         }
@@ -39,6 +47,9 @@ public final class SseStreamSupport {
39 47
         while ((read = input.read(buffer)) != -1) {
40 48
             output.write(buffer, 0, read);
41 49
             output.flush();
50
+            if (aggregator != null) {
51
+                aggregator.appendBytes(buffer, 0, read);
52
+            }
42 53
         }
43 54
     }
44 55
 

+ 68 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationChatService.java

@@ -0,0 +1,68 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+import com.fasterxml.jackson.databind.ObjectMapper;
4
+import com.fasterxml.jackson.databind.node.ArrayNode;
5
+import com.fasterxml.jackson.databind.node.ObjectNode;
6
+import com.huimv.employment.dao.entity.FeConversation;
7
+import com.huimv.employment.integration.kb.KbOpenAiProxyService;
8
+import com.huimv.employment.service.conversation.dto.ConversationChatRequest;
9
+import org.springframework.stereotype.Service;
10
+
11
+import java.io.IOException;
12
+import java.io.OutputStream;
13
+
14
+/**
15
+ * 会话内 AI 对话:组装上游请求、流式转发并在结束后落库。
16
+ */
17
+@Service
18
+public class ConversationChatService {
19
+
20
+    private final ConversationService conversationService;
21
+    private final ConversationMessageService conversationMessageService;
22
+    private final KbOpenAiProxyService kbOpenAiProxyService;
23
+    private final ObjectMapper objectMapper;
24
+
25
+    public ConversationChatService(ConversationService conversationService,
26
+                                   ConversationMessageService conversationMessageService,
27
+                                   KbOpenAiProxyService kbOpenAiProxyService,
28
+                                   ObjectMapper objectMapper) {
29
+        this.conversationService = conversationService;
30
+        this.conversationMessageService = conversationMessageService;
31
+        this.kbOpenAiProxyService = kbOpenAiProxyService;
32
+        this.objectMapper = objectMapper;
33
+    }
34
+
35
+    /**
36
+     * 在指定会话内发起一轮 AI 对话,SSE 流结束后写入 user/assistant 消息。
37
+     */
38
+    public void chat(Long userId, Long conversationId, ConversationChatRequest request, String agentId,
39
+                     OutputStream output) throws IOException {
40
+        FeConversation conversation = conversationService.requireOwnedActive(userId, conversationId);
41
+        String userText = request.getMessage().trim();
42
+        byte[] upstreamBody = buildUpstreamBody(conversation, userId, userText);
43
+
44
+        conversationMessageService.saveUserMessage(conversation.getId(), userText);
45
+        String assistantText = kbOpenAiProxyService.streamConsoleChatCollecting(
46
+                upstreamBody, agentId, output, objectMapper);
47
+        conversationMessageService.saveAssistantMessage(conversation.getId(), assistantText);
48
+    }
49
+
50
+    private byte[] buildUpstreamBody(FeConversation conversation, Long userId, String userText) throws IOException {
51
+        ObjectNode root = objectMapper.createObjectNode();
52
+        ArrayNode input = objectMapper.createArrayNode();
53
+        ObjectNode userTurn = objectMapper.createObjectNode();
54
+        userTurn.put("role", "user");
55
+        ArrayNode content = objectMapper.createArrayNode();
56
+        ObjectNode textPart = objectMapper.createObjectNode();
57
+        textPart.put("type", "text");
58
+        textPart.put("text", userText);
59
+        content.add(textPart);
60
+        userTurn.set("content", content);
61
+        input.add(userTurn);
62
+        root.set("input", input);
63
+        root.put("session_id", conversation.getConversationNo());
64
+        root.put("user_id", String.valueOf(userId));
65
+        root.put("channel", "console");
66
+        return objectMapper.writeValueAsBytes(root);
67
+    }
68
+}

+ 57 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationMessageService.java

@@ -0,0 +1,57 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+import com.huimv.employment.dao.entity.FeConversationMessage;
4
+import com.huimv.employment.dao.mapper.FeConversationMessageMapper;
5
+import org.springframework.stereotype.Service;
6
+import org.springframework.transaction.annotation.Transactional;
7
+import org.springframework.util.StringUtils;
8
+
9
+import java.time.LocalDateTime;
10
+
11
+/**
12
+ * AI 对话消息落库:user 消息在流式开始前写入,assistant 消息在流式结束后写入。
13
+ */
14
+@Service
15
+public class ConversationMessageService {
16
+
17
+    private static final String ROLE_USER = "user";
18
+    private static final String ROLE_ASSISTANT = "assistant";
19
+    private static final String CONTENT_TYPE_TEXT = "text";
20
+
21
+    private final ConversationService conversationService;
22
+    private final FeConversationMessageMapper feConversationMessageMapper;
23
+
24
+    public ConversationMessageService(ConversationService conversationService,
25
+                                      FeConversationMessageMapper feConversationMessageMapper) {
26
+        this.conversationService = conversationService;
27
+        this.feConversationMessageMapper = feConversationMessageMapper;
28
+    }
29
+
30
+    @Transactional(rollbackFor = Exception.class)
31
+    public void saveUserMessage(Long conversationId, String userText) {
32
+        if (conversationId == null || !StringUtils.hasText(userText)) {
33
+            return;
34
+        }
35
+        insertMessage(conversationId, ROLE_USER, userText.trim());
36
+        conversationService.refreshAfterMessage(conversationId, userText);
37
+    }
38
+
39
+    @Transactional(rollbackFor = Exception.class)
40
+    public void saveAssistantMessage(Long conversationId, String assistantText) {
41
+        if (conversationId == null || !StringUtils.hasText(assistantText)) {
42
+            return;
43
+        }
44
+        insertMessage(conversationId, ROLE_ASSISTANT, assistantText.trim());
45
+        conversationService.refreshAfterMessage(conversationId, null);
46
+    }
47
+
48
+    private void insertMessage(Long conversationId, String role, String content) {
49
+        FeConversationMessage message = new FeConversationMessage();
50
+        message.setConversationId(conversationId);
51
+        message.setRole(role);
52
+        message.setContent(content);
53
+        message.setContentType(CONTENT_TYPE_TEXT);
54
+        message.setCreateTime(LocalDateTime.now());
55
+        feConversationMessageMapper.insert(message);
56
+    }
57
+}

+ 47 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationService.java

@@ -115,6 +115,45 @@ public class ConversationService {
115 115
         return toSummary(created);
116 116
     }
117 117
 
118
+    /**
119
+     * 校验会话归属与状态,供落库聊天接口使用。
120
+     */
121
+    public FeConversation requireOwnedActive(Long userId, Long conversationId) {
122
+        if (userId == null || conversationId == null) {
123
+            throw new BizException(ErrorCode.NOT_FOUND, "会话不存在");
124
+        }
125
+        FeConversation conversation = feConversationMapper.selectById(conversationId);
126
+        if (conversation == null || !userId.equals(conversation.getUserId())) {
127
+            throw new BizException(ErrorCode.NOT_FOUND, "会话不存在");
128
+        }
129
+        if (!STATUS_ACTIVE.equals(conversation.getStatus())) {
130
+            throw new BizException(ErrorCode.BAD_REQUEST, "会话已归档,无法继续对话");
131
+        }
132
+        return conversation;
133
+    }
134
+
135
+    /**
136
+     * 消息入库后刷新会话 {@code last_message_at};首条 user 消息可自动生成标题。
137
+     */
138
+    @Transactional(rollbackFor = Exception.class)
139
+    public void refreshAfterMessage(Long conversationId, String userTextForTitle) {
140
+        if (conversationId == null) {
141
+            return;
142
+        }
143
+        LocalDateTime now = LocalDateTime.now();
144
+        FeConversation update = new FeConversation();
145
+        update.setId(conversationId);
146
+        update.setLastMessageAt(now);
147
+        update.setUpdateTime(now);
148
+        if (StringUtils.hasText(userTextForTitle)) {
149
+            FeConversation current = feConversationMapper.selectById(conversationId);
150
+            if (current != null && !StringUtils.hasText(current.getTitle())) {
151
+                update.setTitle(buildTitleFromUserText(userTextForTitle));
152
+            }
153
+        }
154
+        feConversationMapper.updateById(update);
155
+    }
156
+
118 157
     /**
119 158
      * 用户发起 AI 对话时刷新会话记录。
120 159
      * <p>
@@ -244,4 +283,12 @@ public class ConversationService {
244 283
         }
245 284
         return sessionId.substring(0, CONVERSATION_NO_MAX_LEN);
246 285
     }
286
+
287
+    private static String buildTitleFromUserText(String userText) {
288
+        if (!StringUtils.hasText(userText)) {
289
+            return null;
290
+        }
291
+        String trimmed = userText.trim();
292
+        return trimmed.length() <= 200 ? trimmed : trimmed.substring(0, 200);
293
+    }
247 294
 }

+ 26 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/dto/ConversationChatRequest.java

@@ -0,0 +1,26 @@
1
+package com.huimv.employment.service.conversation.dto;
2
+
3
+import io.swagger.v3.oas.annotations.media.Schema;
4
+
5
+import javax.validation.constraints.NotBlank;
6
+import javax.validation.constraints.Size;
7
+
8
+/**
9
+ * 会话内 AI 对话请求(封装上游 console/chat 所需用户输入)。
10
+ */
11
+@Schema(description = "会话内 AI 对话请求")
12
+public class ConversationChatRequest {
13
+
14
+    @Schema(description = "用户本轮输入文本", example = "帮我招 10 个仓库临时工", requiredMode = Schema.RequiredMode.REQUIRED)
15
+    @NotBlank(message = "消息内容不能为空")
16
+    @Size(max = 8000, message = "消息内容不能超过 8000 字")
17
+    private String message;
18
+
19
+    public String getMessage() {
20
+        return message;
21
+    }
22
+
23
+    public void setMessage(String message) {
24
+        this.message = message;
25
+    }
26
+}