统计分析
当前实现范围
官方前端的“应用 → 概览”页面用统计接口渲染四组卡片与曲线。四个官方统计接口在本项目的 Java 后端均已实现,数据取自业务表实时聚合,star_num、trample_num、customer_added_count 不再是占位值。
| 官方接口 | 路径 | 状态 |
|---|---|---|
| 对话相关统计趋势 | /api/application/{applicationId}/statistics/chat_record_aggregate_trend | 已实现 |
| 对话统计汇总 | /api/application/{applicationId}/statistics/chat_record_aggregate | 已实现 |
| 用户统计 | /api/application/{applicationId}/statistics/customer_count | 已实现 |
| 用户统计趋势 | /api/application/{applicationId}/statistics/customer_count_trend | 已实现 |
前端的“概览”只调用趋势接口,其余三个接口供对接方与后续页面使用。
趋势接口字段
| 字段 | 数据来源 |
|---|---|
day | 日期分组(yyyy-MM-dd) |
chat_record_count | 当日问答记录数 |
customer_num | 当日有过会话的不同 client_id 数 |
tokens_num | 当日 message_tokens + coalesce(answer_tokens,0) 之和 |
star_num | 当日赞同数(vote_status='0') |
trample_num | 当日反对数(vote_status='1') |
customer_added_count | 当日新增访客数(公开访问建立访客记录) |
汇总接口字段
chat_record_aggregate 返回区间内一行汇总,另带 customer_added_count(区间内新增访客数)。customer_count 返回 customer_added_count 与 customer_today_added_count(当日新增访客数,今天不在区间内时为 0)。customer_count_trend 按天返回 day 与 customer_added_count,只返回有访客的日期,与官方实现一致。
日期参数为 yyyy-MM-dd,左闭右开区间(create_time >= 起日 and create_time < 止日 + 1)。参数缺失或格式错误返回 开始时间或结束时间格式不正确,起日晚于止日返回 开始时间不能晚于结束时间。普通用户只能查询自己拥有的应用,非本人应用返回 无权限访问应用统计;管理员 id=1 可查看全部应用。
数据来源
统计不读取额外汇总表,全部按需查询业务表:
| 表 | 用途 |
|---|---|
moss_kb_application_chat_record | 问答记录、问题与回答 token、vote_status、创建时间 |
moss_kb_application_chat | 会话归属的应用与 client_id |
moss_kb_application_public_access_client | 公开访问访客记录,用于用户新增统计 |
问答记录在提问时写入 message_tokens,回答结束后回写 answer_tokens;vote_status 默认为 '-1'(未投票),赞同为 '0',反对为 '1'。因此 tokens_num 只统计已完成回答的记录,正在生成的会话当日 token 会偏小。
数据保留策略、会话归属与 token 口径见对话日志、问答模块。
统计趋势接口
GET /api/application/{applicationId}/statistics/chat_record_aggregate_trend?start_time=2026-10-01&end_time=2026-10-03
Authorization: Bearer <JWT_TOKEN>
响应(数据来自本机验证库):
{
"code": 200,
"message": null,
"data": [
{
"trample_num": 0,
"customer_num": 0,
"star_num": 0,
"tokens_num": 0,
"customer_added_count": 0,
"day": "2026-10-01",
"chat_record_count": 0
},
{
"trample_num": 0,
"customer_num": 3,
"star_num": 0,
"tokens_num": 94097,
"customer_added_count": 5,
"day": "2026-10-02",
"chat_record_count": 10
},
{
"trample_num": 0,
"customer_num": 1,
"star_num": 0,
"tokens_num": 27903,
"customer_added_count": 1,
"day": "2026-10-03",
"chat_record_count": 3
}
]
}
区间内没有记录的日期补零行。
其它三个接口
GET /api/application/{applicationId}/statistics/chat_record_aggregate?start_time=2026-10-01&end_time=2026-10-03
{
"code": 200,
"message": null,
"data": {
"trample_num": 0,
"star_num": 0,
"customer_num": 4,
"tokens_num": 122000,
"customer_added_count": 6,
"chat_record_count": 13
}
}
GET /api/application/{applicationId}/statistics/customer_count?start_time=2026-10-01&end_time=2026-10-03
GET /api/application/{applicationId}/statistics/customer_count_trend?start_time=2026-10-01&end_time=2026-10-03
{
"code": 200,
"message": null,
"data": { "customer_added_count": 6, "customer_today_added_count": 1 }
}
{
"code": 200,
"message": null,
"data": [
{ "customer_added_count": 5, "day": "2026-10-02" },
{ "customer_added_count": 1, "day": "2026-10-03" }
]
}
统计值必须是 JSON 数字
统计字段在响应里必须是数字,不能是字符串。前端的 getSum 是拼接语义:
export function getSum(array: Array<any>) {
return array.reduce((total, item) => total + item, 0)
}
字段一旦是字符串,total + item 会逐项拼接,卡片上的“提问次数”会显示成 000,000,000,000,000,000,000,000,000,000,103 这种拼接结果(13 条记录 + 6 个新增用户拼出来的长数字),不是简单的显示偏差。
而框架的 JSON 写出为雪花 ID 开启了 WriteLongAsString(FastJson2.isLongToString()),Long 会被写成字符串:
// tio-utils FastJson2,Long 走字符串分支
JSONWriter.Feature[] features = { JSONWriter.Feature.WriteLongAsString, JSONWriter.Feature.WriteNulls };
因此统计值统一用 BigDecimal 输出,BigDecimal 不受该特性影响:
/** 统计值统一输出为 JSON 数字,前端 getSum 依赖数字而不是字符串。 */
private BigDecimal toNumber(Object value) {
if (value instanceof Number) {
return new BigDecimal(value.toString());
}
try {
return value == null || "null".equals(value) ? BigDecimal.ZERO : new BigDecimal(value.toString().trim());
} catch (RuntimeException e) {
return BigDecimal.ZERO;
}
}
空日期的占位值同样使用 BigDecimal.ZERO。排查时可以用 javap -p -c 看反编译结果确认 WriteLongAsString 的存在,别只按 JDBC 类型推断:驱动返回的是 Long,字符串是序列化阶段加上去的。
服务实现
统计集中在 MossKbApplicationStatisticsService,控制器只做参数透传与权限入口:
@Get("/{applicationId}/statistics/chat_record_aggregate_trend")
public ResultVo statisticsOfCharRecordAggreagteTrend(Long applicationId, String start_time, String end_time) {
return Aop.get(MossKbApplicationStatisticsService.class).chatRecordAggregateTrend(TioRequestContext.getUserIdLong(),
applicationId, start_time, end_time);
}
对话统计趋势按天分组、一次查询取回全部日期,再与用户新增趋势按 day 合并,避免逐日查询:
private List<Map<String, Object>> queryChatRecordTrend(Long applicationId, LocalDate startDate, LocalDate endDate) {
List<Row> rows = Db.find(
"select r.create_time::date as day, count(r.id) as chat_record_count, count(distinct c.client_id) as customer_num,"
+ " coalesce(sum(r.message_tokens + coalesce(r.answer_tokens, 0)), 0) as tokens_num,"
+ " coalesce(sum(case when r.vote_status = '0' then 1 else 0 end), 0) as star_num,"
+ " coalesce(sum(case when r.vote_status = '1' then 1 else 0 end), 0) as trample_num"
+ " from moss_kb_application_chat_record r join moss_kb_application_chat c on c.id = r.chat_id"
+ " where c.application_id = ? and r.create_time >= ? and r.create_time < ? group by day order by day",
applicationId, java.sql.Date.valueOf(startDate), java.sql.Date.valueOf(endDate.plusDays(1)));
// ...
}
实现要点:
- 权限使用
ApplicationAccess.owns,与知识库、文档接口同一套归属判断。 - 时间过滤用左闭右开区间
create_time >= 起日 and create_time < 止日+1,避免BETWEEN在高精度时间戳上重复或遗漏边界记录。 - 满意度、token 都在聚合 SQL 内用
coalesce兜底,空区间返回 0 而不是 null。 - 用户新增按应用与时间过滤,与官方
customer_count_trend.sql口径一致;官方customer_count不过滤时间,这里按区间过滤,避免“区间统计”里混进区间外的访客。 - 趋势查询固定 2 条 SQL(对话趋势 + 用户新增趋势),不随区间长度增长;原先逐日一条 SQL 的写法在 183 天区间要执行 183 次。
用户满意度投票
满意度统计来自问答记录的 vote_status,写入接口在 ApiChatHistoryController:
PUT /api/application/{applicationId}/chat/{chatId}/chat_record/{recordId}/vote
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{"vote_status":"0"}
vote_status 取 '0'(赞同)、'1'(反对)、'-1'(取消),其他值直接拒绝;写入前校验应用与会话归属。
验证时的实际联动(同一条 10-03 的记录):
| 操作 | 趋势接口 2026-10-03 返回 |
|---|---|
| 未投票 | star_num:0、trample_num:0 |
| 投赞同 | star_num:1、trample_num:0 |
| 改投反对 | star_num:0、trample_num:1 |
前端页面
统计页面是官方 Vue 前端的“概览”,路由与接口调用如下:
| 位置 | 说明 |
|---|---|
ui/src/router/modules/application.ts | /application/:id/:type/overview 指向 views/application-overview/index.vue |
ui/src/api/application-overview.ts | getStatistics 调用趋势接口 |
ui/src/views/application-overview/component/StatisticsCharts.vue | 用户总数、提问次数、Tokens 总数、用户满意度四组卡片与折线图 |
页面默认查最近 7 天,可切换最近 30、90、183 天或自定义区间,切换后重新请求趋势接口。四张卡片读取同一个数组:customer_num、customer_added_count、chat_record_count、tokens_num、star_num、trample_num。
构建注意
统计服务的代码在 mosskb-business,而 mosskb-web 会把它的 jar 打进 fat jar,因此父 pom 里 mosskb-business 必须排在 mosskb-web 前面:
<modules>
<module>mosskb-business</module>
<module>mosskb-web</module>
</modules>
顺序反了(原先即如此)会出现“改了 business 的代码、跑起来还是旧逻辑、日志里却写着 BUILD SUCCESS”:
mosskb-web先构建,内嵌的是本地仓库/上一次构建的mosskb-business-1.0.4.jar;- 之后
mosskb-business才编译打包,产出的新 jar 没有任何模块再打包进去。
另一个只在 Windows 出现的坑:后端进程正在运行时会锁住 mosskb-web/target/mosskb-web-1.0.4.jar,maven-jar-plugin 替换失败但构建仍报成功,jar 的时间和内容都不变。重新打包前先停掉后端:
Get-NetTCPConnection -State Listen -LocalPort 10060 | Select-Object -ExpandProperty OwningProcess |
Sort-Object -Unique | ForEach-Object { Stop-Process -Id $_ -Force }
mvn clean "-Dmaven.test.skip=true" "-Dmaven.javadoc.skip=true" "-Dgpg.skip=true" -Pproduction package
PowerShell 下 -Dmaven.test.skip=true 必须加引号,否则会被拆成 .test.skip=true,Maven 报 Unknown lifecycle phase ".test.skip=true"。
本机验证
后端:mosskb-web 重新打包后的 mosskb-web-1.0.4.jar,监听 10060;前端:java-mosskb-ui 的 Vite 开发服务器,监听 3000(/api 代理到 10060);浏览器由 deepseek-browser-use 服务驱动。
接口验证
| 用例 | 结果 |
|---|---|
趋势接口 2026-10-01 ~ 2026-10-03 | 200,10-02 为 10 条 / 3 用户 / 94097 tokens / 新增 5,10-03 为 3 条 / 1 用户 / 27903 tokens / 新增 1,10-01 补零行 |
| 汇总接口同区间 | 200,13 条 / 122000 tokens / 4 用户 / 新增 6 |
| 用户统计同区间 | 200,区间新增 6、当日新增 1 |
| 用户统计趋势同区间 | 200,10-02 新增 5、10-03 新增 1 |
| 投票后重查趋势 | 赞同与反对随 vote_status 变化,见上文联动表 |
非法日期 start_time=abc | 400,开始时间或结束时间格式不正确 |
| 起日晚于止日 | 400,开始时间不能晚于结束时间 |
对照数据库直接执行等价 SQL 可复算同一组数字。
页面验证
打开 http://127.0.0.1:3000/ui/#/application/{applicationId}/SIMPLE/overview,默认“过去 7 天”,直接读取卡片 DOM 文本:
| 卡片 | 页面显示 |
|---|---|
| 用户总数 | 4,新增 +6 |
| 提问次数 | 13 |
| Tokens 总数 | 122.0k |
| 用户满意度 | 赞同 1、反对 0 |
满意度由投票接口写入后刷新得到,说明卡片读的是实时统计而非缓存。修复数字类型前同一页显示为 000,000,000,000,000,000,000,000,000,000,103 这类拼接长数字;改为 BigDecimal 输出后恢复正常。
切到“过去 30 天”,浏览器请求记录为 statistics/chat_record_aggregate_trend?start_time=2026-09-03&end_time=2026-10-03,返回 200 且为 31 天数据。
