优化首页指标展示并修复群唯一用户统计

变更项:

1. 修复首页卡片等高问题,统一用户信息与右侧指标区高度,统一热门用户/群组/插件卡片高度。

2. 首页新增三个分析指标:新增用户数、群渗透率、群健康分,并完成前端数据绑定。

3. 优化仪表盘摘要接口,新增 new_users、avg_group_penetration、group_health_score 返回字段。

4. 修复 t_group_stats.unique_users 统计口径,改为按 group_id+plugin_name+command+user_id 去重统计,避免跨群串数据。

5. 新增 t_group_command_user_stats 表结构及索引,并补充到 init.sql。
This commit is contained in:
liuwei
2026-04-15 17:19:38 +08:00
parent b37396db50
commit d472b1523b
3 changed files with 169 additions and 12 deletions

View File

@@ -92,9 +92,23 @@
</el-col>
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
<div class="metric-label">系统运行时间</div>
<div class="metric-value">{% raw %}{{ formattedUptime }}{% endraw %}</div>
<div class="metric-footnote">当前实例持续在线时长</div>
<div class="metric-label">新增用户数</div>
<div class="metric-value">{% raw %}{{ newUsers }}{% endraw %}</div>
<div class="metric-footnote">统计周期内首次触发用户</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">群渗透率</div>
<div class="metric-value">{% raw %}{{ avgGroupPenetration.toFixed(2) }}{% endraw %}%</div>
<div class="metric-footnote">活跃群触发人数占成员比例(均值)</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
<div class="metric-label">群健康分</div>
<div class="metric-value">{% raw %}{{ groupHealthScore.toFixed(2) }}{% endraw %}</div>
<div class="metric-footnote">综合成功率与响应时延评分</div>
</el-card>
</el-col>
</el-row>
@@ -256,7 +270,10 @@
successRate: 0,
activeUsers: 0,
activeGroups: 0,
newUsers: 0,
avgResponseTime: 0,
avgGroupPenetration: 0,
groupHealthScore: 0,
topUsers: [],
topGroups: [],
topPlugins: [],
@@ -425,7 +442,10 @@
this.successRate = parseFloat(data.success_rate) || 0;
this.activeUsers = data.active_users || 0;
this.activeGroups = data.active_groups || 0;
this.newUsers = data.new_users || 0;
this.avgResponseTime = parseFloat(data.avg_response_time) || 0;
this.avgGroupPenetration = parseFloat(data.avg_group_penetration) || 0;
this.groupHealthScore = parseFloat(data.group_health_score) || 0;
this.topUsers = data.top_users || [];
this.topGroups = data.top_groups || [];
this.topPlugins = data.top_plugins || [];
@@ -791,8 +811,21 @@
margin-bottom: 16px;
}
.hero-row,
.stats-highlight-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.hero-row > .el-col,
.stats-highlight-row > .el-col {
display: flex;
}
.hero-card {
min-height: 228px;
height: 100%;
}
.hero-card--profile {
@@ -894,10 +927,13 @@
.metric-grid .el-col {
margin-bottom: 16px;
display: flex;
}
.metric-card {
min-height: 106px;
width: 100%;
height: 100%;
}
.metric-card .el-card__body {
@@ -942,6 +978,11 @@
padding-top: 12px !important;
}
.insight-card {
width: 100%;
height: 100%;
}
.section-heading,
.chart-card-header {
display: flex;

View File

@@ -232,6 +232,27 @@ create or replace index idx_group_id
create or replace index idx_last_used_at
on message_archive.t_group_stats (last_used_at);
create or replace table message_archive.t_group_command_user_stats
(
id bigint auto_increment
primary key,
group_id varchar(50) not null comment '群组ID',
plugin_name varchar(50) not null comment '插件名称',
command varchar(50) not null comment '触发命令',
user_id varchar(50) not null comment '用户ID',
first_used_at datetime not null comment '首次触发时间',
last_used_at datetime not null comment '最近触发时间',
constraint uk_group_plugin_command_user
unique (group_id, plugin_name, command, user_id)
)
comment '群命令用户去重追踪表';
create or replace index idx_group_plugin_command
on message_archive.t_group_command_user_stats (group_id, plugin_name, command);
create or replace index idx_group_command_user_last_used
on message_archive.t_group_command_user_stats (last_used_at);
create or replace table message_archive.t_plugin_point_config
(
id int auto_increment

View File

@@ -9,6 +9,31 @@ class StatsDBOperator(BaseDBOperator):
def __init__(self, db_manager: DBConnectionManager):
super().__init__(db_manager)
self._group_user_tracker_ready = False
def _ensure_group_user_tracker_table(self) -> bool:
"""确保群维度唯一用户追踪表存在"""
if self._group_user_tracker_ready:
return True
sql = """
CREATE TABLE IF NOT EXISTS t_group_command_user_stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
group_id VARCHAR(50) NOT NULL COMMENT '群组ID',
plugin_name VARCHAR(50) NOT NULL COMMENT '插件名称',
command VARCHAR(50) NOT NULL COMMENT '触发命令',
user_id VARCHAR(50) NOT NULL COMMENT '用户ID',
first_used_at DATETIME NOT NULL COMMENT '首次触发时间',
last_used_at DATETIME NOT NULL COMMENT '最近触发时间',
UNIQUE KEY uk_group_plugin_command_user (group_id, plugin_name, command, user_id),
INDEX idx_group_plugin_command (group_id, plugin_name, command),
INDEX idx_last_used_at (last_used_at)
) COMMENT='群命令用户去重追踪表'
"""
ok = self.execute_update(sql)
if ok:
self._group_user_tracker_ready = True
return ok
def record_plugin_call(self, plugin_name: str, command: str, user_id: str,
group_id: Optional[str], success: bool,
@@ -209,15 +234,31 @@ class StatsDBOperator(BaseDBOperator):
query_params = (group_id, plugin_name, command)
result = self.execute_query(query_sql, query_params, fetch_one=True)
# 查询该命令唯一用户
user_query_sql = """
SELECT COUNT(DISTINCT user_id) as user_count
FROM t_user_stats
WHERE plugin_name = %s AND command = %s
"""
user_query_params = (plugin_name, command)
user_result = self.execute_query(user_query_sql, user_query_params, fetch_one=True)
unique_users = user_result['user_count'] if user_result else 1
# 记录“群 + 插件 + 命令 + 用户”去重关系,再计算该群该命令唯一用户
unique_users = 1
if self._ensure_group_user_tracker_table():
upsert_unique_user_sql = """
INSERT INTO t_group_command_user_stats
(group_id, plugin_name, command, user_id, first_used_at, last_used_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
last_used_at = VALUES(last_used_at)
"""
self.execute_update(
upsert_unique_user_sql,
(group_id, plugin_name, command, user_id, now, now)
)
user_query_sql = """
SELECT COUNT(*) as user_count
FROM t_group_command_user_stats
WHERE group_id = %s AND plugin_name = %s AND command = %s
"""
user_query_params = (group_id, plugin_name, command)
user_result = self.execute_query(user_query_sql, user_query_params, fetch_one=True)
unique_users = user_result['user_count'] if user_result and user_result.get('user_count') is not None else 1
elif result:
unique_users = max(result.get('unique_users') or 0, 1)
if result:
# 更新现有记录
@@ -413,6 +454,15 @@ class StatsDBOperator(BaseDBOperator):
"""
active_users_result = self.execute_query(active_users_sql, (start_date_str,), fetch_one=True)
active_users = active_users_result['active_users'] if active_users_result else 0
# 3.1 新增用户(统计窗口内首次触发)
new_users_sql = """
SELECT COUNT(DISTINCT user_id) as new_users
FROM t_user_stats
WHERE first_used_at >= %s
"""
new_users_result = self.execute_query(new_users_sql, (start_date_str,), fetch_one=True)
new_users = new_users_result['new_users'] if new_users_result else 0
# 4. 活跃群组数
active_groups_sql = """
@@ -440,6 +490,48 @@ class StatsDBOperator(BaseDBOperator):
"""
avg_response_time_result = self.execute_query(avg_response_time_sql, (start_date_str,), fetch_one=True)
avg_response_time = avg_response_time_result['avg_response_time'] if avg_response_time_result and avg_response_time_result['avg_response_time'] else 0
# 6.1 群渗透率(活跃群内,触发人数 / 群成员数 的均值)
group_penetration_sql = """
SELECT AVG(group_penetration) AS avg_group_penetration
FROM (
SELECT
gs.group_id,
CASE
WHEN gm.member_count > 0
THEN (LEAST(gs.unique_users, gm.member_count) / gm.member_count) * 100
ELSE NULL
END AS group_penetration
FROM (
SELECT group_id, MAX(unique_users) AS unique_users
FROM t_group_stats
WHERE last_used_at >= %s
GROUP BY group_id
) gs
LEFT JOIN (
SELECT chatroom_id AS group_id, COUNT(*) AS member_count
FROM t_chatroom_member
WHERE status = 1 OR status IS NULL
GROUP BY chatroom_id
) gm ON gs.group_id = gm.group_id
) t
WHERE group_penetration IS NOT NULL
"""
group_penetration_result = self.execute_query(group_penetration_sql, (start_date_str,), fetch_one=True)
avg_group_penetration = 0
if group_penetration_result and group_penetration_result.get('avg_group_penetration') is not None:
avg_group_penetration = float(group_penetration_result['avg_group_penetration'])
# 6.2 群健康分(成功率 + 响应速度融合评分)
# 响应时间评分: 500ms 及以内为满分, 5000ms 及以上趋近0分
latency_score = 0
if avg_response_time <= 500:
latency_score = 100
elif avg_response_time >= 5000:
latency_score = 0
else:
latency_score = max(0, min(100, ((5000 - avg_response_time) / 4500) * 100))
group_health_score = (success_rate * 0.7) + (latency_score * 0.3)
# 7. 最常用的插件
top_plugins_sql = """
@@ -479,9 +571,12 @@ class StatsDBOperator(BaseDBOperator):
"total_calls": total_calls,
"success_rate": success_rate,
"active_users": active_users,
"new_users": new_users,
"active_groups": active_groups,
"error_count": error_count,
"avg_response_time": avg_response_time,
"avg_group_penetration": round(avg_group_penetration, 2),
"group_health_score": round(group_health_score, 2),
"top_plugins": top_plugins,
"top_users": top_users,
"top_groups": top_groups