Procházet zdrojové kódy

消息增加ai提问分类

wwh před 2 týdny
rodič
revize
8ab6583f81

+ 13 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/AiConsultSendResult.java

@@ -9,6 +9,9 @@ public class AiConsultSendResult
9 9
 
10 10
     private BizConsultMessage aiMessage;
11 11
 
12
+    /** 已写入 biz_consult_session.real_session_id 的大模型会话 ID(可为空) */
13
+    private String realSessionId;
14
+
12 15
     public BizConsultMessage getUserMessage()
13 16
     {
14 17
         return userMessage;
@@ -28,4 +31,14 @@ public class AiConsultSendResult
28 31
     {
29 32
         this.aiMessage = aiMessage;
30 33
     }
34
+
35
+    public String getRealSessionId()
36
+    {
37
+        return realSessionId;
38
+    }
39
+
40
+    public void setRealSessionId(String realSessionId)
41
+    {
42
+        this.realSessionId = realSessionId;
43
+    }
31 44
 }

+ 13 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/BizConsultMessage.java

@@ -27,6 +27,9 @@ public class BizConsultMessage extends BaseEntity
27 27
 
28 28
     private Integer mediaDuration;
29 29
 
30
+    /** AI 提问分类,可为空(最长 32 字符) */
31
+    private String aiCategory;
32
+
30 33
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
31 34
     private Date sendTime;
32 35
 
@@ -110,6 +113,16 @@ public class BizConsultMessage extends BaseEntity
110 113
         this.mediaDuration = mediaDuration;
111 114
     }
112 115
 
116
+    public String getAiCategory()
117
+    {
118
+        return aiCategory;
119
+    }
120
+
121
+    public void setAiCategory(String aiCategory)
122
+    {
123
+        this.aiCategory = aiCategory;
124
+    }
125
+
113 126
     public Date getSendTime()
114 127
     {
115 128
         return sendTime;

+ 19 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/VetConsultSendBody.java

@@ -1,5 +1,7 @@
1 1
 package com.ruoyi.web.modules.diagnosis.domain;
2 2
 
3
+import com.fasterxml.jackson.annotation.JsonAlias;
4
+
3 5
 /**
4 6
  * 兽医发送问诊消息请求体。
5 7
  */
@@ -19,8 +21,15 @@ public class VetConsultSendBody
19 21
     /**
20 22
      * 大模型网关实际会话 ID(/v1/chat/completions 返回的 session_id),可为空。
21 23
      */
24
+    @JsonAlias({ "real_session_id", "session_id", "llmSessionId" })
22 25
     private String realSessionId;
23 26
 
27
+    /**
28
+     * AI 提问分类,可为空(写入提问人消息 ai_category,最长 32 字符)。
29
+     */
30
+    @JsonAlias({ "ai_category", "category" })
31
+    private String aiCategory;
32
+
24 33
     public Integer getMsgType()
25 34
     {
26 35
         return msgType;
@@ -70,4 +79,14 @@ public class VetConsultSendBody
70 79
     {
71 80
         this.realSessionId = realSessionId;
72 81
     }
82
+
83
+    public String getAiCategory()
84
+    {
85
+        return aiCategory;
86
+    }
87
+
88
+    public void setAiCategory(String aiCategory)
89
+    {
90
+        this.aiCategory = aiCategory;
91
+    }
73 92
 }

+ 3 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/service/impl/BizAiOnlineConsultServiceImpl.java

@@ -138,6 +138,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
138 138
         try
139 139
         {
140 140
             VetConsultValidation.validateSendBody(body);
141
+            AiConsultValidation.validateAiCategory(body.getAiCategory());
141 142
             String displayName = resolveDisplayName(askerUserId, askerName);
142 143
             BizConsultMessage userMessage = persistUserMessage(session, body, askerUserId, displayName);
143 144
 
@@ -154,6 +155,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
154 155
             AiConsultSendResult result = new AiConsultSendResult();
155 156
             result.setUserMessage(userMessage);
156 157
             result.setAiMessage(aiMessage);
158
+            result.setRealSessionId(session.getRealSessionId());
157 159
             return result;
158 160
         }
159 161
         finally
@@ -192,6 +194,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
192 194
         message.setMsgType(body.getMsgType());
193 195
         message.setContent(content);
194 196
         message.setMediaDuration(body.getMediaDuration());
197
+        message.setAiCategory(AiConsultValidation.normalizeAiCategory(body.getAiCategory()));
195 198
         message.setSendTime(now);
196 199
         message.setCreateTime(now);
197 200
         bizConsultMessageMapper.insertBizConsultMessage(message);

+ 35 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/support/AiConsultValidation.java

@@ -24,4 +24,39 @@ public final class AiConsultValidation
24 24
             }
25 25
         }
26 26
     }
27
+
28
+    /**
29
+     * AI 提问分类(可为空);非空时长度不超过 32,且须为 1~4 或对应文案编码。
30
+     */
31
+    public static void validateAiCategory(String aiCategory)
32
+    {
33
+        if (StringUtils.isEmpty(aiCategory))
34
+        {
35
+            return;
36
+        }
37
+        String trimmed = aiCategory.trim();
38
+        if (trimmed.length() > 32)
39
+        {
40
+            throw new ServiceException("AI提问分类长度不能超过32个字符");
41
+        }
42
+        if (trimmed.matches("^\\d+$"))
43
+        {
44
+            int code = Integer.parseInt(trimmed);
45
+            if (code < AiConsultConstants.CATEGORY_BREEDING
46
+                    || code > AiConsultConstants.CATEGORY_EQUIPMENT)
47
+            {
48
+                throw new ServiceException("AI提问分类无效");
49
+            }
50
+        }
51
+    }
52
+
53
+    public static String normalizeAiCategory(String aiCategory)
54
+    {
55
+        if (StringUtils.isEmpty(aiCategory))
56
+        {
57
+            return null;
58
+        }
59
+        String trimmed = aiCategory.trim();
60
+        return trimmed.isEmpty() ? null : trimmed;
61
+    }
27 62
 }

+ 5 - 3
baqing-admin/src/main/resources/mapper/diagnosis/BizConsultMessageMapper.xml

@@ -11,13 +11,14 @@
11 11
         <result property="msgType"       column="msg_type"/>
12 12
         <result property="content"       column="content"/>
13 13
         <result property="mediaDuration" column="media_duration"/>
14
+        <result property="aiCategory"    column="ai_category"/>
14 15
         <result property="sendTime"      column="send_time"/>
15 16
         <result property="createTime"    column="create_time"/>
16 17
     </resultMap>
17 18
 
18 19
     <select id="selectMessagesBeforeId" resultMap="BizConsultMessageResult">
19 20
         select id, session_id, sender_role, sender_user_id, sender_name, msg_type, content, media_duration,
20
-               send_time, create_time
21
+               ai_category, send_time, create_time
21 22
         from biz_consult_message
22 23
         where session_id = #{sessionId}
23 24
         <if test="beforeId != null">
@@ -30,10 +31,11 @@
30 31
     <insert id="insertBizConsultMessage" parameterType="com.ruoyi.web.modules.diagnosis.domain.BizConsultMessage"
31 32
             useGeneratedKeys="true" keyProperty="id">
32 33
         insert into biz_consult_message (
33
-            session_id, sender_role, sender_user_id, sender_name, msg_type, content, media_duration, send_time, create_time
34
+            session_id, sender_role, sender_user_id, sender_name, msg_type, content, media_duration, ai_category,
35
+            send_time, create_time
34 36
         ) values (
35 37
             #{sessionId}, #{senderRole}, #{senderUserId}, #{senderName}, #{msgType}, #{content}, #{mediaDuration},
36
-            #{sendTime}, #{createTime}
38
+            #{aiCategory}, #{sendTime}, #{createTime}
37 39
         )
38 40
     </insert>
39 41
 </mapper>

+ 18 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/diagnosis/service/impl/BizAiOnlineConsultServiceImplTest.java

@@ -150,6 +150,24 @@ class BizAiOnlineConsultServiceImplTest
150 150
         verify(sessionMapper).updateRealSessionId(AiOnlineConsultTestSamples.SESSION_ID, "gateway-session-001");
151 151
     }
152 152
 
153
+    @Test
154
+    @DisplayName("提问消息保存 ai_category")
155
+    void sendWithAiCategory()
156
+    {
157
+        when(sessionMapper.selectAiSessionById(AiOnlineConsultTestSamples.SESSION_ID))
158
+                .thenReturn(AiOnlineConsultTestSamples.activeAiSession());
159
+        VetConsultSendBody body = new VetConsultSendBody();
160
+        body.setMsgType(ConsultSessionRules.MSG_TYPE_TEXT);
161
+        body.setContent("牛不吃草");
162
+        body.setAiReplyContent("请先观察体温");
163
+        body.setAiCategory("2");
164
+        service.sendAiMessage(AiOnlineConsultTestSamples.SESSION_ID, body,
165
+                AiOnlineConsultTestSamples.VA_USER_ID, "甲");
166
+        verify(messageMapper).insertBizConsultMessage(argThat(m -> m != null
167
+                && "2".equals(m.getAiCategory())
168
+                && Integer.valueOf(ConsultSessionRules.SENDER_ROLE_ASKER).equals(m.getSenderRole())));
169
+    }
170
+
153 171
     @Test
154 172
     @DisplayName("处理中拒发")
155 173
     void processingLock()

+ 9 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/diagnosis/support/AiConsultValidationTest.java

@@ -22,6 +22,15 @@ class AiConsultValidationTest
22 22
         assertEquals("请输入筛选条件", ex.getMessage());
23 23
     }
24 24
 
25
+    @Test
26
+    @DisplayName("AI提问分类非法")
27
+    void invalidAiCategory()
28
+    {
29
+        ServiceException ex = assertThrows(ServiceException.class,
30
+                () -> AiConsultValidation.validateAiCategory("9"));
31
+        assertEquals("AI提问分类无效", ex.getMessage());
32
+    }
33
+
25 34
     @Test
26 35
     @DisplayName("发送校验复用")
27 36
     void sendValidation()

+ 19 - 16
doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)前端技术方案.md

@@ -1,6 +1,6 @@
1 1
 # AI 诊断(兽医、机构)— 前端技术方案
2 2
 
3
-> 依据:`AI诊断(兽医、机构)功能需求.md`、`AI诊断(兽医、机构)技术方案.md`(v1.1);实现文件 `ruoyi-ui/src/views/diseaseTreatment/aiDiagnosis/index.vue`;布局参考同目录在线接诊(左右分栏 IM)。
3
+> 依据:`AI诊断(兽医、机构)功能需求.md`、`AI诊断(兽医、机构)技术方案.md`(**v1.5**);实现文件 `ruoyi-ui/src/views/diseaseTreatment/onlineConsult/ai/index.vue`;布局参考同目录在线接诊(左右分栏 IM)。
4 4
 
5 5
 ---
6 6
 
@@ -8,10 +8,10 @@
8 8
 
9 9
 | 项 | 说明 |
10 10
 | --- | --- |
11
-| **Vue 路径** | `ruoyi-ui/src/views/diseaseTreatment/aiDiagnosis/index.vue` |
12
-| **组件名** | `AiDiagnosis` |
13
-| **菜单配置(示例)** | 组件路径:`diseaseTreatment/aiDiagnosis/index`(与后端方案 `onlineConsult/ai/index` 二选一,以若依菜单实际配置为准) |
14
-| **权限前缀** | `diseaseTreatment:aiOnlineConsult:list`、`query`、`add`(发送走大模型直连,**未使用** `send` 按钮权限) |
11
+| **Vue 路径** | `ruoyi-ui/src/views/diseaseTreatment/onlineConsult/ai/index.vue` |
12
+| **组件名** | 以若依菜单 `component` 配置为准 |
13
+| **菜单配置(示例)** | 组件路径:`diseaseTreatment/onlineConsult/ai/index` |
14
+| **权限前缀** | `diseaseTreatment:aiOnlineConsult:list`、`query`、`add`(发送走大模型直连 + 落库,**未使用** `send` 按钮权限) |
15 15
 
16 16
 **准入**:列表/新建/历史消息仅需登录与 `diseaseTreatment:aiOnlineConsult:*` 权限;后端**不校验**是否绑定兽医/机构医疗资源。
17 17
 
@@ -26,12 +26,12 @@
26 26
 | 会话列表 | 若依 `request` → `GET /diseaseTreatment/onlineConsult/ai/session/list` | 含 `disclaimer` |
27 27
 | 新建会话 | `POST .../session` | 返回 `id`/`sessionId`、`disclaimer` |
28 28
 | 历史消息 | `GET .../session/{id}/messages` | 打开会话、上拉加载更早 |
29
-| **提问与 AI 回复** | **`axios` 直连** `{VUE_APP_LLM_BASE_URL}/v1/chat/completions` | 对话框问答 |
30
-| **会话落库** | `POST .../session/{id}/message`,Body 含 `aiReplyContent` | 大模型返回后落库,**不**再调后端 AI 网关 |
29
+| **提问与 AI 回复** | **`streamChatMessage`**(`utils/llmStreamChat.js`)→ `{VUE_APP_LLM_BASE_URL}/v1/chat/completions` | 对话框问答,支持 SSE 流式;失败可回退非流式 |
30
+| **会话落库** | `POST .../session/{id}/message` | Body:`msgType`、`content`、`aiReplyContent`、`realSessionId?`、`aiCategory?`;大模型返回后落库,**不**再调后端 AI 网关 |
31 31
 | 媒体上传 | `POST /common/upload` | 先上传再随提问送 LLM |
32 32
 
33
-> **与后端技术方案差异**:后端 **§4.4** 约定 `POST /session/{sessionId}/message` 由 `ConsultAiGateway` 落库并返回 `userMessage` + `aiMessage`。前端已封装 `sendAiConsultMessage`(`aiOnlineConsult.js`),**当前页面未调用**;后续可切换为全后端通道以支持落库、推送与 60s 同步策略
34
-> **大模型上下文**:`session.llmSessionId` 存网关返回的 `session_id`,与后端会话主键 `activeSessionId` 分离。
33
+> **与后端技术方案**:后端 **§4.4** 同时支持全后端 `ConsultAiGateway` 通道与前端 `aiReplyContent` 落库通道。当前页面采用后者
34
+> **大模型上下文**:`session.llmSessionId` / `realSessionId` 存网关返回的 `session_id`,与后端会话主键 `activeSessionId` 分离;落库时通过 `realSessionId` 写入 `biz_consult_session.real_session_id`
35 35
 
36 36
 ---
37 37
 
@@ -47,7 +47,7 @@
47 47
 | `listAiConsultSessions` | GET `/session/list` | ✓ |
48 48
 | `createAiConsultSession` | POST `/session` | ✓ |
49 49
 | `listAiConsultMessages` | GET `/session/{sessionId}/messages` | ✓ |
50
-| `sendAiConsultMessage` | POST `/session/{sessionId}/message`(timeout 65s,`repeatSubmit: false`);Body 含 `msgType`、`content`、`aiReplyContent` | ✓(落库) |
50
+| `sendAiConsultMessage` | POST `/session/{sessionId}/message`(timeout 65s,`repeatSubmit: false`);Body 含 `msgType`、`content`、`aiReplyContent`、`realSessionId?`、`aiCategory?` | ✓(落库) |
51 51
 
52 52
 **列表 Query(小驼峰)**:`pageNum`(默认 1)、`pageSize`(默认 100)、`contentKeyword`、`searchMode`(仅点击搜索为 `true`)。
53 53
 
@@ -64,7 +64,8 @@
64 64
 | **鉴权(直连上游)** | `Authorization: Bearer {VUE_APP_LLM_API_KEY}` |
65 65
 | **鉴权(经 8010 转发)** | **`Authorization: Bearer {getToken()}`**(若依登录 Token,与 `request.js` 一致);**不能**仅用 `VUE_APP_LLM_API_KEY` |
66 66
 | **超时** | 120000 ms |
67
-| **Body** | `model`、`messages[]`、`stream: false`、`user`;可选 `session_id`(`llmSessionId`) |
67
+| **Body** | `model`、`messages[]`、`stream: true`(优先流式)、`user`;可选 `session_id`(`llmSessionId`) |
68
+| **流式解析** | `utils/sseParse.js`、`utils/llmResponseParse.js`(提取 `session_id`、增量 `content`) |
68 69
 | **后端转发(可选)** | `VUE_APP_LLM_BASE_URL` → `http://localhost:8010`;`KbOpenAiProxyController` 须登录,详见 `doc/大模型/大模型网关转发接口说明.md` §3、§6 |
69 70
 
70 71
 **模型选项(前端常量)**
@@ -99,14 +100,14 @@
99 100
 | 会话主键 | `resolveSessionId(s)`:`id` 优先,否则 `sessionId` |
100 101
 | 消息角色 | `senderRole`:`1` 提问人(右)、`3` AI(左) |
101 102
 | 消息类型 | `msgType`:`1` 文本、`2` 图片、`3` 视频、`4` 语音 |
102
-| 发送流程 | 先本地展示提问 → `axios` 调 LLM → 本地展示 AI 回复 → `sendAiConsultMessage`(`aiReplyContent`)落库并合并服务端消息 ID |
103
+| 发送流程 | 先本地展示提问 → `streamChatMessage` 调 LLM → 本地展示 AI 回复 → `sendAiConsultMessage`(`aiReplyContent`、`realSessionId`、`aiCategory`)落库并合并服务端消息 ID |
103 104
 | 思考中 | `sending` 且最后一条为 `senderRole === 1` 时显示占位气泡 |
104 105
 | 上拉历史 | `scrollTop < 40` 时用 `beforeId` 加载更早 50 条 |
105 106
 | 轮询 | 选中会话后 **2s** 仅 `refreshSessionListQuiet`(**不**轮询消息,避免覆盖仅存在本地的 LLM 对话) |
106 107
 | 媒体限制 | 图 jpg/jpeg/png/gif ≤10MB;视频 mp4/mov ≤50MB;语音 mp3/m4a/wav ≤10MB;文件名不含英文逗号 |
107 108
 | 本地消息 ID | 前缀 `m_`(`genLocalId`),与服务端数字 ID 区分 |
108 109
 
109
-**数据持久化说明**:大模型返回后调用 `POST .../message` 并传 `aiReplyContent`,服务端仅落库、不调 `ConsultAiGateway`;落库失败时界面仍保留本地气泡并提示「保存会话失败」。
110
+**数据持久化说明**:大模型返回后调用 `POST .../message` 并传 `aiReplyContent`、可选 `realSessionId`(大模型 `session_id`)、`aiCategory`(提问分类);服务端仅落库、不调 `ConsultAiGateway`;响应 `data.realSessionId` 可与本地 `llmSessionId` 对齐。落库失败时界面仍保留本地气泡并提示「保存会话失败」。
110 111
 
111 112
 ---
112 113
 
@@ -136,8 +137,9 @@
136 137
 
137 138
 | 路径 | 说明 |
138 139
 | --- | --- |
139
-| `src/views/diseaseTreatment/aiDiagnosis/index.vue` | 主页面(IM + LLM 发送) |
140
-| `src/api/diseaseTreatment/aiOnlineConsult.js` | 若依会话/消息 API(含预留 `sendAiConsultMessage`) |
140
+| `src/views/diseaseTreatment/onlineConsult/ai/index.vue` | 主页面(IM + LLM 流式发送 + 落库) |
141
+| `src/api/diseaseTreatment/aiOnlineConsult.js` | 若依会话/消息 API |
142
+| `src/utils/llmStreamChat.js`、`sseParse.js`、`llmResponseParse.js` | 大模型流式与响应解析 |
141 143
 | `src/mixins/diseaseTreatmentLocaleMixin.js` | 文案 |
142 144
 | `src/lang/zh/diseaseTreatment.js` | 中文 `aiOnlineConsult` |
143 145
 | `src/lang/bo/diseaseTreatment.js` | 藏文 `aiOnlineConsult` |
@@ -160,4 +162,5 @@
160 162
 
161 163
 | 版本 | 说明 |
162 164
 | --- | --- |
163
-| 1.0 | 初稿:混合架构(会话后端 + 对话 LLM 直连);对齐 `aiDiagnosis/index.vue` 实现 |
165
+| 1.0 | 初稿:混合架构(会话后端 + 对话 LLM 直连);对齐 `onlineConsult/ai/index.vue` 实现 |
166
+| 1.1 | 落库通道:`sendAiConsultMessage` + `aiReplyContent`;`realSessionId`、`aiCategory`;LLM SSE 流式 |

+ 59 - 15
doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)技术方案.md

@@ -1,7 +1,7 @@
1 1
 # AI 诊断(兽医、机构)— 技术方案
2 2
 
3 3
 > 依据:`AI诊断(兽医、机构)功能需求.md`(v1.1);关联 `在线接诊(兽医)技术方案.md`(v1.1)、`在线接诊(兽医)功能需求.md`  
4
-> **本文档版本:1.4**(管理端隐藏 AI 诊断会话 `POST .../hide`,见 **§4.5**;`/v1` 见 **§1.2**)
4
+> **本文档版本:1.5**(`real_session_id`、`ai_category` 字段与落库接口 Body 同步,见 **§3.1**、**§3.2**、**§4.3**)
5 5
 
6 6
 ---
7 7
 
@@ -120,6 +120,7 @@ Payload 同在线接诊 **§4.2**(`senderRole`:1 提问人,**3** AI 助手
120 120
 | `receiver_provider_id` | **NULL**(禁止写入兽医/机构资源 ID) |
121 121
 | `receiver_name` | **NULL** |
122 122
 | `session_title` | 新建时 **「新会话」**;首条有效文本提问后更新为摘要(≤50 字) |
123
+| `real_session_id` | **varchar(64)**,可为空;大模型网关 `session_id`,由 `POST .../message` 写入/更新,供多轮 `session_id` 回传 |
123 124
 | `last_message_time` / `last_message_preview` | 每次提问或 AI 回复后更新 |
124 125
 | `vet_visible` | 默认 **1**;提问人 `hide` 后置 **0** |
125 126
 
@@ -136,6 +137,12 @@ Payload 同在线接诊 **§4.2**(`senderRole`:1 提问人,**3** AI 助手
136 137
 
137 138
 在线接诊使用的 **2**(接诊兽医)在 AI 会话中**不出现**。
138 139
 
140
+| 字段 | 说明 |
141
+| --- | --- |
142
+| `ai_category` | **varchar(32)**,可为空;仅 **提问人消息**(`sender_role=1`)写入;AI 回复为 `NULL`。纯数字时须为 `1`~`4`(与 `AiConsultConstants` 分类一致),亦可存业务编码字符串 |
143
+
144
+**已有库迁移**:`sql/alter_biz_consult_session_real_session_id.sql`、`sql/alter_biz_consult_message_ai_category.sql`。
145
+
139 146
 ### 3.3 AI 列表查询语义
140 147
 
141 148
 **默认列表**(`searchMode=false`):
@@ -208,19 +215,21 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
208 215
 | 4.1 | 历史会话列表 | GET | `/session/list` | `...:list` | Query:`pageNum`、`pageSize`(默认 20)、`contentKeyword`、`searchMode`;`asker_user_id` 服务端注入 |
209 216
 | 4.2 | 新增会话 | POST | `/session` | `...:add` | 创建 `consult_type=2` 空会话;`session_title=新会话`;`last_message_time=create_time`(**§3.5 #1**);返回 `sessionId`、`disclaimer` |
210 217
 | 4.3 | 历史消息 | GET | `/session/{sessionId}/messages` | `...:query` | 校验 `consult_type=2` + `asker_user_id` + `vet_visible=1`;`beforeId`、`pageSize`(默认 50) |
211
-| 4.4 | 提问(触发 AI) | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`、`content`、`mediaDuration?`;流程见 **§5** |
218
+| 4.4 | 提问(触发 AI) | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`、`content`、`mediaDuration?`、`aiReplyContent?`、`realSessionId?`、`aiCategory?`;流程见 **§5** |
212 219
 | 4.5 | 隐藏会话 | POST | `/session/{sessionId}/hide` | `...:remove` | 管理端隐藏 AI 诊断会话;`vet_visible=0`;仅本人会话;隐藏后列表/消息/提问不可用 |
213 220
 
214 221
 ### 4.1 列表 `rows[]` 字段
215 222
 
216
-`sessionId`、`sessionTitle`、`lastMessagePreview`、`lastMessageTime`(前端格式化为「M月D日」)。
223
+`sessionId`、`sessionTitle`、`lastMessagePreview`、`lastMessageTime`、`realSessionId`(大模型网关会话 ID,可为空)。
217 224
 
218 225
 ### 4.2 消息 `rows[]` / 推送字段
219 226
 
220
-`messageId`、`sessionId`、`senderRole`(1 提问人 / **3** AI)、`senderName`、`msgType`、`content`、`mediaDuration`、`sendTime`。
227
+`messageId`、`sessionId`、`senderRole`(1 提问人 / **3** AI)、`senderName`、`msgType`、`content`、`mediaDuration`、`aiCategory`(可为空)、`sendTime`。
221 228
 
222 229
 ### 4.3 提问 Body 示例
223 230
 
231
+**服务端调 `ConsultAiGateway`(无 `aiReplyContent`)**
232
+
224 233
 ```json
225 234
 {
226 235
   "msgType": 1,
@@ -228,6 +237,37 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
228 237
 }
229 238
 ```
230 239
 
240
+**前端大模型直连后落库(推荐)**
241
+
242
+```json
243
+{
244
+  "msgType": 1,
245
+  "content": "牛不吃草、精神沉郁,请问可能原因?",
246
+  "aiReplyContent": "可能原因包括…",
247
+  "realSessionId": "gateway-session-001",
248
+  "aiCategory": "2"
249
+}
250
+```
251
+
252
+| Body 字段 | 类型 | 必填 | 说明 |
253
+| --- | --- | --- | --- |
254
+| `msgType` | int | Y | 1 文本 2 图 3 视频 4 语音 |
255
+| `content` | string | 条件 | 文本或媒体 URL |
256
+| `mediaDuration` | int | N | 音视频秒数 |
257
+| `aiReplyContent` | string | N | 非空时服务端**仅落库**,不调 `ConsultAiGateway` |
258
+| `realSessionId` | string | N | 大模型 `session_id`;写入 `biz_consult_session.real_session_id`(别名 `real_session_id`、`session_id`、`llmSessionId`) |
259
+| `aiCategory` | string | N | 写入提问人消息 `ai_category`(别名 `ai_category`、`category`);最长 32 |
260
+
261
+**提问成功 `data`**
262
+
263
+```json
264
+{
265
+  "userMessage": { "id": 1, "senderRole": 1, "aiCategory": "2", "...": "..." },
266
+  "aiMessage": { "id": 2, "senderRole": 3, "...": "..." },
267
+  "realSessionId": "gateway-session-001"
268
+}
269
+```
270
+
231 271
 ### 4.4 业务错误(`msg` 示例)
232 272
 
233 273
 | 场景 | 文案 |
@@ -241,6 +281,7 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
241 281
 | AI 超时 | AI 回复超时,请稍后重试 |
242 282
 | AI 失败 | AI 回复失败,请稍后重试 |
243 283
 | AI 处理中 | AI 正在回复中,请稍候 |
284
+| AI 提问分类无效 | AI提问分类无效 / AI提问分类长度不能超过32个字符 |
244 285
 
245 286
 ---
246 287
 
@@ -249,20 +290,22 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
249 290
 ```text
250 291
 1. 校验会话归属(type=2, asker=当前用户, vet_visible=1)
251 292
 2. 若会话 AI 处理中 → 拒绝(§3.5 #7)
252
-3. 校验 Body(VetConsultValidation)
253
-4. 标记会话 processing=1(内存/Redis 锁)
254
-5. INSERT 提问消息(sender_role=1)
255
-6. 更新 session.last_message_*(提问侧摘要)
256
-7. 推送 NEW_MESSAGE(提问)→ 前端立即展示
257
-8. 调用 ConsultAiGateway(≤60s,单轮 §3.5 #6):
293
+3. 校验 Body(VetConsultValidation、AiConsultValidation.validateAiCategory)
294
+4. 若 Body.realSessionId 非空 → 更新 biz_consult_session.real_session_id
295
+5. 标记会话 processing=1(内存/Redis 锁)
296
+6. INSERT 提问消息(sender_role=1,含 ai_category 若有)
297
+7. 更新 session.last_message_*(提问侧摘要)
298
+8. 推送 NEW_MESSAGE(提问)→ 前端立即展示
299
+9a. 若 aiReplyContent 非空:直接作为 AI 文本,跳过 ConsultAiGateway(前端直连大模型落库通道)
300
+9b. 否则调用 ConsultAiGateway(≤60s,单轮 §3.5 #6):
258 301
    - msg_type=1:文本问答
259 302
    - msg_type=2:图文;失败 → §3.5 #4 固定 AI 文案仍入库
260 303
    - msg_type=3/4:§3.5 #3 固定引导文案入库
261
-9. 成功:INSERT AI 消息(sender_role=3
262
-10. 更新 session.last_message_*;首问更新 session_title
263
-11. 推送 NEW_MESSAGE(AI 回复,落库后 ≤1s)
264
-12. 释放 processing;响应 data:{ userMessage, aiMessage }
265
-13. 网关硬失败(非图片软失败):不写 AI 消息;HTTP 错误 + §4.4 文案
304
+10. 成功:INSERT AI 消息(sender_role=3,ai_category=NULL
305
+11. 更新 session.last_message_*;首问更新 session_title
306
+12. 推送 NEW_MESSAGE(AI 回复,落库后 ≤1s)
307
+13. 释放 processing;响应 data:{ userMessage, aiMessage, realSessionId? }
308
+14. 网关硬失败(非图片软失败且无 aiReplyContent):不写 AI 消息;HTTP 错误 + §4.4 文案
266 309
 ```
267 310
 
268 311
 ### 5.1 同步、超时与展示策略(v1.1 已确认)
@@ -379,3 +422,4 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
379 422
 | 1.2 | 补充 §1.2:`KbOpenAiProxyController` 转发 `/v1/models`、`/v1/chat/completions`;区分对话框直连/转发与 `ConsultAiGateway` 兜底 |
380 423
 | 1.3 | §1.2:`/v1/**` 须若依 Token,取消匿名访问 |
381 424
 | 1.4 | 管理端 **§4.5** `POST /session/{id}/hide` 隐藏 AI 诊断会话;权限 `...:remove` |
425
+| 1.5 | **§3.1** `biz_consult_session.real_session_id`;**§3.2** `biz_consult_message.ai_category`(varchar32);**§4.3** 落库 Body/响应字段;**§5** 前端 `aiReplyContent` 落库通道 |

+ 3 - 0
doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)测试用例.md

@@ -97,6 +97,9 @@
97 97
 | ZCZX-AIZD-API-038 | 管理端 | 隐藏会话 | 接口测试 | Postman | §4.5、AI-04 | VA;AS0 | `POST /diseaseTreatment/onlineConsult/ai/session/{id}/hide` | `code=200`;列表无该会话 |
98 98
 | ZCZX-AIZD-API-039 | 管理端 | 隐藏越权 | 接口测试 | Postman | §4.1.1 | ASX 属 VB | VA `POST .../hide` | 「会话不存在或已删除」 |
99 99
 | ZCZX-AIZD-API-040 | 权限 | 无 remove | 接口测试 | Postman | §4.5 | 仅 list/query/add | `POST .../hide` | 无权限 |
100
+| ZCZX-AIZD-API-041 | 落库 | realSessionId | 接口测试 | Postman | §4.3、§5 | VA;AS0 | `POST .../message` Body 含 `aiReplyContent` + `realSessionId` | `code=200`;`data.realSessionId` 与库 `real_session_id` 一致 |
101
+| ZCZX-AIZD-API-042 | 落库 | aiCategory | 接口测试 | Postman | §3.2、§4.3 | VA;AS0 | `POST .../message` Body `aiCategory=2` + `aiReplyContent` | `userMessage.aiCategory=2`;`aiMessage.aiCategory` 为空 |
102
+| ZCZX-AIZD-API-043 | 校验 | aiCategory 无效 | 接口测试 | Postman | §4.4 | VA;AS0 | `aiCategory=99`(纯数字超范围) | 失败;「AI提问分类无效」 |
100 103
 
101 104
 ---
102 105
 

+ 2 - 0
doc/牧业疫病诊疗服务/在线接诊(兽医)/在线接诊(兽医)技术方案.md

@@ -102,6 +102,7 @@ AI 回复消息由异步任务调用大模型后 `INSERT`(`sender_role=AI`)
102 102
 | `receiver_provider_id` | `bigint(20)` | N | 兽医/专家 `biz_medical_resource.id`(AI 为 NULL) |
103 103
 | `receiver_name` | `varchar(64)` | N | 接诊人姓名快照 |
104 104
 | `session_title` | `varchar(200)` | N | 问题/问答标题(AI 列表主标题) |
105
+| `real_session_id` | `varchar(64)` | N | 大模型网关 `session_id`(AI 诊断落库写入,见 AI 诊断方案 §3.1) |
105 106
 | `last_message_time` | `datetime` | N | 最后一条消息时间(列表排序、3 个月窗) |
106 107
 | `last_message_preview` | `varchar(500)` | N | 列表摘要(文本截断或 `[图片]` 等) |
107 108
 | `vet_visible` | `tinyint(4)` | Y | **1**兽医可见 **0**牧民删除后对兽医隐藏(仅 `consult_type=1` 使用) |
@@ -130,6 +131,7 @@ AI 回复消息由异步任务调用大模型后 `INSERT`(`sender_role=AI`)
130 131
 | `msg_type` | `tinyint(4)` | Y | **1**文本 **2**图片 **3**视频 **4**语音 |
131 132
 | `content` | `text` | N | 文本内容或媒体 URL |
132 133
 | `media_duration` | `int(11)` | N | 语音/视频时长(秒,可选) |
134
+| `ai_category` | `varchar(32)` | N | AI 提问分类(仅 AI 诊断提问人消息,见 AI 诊断方案 §3.2) |
133 135
 | `send_time` | `datetime` | Y | 发送时间 |
134 136
 | `create_time` | `datetime` | N | 创建时间 |
135 137
 

+ 1 - 0
sql/biz_consult_message.sql

@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS `biz_consult_message` (
8 8
   `msg_type` tinyint(4) NOT NULL COMMENT '1文本2图片3视频4语音',
9 9
   `content` text COMMENT '文本或媒体URL',
10 10
   `media_duration` int(11) DEFAULT NULL COMMENT '音视频时长秒',
11
+  `ai_category` varchar(32) DEFAULT NULL COMMENT 'AI提问分类',
11 12
   `send_time` datetime NOT NULL COMMENT '发送时间',
12 13
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
13 14
   PRIMARY KEY (`id`),