소스 검색

增加与ai大模型对话

wwh 3 주 전
부모
커밋
2285d95567

+ 6 - 0
doc/灵活用工-mysql.sql

@@ -269,6 +269,7 @@ CREATE TABLE `fe_employment_draft` (
269 269
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '表主键,自增。AI 子模块生成草稿时创建。非空;被 fe_draft_field、fe_draft_cost_snapshot 引用。',
270 270
   `draft_no` VARCHAR(32)     NOT NULL COMMENT '草稿业务编号,对外 API 使用。生成时由编号器写入。示例:draft_001。非空;唯一索引 uk_fe_draft_no。',
271 271
   `enterprise_id` BIGINT          NOT NULL COMMENT '所属企业,外键 REFERENCES fe_enterprise(id)。从对话上下文写入。非空;索引 idx_fe_draft_ent。',
272
+  `user_id` BIGINT          NOT NULL COMMENT '发起草稿的企业主用户,外键 REFERENCES fe_user(id)。创建时从 fe_conversation.user_id 写入。非空;索引 idx_fe_draft_user。',
272 273
   `conversation_id` BIGINT COMMENT '来源 AI 对话,外键 REFERENCES fe_conversation(id)。对话触发生成时写入;后台批量生成可为空。可空。',
273 274
   `scenario` VARCHAR(50)     NOT NULL COMMENT '用工业务场景类型。AI 识别用户意图时写入。枚举:existing_worker_regularization=已有非正规临时工规范转正;new_demand=全新用工需求。非空。',
274 275
   `title` VARCHAR(200) COMMENT '草稿标题,列表与卡片展示。AI 摘要或用户输入。示例:浦东仓库暑期搬运。可空。',
@@ -301,7 +302,9 @@ CREATE TABLE `fe_employment_draft` (
301 302
   PRIMARY KEY (`id`),
302 303
   UNIQUE KEY `uk_fe_draft_no` (`draft_no`),
303 304
   KEY `idx_fe_draft_ent` (`enterprise_id`, `status`, `create_time`),
305
+  KEY `idx_fe_draft_user` (`user_id`, `status`, `create_time`),
304 306
   CONSTRAINT `fk_fe_employment_draft_enterprise_id` FOREIGN KEY (`enterprise_id`) REFERENCES `fe_enterprise` (`id`),
307
+  CONSTRAINT `fk_fe_employment_draft_user_id` FOREIGN KEY (`user_id`) REFERENCES `fe_user` (`id`),
305 308
   CONSTRAINT `fk_fe_employment_draft_conversation_id` FOREIGN KEY (`conversation_id`) REFERENCES `fe_conversation` (`id`),
306 309
   CONSTRAINT `fk_fe_employment_draft_selected_plan_id` FOREIGN KEY (`selected_plan_id`) REFERENCES `fe_service_plan` (`id`),
307 310
   CONSTRAINT `fk_fe_employment_draft_confirmed_by` FOREIGN KEY (`confirmed_by`) REFERENCES `fe_user` (`id`)
@@ -366,6 +369,7 @@ CREATE TABLE `fe_employment_order` (
366 369
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '表主键,自增。草稿确认接口成功时创建。非空。',
367 370
   `order_no` VARCHAR(32)     NOT NULL COMMENT '订单业务编号,企业主可见。示例:2024-001、ORD20260707001。非空;唯一索引 uk_fe_order_no。',
368 371
   `enterprise_id` BIGINT          NOT NULL COMMENT '所属企业,外键 REFERENCES fe_enterprise(id)。非空;索引 idx_fe_order_ent。',
372
+  `user_id` BIGINT          NOT NULL COMMENT '下单企业主用户,外键 REFERENCES fe_user(id)。确认草稿时从 fe_employment_draft.user_id 复制。非空;索引 idx_fe_order_user。',
369 373
   `draft_id` BIGINT COMMENT '来源草稿,外键 REFERENCES fe_employment_draft(id)。确认时写入,追溯对话与方案选择。可空(极少数后台直创订单)。',
370 374
   `selected_plan_id` BIGINT COMMENT '确认选用的用工方案,外键 REFERENCES fe_service_plan(id)。草稿确认时从 draft.selected_plan_id 复制。可空;应尽早补齐。',
371 375
   `title` VARCHAR(200)    NOT NULL COMMENT '订单标题,通常继承草稿标题。非空。',
@@ -387,7 +391,9 @@ CREATE TABLE `fe_employment_order` (
387 391
   PRIMARY KEY (`id`),
388 392
   UNIQUE KEY `uk_fe_order_no` (`order_no`),
389 393
   KEY `idx_fe_order_ent` (`enterprise_id`, `order_status`, `create_time`),
394
+  KEY `idx_fe_order_user` (`user_id`, `order_status`, `create_time`),
390 395
   CONSTRAINT `fk_fe_employment_order_enterprise_id` FOREIGN KEY (`enterprise_id`) REFERENCES `fe_enterprise` (`id`),
396
+  CONSTRAINT `fk_fe_employment_order_user_id` FOREIGN KEY (`user_id`) REFERENCES `fe_user` (`id`),
391 397
   CONSTRAINT `fk_fe_employment_order_draft_id` FOREIGN KEY (`draft_id`) REFERENCES `fe_employment_draft` (`id`),
392 398
   CONSTRAINT `fk_fe_employment_order_selected_plan_id` FOREIGN KEY (`selected_plan_id`) REFERENCES `fe_service_plan` (`id`)
393 399
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用工订单主表,草稿确认并选定方案后生成,承载办理全流程业务状态与登记进度冗余统计。current_step_code 驱动合规状态机;关联 fe_enterprise、fe_employment_draft、fe_service_plan,一对多关联登记批次与结算单。';

+ 2 - 0
doc/灵活用工.sql

@@ -292,6 +292,7 @@ CREATE TABLE fe_employment_draft (
292 292
     id                  BIGSERIAL       PRIMARY KEY,
293 293
     draft_no            VARCHAR(32)     NOT NULL,                   -- draft_001
294 294
     enterprise_id       BIGINT          NOT NULL REFERENCES fe_enterprise(id),
295
+    user_id             BIGINT          NOT NULL REFERENCES fe_user(id),
295 296
     conversation_id     BIGINT          REFERENCES fe_conversation(id),
296 297
     scenario            VARCHAR(50)     NOT NULL,                   -- existing_worker_regularization / new_demand
297 298
     title               VARCHAR(200),                               -- 浦东仓库搬运
@@ -386,6 +387,7 @@ CREATE TABLE fe_employment_order (
386 387
     id                  BIGSERIAL       PRIMARY KEY,
387 388
     order_no            VARCHAR(32)     NOT NULL,                   -- 2024-001
388 389
     enterprise_id       BIGINT          NOT NULL REFERENCES fe_enterprise(id),
390
+    user_id             BIGINT          NOT NULL REFERENCES fe_user(id),
389 391
     draft_id            BIGINT          REFERENCES fe_employment_draft(id),
390 392
     selected_plan_id    BIGINT          REFERENCES fe_service_plan(id),
391 393
     title               VARCHAR(200)    NOT NULL,

+ 22 - 2
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/ConversationController.java

@@ -7,12 +7,14 @@ import com.huimv.employment.integration.kb.SseStreamSupport;
7 7
 import com.huimv.employment.security.LoginUser;
8 8
 import com.huimv.employment.security.LoginUserHolder;
9 9
 import com.huimv.employment.service.conversation.ConversationChatService;
10
+import com.huimv.employment.service.conversation.ConversationDraftQueryService;
10 11
 import com.huimv.employment.service.conversation.ConversationMessageService;
11 12
 import com.huimv.employment.service.conversation.ConversationService;
12 13
 import com.huimv.employment.service.conversation.dto.ConversationChatRequest;
13 14
 import com.huimv.employment.service.conversation.dto.ConversationCreateRequest;
14 15
 import com.huimv.employment.service.conversation.dto.ConversationMessagePageResponse;
15 16
 import com.huimv.employment.service.conversation.dto.ConversationSummaryResponse;
17
+import com.huimv.employment.service.draft.dto.DraftDetailResponse;
16 18
 import io.swagger.v3.oas.annotations.Operation;
17 19
 import io.swagger.v3.oas.annotations.security.SecurityRequirement;
18 20
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -54,13 +56,16 @@ public class ConversationController {
54 56
     private final ConversationService conversationService;
55 57
     private final ConversationChatService conversationChatService;
56 58
     private final ConversationMessageService conversationMessageService;
59
+    private final ConversationDraftQueryService conversationDraftQueryService;
57 60
 
58 61
     public ConversationController(ConversationService conversationService,
59 62
                                   ConversationChatService conversationChatService,
60
-                                  ConversationMessageService conversationMessageService) {
63
+                                  ConversationMessageService conversationMessageService,
64
+                                  ConversationDraftQueryService conversationDraftQueryService) {
61 65
         this.conversationService = conversationService;
62 66
         this.conversationChatService = conversationChatService;
63 67
         this.conversationMessageService = conversationMessageService;
68
+        this.conversationDraftQueryService = conversationDraftQueryService;
64 69
     }
65 70
 
66 71
     /**
@@ -106,6 +111,19 @@ public class ConversationController {
106 111
                 loginUser.getUserId(), conversationId, page, size));
107 112
     }
108 113
 
114
+    /**
115
+     * 查询当前会话关联的最新草稿(兜底:消息未带 draft_id 时使用)。
116
+     */
117
+    @GetMapping("/{id}/draft")
118
+    @Operation(summary = "查询会话当前草稿",
119
+            description = "返回该会话下最新一条 fe_employment_draft 详情(含 fields)。"
120
+                    + "Agent 回复未携带 card_draft 时,前端可在对话结束后调用此接口拉取草稿。")
121
+    public R<DraftDetailResponse> getConversationDraft(@PathVariable("id") Long conversationId) {
122
+        LoginUser loginUser = requireEnterprise();
123
+        return R.ok(conversationDraftQueryService.getDraftByConversation(
124
+                loginUser.getUserId(), conversationId));
125
+    }
126
+
109 127
     /**
110 128
      * 在已有会话内发起 AI 对话(SSE 流式,流结束后落库)。
111 129
      */
@@ -113,7 +131,9 @@ public class ConversationController {
113 131
     @Operation(summary = "会话内 AI 对话(SSE 流式 + 落库)",
114 132
             description = "需先 POST /conversations 创建会话。请求体仅需 message 字段;"
115 133
                     + "服务端组装上游 console/chat 请求,流式开始前写入 user 消息,"
116
-                    + "流式结束后写入 assistant 消息。可选请求头 X-Agent-Id。")
134
+                    + "流式结束后解析 assistant 卡片(content_type/card_payload/next_action)并落库;"
135
+                    + "若 Agent 未返回卡片且会话草稿 status=ready,服务端自动补全 card_draft。"
136
+                    + "可选请求头 X-Agent-Id。")
117 137
     public void chat(@PathVariable("id") Long conversationId,
118 138
                      @Validated @RequestBody ConversationChatRequest request,
119 139
                      HttpServletResponse response,

+ 151 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/AssistantReplyParser.java

@@ -0,0 +1,151 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.huimv.employment.service.conversation.dto.ParsedAssistantReply;
6
+import org.springframework.util.StringUtils;
7
+
8
+import java.util.regex.Matcher;
9
+import java.util.regex.Pattern;
10
+
11
+/**
12
+ * 从 Agent assistant 纯文本中解析结构化卡片(支持 {@code ```fe-card```}/{@code ```json```} 代码块或整段 JSON)。
13
+ */
14
+public final class AssistantReplyParser {
15
+
16
+    private static final Pattern FENCED_BLOCK = Pattern.compile(
17
+            "```(?:fe-card|json)?\\s*([\\s\\S]*?)```",
18
+            Pattern.CASE_INSENSITIVE);
19
+
20
+    private AssistantReplyParser() {
21
+    }
22
+
23
+    public static ParsedAssistantReply parse(String rawText, ObjectMapper objectMapper) {
24
+        if (!StringUtils.hasText(rawText)) {
25
+            return ParsedAssistantReply.textOnly("");
26
+        }
27
+        String trimmed = rawText.trim();
28
+        Matcher matcher = FENCED_BLOCK.matcher(trimmed);
29
+        while (matcher.find()) {
30
+            ParsedAssistantReply fromBlock = tryParseJsonBlock(
31
+                    trimmed.substring(0, matcher.start()).trim(),
32
+                    matcher.group(1).trim(),
33
+                    objectMapper);
34
+            if (fromBlock != null) {
35
+                return fromBlock;
36
+            }
37
+        }
38
+        if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
39
+            ParsedAssistantReply whole = tryParseJsonBlock("", trimmed, objectMapper);
40
+            if (whole != null) {
41
+                return whole;
42
+            }
43
+        }
44
+        return ParsedAssistantReply.textOnly(trimmed);
45
+    }
46
+
47
+    private static ParsedAssistantReply tryParseJsonBlock(String prefixText,
48
+                                                          String jsonPayload,
49
+                                                          ObjectMapper objectMapper) {
50
+        if (!StringUtils.hasText(jsonPayload)) {
51
+            return null;
52
+        }
53
+        try {
54
+            JsonNode root = objectMapper.readTree(jsonPayload);
55
+            if (!looksLikeCard(root)) {
56
+                return null;
57
+            }
58
+            String contentType = resolveContentType(root);
59
+            if (!StringUtils.hasText(contentType)
60
+                    || MessageCardConstants.CONTENT_TYPE_TEXT.equals(contentType)) {
61
+                return null;
62
+            }
63
+            String nextAction = textOrNull(root, "next_action");
64
+            String cardPayload = resolveCardPayloadJson(root, objectMapper);
65
+            Long relatedDraftId = resolveRelatedDraftId(root);
66
+            String displayText = resolveDisplayText(prefixText, root);
67
+            return new ParsedAssistantReply(displayText, contentType, cardPayload, nextAction, relatedDraftId);
68
+        } catch (Exception ignored) {
69
+            return null;
70
+        }
71
+    }
72
+
73
+    private static boolean looksLikeCard(JsonNode root) {
74
+        if (root == null || !root.isObject()) {
75
+            return false;
76
+        }
77
+        return root.has("content_type")
78
+                || root.has("card_type")
79
+                || root.has("card_payload")
80
+                || root.has("next_action");
81
+    }
82
+
83
+    private static String resolveContentType(JsonNode root) {
84
+        String contentType = textOrNull(root, "content_type");
85
+        if (StringUtils.hasText(contentType)) {
86
+            return contentType.trim();
87
+        }
88
+        String cardType = textOrNull(root, "card_type");
89
+        if (!StringUtils.hasText(cardType)) {
90
+            return MessageCardConstants.CONTENT_TYPE_TEXT;
91
+        }
92
+        switch (cardType.trim()) {
93
+            case "cost_calculation_result":
94
+                return MessageCardConstants.CONTENT_TYPE_CARD_COST;
95
+            case "employment_draft":
96
+            case "draft_card":
97
+                return MessageCardConstants.CONTENT_TYPE_CARD_DRAFT;
98
+            case "process_action":
99
+            case "order_progress":
100
+                return MessageCardConstants.CONTENT_TYPE_CARD_ORDER;
101
+            default:
102
+                return MessageCardConstants.CONTENT_TYPE_TEXT;
103
+        }
104
+    }
105
+
106
+    private static String resolveCardPayloadJson(JsonNode root, ObjectMapper objectMapper) throws Exception {
107
+        JsonNode payload = root.get("card_payload");
108
+        if (payload != null && !payload.isNull()) {
109
+            return objectMapper.writeValueAsString(payload);
110
+        }
111
+        JsonNode data = root.get("data");
112
+        if (data != null && !data.isNull()) {
113
+            return objectMapper.writeValueAsString(data);
114
+        }
115
+        return null;
116
+    }
117
+
118
+    private static Long resolveRelatedDraftId(JsonNode root) {
119
+        JsonNode payload = root.get("card_payload");
120
+        if (payload != null && payload.has("draft_id")) {
121
+            return payload.get("draft_id").asLong(0L) > 0 ? payload.get("draft_id").asLong() : null;
122
+        }
123
+        JsonNode data = root.get("data");
124
+        if (data != null && data.has("draft_id")) {
125
+            return data.get("draft_id").asLong(0L) > 0 ? data.get("draft_id").asLong() : null;
126
+        }
127
+        if (root.has("draft_id")) {
128
+            return root.get("draft_id").asLong(0L) > 0 ? root.get("draft_id").asLong() : null;
129
+        }
130
+        return null;
131
+    }
132
+
133
+    private static String resolveDisplayText(String prefixText, JsonNode root) {
134
+        String fromJson = textOrNull(root, "content");
135
+        if (!StringUtils.hasText(fromJson)) {
136
+            fromJson = textOrNull(root, "reply_text");
137
+        }
138
+        if (StringUtils.hasText(fromJson)) {
139
+            return fromJson.trim();
140
+        }
141
+        return StringUtils.hasText(prefixText) ? prefixText.trim() : "请查看卡片详情";
142
+    }
143
+
144
+    private static String textOrNull(JsonNode root, String field) {
145
+        if (root == null || !root.has(field) || root.get(field).isNull()) {
146
+            return null;
147
+        }
148
+        String text = root.get(field).asText("");
149
+        return text.isEmpty() ? null : text;
150
+    }
151
+}

+ 16 - 1
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationChatService.java

@@ -6,7 +6,11 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
6 6
 import com.huimv.employment.dao.entity.FeConversation;
7 7
 import com.huimv.employment.integration.kb.KbOpenAiProxyService;
8 8
 import com.huimv.employment.service.conversation.dto.ConversationChatRequest;
9
+import com.huimv.employment.service.conversation.dto.ParsedAssistantReply;
10
+import org.slf4j.Logger;
11
+import org.slf4j.LoggerFactory;
9 12
 import org.springframework.stereotype.Service;
13
+import org.springframework.util.StringUtils;
10 14
 
11 15
 import java.io.IOException;
12 16
 import java.io.OutputStream;
@@ -17,20 +21,25 @@ import java.io.OutputStream;
17 21
 @Service
18 22
 public class ConversationChatService {
19 23
 
24
+    private static final Logger log = LoggerFactory.getLogger(ConversationChatService.class);
25
+
20 26
     private final ConversationService conversationService;
21 27
     private final ConversationMessageService conversationMessageService;
22 28
     private final ConversationIntentService conversationIntentService;
29
+    private final ConversationDraftCardEnricher conversationDraftCardEnricher;
23 30
     private final KbOpenAiProxyService kbOpenAiProxyService;
24 31
     private final ObjectMapper objectMapper;
25 32
 
26 33
     public ConversationChatService(ConversationService conversationService,
27 34
                                    ConversationMessageService conversationMessageService,
28 35
                                    ConversationIntentService conversationIntentService,
36
+                                   ConversationDraftCardEnricher conversationDraftCardEnricher,
29 37
                                    KbOpenAiProxyService kbOpenAiProxyService,
30 38
                                    ObjectMapper objectMapper) {
31 39
         this.conversationService = conversationService;
32 40
         this.conversationMessageService = conversationMessageService;
33 41
         this.conversationIntentService = conversationIntentService;
42
+        this.conversationDraftCardEnricher = conversationDraftCardEnricher;
34 43
         this.kbOpenAiProxyService = kbOpenAiProxyService;
35 44
         this.objectMapper = objectMapper;
36 45
     }
@@ -52,7 +61,13 @@ public class ConversationChatService {
52 61
         }
53 62
         String assistantText = kbOpenAiProxyService.streamConsoleChatCollecting(
54 63
                 upstreamBody, agentId, output, objectMapper);
55
-        conversationMessageService.saveAssistantMessage(conversation.getId(), assistantText);
64
+        ParsedAssistantReply parsed = AssistantReplyParser.parse(assistantText, objectMapper);
65
+        parsed = conversationDraftCardEnricher.enrichIfReady(conversation.getId(), parsed);
66
+        if (!StringUtils.hasText(parsed.getDisplayText()) && !parsed.hasCard()) {
67
+            log.warn("assistant 回复为空,未写入 fe_conversation_message conversationId={} conversationNo={}",
68
+                    conversation.getId(), conversation.getConversationNo());
69
+        }
70
+        conversationMessageService.saveAssistantMessage(conversation.getId(), parsed);
56 71
     }
57 72
 
58 73
     private byte[] buildUpstreamBody(FeConversation conversation, Long userId, String userText) throws IOException {

+ 101 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationDraftCardEnricher.java

@@ -0,0 +1,101 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.fasterxml.jackson.core.type.TypeReference;
5
+import com.fasterxml.jackson.databind.ObjectMapper;
6
+import com.huimv.employment.dao.entity.FeEmploymentDraft;
7
+import com.huimv.employment.dao.mapper.FeEmploymentDraftMapper;
8
+import com.huimv.employment.service.conversation.dto.ParsedAssistantReply;
9
+import org.slf4j.Logger;
10
+import org.slf4j.LoggerFactory;
11
+import org.springframework.stereotype.Component;
12
+import org.springframework.util.StringUtils;
13
+
14
+import java.util.Collections;
15
+import java.util.LinkedHashMap;
16
+import java.util.List;
17
+import java.util.Map;
18
+
19
+/**
20
+ * 当 Agent 未返回卡片 JSON 时,根据会话 pending 且 ready 的草稿自动补全 card_draft 消息。
21
+ */
22
+@Component
23
+public class ConversationDraftCardEnricher {
24
+
25
+    private static final Logger log = LoggerFactory.getLogger(ConversationDraftCardEnricher.class);
26
+
27
+    private static final String DB_STATUS_PENDING = "pending";
28
+
29
+    private final FeEmploymentDraftMapper feEmploymentDraftMapper;
30
+    private final ObjectMapper objectMapper;
31
+
32
+    public ConversationDraftCardEnricher(FeEmploymentDraftMapper feEmploymentDraftMapper,
33
+                                         ObjectMapper objectMapper) {
34
+        this.feEmploymentDraftMapper = feEmploymentDraftMapper;
35
+        this.objectMapper = objectMapper;
36
+    }
37
+
38
+    public ParsedAssistantReply enrichIfReady(Long conversationId, ParsedAssistantReply parsed) {
39
+        if (parsed == null || parsed.hasCard() || conversationId == null) {
40
+            return parsed;
41
+        }
42
+        FeEmploymentDraft draft = findPendingDraft(conversationId);
43
+        if (draft == null || !isReady(draft)) {
44
+            return parsed;
45
+        }
46
+        try {
47
+            Map<String, Object> payload = buildDraftCardPayload(draft);
48
+            String cardPayload = objectMapper.writeValueAsString(payload);
49
+            log.debug("会话 {} 草稿 {} 已 ready,自动补全 card_draft 消息", conversationId, draft.getId());
50
+            return parsed.withCard(
51
+                    MessageCardConstants.CONTENT_TYPE_CARD_DRAFT,
52
+                    MessageCardConstants.NEXT_ACTION_SHOW_EMPLOYMENT_DRAFT,
53
+                    cardPayload,
54
+                    draft.getId());
55
+        } catch (Exception ex) {
56
+            log.warn("会话 {} 草稿卡片补全失败: {}", conversationId, ex.getMessage());
57
+            return parsed;
58
+        }
59
+    }
60
+
61
+    private FeEmploymentDraft findPendingDraft(Long conversationId) {
62
+        return feEmploymentDraftMapper.selectOne(new LambdaQueryWrapper<FeEmploymentDraft>()
63
+                .eq(FeEmploymentDraft::getConversationId, conversationId)
64
+                .eq(FeEmploymentDraft::getStatus, DB_STATUS_PENDING)
65
+                .orderByDesc(FeEmploymentDraft::getCreateTime)
66
+                .last("LIMIT 1"));
67
+    }
68
+
69
+    private boolean isReady(FeEmploymentDraft draft) {
70
+        return parseMissingFields(draft.getMissingFields()).isEmpty();
71
+    }
72
+
73
+    private List<String> parseMissingFields(String json) {
74
+        if (!StringUtils.hasText(json)) {
75
+            return Collections.emptyList();
76
+        }
77
+        try {
78
+            List<String> parsed = objectMapper.readValue(json.trim(), new TypeReference<List<String>>() {
79
+            });
80
+            return parsed != null ? parsed : Collections.emptyList();
81
+        } catch (Exception ex) {
82
+            return Collections.emptyList();
83
+        }
84
+    }
85
+
86
+    private Map<String, Object> buildDraftCardPayload(FeEmploymentDraft draft) {
87
+        Map<String, Object> payload = new LinkedHashMap<>();
88
+        payload.put("draft_id", draft.getId());
89
+        payload.put("draft_no", draft.getDraftNo());
90
+        payload.put("title", draft.getTitle());
91
+        payload.put("worker_count", draft.getWorkerCount());
92
+        payload.put("work_days", draft.getWorkDays());
93
+        payload.put("work_type", draft.getWorkType());
94
+        payload.put("work_location", draft.getWorkLocation());
95
+        payload.put("settlement_mode", draft.getSettlementMode());
96
+        payload.put("total_amount", draft.getEstimatedTotal());
97
+        payload.put("status", "ready");
98
+        payload.put("missing_fields", Collections.emptyList());
99
+        return payload;
100
+    }
101
+}

+ 44 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationDraftQueryService.java

@@ -0,0 +1,44 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.huimv.employment.common.exception.BizException;
5
+import com.huimv.employment.common.exception.ErrorCode;
6
+import com.huimv.employment.dao.entity.FeEmploymentDraft;
7
+import com.huimv.employment.dao.mapper.FeEmploymentDraftMapper;
8
+import com.huimv.employment.service.draft.DraftService;
9
+import com.huimv.employment.service.draft.dto.DraftDetailResponse;
10
+import org.springframework.stereotype.Service;
11
+
12
+/**
13
+ * 按会话查询当前关联草稿(小程序兜底,无需 draft_id)。
14
+ */
15
+@Service
16
+public class ConversationDraftQueryService {
17
+
18
+    private final ConversationService conversationService;
19
+    private final FeEmploymentDraftMapper feEmploymentDraftMapper;
20
+    private final DraftService draftService;
21
+
22
+    public ConversationDraftQueryService(ConversationService conversationService,
23
+                                         FeEmploymentDraftMapper feEmploymentDraftMapper,
24
+                                         DraftService draftService) {
25
+        this.conversationService = conversationService;
26
+        this.feEmploymentDraftMapper = feEmploymentDraftMapper;
27
+        this.draftService = draftService;
28
+    }
29
+
30
+    /**
31
+     * 返回会话下最新一条草稿详情(含 pending / confirmed)。
32
+     */
33
+    public DraftDetailResponse getDraftByConversation(Long userId, Long conversationId) {
34
+        conversationService.requireOwned(userId, conversationId);
35
+        FeEmploymentDraft draft = feEmploymentDraftMapper.selectOne(new LambdaQueryWrapper<FeEmploymentDraft>()
36
+                .eq(FeEmploymentDraft::getConversationId, conversationId)
37
+                .orderByDesc(FeEmploymentDraft::getCreateTime)
38
+                .last("LIMIT 1"));
39
+        if (draft == null) {
40
+            throw new BizException(ErrorCode.NOT_FOUND, "当前会话暂无草稿");
41
+        }
42
+        return draftService.getDetail(userId, draft.getId());
43
+    }
44
+}

+ 63 - 6
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationMessageService.java

@@ -6,6 +6,7 @@ import com.huimv.employment.dao.entity.FeConversationMessage;
6 6
 import com.huimv.employment.dao.mapper.FeConversationMessageMapper;
7 7
 import com.huimv.employment.service.conversation.dto.ConversationMessagePageResponse;
8 8
 import com.huimv.employment.service.conversation.dto.ConversationMessageResponse;
9
+import com.huimv.employment.service.conversation.dto.ParsedAssistantReply;
9 10
 import org.springframework.stereotype.Service;
10 11
 import org.springframework.transaction.annotation.Transactional;
11 12
 import org.springframework.util.StringUtils;
@@ -21,7 +22,6 @@ public class ConversationMessageService {
21 22
 
22 23
     private static final String ROLE_USER = "user";
23 24
     private static final String ROLE_ASSISTANT = "assistant";
24
-    private static final String CONTENT_TYPE_TEXT = "text";
25 25
 
26 26
     private static final int MIN_PAGE = 1;
27 27
     private static final int MIN_SIZE = 1;
@@ -74,7 +74,8 @@ public class ConversationMessageService {
74 74
         if (conversationId == null || !StringUtils.hasText(userText)) {
75 75
             return;
76 76
         }
77
-        insertMessage(conversationId, ROLE_USER, userText.trim());
77
+        insertMessage(conversationId, ROLE_USER, userText.trim(),
78
+                MessageCardConstants.CONTENT_TYPE_TEXT, null, null, null);
78 79
         conversationService.refreshAfterMessage(conversationId, refreshTitle ? userText : null);
79 80
     }
80 81
 
@@ -91,19 +92,74 @@ public class ConversationMessageService {
91 92
 
92 93
     @Transactional(rollbackFor = Exception.class)
93 94
     public void saveAssistantMessage(Long conversationId, String assistantText) {
94
-        if (conversationId == null || !StringUtils.hasText(assistantText)) {
95
+        saveAssistantMessage(conversationId, ParsedAssistantReply.textOnly(
96
+                assistantText != null ? assistantText.trim() : ""));
97
+    }
98
+
99
+    @Transactional(rollbackFor = Exception.class)
100
+    public void saveAssistantMessage(Long conversationId, ParsedAssistantReply parsed) {
101
+        if (conversationId == null || parsed == null) {
95 102
             return;
96 103
         }
97
-        insertMessage(conversationId, ROLE_ASSISTANT, assistantText.trim());
104
+        String content = resolveAssistantContent(parsed);
105
+        if (!StringUtils.hasText(content) && !parsed.hasCard()) {
106
+            return;
107
+        }
108
+        String contentType = parsed.hasCard()
109
+                ? parsed.getContentType()
110
+                : MessageCardConstants.CONTENT_TYPE_TEXT;
111
+        String nextAction = parsed.getNextAction();
112
+        if (parsed.hasCard() && !StringUtils.hasText(nextAction)) {
113
+            nextAction = defaultNextAction(contentType);
114
+        }
115
+        insertMessage(conversationId, ROLE_ASSISTANT, content,
116
+                contentType, parsed.getCardPayload(), nextAction, parsed.getRelatedDraftId());
98 117
         conversationService.refreshAfterMessage(conversationId, null);
99 118
     }
100 119
 
101
-    private void insertMessage(Long conversationId, String role, String content) {
120
+    private static String resolveAssistantContent(ParsedAssistantReply parsed) {
121
+        if (StringUtils.hasText(parsed.getDisplayText())) {
122
+            return parsed.getDisplayText().trim();
123
+        }
124
+        if (parsed.hasCard()) {
125
+            if (MessageCardConstants.CONTENT_TYPE_CARD_DRAFT.equals(parsed.getContentType())) {
126
+                return "用工草稿已生成,请查看并确认。";
127
+            }
128
+            if (MessageCardConstants.CONTENT_TYPE_CARD_COST.equals(parsed.getContentType())) {
129
+                return "成本测算完成,请选择方案。";
130
+            }
131
+            return "请查看卡片详情。";
132
+        }
133
+        return "";
134
+    }
135
+
136
+    private static String defaultNextAction(String contentType) {
137
+        if (MessageCardConstants.CONTENT_TYPE_CARD_DRAFT.equals(contentType)) {
138
+            return MessageCardConstants.NEXT_ACTION_SHOW_EMPLOYMENT_DRAFT;
139
+        }
140
+        if (MessageCardConstants.CONTENT_TYPE_CARD_COST.equals(contentType)) {
141
+            return MessageCardConstants.NEXT_ACTION_OPEN_COST_COMPARISON;
142
+        }
143
+        return null;
144
+    }
145
+
146
+    private void insertMessage(Long conversationId,
147
+                               String role,
148
+                               String content,
149
+                               String contentType,
150
+                               String cardPayload,
151
+                               String nextAction,
152
+                               Long relatedDraftId) {
102 153
         FeConversationMessage message = new FeConversationMessage();
103 154
         message.setConversationId(conversationId);
104 155
         message.setRole(role);
105 156
         message.setContent(content);
106
-        message.setContentType(CONTENT_TYPE_TEXT);
157
+        message.setContentType(StringUtils.hasText(contentType)
158
+                ? contentType
159
+                : MessageCardConstants.CONTENT_TYPE_TEXT);
160
+        message.setCardPayload(cardPayload);
161
+        message.setNextAction(nextAction);
162
+        message.setRelatedDraftId(relatedDraftId);
107 163
         message.setCreateTime(LocalDateTime.now());
108 164
         feConversationMessageMapper.insert(message);
109 165
     }
@@ -116,6 +172,7 @@ public class ConversationMessageService {
116 172
         response.setContentType(entity.getContentType());
117 173
         response.setCardPayload(entity.getCardPayload());
118 174
         response.setNextAction(entity.getNextAction());
175
+        response.setRelatedDraftId(entity.getRelatedDraftId());
119 176
         response.setCreateTime(entity.getCreateTime());
120 177
         return response;
121 178
     }

+ 18 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/MessageCardConstants.java

@@ -0,0 +1,18 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+/**
4
+ * 会话消息卡片类型与前端动作编码(对齐 fe_conversation_message 与 v2 交互规则)。
5
+ */
6
+public final class MessageCardConstants {
7
+
8
+    public static final String CONTENT_TYPE_TEXT = "text";
9
+    public static final String CONTENT_TYPE_CARD_DRAFT = "card_draft";
10
+    public static final String CONTENT_TYPE_CARD_COST = "card_cost";
11
+    public static final String CONTENT_TYPE_CARD_ORDER = "card_order";
12
+
13
+    public static final String NEXT_ACTION_SHOW_EMPLOYMENT_DRAFT = "show_employment_draft";
14
+    public static final String NEXT_ACTION_OPEN_COST_COMPARISON = "open_cost_comparison";
15
+
16
+    private MessageCardConstants() {
17
+    }
18
+}

+ 11 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/dto/ConversationMessageResponse.java

@@ -28,6 +28,9 @@ public class ConversationMessageResponse {
28 28
     @Schema(description = "建议前端下一步动作编码")
29 29
     private String nextAction;
30 30
 
31
+    @Schema(description = "关联草稿 ID")
32
+    private Long relatedDraftId;
33
+
31 34
     @Schema(description = "消息时间")
32 35
     private LocalDateTime createTime;
33 36
 
@@ -79,6 +82,14 @@ public class ConversationMessageResponse {
79 82
         this.nextAction = nextAction;
80 83
     }
81 84
 
85
+    public Long getRelatedDraftId() {
86
+        return relatedDraftId;
87
+    }
88
+
89
+    public void setRelatedDraftId(Long relatedDraftId) {
90
+        this.relatedDraftId = relatedDraftId;
91
+    }
92
+
82 93
     public LocalDateTime getCreateTime() {
83 94
         return createTime;
84 95
     }

+ 69 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/dto/ParsedAssistantReply.java

@@ -0,0 +1,69 @@
1
+package com.huimv.employment.service.conversation.dto;
2
+
3
+import com.huimv.employment.service.conversation.MessageCardConstants;
4
+import org.springframework.util.StringUtils;
5
+
6
+/**
7
+ * 解析后的 assistant 回复:展示文本 + 可选卡片元数据。
8
+ */
9
+public class ParsedAssistantReply {
10
+
11
+    private final String displayText;
12
+    private final String contentType;
13
+    private final String cardPayload;
14
+    private final String nextAction;
15
+    private final Long relatedDraftId;
16
+
17
+    public ParsedAssistantReply(String displayText,
18
+                                String contentType,
19
+                                String cardPayload,
20
+                                String nextAction,
21
+                                Long relatedDraftId) {
22
+        this.displayText = displayText;
23
+        this.contentType = contentType;
24
+        this.cardPayload = cardPayload;
25
+        this.nextAction = nextAction;
26
+        this.relatedDraftId = relatedDraftId;
27
+    }
28
+
29
+    public static ParsedAssistantReply textOnly(String displayText) {
30
+        return new ParsedAssistantReply(
31
+                displayText,
32
+                MessageCardConstants.CONTENT_TYPE_TEXT,
33
+                null,
34
+                null,
35
+                null);
36
+    }
37
+
38
+    public boolean hasCard() {
39
+        return StringUtils.hasText(contentType)
40
+                && !MessageCardConstants.CONTENT_TYPE_TEXT.equals(contentType);
41
+    }
42
+
43
+    public ParsedAssistantReply withCard(String contentType,
44
+                                         String nextAction,
45
+                                         String cardPayload,
46
+                                         Long relatedDraftId) {
47
+        return new ParsedAssistantReply(displayText, contentType, cardPayload, nextAction, relatedDraftId);
48
+    }
49
+
50
+    public String getDisplayText() {
51
+        return displayText;
52
+    }
53
+
54
+    public String getContentType() {
55
+        return contentType;
56
+    }
57
+
58
+    public String getCardPayload() {
59
+        return cardPayload;
60
+    }
61
+
62
+    public String getNextAction() {
63
+        return nextAction;
64
+    }
65
+
66
+    public Long getRelatedDraftId() {
67
+        return relatedDraftId;
68
+    }
69
+}