Procházet zdrojové kódy

消息增加ai提问分类

wwh před 2 týdny
rodič
revize
077ffa9fe2
18 změnil soubory, kde provedl 308 přidání a 23 odebrání
  1. 19 0
      baqing-admin/src/main/java/com/ruoyi/web/kb/support/KbOpenAiProxyHeaders.java
  2. 13 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/BizConsultMessage.java
  3. 48 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/BizDiagnosisUser.java
  4. 16 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/VetConsultSendBody.java
  5. 17 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/mapper/BizDiagnosisUserMapper.java
  6. 43 3
      baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/service/impl/BizAiOnlineConsultServiceImpl.java
  7. 20 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/support/AiConsultValidation.java
  8. 4 3
      baqing-admin/src/main/resources/mapper/diagnosis/BizConsultMessageMapper.xml
  9. 1 1
      baqing-admin/src/main/resources/mapper/diagnosis/BizConsultSessionMapper.xml
  10. 27 0
      baqing-admin/src/main/resources/mapper/diagnosis/BizDiagnosisUserMapper.xml
  11. 1 3
      baqing-admin/src/test/java/com/ruoyi/web/kb/KbOpenAiProxyControllerApiTest.java
  12. 40 0
      baqing-admin/src/test/java/com/ruoyi/web/modules/diagnosis/service/impl/BizAiOnlineConsultServiceImplTest.java
  13. 12 2
      doc/大屏/畜牧资源/大屏畜牧资源功能需求-草稿.md
  14. 29 8
      doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)技术方案.md
  15. 8 2
      doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)测试用例.md
  16. 2 1
      doc/牧业疫病诊疗服务/在线接诊(兽医)/在线接诊(兽医)技术方案.md
  17. 1 0
      sql/biz_consult_message.sql
  18. 7 0
      sql/biz_diagnosis_user.sql

+ 19 - 0
baqing-admin/src/main/java/com/ruoyi/web/kb/support/KbOpenAiProxyHeaders.java

@@ -0,0 +1,19 @@
1
+package com.ruoyi.web.kb.support;
2
+
3
+/**
4
+ * 大模型 OpenAI 兼容转发响应头。
5
+ */
6
+public final class KbOpenAiProxyHeaders
7
+{
8
+    /** 本次对话请求耗时(毫秒) */
9
+    public static final String X_COST = "X-COST";
10
+
11
+    private KbOpenAiProxyHeaders()
12
+    {
13
+    }
14
+
15
+    public static String formatCostMs(long elapsedMs)
16
+    {
17
+        return String.valueOf(Math.max(0L, elapsedMs));
18
+    }
19
+}

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

@@ -30,6 +30,9 @@ public class BizConsultMessage extends BaseEntity
30 30
     /** AI 提问分类,可为空(最长 32 字符) */
31 31
     private String aiCategory;
32 32
 
33
+    /** 大模型对话耗时(毫秒),可为空 */
34
+    private Long costTime;
35
+
33 36
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
34 37
     private Date sendTime;
35 38
 
@@ -123,6 +126,16 @@ public class BizConsultMessage extends BaseEntity
123 126
         this.aiCategory = aiCategory;
124 127
     }
125 128
 
129
+    public Long getCostTime()
130
+    {
131
+        return costTime;
132
+    }
133
+
134
+    public void setCostTime(Long costTime)
135
+    {
136
+        this.costTime = costTime;
137
+    }
138
+
126 139
     public Date getSendTime()
127 140
     {
128 141
         return sendTime;

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

@@ -0,0 +1,48 @@
1
+package com.ruoyi.web.modules.diagnosis.domain;
2
+
3
+import java.util.Date;
4
+import com.fasterxml.jackson.annotation.JsonFormat;
5
+
6
+/**
7
+ * AI 诊断使用用户 biz_diagnosis_user
8
+ */
9
+public class BizDiagnosisUser
10
+{
11
+    private Long userId;
12
+
13
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
14
+    private Date firstUseTime;
15
+
16
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
17
+    private Date lastUseTime;
18
+
19
+    public Long getUserId()
20
+    {
21
+        return userId;
22
+    }
23
+
24
+    public void setUserId(Long userId)
25
+    {
26
+        this.userId = userId;
27
+    }
28
+
29
+    public Date getFirstUseTime()
30
+    {
31
+        return firstUseTime;
32
+    }
33
+
34
+    public void setFirstUseTime(Date firstUseTime)
35
+    {
36
+        this.firstUseTime = firstUseTime;
37
+    }
38
+
39
+    public Date getLastUseTime()
40
+    {
41
+        return lastUseTime;
42
+    }
43
+
44
+    public void setLastUseTime(Date lastUseTime)
45
+    {
46
+        this.lastUseTime = lastUseTime;
47
+    }
48
+}

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

@@ -30,6 +30,12 @@ public class VetConsultSendBody
30 30
     @JsonAlias({ "ai_category", "category" })
31 31
     private String aiCategory;
32 32
 
33
+    /**
34
+     * 大模型对话耗时(毫秒),写入 AI 回复消息 cost_time,可为空。
35
+     */
36
+    @JsonAlias({ "cost_time", "xCost" })
37
+    private Long costTime;
38
+
33 39
     public Integer getMsgType()
34 40
     {
35 41
         return msgType;
@@ -89,4 +95,14 @@ public class VetConsultSendBody
89 95
     {
90 96
         this.aiCategory = aiCategory;
91 97
     }
98
+
99
+    public Long getCostTime()
100
+    {
101
+        return costTime;
102
+    }
103
+
104
+    public void setCostTime(Long costTime)
105
+    {
106
+        this.costTime = costTime;
107
+    }
92 108
 }

+ 17 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/mapper/BizDiagnosisUserMapper.java

@@ -0,0 +1,17 @@
1
+package com.ruoyi.web.modules.diagnosis.mapper;
2
+
3
+import java.util.Date;
4
+import org.apache.ibatis.annotations.Param;
5
+import com.ruoyi.web.modules.diagnosis.domain.BizDiagnosisUser;
6
+
7
+/**
8
+ * AI 诊断使用用户 Mapper。
9
+ */
10
+public interface BizDiagnosisUserMapper
11
+{
12
+    BizDiagnosisUser selectByUserId(@Param("userId") Long userId);
13
+
14
+    int insertBizDiagnosisUser(BizDiagnosisUser row);
15
+
16
+    int updateLastUseTime(@Param("userId") Long userId, @Param("lastUseTime") Date lastUseTime);
17
+}

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

@@ -17,6 +17,8 @@ import com.ruoyi.web.modules.diagnosis.domain.BizConsultMessage;
17 17
 import com.ruoyi.web.modules.diagnosis.domain.BizConsultSession;
18 18
 import com.ruoyi.web.modules.diagnosis.domain.ConsultPushPayload;
19 19
 import com.ruoyi.web.modules.diagnosis.domain.VetConsultSendBody;
20
+import com.ruoyi.web.modules.diagnosis.domain.BizDiagnosisUser;
21
+import com.ruoyi.web.modules.diagnosis.mapper.BizDiagnosisUserMapper;
20 22
 import com.ruoyi.web.modules.diagnosis.mapper.BizConsultMessageMapper;
21 23
 import com.ruoyi.web.modules.diagnosis.mapper.BizConsultSessionMapper;
22 24
 import com.ruoyi.web.modules.diagnosis.service.IBizAiOnlineConsultService;
@@ -39,6 +41,9 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
39 41
     @Autowired
40 42
     private BizConsultSessionMapper bizConsultSessionMapper;
41 43
 
44
+    @Autowired
45
+    private BizDiagnosisUserMapper bizDiagnosisUserMapper;
46
+
42 47
     @Autowired
43 48
     private BizConsultMessageMapper bizConsultMessageMapper;
44 49
 
@@ -90,6 +95,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
90 95
         row.setCreateTime(now);
91 96
         row.setUpdateTime(now);
92 97
         bizConsultSessionMapper.insertBizConsultSession(row);
98
+        ensureDiagnosisUserOnNewSession(askerUserId, now);
93 99
 
94 100
         AiConsultSessionView view = new AiConsultSessionView();
95 101
         view.setId(row.getId());
@@ -139,6 +145,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
139 145
         {
140 146
             VetConsultValidation.validateSendBody(body);
141 147
             AiConsultValidation.validateAiCategory(body.getAiCategory());
148
+            AiConsultValidation.validateCostTime(body.getCostTime());
142 149
             String displayName = resolveDisplayName(askerUserId, askerName);
143 150
             BizConsultMessage userMessage = persistUserMessage(session, body, askerUserId, displayName);
144 151
 
@@ -148,7 +155,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
148 155
                 throw new ServiceException("AI 回复失败,请稍后重试");
149 156
             }
150 157
 
151
-            BizConsultMessage aiMessage = persistAiMessage(session, aiText);
158
+            BizConsultMessage aiMessage = persistAiMessage(session, aiText, body.getCostTime());
152 159
             updateSessionAfterAiReply(session, body, aiText);
153 160
             saveRealSessionIdIfPresent(session, body.getRealSessionId());
154 161
 
@@ -205,13 +212,13 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
205 212
         patch.setLastMessageTime(now);
206 213
         patch.setLastMessagePreview(preview);
207 214
         patch.setUpdateTime(now);
208
-        bizConsultSessionMapper.updateSessionLastMessage(patch);
215
+        updateSessionLastMessageAndUserLastUse(session, patch);
209 216
 
210 217
         consultPushService.publishNewMessage(buildPushPayload(message, session, preview, now));
211 218
         return message;
212 219
     }
213 220
 
214
-    private BizConsultMessage persistAiMessage(BizConsultSession session, String aiText)
221
+    private BizConsultMessage persistAiMessage(BizConsultSession session, String aiText, Long costTime)
215 222
     {
216 223
         Date now = new Date();
217 224
         BizConsultMessage message = new BizConsultMessage();
@@ -221,6 +228,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
221 228
         message.setSenderName(AiConsultConstants.AI_SENDER_NAME);
222 229
         message.setMsgType(ConsultSessionRules.MSG_TYPE_TEXT);
223 230
         message.setContent(aiText);
231
+        message.setCostTime(AiConsultValidation.normalizeCostTime(costTime));
224 232
         message.setSendTime(now);
225 233
         message.setCreateTime(now);
226 234
         bizConsultMessageMapper.insertBizConsultMessage(message);
@@ -246,7 +254,39 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
246 254
             String userPreview = ConsultMessagePreview.build(body.getMsgType(), body.getContent());
247 255
             patch.setSessionTitle(userPreview);
248 256
         }
257
+        updateSessionLastMessageAndUserLastUse(session, patch);
258
+    }
259
+
260
+    /**
261
+     * 更新会话最后消息时间,并同步提问人在 AI 诊断用户表的末次使用时间。
262
+     */
263
+    private void updateSessionLastMessageAndUserLastUse(BizConsultSession session, BizConsultSession patch)
264
+    {
249 265
         bizConsultSessionMapper.updateSessionLastMessage(patch);
266
+        if (session != null && session.getAskerUserId() != null && patch.getLastMessageTime() != null)
267
+        {
268
+            bizDiagnosisUserMapper.updateLastUseTime(session.getAskerUserId(), patch.getLastMessageTime());
269
+        }
270
+    }
271
+
272
+    /**
273
+     * 新建 AI 会话:用户表无记录则插入(首次/末次均为当前时间)。
274
+     */
275
+    private void ensureDiagnosisUserOnNewSession(Long askerUserId, Date now)
276
+    {
277
+        if (askerUserId == null || now == null)
278
+        {
279
+            return;
280
+        }
281
+        if (bizDiagnosisUserMapper.selectByUserId(askerUserId) != null)
282
+        {
283
+            return;
284
+        }
285
+        BizDiagnosisUser row = new BizDiagnosisUser();
286
+        row.setUserId(askerUserId);
287
+        row.setFirstUseTime(now);
288
+        row.setLastUseTime(now);
289
+        bizDiagnosisUserMapper.insertBizDiagnosisUser(row);
250 290
     }
251 291
 
252 292
     private String resolveAiReplyText(VetConsultSendBody body)

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

@@ -59,4 +59,24 @@ public final class AiConsultValidation
59 59
         String trimmed = aiCategory.trim();
60 60
         return trimmed.isEmpty() ? null : trimmed;
61 61
     }
62
+
63
+    /**
64
+     * 大模型耗时(毫秒,可为空);非空时须为非负且不超过 24 小时。
65
+     */
66
+    public static void validateCostTime(Long costTime)
67
+    {
68
+        if (costTime == null)
69
+        {
70
+            return;
71
+        }
72
+        if (costTime < 0L || costTime > 86_400_000L)
73
+        {
74
+            throw new ServiceException("对话耗时无效");
75
+        }
76
+    }
77
+
78
+    public static Long normalizeCostTime(Long costTime)
79
+    {
80
+        return costTime == null || costTime < 0L ? null : costTime;
81
+    }
62 82
 }

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

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

+ 1 - 1
baqing-admin/src/main/resources/mapper/diagnosis/BizConsultSessionMapper.xml

@@ -145,7 +145,7 @@
145 145
         update biz_consult_session
146 146
         set last_message_time = #{lastMessageTime},
147 147
             last_message_preview = #{lastMessagePreview},
148
-            update_time = #{updateTime}
148
+            update_time = sysdate()
149 149
             <if test="sessionTitle != null and sessionTitle != ''">
150 150
                 , session_title = #{sessionTitle}
151 151
             </if>

+ 27 - 0
baqing-admin/src/main/resources/mapper/diagnosis/BizDiagnosisUserMapper.xml

@@ -0,0 +1,27 @@
1
+<?xml version="1.0" encoding="UTF-8" ?>
2
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
3
+<mapper namespace="com.ruoyi.web.modules.diagnosis.mapper.BizDiagnosisUserMapper">
4
+
5
+    <resultMap type="com.ruoyi.web.modules.diagnosis.domain.BizDiagnosisUser" id="BizDiagnosisUserResult">
6
+        <id     property="userId"       column="user_id"/>
7
+        <result property="firstUseTime" column="first_use_time"/>
8
+        <result property="lastUseTime"  column="last_use_time"/>
9
+    </resultMap>
10
+
11
+    <select id="selectByUserId" resultMap="BizDiagnosisUserResult">
12
+        select user_id, first_use_time, last_use_time
13
+        from biz_diagnosis_user
14
+        where user_id = #{userId}
15
+    </select>
16
+
17
+    <insert id="insertBizDiagnosisUser" parameterType="com.ruoyi.web.modules.diagnosis.domain.BizDiagnosisUser">
18
+        insert into biz_diagnosis_user (user_id, first_use_time, last_use_time)
19
+        values (#{userId}, #{firstUseTime}, #{lastUseTime})
20
+    </insert>
21
+
22
+    <update id="updateLastUseTime">
23
+        update biz_diagnosis_user
24
+        set last_use_time = #{lastUseTime}
25
+        where user_id = #{userId}
26
+    </update>
27
+</mapper>

+ 1 - 3
baqing-admin/src/test/java/com/ruoyi/web/kb/KbOpenAiProxyControllerApiTest.java

@@ -1,6 +1,5 @@
1 1
 package com.ruoyi.web.kb;
2 2
 
3
-import static org.junit.jupiter.api.Assertions.assertEquals;
4 3
 import static org.junit.jupiter.api.Assertions.assertTrue;
5 4
 import static org.mockito.ArgumentMatchers.any;
6 5
 import static org.mockito.Mockito.doAnswer;
@@ -10,9 +9,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
10 9
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
11 10
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
12 11
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
13
-import java.io.ByteArrayOutputStream;
14
-import java.nio.charset.StandardCharsets;
15 12
 import java.io.OutputStream;
13
+import java.nio.charset.StandardCharsets;
16 14
 import org.springframework.mock.web.MockHttpServletRequest;
17 15
 import org.springframework.mock.web.MockHttpServletResponse;
18 16
 

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

@@ -31,6 +31,8 @@ import com.ruoyi.web.modules.diagnosis.domain.AiConsultOpenBody;
31 31
 import com.ruoyi.web.modules.diagnosis.domain.AiConsultSendResult;
32 32
 import com.ruoyi.web.modules.diagnosis.domain.BizConsultSession;
33 33
 import com.ruoyi.web.modules.diagnosis.domain.VetConsultSendBody;
34
+import com.ruoyi.web.modules.diagnosis.domain.BizDiagnosisUser;
35
+import com.ruoyi.web.modules.diagnosis.mapper.BizDiagnosisUserMapper;
34 36
 import com.ruoyi.web.modules.diagnosis.mapper.BizConsultMessageMapper;
35 37
 import com.ruoyi.web.modules.diagnosis.mapper.BizConsultSessionMapper;
36 38
 import com.ruoyi.web.modules.diagnosis.support.AiConsultConstants;
@@ -47,6 +49,9 @@ class BizAiOnlineConsultServiceImplTest
47 49
     @Mock
48 50
     private BizConsultSessionMapper sessionMapper;
49 51
 
52
+    @Mock
53
+    private BizDiagnosisUserMapper diagnosisUserMapper;
54
+
50 55
     @Mock
51 56
     private BizConsultMessageMapper messageMapper;
52 57
 
@@ -93,12 +98,28 @@ class BizAiOnlineConsultServiceImplTest
93 98
     @DisplayName("创建会话 receiver 全 NULL")
94 99
     void createSession()
95 100
     {
101
+        when(diagnosisUserMapper.selectByUserId(AiOnlineConsultTestSamples.VA_USER_ID)).thenReturn(null);
96 102
         service.createAiSession(AiOnlineConsultTestSamples.VA_USER_ID, "甲", null);
97 103
         verify(sessionMapper).insertBizConsultSession(argThat(r -> r.getConsultType() == ConsultSessionRules.CONSULT_TYPE_AI
98 104
                 && r.getReceiverProviderId() == null
99 105
                 && r.getReceiverUserId() == null
100 106
                 && AiConsultConstants.SESSION_TITLE_NEW.equals(r.getSessionTitle())
101 107
                 && r.getLastMessageTime() != null));
108
+        verify(diagnosisUserMapper).insertBizDiagnosisUser(argThat(u -> u != null
109
+                && AiOnlineConsultTestSamples.VA_USER_ID.equals(u.getUserId())
110
+                && u.getFirstUseTime() != null
111
+                && u.getLastUseTime() != null));
112
+    }
113
+
114
+    @Test
115
+    @DisplayName("创建会话不重复插入用户")
116
+    void createSessionSkipsExistingUser()
117
+    {
118
+        BizDiagnosisUser existing = new BizDiagnosisUser();
119
+        existing.setUserId(AiOnlineConsultTestSamples.VA_USER_ID);
120
+        when(diagnosisUserMapper.selectByUserId(AiOnlineConsultTestSamples.VA_USER_ID)).thenReturn(existing);
121
+        service.createAiSession(AiOnlineConsultTestSamples.VA_USER_ID, "甲", null);
122
+        verify(diagnosisUserMapper, never()).insertBizDiagnosisUser(any());
102 123
     }
103 124
 
104 125
     @Test
@@ -116,6 +137,7 @@ class BizAiOnlineConsultServiceImplTest
116 137
         assertNotNull(result.getAiMessage());
117 138
         assertEquals(ConsultSessionRules.SENDER_ROLE_AI, result.getAiMessage().getSenderRole());
118 139
         verify(consultPushService, atLeastOnce()).publishNewMessage(any());
140
+        verify(diagnosisUserMapper, atLeastOnce()).updateLastUseTime(eq(AiOnlineConsultTestSamples.VA_USER_ID), any());
119 141
     }
120 142
 
121 143
     @Test
@@ -150,6 +172,24 @@ class BizAiOnlineConsultServiceImplTest
150 172
         verify(sessionMapper).updateRealSessionId(AiOnlineConsultTestSamples.SESSION_ID, "gateway-session-001");
151 173
     }
152 174
 
175
+    @Test
176
+    @DisplayName("AI消息保存 cost_time")
177
+    void sendWithCostTime()
178
+    {
179
+        when(sessionMapper.selectAiSessionById(AiOnlineConsultTestSamples.SESSION_ID))
180
+                .thenReturn(AiOnlineConsultTestSamples.activeAiSession());
181
+        VetConsultSendBody body = new VetConsultSendBody();
182
+        body.setMsgType(ConsultSessionRules.MSG_TYPE_TEXT);
183
+        body.setContent("牛不吃草");
184
+        body.setAiReplyContent("请先观察体温");
185
+        body.setCostTime(1523L);
186
+        service.sendAiMessage(AiOnlineConsultTestSamples.SESSION_ID, body,
187
+                AiOnlineConsultTestSamples.VA_USER_ID, "甲");
188
+        verify(messageMapper).insertBizConsultMessage(argThat(m -> m != null
189
+                && Long.valueOf(1523L).equals(m.getCostTime())
190
+                && Integer.valueOf(ConsultSessionRules.SENDER_ROLE_AI).equals(m.getSenderRole())));
191
+    }
192
+
153 193
     @Test
154 194
     @DisplayName("提问消息保存 ai_category")
155 195
     void sendWithAiCategory()

Diff nebyl zobrazen, protože je příliš veliký
+ 12 - 2
doc/大屏/畜牧资源/大屏畜牧资源功能需求-草稿.md


+ 29 - 8
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.5**(`real_session_id`、`ai_category` 字段与落库接口 Body 同步,见 **§3.1**、**§3.2**、**§4.3**)
4
+> **本文档版本:1.6**(`biz_diagnosis_user` 使用用户表,见 **§3.0**)
5 5
 
6 6
 ---
7 7
 
@@ -12,7 +12,7 @@
12 12
 | **整体** | RuoYi **v3.9.2**(**springboot2** 分支)单体后端 + 若依 **Vue2** 管理端 + 移动端 |
13 13
 | **运行时** | JDK 8、Spring Boot 2.x、Spring MVC、MyBatis、Druid |
14 14
 | **数据库** | MySQL **5.7.39**,InnoDB,`utf8mb4` |
15
-| **问诊 IM 表** | **复用** `biz_consult_session`、`biz_consult_message`(与在线接诊共用,见 **§2**) |
15
+| **问诊 IM 表** | **复用** `biz_consult_session`、`biz_consult_message`(与在线接诊共用,见 **§2**);另增 `biz_diagnosis_user` 记录提问人首次/末次使用时间(**§3.0**) |
16 16
 | **近实时** | 复用 **Redis Pub/Sub** `consult:push` + STOMP(与在线接诊 **§5.1** 一致);提问落库即推;AI 回复落库后推,**≤ 1 秒** |
17 17
 | **文件** | `POST /common/upload`;消息存 URL |
18 18
 | **AI 能力(对话框)** | 管理端/移动端 **直连或经本服务转发** `GET /v1/models`、`POST /v1/chat/completions`(`com.ruoyi.web.kb.controller.KbOpenAiProxyController`,配置 `ruoyi.kb`);详见 `doc/大模型/大模型网关转发接口说明.md`、前端方案 §4 |
@@ -27,12 +27,12 @@
27 27
 | `Controller` | 后台 `.../onlineConsult/ai`;移动端 `.../app/consult/ai` |
28 28
 | `Service` | 列表/新建/消息/提问;调用 AI 生成回复;`hide` |
29 29
 | `ConsultAiGateway` | 文本/图片调用模型;视频/语音返回固定引导文案 |
30
-| `Mapper` + XML | 扩展 `BizConsultSessionMapper`(`consult_type=2` 查询) |
30
+| `Mapper` + XML | 扩展 `BizConsultSessionMapper`(`consult_type=2` 查询);`BizDiagnosisUserMapper`(**§3.0**) |
31 31
 | `support` | 复用 `ConsultSessionRules`、`ConsultThreeMonthsWindow`、`ConsultMessagePreview`、`VetConsultValidation`;新增 `AiConsultValidation` |
32 32
 
33 33
 **代码包**:`com.ruoyi.web.modules.diagnosis`(与在线接诊、医疗资源同域)  
34 34
 **大模型转发包**:`com.ruoyi.web.kb`(与知识库共用 `ruoyi.kb`,见 **§1.2**)  
35
-**DDL**:已存在 `sql/biz_consult_session.sql`、`sql/biz_consult_message.sql`(在线接诊迭代已建则**无需新表**)
35
+**DDL**:`sql/biz_consult_session.sql`、`sql/biz_consult_message.sql`(会话/消息,与在线接诊共用);`sql/biz_diagnosis_user.sql`(AI 诊断使用用户,**§3.0**)
36 36
 
37 37
 ### 1.2 大模型 OpenAI 兼容转发(`/v1`)
38 38
 
@@ -108,7 +108,24 @@ Payload 同在线接诊 **§4.2**(`senderRole`:1 提问人,**3** AI 助手
108 108
 
109 109
 ## 3. 数据库设计
110 110
 
111
-**不新建表**;在共用表上按 `consult_type=2` 读写。
111
+会话/消息在共用表上按 `consult_type=2` 读写;另增 **AI 诊断使用用户** 表记录提问人首次/末次使用时间。
112
+
113
+### 3.0 表 `biz_diagnosis_user`(AI 诊断使用用户)
114
+
115
+| 字段 | 类型 | 说明 |
116
+| --- | --- | --- |
117
+| `user_id` | `bigint(20)` | 主键;与提问人 `asker_user_id` 一致 |
118
+| `first_use_time` | `datetime` | 开始使用 AI 诊断时间 |
119
+| `last_use_time` | `datetime` | 最后一次使用 AI 诊断时间 |
120
+
121
+**写入规则**
122
+
123
+| 时机 | 行为 |
124
+| --- | --- |
125
+| `POST .../session` 新建会话 | 若 `user_id` 不存在则 **INSERT**,`first_use_time`、`last_use_time` 均为当前时间 |
126
+| 更新 `biz_consult_session.last_message_time` | 同步 **UPDATE** 对应 `asker_user_id` 的 `last_use_time`(与 `last_message_time` 一致) |
127
+
128
+**DDL**:`sql/biz_diagnosis_user.sql`。
112 129
 
113 130
 ### 3.1 表 `biz_consult_session`(AI 会话写入约定)
114 131
 
@@ -140,8 +157,9 @@ Payload 同在线接诊 **§4.2**(`senderRole`:1 提问人,**3** AI 助手
140 157
 | 字段 | 说明 |
141 158
 | --- | --- |
142 159
 | `ai_category` | **varchar(32)**,可为空;仅 **提问人消息**(`sender_role=1`)写入;AI 回复为 `NULL`。纯数字时须为 `1`~`4`(与 `AiConsultConstants` 分类一致),亦可存业务编码字符串 |
160
+| `cost_time` | **bigint**,可为空;大模型对话耗时(**毫秒**),仅 **AI 消息**(`sender_role=3`)写入;来自 `/v1/chat/completions` 响应头 **`X-COST`** |
143 161
 
144
-**已有库迁移**:`sql/alter_biz_consult_session_real_session_id.sql`、`sql/alter_biz_consult_message_ai_category.sql`。
162
+**已有库迁移**:`sql/biz_diagnosis_user.sql`(或 `sql/alter_rename_biz_ai_diagnosis_user_to_biz_diagnosis_user.sql`);`sql/alter_biz_consult_session_real_session_id.sql`、`sql/alter_biz_consult_message_ai_category.sql`、`sql/alter_biz_consult_message_cost_time.sql`。
145 163
 
146 164
 ### 3.3 AI 列表查询语义
147 165
 
@@ -215,7 +233,7 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
215 233
 | 4.1 | 历史会话列表 | GET | `/session/list` | `...:list` | Query:`pageNum`、`pageSize`(默认 20)、`contentKeyword`、`searchMode`;`asker_user_id` 服务端注入 |
216 234
 | 4.2 | 新增会话 | POST | `/session` | `...:add` | 创建 `consult_type=2` 空会话;`session_title=新会话`;`last_message_time=create_time`(**§3.5 #1**);返回 `sessionId`、`disclaimer` |
217 235
 | 4.3 | 历史消息 | GET | `/session/{sessionId}/messages` | `...:query` | 校验 `consult_type=2` + `asker_user_id` + `vet_visible=1`;`beforeId`、`pageSize`(默认 50) |
218
-| 4.4 | 提问(触发 AI) | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`、`content`、`mediaDuration?`、`aiReplyContent?`、`realSessionId?`、`aiCategory?`;流程见 **§5** |
236
+| 4.4 | 提问(触发 AI) | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`、`content`、`mediaDuration?`、`aiReplyContent?`、`realSessionId?`、`aiCategory?`、`costTime?`(毫秒,来自 `X-COST`);落库时更新 `last_message_*` 与 **`update_time`**;流程见 **§5** |
219 237
 | 4.5 | 隐藏会话 | POST | `/session/{sessionId}/hide` | `...:remove` | 管理端隐藏 AI 诊断会话;`vet_visible=0`;仅本人会话;隐藏后列表/消息/提问不可用 |
220 238
 
221 239
 ### 4.1 列表 `rows[]` 字段
@@ -245,7 +263,8 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
245 263
   "content": "牛不吃草、精神沉郁,请问可能原因?",
246 264
   "aiReplyContent": "可能原因包括…",
247 265
   "realSessionId": "gateway-session-001",
248
-  "aiCategory": "2"
266
+  "aiCategory": "2",
267
+  "costTime": 1523
249 268
 }
250 269
 ```
251 270
 
@@ -257,6 +276,7 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
257 276
 | `aiReplyContent` | string | N | 非空时服务端**仅落库**,不调 `ConsultAiGateway` |
258 277
 | `realSessionId` | string | N | 大模型 `session_id`;写入 `biz_consult_session.real_session_id`(别名 `real_session_id`、`session_id`、`llmSessionId`) |
259 278
 | `aiCategory` | string | N | 写入提问人消息 `ai_category`(别名 `ai_category`、`category`);最长 32 |
279
+| `costTime` | long | N | 写入 **AI 消息** `cost_time`(别名 `cost_time`、`xCost`);毫秒,来自大模型响应头 `X-COST` |
260 280
 
261 281
 **提问成功 `data`**
262 282
 
@@ -423,3 +443,4 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
423 443
 | 1.3 | §1.2:`/v1/**` 须若依 Token,取消匿名访问 |
424 444
 | 1.4 | 管理端 **§4.5** `POST /session/{id}/hide` 隐藏 AI 诊断会话;权限 `...:remove` |
425 445
 | 1.5 | **§3.1** `biz_consult_session.real_session_id`;**§3.2** `biz_consult_message.ai_category`(varchar32);**§4.3** 落库 Body/响应字段;**§5** 前端 `aiReplyContent` 落库通道 |
446
+| 1.6 | **§3.0** `biz_diagnosis_user`:新建会话写入首次用户、更新 `last_message_time` 同步 `last_use_time` |

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

@@ -1,6 +1,6 @@
1 1
 # AI 诊断(兽医、机构)— 测试用例
2 2
 
3
-> 依据:`AI诊断(兽医、机构)功能需求.md`(v1.1)、`AI诊断(兽医、机构)技术方案.md`(v1.1
3
+> 依据:`AI诊断(兽医、机构)功能需求.md`(v1.1)、`AI诊断(兽医、机构)技术方案.md`(v1.6
4 4
 > **后台 Base Path**:`/diseaseTreatments/onlineConsult/ai`;**移动端**:`/app/consult/ai`;鉴权与若依一致。
5 5
 
6 6
 **通用前置(无特殊说明)**
@@ -8,7 +8,7 @@
8 8
 - 兽医账号 **VA**(角色 **100**)、机构账号 **OA**(角色 **102**);权限 `diseaseTreatment:aiOnlineConsult:list|query|send|add|remove`(**不要求**绑定 `biz_medical_resource`)。  
9 9
 - 普通后台账号 **NB**(有 AI 权限、无医疗资源绑定)、另一用户 **VB**(有 AI 会话)。  
10 10
 - 牧民 **U1** 已登录移动端。  
11
-- `biz_consult_session` / `biz_consult_message` 已执行 DDL;宜预置:
11
+- `biz_consult_session` / `biz_consult_message` / `biz_diagnosis_user` 已执行 DDL(见 `sql/biz_diagnosis_user.sql`);宜预置:
12 12
 
13 13
 | 样本 | 说明 | 要点 |
14 14
 | --- | --- | --- |
@@ -50,6 +50,9 @@
50 50
 | ZCZX-AIZD-UT-023 | 隔离 | 不进接诊列表 | 单元测试 | JUnit5+Mockito | AI-05 | 有 AV0 | 兽医接诊 `list` | 无 AS0 |
51 51
 | ZCZX-AIZD-UT-024 | 隔离 | 接诊不进 AI 列表 | 单元测试 | JUnit5+Mockito | AI-05 | 有 AV0 | VA `ai list` | 无 AV0 |
52 52
 | ZCZX-AIZD-UT-025 | 约束 | 不校验预约 | 单元测试 | JUnit5+Mockito | §3.4 | 无预约单 | `create`+`send` | 成功;不查 `biz_service_appointment` |
53
+| ZCZX-AIZD-UT-026 | 诊断用户 | 新建会话插入 | 单元测试 | JUnit5+Mockito | §3.0 | `biz_diagnosis_user` 无 VA | `createAiSession` | `INSERT` 一行;`user_id=VA`;`first_use_time`、`last_use_time` 非空且相等 |
54
+| ZCZX-AIZD-UT-027 | 诊断用户 | 已存在不重复插 | 单元测试 | JUnit5+Mockito | §3.0 | VA 已在用户表 | `createAiSession` | 不调用 `insertBizDiagnosisUser` |
55
+| ZCZX-AIZD-UT-028 | 诊断用户 | 发消息更新末次 | 单元测试 | JUnit5+Mockito | §3.0 | 有 AS0 | `sendAiMessage` | `updateLastUseTime(VA, last_message_time)` 至少 1 次 |
53 56
 
54 57
 ---
55 58
 
@@ -100,6 +103,8 @@
100 103
 | ZCZX-AIZD-API-041 | 落库 | realSessionId | 接口测试 | Postman | §4.3、§5 | VA;AS0 | `POST .../message` Body 含 `aiReplyContent` + `realSessionId` | `code=200`;`data.realSessionId` 与库 `real_session_id` 一致 |
101 104
 | ZCZX-AIZD-API-042 | 落库 | aiCategory | 接口测试 | Postman | §3.2、§4.3 | VA;AS0 | `POST .../message` Body `aiCategory=2` + `aiReplyContent` | `userMessage.aiCategory=2`;`aiMessage.aiCategory` 为空 |
102 105
 | ZCZX-AIZD-API-043 | 校验 | aiCategory 无效 | 接口测试 | Postman | §4.4 | VA;AS0 | `aiCategory=99`(纯数字超范围) | 失败;「AI提问分类无效」 |
106
+| ZCZX-AIZD-API-044 | 诊断用户 | 新建写入用户表 | 接口测试 | Postman/SQL | §3.0 | VA;`biz_diagnosis_user` 无 VA | `POST /session` 后查库 | 新增 1 行;`first_use_time`、`last_use_time`≈会话 `create_time` |
107
+| ZCZX-AIZD-API-045 | 诊断用户 | 提问更新末次 | 接口测试 | Postman/SQL | §3.0 | VA;AS0;记下提问前 `last_use_time` | `POST .../message` 成功后查库 | `biz_diagnosis_user.last_use_time` 与 `biz_consult_session.last_message_time` 一致(或晚于提问前) |
103 108
 
104 109
 ---
105 110
 
@@ -185,3 +190,4 @@
185 190
 | 版本 | 说明 |
186 191
 | --- | --- |
187 192
 | 1.0 | 初版:单元 25、接口 37、UI 23;覆盖列表/新建/提问/AI 回复/搜索/hide/隔离/机构/移动/网关/免责声明;Playwright+Chrome |
193
+| 1.1 | 补充 `biz_diagnosis_user`:UT-026~028、API-044~045;前置 DDL 含 `sql/biz_diagnosis_user.sql` |

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

@@ -132,6 +132,7 @@ AI 回复消息由异步任务调用大模型后 `INSERT`(`sender_role=AI`)
132 132
 | `content` | `text` | N | 文本内容或媒体 URL |
133 133
 | `media_duration` | `int(11)` | N | 语音/视频时长(秒,可选) |
134 134
 | `ai_category` | `varchar(32)` | N | AI 提问分类(仅 AI 诊断提问人消息,见 AI 诊断方案 §3.2) |
135
+| `cost_time` | `bigint(20)` | N | 大模型耗时毫秒(仅 AI 诊断 AI 消息,见 AI 诊断方案 §3.2) |
135 136
 | `send_time` | `datetime` | Y | 发送时间 |
136 137
 | `create_time` | `datetime` | N | 创建时间 |
137 138
 
@@ -190,7 +191,7 @@ ORDER BY last_message_time DESC
190 191
 | --- | --- | --- | --- | --- |
191 192
 | 4.1 | 接诊会话列表 | GET | `/session/list` | `...:list` | Query:`pageNum`、`pageSize`(默认 20)、`askerName`、`contentKeyword`、**`searchMode`**(**仅「搜索」为 `true`**,见 **§4.6**);服务端注入 `receiverProviderId`,禁止前端传 |
192 193
 | 4.2 | 历史消息 | GET | `/session/{sessionId}/messages` | `...:query` | 校验会话归属与 `vet_visible=1`;未传 `beforeId` 取最新 50 条(**§3.4 #5**);传 `beforeId` 上拉更早;`pageSize` 默认 50;响应 `rows` **升序** |
193
-| 4.3 | 发送消息 | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`(1~4)、`content`(文本或上传后的 URL)、`mediaDuration`(可选);落库后 **WebSocket 推送**;更新 `last_message_*` |
194
+| 4.3 | 发送消息 | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`(1~4)、`content`(文本或上传后的 URL)、`mediaDuration`(可选);落库后 **WebSocket 推送**;更新 `last_message_*` 与 **`update_time`** |
194 195
 
195 196
 ### 4.1 列表 `rows[]` 字段
196 197
 

+ 1 - 0
sql/biz_consult_message.sql

@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS `biz_consult_message` (
9 9
   `content` text COMMENT '文本或媒体URL',
10 10
   `media_duration` int(11) DEFAULT NULL COMMENT '音视频时长秒',
11 11
   `ai_category` varchar(32) DEFAULT NULL COMMENT 'AI提问分类',
12
+  `cost_time` bigint(20) DEFAULT NULL COMMENT '大模型对话耗时毫秒',
12 13
   `send_time` datetime NOT NULL COMMENT '发送时间',
13 14
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
14 15
   PRIMARY KEY (`id`),

+ 7 - 0
sql/biz_diagnosis_user.sql

@@ -0,0 +1,7 @@
1
+-- AI 诊断使用用户(按提问人 asker_user_id 记录首次/末次使用时间)
2
+CREATE TABLE IF NOT EXISTS `biz_diagnosis_user` (
3
+  `user_id` bigint(20) NOT NULL COMMENT '用户ID(提问人asker_user_id)',
4
+  `first_use_time` datetime NOT NULL COMMENT '开始使用AI诊断时间',
5
+  `last_use_time` datetime NOT NULL COMMENT '最后一次使用AI诊断时间',
6
+  PRIMARY KEY (`user_id`)
7
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI诊断使用用户';