wwh недель назад: 2
Родитель
Сommit
b0b922f51c

+ 13 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/domain/dto/LivestockResourceCategoryCountDto.java

@@ -9,6 +9,9 @@ public class LivestockResourceCategoryCountDto
9 9
 
10 10
     private Long count;
11 11
 
12
+    /** 该分类下最近一条 AI 消息 create_time,热点榜二次排序用 */
13
+    private java.util.Date lastEventTime;
14
+
12 15
     public String getCategoryCode()
13 16
     {
14 17
         return categoryCode;
@@ -28,4 +31,14 @@ public class LivestockResourceCategoryCountDto
28 31
     {
29 32
         this.count = count;
30 33
     }
34
+
35
+    public java.util.Date getLastEventTime()
36
+    {
37
+        return lastEventTime;
38
+    }
39
+
40
+    public void setLastEventTime(java.util.Date lastEventTime)
41
+    {
42
+        this.lastEventTime = lastEventTime;
43
+    }
31 44
 }

+ 2 - 2
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/mapper/LivestockResourceScreenMapper.java

@@ -39,10 +39,10 @@ public interface LivestockResourceScreenMapper
39 39
             @Param("yearStart") Date yearStart, @Param("yearEnd") Date yearEnd);
40 40
 
41 41
     List<LivestockResourceDateCountDto> selectHourlySessionCounts(
42
-            @Param("windowStart") Date windowStart, @Param("windowEnd") Date windowEnd);
42
+            @Param("dayStart") Date dayStart, @Param("dayEnd") Date dayEnd);
43 43
 
44 44
     List<LivestockResourceDateCountDto> selectHourlyAiReplyCounts(
45
-            @Param("windowStart") Date windowStart, @Param("windowEnd") Date windowEnd);
45
+            @Param("dayStart") Date dayStart, @Param("dayEnd") Date dayEnd);
46 46
 
47 47
     Long countYearSessions(@Param("yearStart") Date yearStart, @Param("yearEnd") Date yearEnd);
48 48
 

+ 10 - 9
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/service/impl/LivestockResourceScreenServiceImpl.java

@@ -47,8 +47,8 @@ public class LivestockResourceScreenServiceImpl implements ILivestockResourceScr
47 47
         Date dayEnd = toDateEndOfDay(statDate);
48 48
         Date sevenStart = toDate(statDate.minusDays(LivestockResourceScreenRules.ACTIVITY_DAYS - 1));
49 49
         Date sevenEnd = yearEnd;
50
-        Date windowEnd = toDateTime(statDateTime);
51
-        Date windowStart = toDateTime(statDateTime.minusHours(LivestockResourceScreenRules.HOURLY_BUCKETS));
50
+        Date hourlyDayStart = dayStart;
51
+        Date hourlyDayEnd = dayEnd;
52 52
 
53 53
         LivestockResourceOverviewDto yearMetrics = livestockResourceScreenMapper.selectYearOverviewMetrics(
54 54
                 yearStart, yearEnd);
@@ -72,16 +72,17 @@ public class LivestockResourceScreenServiceImpl implements ILivestockResourceScr
72 72
         LivestockResourceUserStructureVo userStructure = LivestockResourceScreenSupport.buildUserStructure(
73 73
                 livestockResourceScreenMapper.selectUserStructure());
74 74
 
75
-        long totalAsker = safeLong(livestockResourceScreenMapper.countAskerMessagesInYear(yearStart, yearEnd));
75
+        long totalAiMessages = safeLong(livestockResourceScreenMapper.countAskerMessagesInYear(yearStart, yearEnd));
76
+        List<com.ruoyi.web.modules.screen.domain.dto.LivestockResourceCategoryCountDto> categoryRows =
77
+                livestockResourceScreenMapper.selectAskerCategoryCounts(yearStart, yearEnd);
76 78
         LivestockResourceCategoryShareVo categoryShare = LivestockResourceScreenSupport.buildCategoryShare(
77
-                livestockResourceScreenMapper.selectAskerCategoryCounts(yearStart, yearEnd),
78
-                totalAsker);
79
+                categoryRows, totalAiMessages);
79 80
         List<LivestockResourceCategoryItemVo> categoryItems = categoryShare.getItems();
80 81
 
81 82
         LivestockResourceHourlyHeatVo hourlyHeat = LivestockResourceScreenSupport.buildHourlyHeat(
82
-                livestockResourceScreenMapper.selectHourlySessionCounts(windowStart, windowEnd),
83
-                livestockResourceScreenMapper.selectHourlyAiReplyCounts(windowStart, windowEnd),
84
-                statDateTime);
83
+                livestockResourceScreenMapper.selectHourlySessionCounts(hourlyDayStart, hourlyDayEnd),
84
+                livestockResourceScreenMapper.selectHourlyAiReplyCounts(hourlyDayStart, hourlyDayEnd),
85
+                statDate);
85 86
 
86 87
         long yearSessionTotal = safeLong(livestockResourceScreenMapper.countYearSessions(yearStart, yearEnd));
87 88
 
@@ -106,7 +107,7 @@ public class LivestockResourceScreenServiceImpl implements ILivestockResourceScr
106 107
                 yearSessionTotal));
107 108
         vo.setReplyStatusDistribution(replyStatus);
108 109
         vo.setHotTopics(LivestockResourceScreenSupport.buildHotTopics(
109
-                categoryItems, LivestockResourceScreenRules.HOT_TOPIC_TOP));
110
+                categoryRows, LivestockResourceScreenRules.HOT_TOPIC_TOP));
110 111
         vo.setTopKeywords(LivestockResourceScreenSupport.extractTopKeywords(
111 112
                 livestockResourceScreenMapper.selectTextAskerContents(yearStart, yearEnd),
112 113
                 LivestockResourceScreenRules.KEYWORD_TOP));

+ 25 - 14
baqing-admin/src/main/java/com/ruoyi/web/modules/screen/support/LivestockResourceScreenSupport.java

@@ -206,39 +206,50 @@ public final class LivestockResourceScreenSupport
206 206
     }
207 207
 
208 208
     public static List<LivestockResourceCategoryItemVo> buildHotTopics(
209
-            List<LivestockResourceCategoryItemVo> items, int topN)
209
+            List<LivestockResourceCategoryCountDto> rows, int topN)
210 210
     {
211
-        if (items == null || items.isEmpty())
211
+        if (rows == null || rows.isEmpty())
212 212
         {
213 213
             return Collections.emptyList();
214 214
         }
215
-        List<LivestockResourceCategoryItemVo> sorted = new ArrayList<>(items);
216
-        sorted.sort(Comparator.comparingLong(LivestockResourceCategoryItemVo::getCount).reversed()
217
-                .thenComparing(LivestockResourceCategoryItemVo::getCategoryCode));
215
+        List<LivestockResourceCategoryCountDto> sorted = new ArrayList<>(rows);
216
+        sorted.sort(Comparator.comparingLong((LivestockResourceCategoryCountDto r) -> safeLong(r.getCount())).reversed()
217
+                .thenComparing(LivestockResourceCategoryCountDto::getLastEventTime,
218
+                        Comparator.nullsLast(Comparator.reverseOrder()))
219
+                .thenComparing(r -> normalizeCategoryCode(r.getCategoryCode())));
218 220
         int limit = Math.min(topN, sorted.size());
219
-        return new ArrayList<>(sorted.subList(0, limit));
221
+        List<LivestockResourceCategoryItemVo> result = new ArrayList<>();
222
+        for (int i = 0; i < limit; i++)
223
+        {
224
+            LivestockResourceCategoryCountDto row = sorted.get(i);
225
+            LivestockResourceCategoryItemVo item = new LivestockResourceCategoryItemVo();
226
+            String code = normalizeCategoryCode(row.getCategoryCode());
227
+            item.setCategoryCode(code);
228
+            item.setCategoryName(resolveCategoryName(code));
229
+            item.setCount(safeLong(row.getCount()));
230
+            result.add(item);
231
+        }
232
+        return result;
220 233
     }
221 234
 
222 235
     public static LivestockResourceHourlyHeatVo buildHourlyHeat(
223 236
             List<LivestockResourceDateCountDto> sessionRows,
224 237
             List<LivestockResourceDateCountDto> questionRows,
225
-            LocalDateTime windowEnd)
238
+            LocalDate statDate)
226 239
     {
227 240
         Map<Integer, Long> sessions = toHourBucketMap(sessionRows);
228 241
         Map<Integer, Long> questions = toHourBucketMap(questionRows);
229
-        LocalDateTime windowStart = windowEnd.minusHours(LivestockResourceScreenRules.HOURLY_BUCKETS);
230 242
         List<LivestockResourceHourlyPointVo> series = new ArrayList<>();
231
-        for (int i = 0; i < LivestockResourceScreenRules.HOURLY_BUCKETS; i++)
243
+        for (int h = 0; h < LivestockResourceScreenRules.HOURLY_BUCKETS; h++)
232 244
         {
233
-            LocalDateTime bucketStart = windowStart.plusHours(i);
234 245
             LivestockResourceHourlyPointVo point = new LivestockResourceHourlyPointVo();
235
-            point.setBucketStart(bucketStart.format(HOUR_FMT));
236
-            point.setSessionCount(sessions.getOrDefault(i, 0L));
237
-            point.setQuestionCount(questions.getOrDefault(i, 0L));
246
+            point.setBucketStart(String.format("%02d:00", h));
247
+            point.setSessionCount(sessions.getOrDefault(h, 0L));
248
+            point.setQuestionCount(questions.getOrDefault(h, 0L));
238 249
             series.add(point);
239 250
         }
240 251
         LivestockResourceHourlyHeatVo vo = new LivestockResourceHourlyHeatVo();
241
-        vo.setWindowEnd(formatStatDateTime(windowEnd));
252
+        vo.setWindowEnd(statDate.format(DATE_FMT));
242 253
         vo.setHourlySeries(series);
243 254
         return vo;
244 255
     }

+ 97 - 66
baqing-admin/src/main/resources/mapper/screen/LivestockResourceScreenMapper.xml

@@ -10,6 +10,11 @@
10 10
         inner join biz_consult_session s on s.id = m.session_id and s.consult_type = 2
11 11
     </sql>
12 12
 
13
+    <sql id="aiMessageFilter">
14
+        m.sender_role = 3
15
+        and m.sender_user_id = 0
16
+    </sql>
17
+
13 18
     <select id="selectYearOverviewMetrics" resultType="com.ruoyi.web.modules.screen.domain.dto.LivestockResourceOverviewDto">
14 19
         select
15 20
             (select count(distinct asker_user_id)
@@ -25,38 +30,40 @@
25 30
             (select count(*)
26 31
              from biz_consult_message m
27 32
              <include refid="aiMessageJoin"/>
28
-             where m.sender_role = 3
29
-               and m.sender_user_id = 0
30
-               and m.send_time &gt;= #{yearStart}
31
-               and m.send_time &lt;= #{yearEnd}) as totalQuestionCount,
33
+             where <include refid="aiMessageFilter"/>
34
+               and m.create_time &gt;= #{yearStart}
35
+               and m.create_time &lt;= #{yearEnd}) as totalQuestionCount,
32 36
             (select avg(m.cost_time)
33 37
              from biz_consult_message m
34 38
              <include refid="aiMessageJoin"/>
35
-             where m.sender_role = 3
36
-               and m.sender_user_id = 0
39
+             where <include refid="aiMessageFilter"/>
37 40
                and m.cost_time is not null
38
-               and m.send_time &gt;= #{yearStart}
39
-               and m.send_time &lt;= #{yearEnd}) as avgCostTimeMs
41
+               and m.create_time &gt;= #{yearStart}
42
+               and m.create_time &lt;= #{yearEnd}) as avgCostTimeMs
40 43
     </select>
41 44
 
42 45
     <select id="countTodayActiveUsers" resultType="long">
43 46
         select count(distinct asker_user_id)
44 47
         from biz_consult_session
45 48
         where <include refid="aiSession"/>
46
-          and last_message_time &gt;= #{dayStart}
47
-          and last_message_time &lt;= #{dayEnd}
49
+          and update_time &gt;= #{dayStart}
50
+          and update_time &lt;= #{dayEnd}
48 51
     </select>
49 52
 
50 53
     <select id="countTodayNewUsers" resultType="long">
51
-        select count(*)
52
-        from (
53
-            select asker_user_id
54
-            from biz_consult_session
55
-            where <include refid="aiSession"/>
56
-            group by asker_user_id
57
-            having min(create_time) &gt;= #{dayStart}
58
-               and min(create_time) &lt;= #{dayEnd}
59
-        ) t
54
+        select count(distinct asker_user_id)
55
+        from biz_consult_session
56
+        where <include refid="aiSession"/>
57
+          and create_time &gt;= #{dayStart}
58
+          and create_time &lt;= #{dayEnd}
59
+          and update_time &gt;= #{dayStart}
60
+          and update_time &lt;= #{dayEnd}
61
+          and asker_user_id not in (
62
+              select distinct asker_user_id
63
+              from biz_consult_session
64
+              where <include refid="aiSession"/>
65
+                and create_time &lt; #{dayStart}
66
+          )
60 67
     </select>
61 68
 
62 69
     <select id="countTodaySessions" resultType="long">
@@ -71,10 +78,9 @@
71 78
         select count(*)
72 79
         from biz_consult_message m
73 80
         <include refid="aiMessageJoin"/>
74
-        where m.sender_role = 3
75
-          and m.sender_user_id = 0
76
-          and m.send_time &gt;= #{dayStart}
77
-          and m.send_time &lt;= #{dayEnd}
81
+        where <include refid="aiMessageFilter"/>
82
+          and m.create_time &gt;= #{dayStart}
83
+          and m.create_time &lt;= #{dayEnd}
78 84
     </select>
79 85
 
80 86
     <select id="selectDailySessionCounts" resultType="com.ruoyi.web.modules.screen.domain.dto.LivestockResourceDateCountDto">
@@ -88,15 +94,14 @@
88 94
     </select>
89 95
 
90 96
     <select id="selectDailyAiReplyCounts" resultType="com.ruoyi.web.modules.screen.domain.dto.LivestockResourceDateCountDto">
91
-        select date_format(m.send_time, '%Y-%m-%d') as bucketKey,
97
+        select date_format(m.create_time, '%Y-%m-%d') as bucketKey,
92 98
                count(*) as count
93 99
         from biz_consult_message m
94 100
         <include refid="aiMessageJoin"/>
95
-        where m.sender_role = 3
96
-          and m.sender_user_id = 0
97
-          and m.send_time &gt;= #{rangeStart}
98
-          and m.send_time &lt;= #{rangeEnd}
99
-        group by date_format(m.send_time, '%Y-%m-%d')
101
+        where <include refid="aiMessageFilter"/>
102
+          and m.create_time &gt;= #{rangeStart}
103
+          and m.create_time &lt;= #{rangeEnd}
104
+        group by date_format(m.create_time, '%Y-%m-%d')
100 105
     </select>
101 106
 
102 107
     <select id="selectUserStructure" resultType="com.ruoyi.web.modules.screen.domain.dto.LivestockResourceUserStructureDto">
@@ -109,48 +114,75 @@
109 114
         select count(*)
110 115
         from biz_consult_message m
111 116
         <include refid="aiMessageJoin"/>
112
-        where m.sender_role = 1
113
-          and m.send_time &gt;= #{yearStart}
114
-          and m.send_time &lt;= #{yearEnd}
117
+        where <include refid="aiMessageFilter"/>
118
+          and m.create_time &gt;= #{yearStart}
119
+          and m.create_time &lt;= #{yearEnd}
115 120
     </select>
116 121
 
117 122
     <select id="selectAskerCategoryCounts" resultType="com.ruoyi.web.modules.screen.domain.dto.LivestockResourceCategoryCountDto">
118
-        select case
119
-                   when trim(m.ai_category) is null or trim(m.ai_category) = '' then 'UNCAT'
120
-                   else trim(m.ai_category)
121
-               end as categoryCode,
122
-               count(*) as count
123
-        from biz_consult_message m
124
-        <include refid="aiMessageJoin"/>
125
-        where m.sender_role = 1
126
-          and m.send_time &gt;= #{yearStart}
127
-          and m.send_time &lt;= #{yearEnd}
128
-        group by case
129
-                     when trim(m.ai_category) is null or trim(m.ai_category) = '' then 'UNCAT'
130
-                     else trim(m.ai_category)
131
-                 end
123
+        select category_code as categoryCode,
124
+               count(*) as count,
125
+               max(last_time) as lastEventTime
126
+        from (
127
+            select m.id,
128
+                   case
129
+                       when trim(coalesce((
130
+                           select m2.ai_category
131
+                           from biz_consult_message m2
132
+                           where m2.session_id = m.session_id
133
+                             and m2.sender_role = 1
134
+                             and m2.send_time &lt;= m.send_time
135
+                           order by m2.send_time desc
136
+                           limit 1
137
+                       ), '')) is null
138
+                           or trim(coalesce((
139
+                           select m2.ai_category
140
+                           from biz_consult_message m2
141
+                           where m2.session_id = m.session_id
142
+                             and m2.sender_role = 1
143
+                             and m2.send_time &lt;= m.send_time
144
+                           order by m2.send_time desc
145
+                           limit 1
146
+                       ), '')) = '' then 'UNCAT'
147
+                       else trim(coalesce((
148
+                           select m2.ai_category
149
+                           from biz_consult_message m2
150
+                           where m2.session_id = m.session_id
151
+                             and m2.sender_role = 1
152
+                             and m2.send_time &lt;= m.send_time
153
+                           order by m2.send_time desc
154
+                           limit 1
155
+                       ), ''))
156
+                   end as category_code,
157
+                   m.create_time as last_time
158
+            from biz_consult_message m
159
+            <include refid="aiMessageJoin"/>
160
+            where <include refid="aiMessageFilter"/>
161
+              and m.create_time &gt;= #{yearStart}
162
+              and m.create_time &lt;= #{yearEnd}
163
+        ) t
164
+        group by category_code
132 165
     </select>
133 166
 
134 167
     <select id="selectHourlySessionCounts" resultType="com.ruoyi.web.modules.screen.domain.dto.LivestockResourceDateCountDto">
135
-        select cast(floor(timestampdiff(minute, #{windowStart}, create_time) / 60) as char) as bucketKey,
168
+        select cast(hour(create_time) as char) as bucketKey,
136 169
                count(*) as count
137 170
         from biz_consult_session
138 171
         where <include refid="aiSession"/>
139
-          and create_time &gt; #{windowStart}
140
-          and create_time &lt;= #{windowEnd}
141
-        group by floor(timestampdiff(minute, #{windowStart}, create_time) / 60)
172
+          and create_time &gt;= #{dayStart}
173
+          and create_time &lt;= #{dayEnd}
174
+        group by hour(create_time)
142 175
     </select>
143 176
 
144 177
     <select id="selectHourlyAiReplyCounts" resultType="com.ruoyi.web.modules.screen.domain.dto.LivestockResourceDateCountDto">
145
-        select cast(floor(timestampdiff(minute, #{windowStart}, m.send_time) / 60) as char) as bucketKey,
178
+        select cast(hour(m.create_time) as char) as bucketKey,
146 179
                count(*) as count
147 180
         from biz_consult_message m
148 181
         <include refid="aiMessageJoin"/>
149
-        where m.sender_role = 3
150
-          and m.sender_user_id = 0
151
-          and m.send_time &gt; #{windowStart}
152
-          and m.send_time &lt;= #{windowEnd}
153
-        group by floor(timestampdiff(minute, #{windowStart}, m.send_time) / 60)
182
+        where <include refid="aiMessageFilter"/>
183
+          and m.create_time &gt;= #{dayStart}
184
+          and m.create_time &lt;= #{dayEnd}
185
+        group by hour(m.create_time)
154 186
     </select>
155 187
 
156 188
     <select id="countYearSessions" resultType="long">
@@ -179,8 +211,8 @@
179 211
                 inner join biz_consult_session s2 on s2.id = m.session_id and s2.consult_type = 2
180 212
                 where m.sender_role = 3
181 213
                   and m.sender_user_id = 0
182
-                  and m.send_time &gt;= #{yearStart}
183
-                  and m.send_time &lt;= #{yearEnd}
214
+                  and m.create_time &gt;= #{yearStart}
215
+                  and m.create_time &lt;= #{yearEnd}
184 216
                 group by m.session_id
185 217
             ) r on r.session_id = s.id
186 218
             where s.consult_type = 2
@@ -194,22 +226,20 @@
194 226
         select count(*)
195 227
         from biz_consult_message m
196 228
         <include refid="aiMessageJoin"/>
197
-        where m.sender_role = 3
198
-          and m.sender_user_id = 0
229
+        where <include refid="aiMessageFilter"/>
199 230
           and m.cost_time is not null
200
-          and m.send_time &gt;= #{yearStart}
201
-          and m.send_time &lt;= #{yearEnd}
231
+          and m.create_time &gt;= #{yearStart}
232
+          and m.create_time &lt;= #{yearEnd}
202 233
     </select>
203 234
 
204 235
     <select id="countAiRepliesFail" resultType="long">
205 236
         select count(*)
206 237
         from biz_consult_message m
207 238
         <include refid="aiMessageJoin"/>
208
-        where m.sender_role = 3
209
-          and m.sender_user_id = 0
239
+        where <include refid="aiMessageFilter"/>
210 240
           and m.cost_time is null
211
-          and m.send_time &gt;= #{yearStart}
212
-          and m.send_time &lt;= #{yearEnd}
241
+          and m.create_time &gt;= #{yearStart}
242
+          and m.create_time &lt;= #{yearEnd}
213 243
     </select>
214 244
 
215 245
     <select id="selectTextAskerContents" resultType="string">
@@ -217,6 +247,7 @@
217 247
         from biz_consult_message m
218 248
         <include refid="aiMessageJoin"/>
219 249
         where m.sender_role = 1
250
+          and m.sender_user_id &lt;&gt; 0
220 251
           and m.msg_type = 1
221 252
           and m.content is not null
222 253
           and trim(m.content) != ''

+ 8 - 7
baqing-admin/src/test/java/com/ruoyi/web/modules/screen/support/LivestockResourceScreenSupportTest.java

@@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
5 5
 
6 6
 import java.math.BigDecimal;
7 7
 import java.time.LocalDate;
8
-import java.time.LocalDateTime;
9 8
 import java.util.Arrays;
10 9
 import java.util.Collections;
11 10
 import java.util.List;
@@ -77,15 +76,16 @@ class LivestockResourceScreenSupportTest
77 76
     }
78 77
 
79 78
     @Test
80
-    @DisplayName("DP-XM-UT-012 24 小时桶")
79
+    @DisplayName("DP-XM-UT-012 统计日 24 自然小时桶")
81 80
     void ut012_hourlyTwentyFour()
82 81
     {
83
-        LocalDateTime end = LocalDateTime.of(2026, 5, 20, 15, 30, 0);
84 82
         LivestockResourceHourlyHeatVo heat = LivestockResourceScreenSupport.buildHourlyHeat(
85 83
                 Collections.singletonList(hourCount("10", 2L)),
86 84
                 Collections.singletonList(hourCount("10", 5L)),
87
-                end);
85
+                LocalDate.of(2026, 5, 20));
88 86
         assertEquals(24, heat.getHourlySeries().size());
87
+        assertEquals("2026-05-20", heat.getWindowEnd());
88
+        assertEquals("10:00", heat.getHourlySeries().get(10).getBucketStart());
89 89
         assertEquals(2L, heat.getHourlySeries().get(10).getSessionCount());
90 90
         assertEquals(5L, heat.getHourlySeries().get(10).getQuestionCount());
91 91
     }
@@ -143,15 +143,16 @@ class LivestockResourceScreenSupportTest
143 143
     }
144 144
 
145 145
     @Test
146
-    @DisplayName("DP-XM-UT-019 热点 Top5")
146
+    @DisplayName("DP-XM-UT-019 热点 Top5 条数优先")
147 147
     void ut019_hotTopics()
148 148
     {
149
-        LivestockResourceCategoryItemVo a = item("2", "疾病诊疗", 50L, new BigDecimal("50"));
150
-        LivestockResourceCategoryItemVo b = item("1", "养殖技巧", 30L, new BigDecimal("30"));
149
+        LivestockResourceCategoryCountDto a = cat("2", 50L);
150
+        LivestockResourceCategoryCountDto b = cat("1", 30L);
151 151
         List<LivestockResourceCategoryItemVo> hot = LivestockResourceScreenSupport.buildHotTopics(
152 152
                 Arrays.asList(a, b), 5);
153 153
         assertEquals(2, hot.size());
154 154
         assertEquals("2", hot.get(0).getCategoryCode());
155
+        assertEquals(50L, hot.get(0).getCount());
155 156
     }
156 157
 
157 158
     @Test

+ 1 - 1
doc/大屏/畜牧资源/大屏畜牧资源功能需求-草稿.md

@@ -7,7 +7,7 @@
7 7
 5. 用户结构分析:统计biz_diagnosis_user表中的新用户人数、老用户人数;新用户:last_use_time-first_use_time <=半年;老用户:last_use_time-first_use_time > 半年;
8 8
 6. 提问分类占比:查询biz_consult_message表中sender_role=3、sender_user_id=0截至到当前时间的通过ai_category进行分类统计各个类型占问题总数的比例;饼状图展示。
9 9
 7. 模型调用分析:查询biz_consult_message表中sender_role=3、sender_user_id=0截至到当前时间的通过ai_category进行分类统计提问次数。
10
-8. 分时使用热度: 分会话数(次)、提问量(次)两个维度统过去24小时每小时的会话数、提问量的趋势变化;会话数:biz_consult_session表中过去24小时某个小时内创建的会话数;提问量:统计biz_consult_message表中sender_role=3、sender_user_id=0、create_time为过去24小时某个小时的记录行数。
10
+8. 分时使用热度: 分会话数(次)、提问量(次)两个维度统当天24小时内每小时的会话数、提问量的趋势变化;会话数:biz_consult_session表中当天24小时内某个小时内创建的会话数;提问量:统计biz_consult_message表中sender_role=3、sender_user_id=0、create_time为当天24小时内某个小时的记录行数。
11 11
 9. 单次会话提问数量分布:通过会话提问次数(1-2条|3-4条|5-10条|10条以上)分类统计对对应类型的会话与总会话数的占比;通过biz_consult_session表和biz_consult_message关联,统计每个会话的提问数量(sender_role=3、sender_user_id=0的记录条数);
12 12
 10. 会话状态分布:分正常应答、调用失败2个维度统计提问次数;正常应答:cost_time不为空;调用失败:cost_time为空;查询biz_consult_message表中sender_role=3、sender_user_id=0截至到当前时间的按维度统计记录的条数。
13 13
 11. 热点问题榜单:查询biz_consult_message表中sender_role=3、sender_user_id=0截至到当前时间的通过ai_category进行分类统计各个类型占问题总数的比例;占比多的即为热点问题;按时间倒序取前5条。

+ 30 - 27
doc/大屏/畜牧资源/大屏畜牧资源功能需求.md

@@ -9,7 +9,7 @@
9 9
 | 目标 | 按**统计年份**与**统计日**,集中展示 **AI 诊断(兽医、机构)** 及 **移动端 AI 助手** 的使用规模、活跃趋势、用户结构、提问分类与模型应答质量,形成「畜牧资源 / 智能问诊」成效一屏总览 |
10 10
 | 数据来源 | **牧业疫病诊疗服务 — AI 诊断**:AI 问诊会话、问答消息、AI 诊断使用用户台账;数据由兽医/机构后台、移动端用户日常使用 AI 助手产生,大屏**不录入、不修改** |
11 11
 | 修订依据 | 同目录 `大屏畜牧资源功能需求-草稿.md`;大屏页面布局见 `ruoyi-screen` 畜牧资源专题页;指标口径与《AI 诊断(兽医、机构)功能需求》v1.1 对齐 |
12
-| **文档版本** | **1.1**(产品确认见 **§2.18**) |
12
+| **文档版本** | **1.2**(对齐草稿修订;产品确认见 **§2.18**) |
13 13
 | 关联维护 | 会话、提问、AI 回复须在业务端(管理端 AI 诊断、移动端 AI 助手)产生;大屏通过刷新或重新进入获取最新结果 |
14 14
 
15 15
 **命名说明:** 本专题在大屏导航中称为「畜牧资源」,**本期统计对象为 AI 智能问诊使用数据**,与移动端「畜牧资源」资讯列表(专家团队、技术成果等)**不是同一业务**,二者勿混用。
@@ -25,7 +25,7 @@
25 25
 - 大屏顶部(或全局筛选区)提供**统计年份**选择,格式为自然年 **YYYY**(如 `2026`)。
26 26
 - 进入大屏时默认选中**当前自然年**。
27 27
 - 变更统计年份后,凡标注「与 **Y** 相关」的区块须**同步**按新年份重算;**不得**出现区块之间年份不一致。
28
-- **近 7 日活跃度**、**近 24 小时分时热度**按 **§2.2 统计日** 滚动计算,**不随 Y 切换**(见 **§2.4**)。
28
+- **近 7 日活跃度**、**统计日分时热度**按 **§2.2 统计日** 计算,**不随 Y 切换**(见 **§2.4**)。
29 29
 
30 30
 ### 2.2 统计日
31 31
 
@@ -41,7 +41,8 @@
41 41
 | --- | --- | --- |
42 42
 | **Y 年内累计** | 累计使用用户、累计会话数、累计提问量、平均响应时长、分类占比、模型调用次数、会话提问分布、会话状态分布、高频关键词 | 仅统计 **Y** 年内纳入 **§2.5** 的数据 |
43 43
 | **统计日当天** | 今日活跃用户、日新增用户、今日会话数、今日提问量 | 以**统计日**为自然日边界;当 **Y** 不等于当前自然年时,上述四项按 **0** 展示(或展示 **—** 并标注「仅当前年有效」,产品择一,全文统一) |
44
-| **滚动窗口** | 近 7 日会话数/提问量趋势;过去 24 小时分时热度 | 以**统计日**/**统计时刻**为终点向前滚动;**与 Y 无关** |
44
+| **滚动窗口** | 近 7 日会话数/提问量趋势 | 以**统计日**为终点向前 7 日;**与 Y 无关** |
45
+| **统计日分时** | 分时使用热度(24 整点小时) | 以**统计日**自然日为区间;**与 Y 无关** |
45 46
 
46 47
 ### 2.5 AI 问诊数据纳入规则
47 48
 
@@ -69,7 +70,7 @@
69 70
 
70 71
 | 指标 | 规则 |
71 72
 | --- | --- |
72
-| 提问量(次) | **Y 年内**或**统计日**内,AI 助手回复消息条数(一条回复计 1 次);展示名见 **§2.6** |
73
+| 提问量(次) | **Y 年内**或**统计日**内,AI 助手回复(`sender_role=3`、`sender_user_id=0`)消息条数,以消息 **`create_time`** 归属日/年(一条回复计 1 次);展示名见 **§2.6** |
73 74
 | 平均响应时长(秒) | **Y 年内**、**已记录耗时数值**的 AI 应答(**含耗时为 0**,不排除 0)求**算术平均**;**未记录耗时**(空值)不参与平均;分母为 0 时展示 **0** |
74 75
 | 正常应答 | AI 应答**已记录耗时数值**(**含 0**) |
75 76
 | 调用失败 | AI 应答**未记录耗时**(空值;含超时、网关失败等未落耗时场景) |
@@ -79,8 +80,8 @@
79 80
 | 指标 | 规则 |
80 81
 | --- | --- |
81 82
 | **累计使用用户(人)** | **Y 年内**至少参与过 1 条有效 AI 问诊会话的**提问人**去重人数 |
82
-| **今日活跃用户(人)** | **统计日**内,存在 AI 问诊会话且该会话**最后活跃时间**落在统计日的**提问人**去重人数 |
83
-| **日新增用户(人)** | **统计日**首次产生 AI 问诊会话的**提问人**去重人数:该提问人在统计日 **00:00:00** 之前**无任何** AI 问诊会话记录 |
83
+| **今日活跃用户(人)** | **统计日**内,`biz_consult_session` 中 `consult_type=2` 且 **`update_time`** 落在统计日的 **提问人**去重人数 |
84
+| **日新增用户(人)** | **统计日**内 `create_time`、`update_time` **均**落在统计日的 **提问人**去重人数,且该提问人在统计日 **00:00:00** 之前**未**出现在任意 AI 问诊会话中 |
84 85
 | **累计会话数(次)** | **Y 年内**创建的有效 AI 问诊会话条数 |
85 86
 | **今日会话数(次)** | **统计日**内**创建**的有效 AI 问诊会话条数 |
86 87
 
@@ -107,23 +108,23 @@
107 108
 
108 109
 | 项 | 规则 |
109 110
 | --- | --- |
110
-| 统计对象 | **提问人消息**上登记的分类;**未登记**或无法识别的归入 **「未分类」** |
111
-| 占比 | 某分类的提问条数 ÷ **Y 年内全部提问人消息条数**(**含未分类**);有数据时各分类占比之和为 **100%**(允许四舍五入误差);分母为 0 时走 **§7.1** 空态 |
112
-| 模型调用分析 | 按同一分类维度统计 **Y 年内**各分类的**提问条数**(可与占比区块共用数据,展示形式以原型为准:柱状/条形等) |
111
+| 统计对象 | **Y 年内** AI 助手回复条数,分类取自同会话内**不晚于该条回复**的最近一条提问人消息的 `ai_category`;**未登记**归入 **「未分类」**(与草稿「按 AI 消息 + ai_category 统计」一致) |
112
+| 占比 | 某分类 AI 回复条数 ÷ **Y 年内全部 AI 回复条数**;有数据时各分类占比之和为 **100%**(允许四舍五入误差);分母为 0 时走 **§7.1** 空态 |
113
+| 模型调用分析 | 按同一分类维度统计 **Y 年内**各分类的 **AI 回复条数**(柱状/条形等,与占比同源) |
113 114
 
114 115
 ### 2.11 近 7 日活跃度窗口
115 116
 
116 117
 - 区间:**[统计日 − 6 日, 统计日]**,共 **7** 个自然日(含统计日当天)。
117 118
 - **会话数**:该日**创建**的有效 AI 问诊会话数。
118
-- **提问量**:该日**发送**的 AI 助手回复条数(**§2.6**)。
119
+- **提问量**:该日 **`create_time`** 落在当日的 AI 助手回复条数(**§2.6**)。
119 120
 - 缺日无数据时该日按 **0** 展示;**不**省略刻度。
120 121
 
121
-### 2.12 过去 24 小时分时热度窗口(产品确认
122
+### 2.12 统计日分时热度窗口(对齐草稿
122 123
 
123
-- 区间:以**统计时刻**为终点,向前连续 **24 小时**的滑动窗口 **(统计时刻 − 24 小时, 统计时刻]**(**过去 24 小时**,**不**按自然日 `00:00~23:59` 统计)。
124
-- 分桶:将上述区间划分为 **24** 个连续、等长的 **1 小时**时段(第 1 段为最早 1 小时,第 24 段为含统计时刻的最近 1 小时);横轴展示 24 个时段标签(建议 `HH:mm` 时段起点或等价格式,以原型为准)。
125
-- **会话数**:该时内**创建**的有效 AI 问诊会话数。
126
-- **提问量**:该时段内**发送**的 AI 助手回复条数(**§2.6**)。
124
+- 区间:**统计日**自然日 **00:00:00~23:59:59**(**当天 24 小时**,**不**使用滑动 24 小时窗口)。
125
+- 分桶:按自然小时 **0~23** 共 **24** 桶;横轴标签建议 `HH:00`(如 `00:00` … `23:00`)。
126
+- **会话数**:该时内**创建**的有效 AI 问诊会话数。
127
+- **提问量**:该小时内 **`create_time`** 落在该小时的 AI 助手回复条数(**§2.6**)。
127 128
 
128 129
 ### 2.13 单次会话 AI 应答数量分布
129 130
 
@@ -151,9 +152,8 @@
151 152
 
152 153
 ### 2.15 热点问题类型榜单
153 154
 
154
-- 按 **§2.10** 分类统计 **Y 年内**提问条数及占比(分母为全部提问人消息)。
155
-- 取占比(或条数)**最高的前 5 个**分类展示为榜单;不足 5 类时按实际类数展示。
156
-- **不按**单条消息时间倒序取 5 条(与草稿「分类统计 + 时间倒序」混写区分)。
155
+- 按 **§2.10** 对 **Y 年内** AI 回复分类统计条数及占比。
156
+- 取条数(占比)**最高的前 5 个**分类;同条数时按该分类下**最近一条 AI 回复时间**倒序(对齐草稿「按时间倒序取前 5 条」的排序语义)。
157 157
 
158 158
 ### 2.16 高频关键词
159 159
 
@@ -173,14 +173,16 @@
173 173
 | 秒 | 平均响应时长;建议保留 **1** 位小数或整数,全文统一 |
174 174
 | % | 占比、新老用户结构;建议保留 **1** 位小数或整数 **%**,全文统一 |
175 175
 
176
-### 2.18 产品确认(v1.1)
176
+### 2.18 产品确认(v1.1 / v1.2 增补
177 177
 
178 178
 | # | 确认项 | 约定 |
179 179
 | --- | --- | --- |
180 180
 | 1 | 总览/趋势中的「提问量」名称 | 界面**统一**使用「提问量」;统计对象为 **AI 助手回复条数** |
181 181
 | 2 | 已隐藏会话 | **纳入**统计 |
182
-| 3 | 提问分类占比分母 | **Y 年内全部提问人消息**(含未分类);有数据时占比之和 **100%** |
183
-| 4 | 分时热度窗口 | **过去 24 小时**滑动窗口(**§2.12**),非自然日 24 小时 |
182
+| 3 | 提问分类占比分母 | **Y 年内全部 AI 回复条数**(含未分类);有数据时占比之和 **100%** |
183
+| 4 | 分时热度窗口 | **统计日自然日** 24 个整点小时桶(**§2.12**),非滑动 24 小时 |
184
+| 7 | 今日活跃 / 日新增 | 今日活跃按会话 **`update_time`**;日新增须 **`create_time` 与 `update_time` 均在统计日** 且此前无会话(**§2.8**) |
185
+| 8 | 分类 / 模型调用 / 热点 | 统计 **AI 回复条数** 及分类归因(**§2.10**);占比分母为年内全部 AI 回复 |
184 186
 | 5 | 平均响应时长 | 已记录耗时的应答**含 0** 参与平均;**不排除 0** |
185 187
 | 6 | 高频关键词语料 | **仅**文本类提问 |
186 188
 
@@ -461,6 +463,7 @@ flowchart TB
461 463
 | --- | --- | --- |
462 464
 | 1.0 | 2026-05-20 | 由草稿优化:补术语、流程、十区块需求与验收;对齐 AI 诊断业务;不涉及库表与接口;草稿文件不修改 |
463 465
 | 1.1 | 2026-05-20 | **§2.18** 产品确认:提问量名称、隐藏会话计入、分类占比 100% 分母、过去 24 小时窗口、耗时含 0、关键词仅文本 |
466
+| 1.2 | 2026-05-20 | 对齐草稿修订:**§2.8** `update_time` 今日活跃、日新增双时间+历史未出现;**§2.10~2.15** AI 回复分类统计;**§2.12** 统计日 24 自然小时桶;消息时间以 `create_time` 为主 |
464 467
 
465 468
 ---
466 469
 
@@ -471,15 +474,15 @@ flowchart TB
471 474
 | 直接写表名、字段、SQL | 改为 **§2** 业务口径与纳入规则 |
472 475
 | 「统计当前年份」与「截至当前时间」混用 | 统一 **§2.1、§2.4**:年内指标随 **Y**;今日类随统计日;7 日/24 小时滚动 |
473 476
 | 「提问量」统计 AI 回复行 | **§2.6** 展示名固定「提问量」,对象为 AI 回复条数 |
474
-| 分类占比分母 | **§2.10** 全部提问人消息,占比之和 100% |
475
-| 24 小时窗口 | **§2.12** 过去 24 小时滑动窗口 |
477
+| 分类占比分母 | **§2.10** 全部 AI 回复,占比之和 100% |
478
+| 24 小时窗口 | **§2.12** 统计日 **0~23** 自然小时(v1.2 对齐草稿) |
476 479
 | 平均耗时排除 0 | **§2.7** 含 0 参与平均 |
477 480
 | 隐藏会话 | **§2.5** 明确纳入 |
478 481
 | 关键词语料 | **§2.16** 仅文本提问 |
479
-| 分类占比/模型分析写在 AI 消息上 | 改为 **提问人消息**分类(**§2.10**),与《AI 诊断》一致 |
480
-| 热点榜单「比例 + 时间倒序 5 条」矛盾 | 改为 Top5 **问题类型(分类)**(**§2.15**) |
481
-| 日新增用户条件冗长 | 收束为统计日**首次**出现 AI 会话的提问人(**§2.8**) |
482
-| 今日活跃仅用会话更新时间 | 保留并写入 **§2.8**(最后活跃时间在统计日) |
482
+| 分类占比/模型分析写在 AI 消息上 | v1.2:**AI 回复条数** + 提问人 `ai_category` 归因(**§2.10**) |
483
+| 热点榜单「比例 + 时间倒序 5 条」 | Top5 分类;条数优先、同分按最近 AI 回复时间倒序(**§2.15**) |
484
+| 日新增用户 | v1.2:`create_time` 与 `update_time` 均在统计日,且此前无会话(**§2.8**) |
485
+| 今日活跃 | v1.2:会话 **`update_time`** 在统计日(**§2.8**) |
483 486
 | 用户结构未说明半年 | **§2.9** 明确 180 自然日 |
484 487
 | 未区分大屏「畜牧资源」与 app 资讯 | **§1** 命名说明 |
485 488
 | 技术栈、接口、异常响应格式 | 不写入本文档 |

+ 33 - 32
doc/大屏/畜牧资源/大屏畜牧资源技术方案.md

@@ -1,6 +1,6 @@
1 1
 # 大屏 — 畜牧资源统计 — 技术方案
2 2
 
3
-> 依据:同目录 `大屏畜牧资源功能需求.md` v1.1。数据源为 **AI 诊断** 既有表(会话、消息、使用用户台账)。本模块为**大屏只读看板**;**不**统计移动端「畜牧资源」资讯视图 `v_livestock_resource`。
3
+> 依据:同目录 `大屏畜牧资源功能需求.md` v1.2。数据源为 **AI 诊断** 既有表(会话、消息、使用用户台账)。本模块为**大屏只读看板**;**不**统计移动端「畜牧资源」资讯视图 `v_livestock_resource`。
4 4
 
5 5
 ---
6 6
 
@@ -20,11 +20,11 @@
20 20
 | 进入 / 切换 `statYear` | 调用 **§3.1** |
21 21
 | 数据权限 | **全县**聚合;不按提问人/机构过滤 |
22 22
 | 统计日 `statDate` | 复用 `HomeScreenSupport.resolveStatDate(Y)`:`Y`=当前年 → 今日(`Asia/Shanghai`);历史年 → `Y-12-31` |
23
-| 统计时刻 `statDateTime` | 请求时 `LocalDateTime.now(Asia/Shanghai)`;**过去 24 小时**窗口以其为终点 |
24
-| 年内上界 | 消息/会话:`create_time` 或 `send_time` ≤ `statDate 23:59:59`(当前年);历史年 ≤ `Y-12-31 23:59:59` |
23
+| 统计时刻 `statDateTime` | 请求时 `LocalDateTime.now(Asia/Shanghai)`;响应回显用 |
24
+| 年内上界 | 会话 `create_time`;AI 消息 **`create_time`** ≤ `statDate 23:59:59`(当前年) |
25 25
 | 「今日」四项 | 仅当 `Y` = 当前自然年时按 `statDate` 自然日统计;否则固定 **0** |
26 26
 | 用户结构 | `biz_consult_user` **全表快照**,**不**随 `Y` 变 |
27
-| 7 日 / 24 小时 | 以 `statDate` / `statDateTime` 滚动,**与 Y 无关** |
27
+| 7 日 / 分时 | 7 日以 `statDate` 滚动;分时为 **统计日 0~23 时**,**与 Y 无关** |
28 28
 | 已隐藏会话 | **不**过滤 `vet_visible`(对齐功能需求 **§2.18#2**) |
29 29
 | 性能 | 默认实时聚合;可选 **§2.3** 缓存表 |
30 30
 
@@ -63,10 +63,10 @@
63 63
 | 边界 | 表达式 |
64 64
 | --- | --- |
65 65
 | 年区间起 | `Y-01-01 00:00:00` |
66
-| 年区间止(会话 `create_time`、消息 `send_time`) | `statDate 23:59:59` |
66
+| 年区间止(会话 `create_time`、AI 消息 `create_time`) | `statDate 23:59:59` |
67 67
 | 统计日区间 | `statDate 00:00:00` ~ `statDate 23:59:59` |
68 68
 | 近 7 日 | `[statDate − 6 日 00:00:00, statDate 23:59:59]` |
69
-| 过去 24 小时 | `(statDateTime − 24h, statDateTime]`,划分为 **24** 个连续 1 小时桶 |
69
+| 统计日分时 | 统计日自然日;`HOUR(create_time)` / `HOUR(m.create_time)` 分 **24** 桶(0~23) |
70 70
 
71 71
 **会话别名(下文)**:`s` = `biz_consult_session`,条件 `s.consult_type = 2`。  
72 72
 **AI 消息别名**:`m_ai` = `biz_consult_message`,`m_ai.sender_role = 3 AND m_ai.sender_user_id = 0`,且 `EXISTS` 关联 `s.id = m_ai.session_id`。  
@@ -93,12 +93,12 @@
93 93
 | 指标 | SQL 要点 |
94 94
 | --- | --- |
95 95
 | 累计使用用户 | `COUNT(DISTINCT s.asker_user_id)`,`s.create_time` ∈ 年区间 |
96
-| 今日活跃用户 | `Y`=当前年:`COUNT(DISTINCT s.asker_user_id)`,`s.last_message_time` ∈ 统计日区间;否则 **0** |
97
-| 日新增用户 | `Y`=当前年:子查询 `asker_user_id` 满足 `MIN(s.create_time)` ∈ 统计日区间;否则 **0** |
96
+| 今日活跃用户 | `Y`=当前年:`COUNT(DISTINCT asker_user_id)`,`s.update_time` ∈ 统计日;否则 **0** |
97
+| 日新增用户 | `Y`=当前年:`create_time` 与 `update_time` 均在统计日,且 `asker_user_id` 不在 `create_time < 统计日` 的会话中;否则 **0** |
98 98
 | 累计会话数 | `COUNT(s.id)`,`s.create_time` ∈ 年区间 |
99 99
 | 今日会话数 | `Y`=当前年:`COUNT(s.id)`,`s.create_time` ∈ 统计日区间;否则 **0** |
100
-| 累计提问量 | `COUNT(m_ai.id)`,`m_ai.send_time` ∈ 年区间 |
101
-| 今日提问量 | `Y`=当前年:`COUNT(m_ai.id)`,`m_ai.send_time` ∈ 统计日区间;否则 **0** |
100
+| 累计提问量 | `COUNT(m_ai.id)`,`m_ai.create_time` ∈ 年区间 |
101
+| 今日提问量 | `Y`=当前年:`COUNT(m_ai.id)`,`m_ai.create_time` ∈ 统计日;否则 **0** |
102 102
 | 平均响应时长(秒) | `AVG(m_ai.cost_time) / 1000.0`,年区间内且 `m_ai.cost_time IS NOT NULL`(**含 0**);无记录 → **0** |
103 103
 
104 104
 #### 2.4.2 近 7 日活跃度
@@ -106,7 +106,7 @@
106 106
 | 维度 | SQL 要点 |
107 107
 | --- | --- |
108 108
 | 会话数/日 | `DATE(s.create_time)` 分组,`COUNT(s.id)` |
109
-| 提问量/日 | `DATE(m_ai.send_time)` 分组,`COUNT(m_ai.id)` |
109
+| 提问量/日 | `DATE(m_ai.create_time)` 分组,`COUNT(m_ai.id)` |
110 110
 
111 111
 应用层:固定输出 **7** 个点(`statDate−6` … `statDate`),缺日补 **0**。
112 112
 
@@ -123,20 +123,20 @@
123 123
 
124 124
 | 项 | 条件 |
125 125
 | --- | --- |
126
-| 统计行 | `m_ask`,`m_ask.send_time` ∈ 年区间 |
127
-| 分类键 | `COALESCE(NULLIF(TRIM(m_ask.ai_category), ''), '__UNCAT__')`;`Support` 映射:`1`~`4` → 中文名,其余 →「未分类」 |
128
-| 条数 | `GROUP BY` 分类键 `COUNT(*)` |
129
-| 占比 | 分类条数 / **当年全部** `m_ask` 条数 × 100(保留 **1** 位小数) |
130
-| 热点 Top5 | 按条数或占比降序取前 **5**(`Support` 排序) |
126
+| 统计行 | `m_ai`(`sender_role=3`、`sender_user_id=0`),`create_time` ∈ 年区间 |
127
+| 分类键 | 同会话内 `send_time ≤ m_ai.send_time` 的最近提问人消息 `ai_category`;空 → `UNCAT` |
128
+| 条数 | `GROUP BY` 分类键 `COUNT(*)`;`MAX(m_ai.create_time)` 供热点二次排序 |
129
+| 占比 | 分类 AI 回复条数 / **当年全部 AI 回复** × 100 |
130
+| 热点 Top5 | 条数降序;同分按 `lastEventTime` 降序;取 **5** |
131 131
 
132
-#### 2.4.5 过去 24 小时分时热度
132
+#### 2.4.5 统计日分时热度
133 133
 
134 134
 | 维度 | SQL 要点 |
135 135
 | --- | --- |
136
-| 会话 | `s.create_time` ∈ 24h 窗口,按小时桶 `FLOOR(TIMESTAMPDIFF(HOUR, window_start, s.create_time))` 或 `DATE_FORMAT` 分桶 |
137
-| 提问量 | `m_ai.send_time` 同上 |
136
+| 会话 | `s.create_time` ∈ 统计日,`GROUP BY HOUR(create_time)` |
137
+| 提问量 | `m_ai.create_time` ∈ 统计日,`GROUP BY HOUR(m_ai.create_time)` |
138 138
 
139
-应用层:固定 **24** 个桶,标签为桶起点 `HH:mm`(`Support.buildHourlySeries`)。
139
+应用层:固定 **24** 桶 `00:00`~`23:00`;`hourlyHeat.windowEnd` = `statDate`(`yyyy-MM-dd`)。
140 140
 
141 141
 #### 2.4.6 单次会话提问数量分布
142 142
 
@@ -171,8 +171,8 @@
171 171
 
172 172
 | 表 | 建议 |
173 173
 | --- | --- |
174
-| `biz_consult_session` | `(consult_type, create_time)`、`(consult_type, last_message_time)` |
175
-| `biz_consult_message` | `(session_id, sender_role, send_time)`、`(send_time, sender_role)` |
174
+| `biz_consult_session` | `(consult_type, create_time)`、`(consult_type, update_time)` |
175
+| `biz_consult_message` | `(session_id, sender_role, create_time)`、`(create_time, sender_role)` |
176 176
 
177 177
 ### 2.6 Mapper 方法(建议)
178 178
 
@@ -182,10 +182,10 @@
182 182
 | | `selectDailySessionCounts(start, end)` | 7 日会话 |
183 183
 | | `selectDailyAiReplyCounts(start, end)` | 7 日提问量 |
184 184
 | | `selectUserStructure()` | 新老用户 |
185
-| | `selectAskerCategoryCounts(yearStart, yearEnd)` | 分类条数 |
186
-| | `selectAskerMessageTotal(yearStart, yearEnd)` | 分类占比分母 |
187
-| | `selectHourlySessionCounts(windowStart, windowEnd)` | 24h 会话 |
188
-| | `selectHourlyAiReplyCounts(windowStart, windowEnd)` | 24h 提问量 |
185
+| | `selectAskerCategoryCounts(yearStart, yearEnd)` | AI 回复按分类键聚合 |
186
+| | `selectAiReplyTotal(yearStart, yearEnd)` | 分类占比分母(年内 AI 回复总数) |
187
+| | `selectHourlySessionCounts(dayStart, dayEnd)` | 统计日 24 小时会话 |
188
+| | `selectHourlyAiReplyCounts(dayStart, dayEnd)` | 统计日 24 小时提问量 |
189 189
 | | `selectSessionReplyBuckets(yearStart, yearEnd)` | 四档会话数 |
190 190
 | | `selectYearSessionTotal(yearStart, yearEnd)` | 年内会话总数 |
191 191
 | | `selectAiReplyStatusCounts(yearStart, yearEnd)` | 正常/失败 |
@@ -280,7 +280,7 @@ XML:`mapper/screen/LivestockResourceScreenMapper.xml`。
280 280
 
281 281
 | 字段 | 类型 | 说明 |
282 282
 | --- | --- | --- |
283
-| `totalAskerMessages` | long | 分母:年内全部提问人消息 |
283
+| `totalAskerMessages` | long | 分母:年内全部 **AI 回复**(字段名沿用,与提问人无关) |
284 284
 | `items` | array | 分类项 |
285 285
 
286 286
 `items[]`:`categoryCode`、`categoryName`、`count`、`ratio`(0~100,1 位小数)。固定含 `1`~`4` + `UNCAT`(未分类),无数据 `count=0`。
@@ -387,11 +387,11 @@ SQL 示例:`sql/big_screen_livestock_resource_perm.sql`(挂载「大屏」
387 387
 
388 388
 ## 5. 交付清单
389 389
 
390
-- [ ] `LivestockResourceScreenController` + `ILivestockResourceScreenService` + VO + `LivestockResourceScreenSupport`
391
-- [ ] `LivestockResourceScreenMapper` + XML(**§2.6**)
392
-- [ ] `ruoyi-screen` 对接 `GET /bigScreen/livestockResource/dashboard`
393
-- [ ] 单元测试:历史年今日为 0、7 日补 0、24 小时 24 桶、分类占比 100%、耗时含 0、隐藏会话计入
394
-- [ ] 接口测试:MockMvc `dashboard`
390
+- [x] `LivestockResourceScreenController` + `ILivestockResourceScreenService` + VO + `LivestockResourceScreenSupport`
391
+- [x] `LivestockResourceScreenMapper` + XML(**§2.6**)
392
+- [ ] `ruoyi-screen` 对接 `GET /bigScreen/livestockResource/dashboard`(本期不改前端)
393
+- [x] 单元测试:历史年今日为 0、7 日补 0、统计日 24 桶、分类占比、耗时含 0
394
+- [x] 接口测试:MockMvc `dashboard`
395 395
 - [ ] 菜单权限 SQL;依赖 `biz_consult_user`、消息 `cost_time`/`ai_category` 字段已上线
396 396
 
397 397
 ---
@@ -401,3 +401,4 @@ SQL 示例:`sql/big_screen_livestock_resource_perm.sql`(挂载「大屏」
401 401
 | 版本 | 日期 | 说明 |
402 402
 | --- | --- | --- |
403 403
 | 1.0 | 2026-05-20 | 初稿:无新表;单接口十区块;对齐 `大屏畜牧资源功能需求.md` v1.1 |
404
+| 1.2 | 2026-05-20 | 对齐草稿:今日活跃 `update_time`、日新增双时间、AI 回复+分类归因、统计日 0~23 时分、`create_time` 消息归属 |

+ 15 - 10
doc/大屏/畜牧资源/大屏畜牧资源测试用例.md

@@ -1,6 +1,6 @@
1 1
 # 大屏 — 畜牧资源统计 — 测试用例
2 2
 
3
-> 依据:`大屏畜牧资源功能需求.md`(v1.1)、`大屏畜牧资源技术方案.md`(v1.0
3
+> 依据:`大屏畜牧资源功能需求.md`(v1.2)、`大屏畜牧资源技术方案.md`(v1.2
4 4
 > **接口 Base Path**:`/bigScreen/livestockResource`(含 `context-path`、网关前缀须补齐)。鉴权:若依 Cookie / `Authorization` Token;权限 `bigScreen:livestockResource:query`。
5 5
 
6 6
 **通用前置(无特殊说明)**:已执行 `sql/biz_consult_session.sql`、`sql/biz_consult_message.sql`、`sql/biz_consult_user.sql`;测试账号具备大屏畜牧资源查询权限;时区 `Asia/Shanghai`。
@@ -15,11 +15,14 @@
15 15
 | 接诊隔离 | `consult_type=1` **不参与** |
16 16
 | 提问量 | **AI 回复**(`sender_role=3`、`sender_user_id=0`)条数;界面名称仍为「提问量」 |
17 17
 | 今日四项 | 仅 **Y**=当前自然年按 `statDate` 统计;历史年固定 **0** |
18
-| 分类占比 | **提问人消息**;分母=年内**全部**提问人消息;占比之和 **100%** |
18
+| 分类占比 | **AI 回复** + 提问人 `ai_category` 归因;分母=年内**全部 AI 回复**;占比之和 **100%** |
19 19
 | 平均耗时 | `cost_time` 非空(**含 0**)参与平均;空值不参与;展示**秒** |
20 20
 | 用户结构 | `biz_consult_user` 全表;新≤180 天、老>180 天;**不随 Y** |
21 21
 | 近 7 日 | `[statDate−6, statDate]` 共 **7** 日;缺日 **0** |
22
-| 分时热度 | **过去 24 小时**滑动窗口、**24** 小时桶;**与 Y 无关** |
22
+| 分时热度 | **统计日**自然日 **0~23** 整点小时桶;**与 Y 无关** |
23
+| 今日活跃 | 会话 **`update_time`** 在统计日 |
24
+| 日新增 | **`create_time` 与 `update_time` 均在统计日**,且此前无 AI 会话 |
25
+| 分类/模型/热点 | **AI 回复条数** + 提问人 `ai_category` 归因;占比分母=年内 AI 回复总数 |
23 26
 | 关键词 | 仅 **文本**提问(`msg_type=1`) |
24 27
 
25 28
 **本期不测**:移动端「畜牧资源」资讯 `v_livestock_resource`、AI 诊断维护接口、在线接诊数据。
@@ -43,7 +46,7 @@
43 46
 | **AI-COST** | AI 回复:`cost_time=0`、正数毫秒、NULL 各至少 1 条 |
44 47
 | **AI-BAND** | 年内会话 A~D 的 AI 回复数分别为 2、4、8、12 条(四档分布) |
45 48
 | **AI-7D** | 近 7 日内每日各有会话创建与 AI 回复 |
46
-| **AI-24H** | 过去 24h 窗口内跨 3 个小时桶有会话/回复 |
49
+| **AI-DAY-HOUR** | 统计日自然日内跨 3 个整点小时桶有会话/回复 |
47 50
 | **DU-MIX** | `biz_consult_user`:新用户 3、老用户 2(间隔按 180 天规则构造) |
48 51
 | **DU-EMPTY** | 无 `biz_consult_user` 行 |
49 52
 | **KW-TEXT** | **2026** 文本提问含可分词关键词「牦牛」「防疫」等 |
@@ -66,10 +69,10 @@
66 69
 | DP-XM-UT-009 | 今日指标 | 当前年今日 | 单元测试 | JUnit5 | §2.8 | `AI-TODAY`;statDate=今日 | `buildOverview(2026)` | 今日四项与样本一致 |
67 70
 | DP-XM-UT-010 | 平均耗时 | 含 0 不排除 | 单元测试 | JUnit5 | §2.18#5 | `AI-COST`:0ms、3000ms、NULL | `avgResponseSeconds` | 平均=(0+3)/2 秒;NULL 不入分母 |
68 71
 | DP-XM-UT-011 | 7 日序列 | 补零 7 点 | 单元测试 | JUnit5 | §2.11 | 仅首尾 2 日有数据 | `buildDailySeries(statDate)` | `length=7`;中间日 `sessionCount=questionCount=0` |
69
-| DP-XM-UT-012 | 24 小时 | 滑动 24 桶 | 单元测试 | JUnit5 | §2.12、SY-XM-07 | 固定 `statDateTime` | `buildHourlySeries` | `hourlySeries.length=24`;窗口为 `now-24h~now` |
72
+| DP-XM-UT-012 | 分时 | 统计日 24 自然小时桶 | 单元测试 | JUnit5 | §2.12、SY-XM-07 | `statDate=2026-05-20` | `buildHourlyHeat` | `hourlySeries.length=24`;`windowEnd=2026-05-20`;`10:00` 桶有数据 |
70 73
 | DP-XM-UT-013 | 用户结构 | 180 天分界 | 单元测试 | JUnit5 | §2.9 | `DU-MIX` | `buildUserStructure` | 新/老人数与 `DATEDIFF` 规则一致 |
71 74
 | DP-XM-UT-014 | 用户结构 | 不随 Y 变 | 单元测试 | JUnit5 | SY-XM-05 | 固定 `DU-MIX` | `loadDashboard(2025)` 与 `2026` | `userStructure` 相同 |
72
-| DP-XM-UT-015 | 分类占比 | 分母全量 | 单元测试 | JUnit5 | §2.18#3 | 5 类提问人消息已知 | `buildCategoryShare` | 各 `ratio` 之和≈100(1 位小数) |
75
+| DP-XM-UT-015 | 分类占比 | 分母全量 | 单元测试 | JUnit5 | §2.18#3 | 5 类 AI 回复已知 | `buildCategoryShare` | 各 `ratio` 之和≈100(1 位小数);分母=年内 AI 回复总数 |
73 76
 | DP-XM-UT-016 | 分类映射 | 编码转中文 | 单元测试 | JUnit5 | §2.10 | `ai_category` 为 `1`~`4`、空 | `normalizeCategory` | 养殖技巧…设备操作;空→未分类 |
74 77
 | DP-XM-UT-017 | 会话分档 | 四档固定 | 单元测试 | JUnit5 | §2.13 | `AI-BAND` | `buildSessionReplyDistribution` | 4 项 LIGHT~DEEP;`sum(sessionCount)`=年内会话数 |
75 78
 | DP-XM-UT-018 | 状态分布 | 正常/失败 | 单元测试 | JUnit5 | §2.14 | `AI-COST` | `buildReplyStatus` | OK+FAIL=`totalAiReplies`;NULL→FAIL |
@@ -96,9 +99,9 @@
96 99
 | DP-XM-API-008 | 看板 | 7 日与 Y 无关 | 接口测试 | Postman | SY-XM-02 | 固定 `AI-7D` | `statYear=2025` 与 `2026`(同日请求) | `activityTrend` 一致 |
97 100
 | DP-XM-API-009 | 看板 | 用户结构 | 接口测试 | Postman | SY-XM-05 | `DU-MIX` | `GET dashboard` | `newUserCount`/`oldUserCount` 正确;`newUserRatio+oldUserRatio≈100` |
98 101
 | DP-XM-API-010 | 看板 | 用户结构切年不变 | 接口测试 | Postman | §2.9 | `DU-MIX` 固定 | `statYear=2025` 与 `2026` | `userStructure` 相同 |
99
-| DP-XM-API-011 | 看板 | 分类占比 100% | 接口测试 | Postman | SY-XM-06 | `AI-Y` 多分类 | `categoryShare.items` | `sum(ratio)≈100`;分母=`totalAskerMessages` |
102
+| DP-XM-API-011 | 看板 | 分类占比 100% | 接口测试 | Postman | SY-XM-06 | `AI-Y` 多分类 | `categoryShare.items` | `sum(ratio)≈100`;分母=年内 **AI 回复** 总数(`totalAskerMessages` 字段名沿用) |
100 103
 | DP-XM-API-012 | 看板 | 模型调用同源 | 接口测试 | Postman | §5.6 | `AI-Y` | 对比 `modelCallAnalysis` 与 `categoryShare` | 分类条数一致 |
101
-| DP-XM-API-013 | 看板 | 24 小时热度 | 接口测试 | Postman | SY-XM-07 | `AI-24H` | `GET dashboard` | `hourlyHeat.hourlySeries.length=24`;含 `windowEnd`≈`statDateTime` |
104
+| DP-XM-API-013 | 看板 | 统计日分时热度 | 接口测试 | Postman | SY-XM-07 | 统计日有跨小时数据 | `GET dashboard` | `hourlyHeat.hourlySeries.length=24`;`windowEnd`=`statDate`(yyyy-MM-dd) |
102 105
 | DP-XM-API-014 | 看板 | 会话分档四档 | 接口测试 | Postman | SY-XM-08 | `AI-BAND` | `sessionReplyDistribution` | 4 条;`sum(ratio)≈100` |
103 106
 | DP-XM-API-015 | 看板 | 应答状态 | 接口测试 | Postman | SY-XM-08 | `AI-COST` | `replyStatusDistribution` | OK+FAIL=`totalAiReplies` |
104 107
 | DP-XM-API-016 | 看板 | 热点 Top5 | 接口测试 | Postman | SY-XM-09 | `AI-Y` | `hotTopics` | ≤5;为分类非单条消息 |
@@ -134,7 +137,7 @@
134 137
 | DP-XM-UI-007 | 图表 | 用户结构 | UI 测试 | Playwright+Chrome | SY-XM-05 | `DU-MIX` | 查看「用户结构分析」 | 新/老用户人数与占比;切年不变 |
135 138
 | DP-XM-UI-008 | 图表 | 提问分类占比 | UI 测试 | Playwright+Chrome | SY-XM-06 | `AI-Y` | 查看饼图 | 含 1~4 类及未分类;占比之和 100% |
136 139
 | DP-XM-UI-009 | 图表 | 模型调用分析 | UI 测试 | Playwright+Chrome | §5.6 | `AI-Y` | 查看柱状/条形区 | 各分类条数与占比区一致 |
137
-| DP-XM-UI-010 | 图表 | 24 小时热度 | UI 测试 | Playwright+Chrome | SY-XM-07 | `AI-24H` | 查看「分时使用热度」 | 24 时段;会话数/提问量维度 |
140
+| DP-XM-UI-010 | 图表 | 统计日分时热度 | UI 测试 | Playwright+Chrome | SY-XM-07 | 统计日有数据 | 查看「分时使用热度」 | 24 整点(00:00~23:00);会话数/提问量维度 |
138 141
 | DP-XM-UI-011 | 图表 | 会话提问分布 | UI 测试 | Playwright+Chrome | SY-XM-08 | `AI-BAND` | 查看「会话提问数量分布」 | 四档轻度~深度;占比展示 |
139 142
 | DP-XM-UI-012 | 图表 | 会话状态分布 | UI 测试 | Playwright+Chrome | SY-XM-08 | `AI-COST` | 查看「会话状态分布」 | 正常应答/调用失败两档 |
140 143
 | DP-XM-UI-013 | 榜单 | 热点问题 Top5 | UI 测试 | Playwright+Chrome | SY-XM-09 | `AI-Y` | 查看「热点问题榜单」 | ≤5 条分类排行 |
@@ -195,7 +198,7 @@ await page.route('**/bigScreen/livestockResource/dashboard**', route =>
195 198
 | SY-XM-04 | 活跃度 7 日 | UT-011;API-007;UI-006 |
196 199
 | SY-XM-05 | 用户结构 | UT-013~014;API-009~010;UI-007 |
197 200
 | SY-XM-06 | 分类 100% | UT-015;API-011~012;UI-008~009 |
198
-| SY-XM-07 | 过去 24h | UT-012;API-013;UI-010 |
201
+| SY-XM-07 | 统计日 24 自然小时 | UT-012;API-013;UI-010 |
199 202
 | SY-XM-08 | 分档与状态 | UT-017~018;API-014~015;UI-011~012 |
200 203
 | SY-XM-09 | 热点 Top5 | UT-019;API-016;UI-013 |
201 204
 | SY-XM-10 | 关键词 | UT-020;API-017;UI-014 |
@@ -213,3 +216,5 @@ await page.route('**/bigScreen/livestockResource/dashboard**', route =>
213 216
 | 版本 | 说明 |
214 217
 | --- | --- |
215 218
 | 1.0 | 初版:单元 24、接口 32、UI 22;覆盖 SY-XM-01~13 与正常/异常/约束;Playwright+Chrome;Base `/bigScreen/livestockResource` |
219
+| 1.1 | 对齐功能需求 v1.1(提问量、滑动 24h 等) |
220
+| 1.2 | 对齐草稿 v1.2:`update_time` 今日活跃、日新增双时间、AI 回复分类、统计日分时、`create_time` 归属 |