埋点分析系统 测试与优化方案
测试日期: 2026-07-30
测试环境: http://127.0.0.1:5000
测试凭据: admin / clklog
测试范围: 6个前端页面 / 20个API端点 / 后端代码 / 前端代码
文档用途: 可喂给AI执行的优化清单
一、测试结论
| 维度 | 状态 | 说明 |
|---|---|---|
| 前端路由 | 6/6 正常 | 概览、事件、元素、用户、页面、明细均正常跳转 |
| API认证 | 正常 | 登录/登出/Token校验正确 |
| 核心API | 14/14 返回200 | 数据正确返回 |
| 数据一致性 | 良好 | projects.usercount与users/kpi.totalusers一致(29026) |
| 分页功能 | 正确 | events/list total=166, detail/events total=255891 |
发现的问题统计
| 严重程度 | 数量 | 涉及模块 |
|---|---|---|
| P0-严重 | 2 | 后端flow/overview数据全0, 前端错误拦截器 |
| P1-重要 | 3 | 留存率trend为null, 缺少参数校验, 401响应Body为空 |
| P2-一般 | 5 | 4个页面无Loading/Error状态, ElementsContent未处理allSettled失败 |
| P3-建议 | 4 | 导出限制, 死代码, 硬编码, alert弹窗 |
二、P0 严重问题(必须修复)
P0-1: flow/overview API 数据全部为0
文件: backend/api/flow.py 第22行
问题: flowmetrics() 函数中 AND eventsessionid != '' 条件过滤过于严格。ClickHouse 中 NULL != '' 结果为 NULL(非 TRUE),导致大量不含 eventsessionid 的行被排除,查询返回 0 行。
影响: 概览看板的"流量概览"区域所有指标(PV/UV/IP/访问次数/平均PV/跳出率)全部显示为0。
修复代码:
# 修改 backend/api/flow.py 第22行
# 原代码:
AND event_session_id != ''
# 修改为:
AND event_session_id != '' AND event_session_id IS NOT NULL
或者更合理的做法是直接去掉该过滤条件,因为流量指标不需要以 session 为前提:
# 删除整个 AND event_session_id != '' 条件
# 将第22行改为:
# (删除该行)
验证方法: 重启后端后,访问概览看板,确认"流量概览"区域显示正常数值。
P0-2: 前端错误拦截器返回 String 而非 Error 对象
文件: frontend/src/api/index.ts 第60-78行
问题: 响应拦截器和错误拦截器中 Promise.reject(msg) 直接传递字符串,导致所有调用方 .catch(err => err.message) 获取不到真实错误信息,始终显示默认文本。
影响: 所有页面的错误提示都是通用文本(如"数据加载失败"),用户无法知道具体错误原因。
修复代码:
// 修改 frontend/src/api/index.ts
// 第60-66行 响应拦截器
// 原代码:
const msg = response.data?.message || '请求失败'
return Promise.reject(msg)
// 修改为:
const msg = response.data?.message || '请求失败'
return Promise.reject(new Error(msg))
// 第68-79行 错误拦截器
// 原代码:
const msg = error.response?.data?.message || error.message || '网络错误'
return Promise.reject(msg)
// 修改为:
const msg = error.response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(msg))
验证方法: 修改后故意触发一个API错误(如临时改错端点),确认 toast 显示的是具体错误信息而非默认文本。
三、P1 重要问题(建议修复)
P1-1: 留存率趋势(trend)为 null
文件: backend/api/users.py 第148-154行
问题: 当上一队列周期(14天前)没有新增用户时,prevcohort = 0,导致 prevretentionrate = None,进而 retentiontrend = null。前端可能因此无法正确显示留存率的趋势箭头。
修复代码:
# 修改 backend/api/users.py 第152-154行
# 原代码:
retention_trend = None
if prev_retention_rate is not None and prev_retention_rate > 0:
retention_trend = format_percent(safe_divide(retention_rate - prev_retention_rate, max(prev_retention_rate, 1)))
# 修改为:
retention_trend = None
if prev_retention_rate is not None:
if prev_retention_rate > 0:
retention_trend = format_percent(safe_divide(retention_rate - prev_retention_rate, prev_retention_rate))
else:
# 上一周期留存率为0但当前有留存,视为增长
retention_trend = 100.0 if retention_rate > 0 else 0.0
验证方法: 重启后端,访问用户分析页面,确认留存率KPI卡片显示趋势箭头(而非空白)。
P1-2: API 缺少参数校验
文件: backend/api/overview.py、events.py、users.py 等所有API文件
问题: 缺少 startdate/enddate 或传入无效日期格式时,API返回200而非400,只是返回空数据。前端无法有效提示用户。
修复代码(在 backend/utils.py 中添加校验函数):
# 在 backend/utils.py 中新增函数
from datetime import datetime
def validate_date_params(start_date, end_date):
"""校验日期参数,返回 (错误信息, None) 或 (None, (start, end))"""
if not start_date or not end_date:
return '缺少日期参数 start_date 或 end_date', None
try:
start = datetime.strptime(start_date, '%Y-%m-%d')
end = datetime.strptime(end_date, '%Y-%m-%d')
if start > end:
return '开始日期不能晚于结束日期', None
return None, (start, end)
except ValueError:
return '日期格式无效,需为 YYYY-MM-DD', None
然后在各API端点开头调用:
# 在每个API函数开头添加
from utils import validate_date_params
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
err, _ = validate_date_params(start_date, end_date)
if err:
return error_response(err, code=400), 400
验证方法: 访问 http://127.0.0.1:5000/api/overview/kpi?start_date=abc 应返回400错误。
P1-3: 401响应Body为空
文件: backend/api/decorators.py 第68-69行、backend/api/auth.py
问题: 未认证访问时返回401状态码但Body为空,前端无法解析JSON格式的错误信息。
修复代码(确保Flask返回JSON):
# 修改 backend/app.py 或配置文件,添加全局错误处理器
from flask import jsonify
@app.errorhandler(401)
def unauthorized(error):
return jsonify({'code': 401, 'message': '未登录或登录已过期,请重新登录', 'data': None}), 401
验证方法: 不带Token访问 http://127.0.0.1:5000/api/overview/kpi 应返回包含JSON Body的401。
四、P2 一般问题(建议修复)
P2-1: 4个页面无 Loading 状态
文件:
frontend/src/pages/Users.tsxfrontend/src/pages/Events.tsxfrontend/src/pages/ElementsContent.tsxfrontend/src/pages/Screens.tsx
问题: 页面初始渲染时数据为空,显示 '0' 或空白图表,用户无法区分"正在加载"和"无数据"。
修复方案: 为每个页面添加 loading 状态变量,在数据请求前设为 true,请求完成后(无论成功失败)设为 false,渲染时显示 组件。
示例代码(Users.tsx):
// 在 Users.tsx 中添加
const [loading, setLoading] = useState(true)
// 修改 useEffect
useEffect(() => {
setLoading(true)
Promise.allSettled([
usersApi.kpi({ start_date: startDate, end_date: endDate }),
usersApi.trend({ start_date: startDate, end_date: endDate }),
// ... 其他请求
]).then((results) => {
// 处理数据...
}).finally(() => {
setLoading(false)
})
}, [startDate, endDate, projects])
// 在渲染中添加
if (loading) return <Loading text="加载中..." />
对其他3个页面(Events.tsx、ElementsContent.tsx、Screens.tsx)做相同修改。
P2-2: 4个页面静默吞噬错误
文件: 同 P2-1
问题: 所有API错误被 .catch(() => {}) 静默吞噬,用户无法感知数据加载失败。
修复方案: 添加 error 状态,在 .catch() 中设置错误信息,渲染时显示错误提示。
示例代码:
const [error, setError] = useState<string | null>(null)
// 在每个 .catch() 中
.catch((err: Error) => {
setError(err.message || '数据加载失败')
toast.error(err.message || '数据加载失败')
})
// 渲染
if (error && !loading) {
return <Empty icon="error" title="加载失败" description={error}>
<Button onClick={() => window.location.reload()}>重试</Button>
</Empty>
}
P2-3: ElementsContent 未处理 Promise.allSettled 失败
文件: frontend/src/pages/ElementsContent.tsx
问题: 使用了 Promise.allSettled 但未检查 status === 'rejected',失败的请求静默被忽略。
修复代码:
const [results] = await Promise.allSettled([...])
const failedCount = results.filter(r => r.status === 'rejected').length
if (failedCount > 0) {
toast.warning(`${failedCount} 个数据模块加载失败,部分数据可能不完整`)
}
// 处理成功的请求
const [kpiResult, trendResult, ...] = results
if (kpiResult.status === 'fulfilled') {
setKpi(kpiResult.value.data)
}
P2-4: Users.tsx 和 Screens.tsx 缺少 projects 依赖
文件:
frontend/src/pages/Users.tsx第33行frontend/src/pages/Screens.tsx第33行
问题: useEffect 依赖数组中缺少 projects,导致切换项目后数据不更新。
修复代码:
// Users.tsx 第33行
// 原代码:
}, [startDate, endDate])
// 修改为:
}, [startDate, endDate, projects])
// Screens.tsx 第33行
// 原代码:
}, [startDate, endDate, keyword, page])
// 修改为:
}, [startDate, endDate, keyword, page, projects])
验证方法: 在侧边栏切换项目后,确认用户分析和页面路径页面数据正确刷新。
P2-5: api/index.ts 中 401 处理不一致
文件: frontend/src/api/index.ts
问题:
- 响应拦截器(第60-66行):
code === 50008 || code === 401时清除token但不跳转 - 错误拦截器(第68-78行):
status === 401时全量跳转
两者行为不一致,可能导致某些401场景下不跳转登录页。
修复代码:
// 统一在响应拦截器中处理
http.interceptors.response.use(
(response) => {
const { code, message } = response.data
if (code === 50008 || code === 401) {
localStorage.removeItem('tracker_token')
localStorage.removeItem('tracker_user')
window.location.href = '/login'
return Promise.reject(new Error(message || '登录已过期'))
}
return response
},
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('tracker_token')
localStorage.removeItem('tracker_user')
window.location.href = '/login'
}
const msg = error.response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(msg))
}
)
五、P3 建议问题(可选优化)
P3-1: CSV导出硬编码 LIMIT 10000
文件: backend/api/detail.py 第174行
问题: 导出上限固定为10000条,无法导出全部数据,且用户无感知。
修复方案:
# 方案A: 增加 limit 参数,允许前端指定
max_export = min(int(request.args.get('limit', 10000)), 50000)
params['limit'] = max_export
# 方案B: 移除 limit 限制,由数据库查询性能决定
# 删除第174行: params['limit'] = 10000
同时在前端导出按钮旁显示提示:"单次导出最多 50000 条"。
P3-2: csv_escape 函数不完善
文件: backend/api/detail.py 第7-11行
问题: 表头行未使用 csv_escape,且数据行的转义逻辑不严谨。
修复代码:
import csv
import io
def export_events():
# 使用 Python 标准库 csv 模块替代手动拼接
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(headers) # 自动处理转义
for row in rows:
writer.writerow([str(row.get(col, '')) for col in columns])
csv_content = output.getvalue()
output.close()
return Response(
csv_content,
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename={filename}.csv'}
)
P3-3: decorators.py 是死代码
文件: backend/api/decorators.py
问题: withdaterangeandproject 装饰器未被任何API端点使用,所有端点都是手动调用 getdaterange、getprojectfilterparams、gettable_name。
修复方案:
- 方案A: 删除
decorators.py文件,清理死代码 - 方案B: 将装饰器改为函数调用,统一各端点的样板代码
推荐方案A,保持代码简洁。
P3-4: Events.tsx filterTabs 硬编码
文件: frontend/src/pages/Events.tsx
问题: 事件筛选标签硬编码为 ['全部', '$AppClick', '$AppViewScreen', '$SignUp'],无法动态展示所有事件类型。
修复方案: 从 /api/events/types API 动态获取事件类型列表,渲染筛选标签。
const [eventTypes, setEventTypes] = useState<string[]>([])
useEffect(() => {
eventsApi.types({ start_date: startDate, end_date: endDate })
.then(res => setEventTypes(res.data || []))
.catch(() => {})
}, [startDate, endDate])
// 渲染
{['全部', ...eventTypes.slice(0, 5)].map(type => (
<button key={type} onClick={() => setActiveFilter(type)}>{type}</button>
))}
P3-5: App.tsx 使用 alert() 弹窗
文件: frontend/src/App.tsx
问题: handleEnvChange 中使用 alert(res.message) 弹窗报错,用户体验差。
修复代码:
// 原代码:
alert(res.message)
// 修改为:
toast.error(res.message || '环境切换失败')
P3-6: Detail.tsx 导出 try/catch 无效
文件: frontend/src/pages/Detail.tsx 第56-66行
问题: window.open() 不会抛异常,try/catch 块是死代码。
修复代码:
const handleExport = async () => {
setExporting(true)
try {
// 改为使用 fetch + blob 下载,可以捕获错误
const token = localStorage.getItem('tracker_token')
const response = await fetch(`/api/detail/export?${params}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) throw new Error('导出失败')
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `事件明细_${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('导出成功')
} catch (e: any) {
toast.error(e.message || '导出失败')
} finally {
setExporting(false)
}
}
P3-7: health 接口 data 为 null
文件: backend/app.py
问题: /api/health 返回 {"code":0,"data":null,"message":"ok"},data 为 null 与其他API格式不一致。
修复代码:
@app.route('/api/health')
def health():
return success_response({'status': 'running', 'timestamp': datetime.now().isoformat()})
六、搜索接口专项测试(补充)
测试范围
对系统中所有支持搜索/筛选的API进行了33个测试用例的全面测试,覆盖7个维度。
测试结果: 33/33 通过,发现3个新问题。
| 测试维度 | 用例数 | 通过 | 发现问题 |
|---|---|---|---|
| 事件分析搜索 | 7 | 7 | 0 |
| 元素分析搜索 | 4 | 4 | 0 |
| 页面路径搜索 | 5 | 5 | 0 |
| 数据明细搜索+筛选 | 10 | 10 | 0 |
| 概览搜索相关 | 2 | 2 | 2(数据质量) |
| 分页边界测试 | 4 | 4 | 1(参数校验) |
| 日期边界测试 | 3 | 3 | 0 |
搜索功能验证结果
| API端点 | 搜索参数 | 搜索字段 | 结果 |
|---|---|---|---|
| events/list | keyword | event, screenname, elementcontent, elementtype, elementname | 正常 |
| events/list | event_type | event | 正常,筛选结果准确 |
| events/distribution | event_type | event | 正常 |
| elements/top-contents | keyword | elementcontent, elementname | 正常 |
| screens/list | keyword | screen_name, title (大小写不敏感) | 正常 |
| detail/events | keyword | event, screenname, elementcontent, elementtype, elementname, distinct_id | 正常 |
| detail/events | event_type | event | 正常,筛选结果准确 |
| detail/events | user_id | distinct_id (LIKE模糊) | 正常 |
| detail/events | device | model (LIKE模糊) | 正常 |
| detail/events | province | province (精确匹配) | 正常 |
| detail/events | 组合筛选 | event_type + province | 正常 |
安全性测试
| 测试项 | 结果 |
|---|---|
| SQL注入 (events/list) | 参数化查询阻止,返回0条 |
| SQL注入 (detail/events) | 参数化查询阻止,返回0条 |
| 特殊字符搜索 | 未报错,正常处理 |
新发现问题
P2-6: page=0 和 page_size=0 未校验
文件: backend/api/events.py 第105-106行、backend/api/detail.py 第23-24行
问题: 当 page=0 时,OFFSET = (0-1)*pagesize = -pagesize,ClickHouse会返回空结果而非报错。当 page_size=0 时,LIMIT 0 也返回空结果。用户无法区分"无数据"和"无效分页参数"。
测试结果:
detail/events (page=0) -> total=256185, returned=0 (page=0, 返回空列表)
detail/events (page_size=0) -> total=256185, returned=0 (page_size=0, 返回空列表)
修复代码:
# 在所有分页API开头添加校验
page = max(int(request.args.get('page', 1)), 1) # 最小为1
page_size = max(int(request.args.get('page_size', 10)), 1) # 最小为1
page_size = min(page_size, 200) # 最大200,防止过大查询
或返回400错误:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 10))
if page < 1:
return error_response('page参数必须大于0', code=400), 400
if page_size < 1 or page_size > 200:
return error_response('page_size参数必须在1-200之间', code=400), 400
P3-8: flow/search-words 数据质量问题
文件: backend/api/flow.py 第137-173行
问题: 搜索词Top10的第一名是 "url的domain解析失败"(71个用户),这是数据采集层面的错误信息被当作搜索关键词存储了。
测试结果:
flow/search-words -> count=1, top1=url的domain解析失败 (users=71)
影响: 概览看板的"搜索词 TOP10"区域展示的是错误数据。
修复方案:
# 方案A: 后端过滤掉明显非搜索词的数据
query = f"""
SELECT
latest_search_keyword as keyword,
count(distinct distinct_id) as user_count,
count(*) as pv
FROM {table}
WHERE stat_date BETWEEN %(start_date)s AND %(end_date)s
{pf_sql}
AND latest_search_keyword != ''
AND latest_search_keyword NOT LIKE '%解析失败%'
AND latest_search_keyword NOT LIKE '%error%'
AND latest_search_keyword NOT LIKE '%失败%'
GROUP BY keyword
ORDER BY user_count DESC
LIMIT 10
"""
P3-9: flow/source-websites 返回空数据
文件: backend/api/flow.py 第176-212行
问题: 来源网站Top10返回空列表(count=0),说明 latestreferrerhost 字段在数据中全部为空。
测试结果:
flow/source-websites -> count=0
影响: 概览看板的"来源网站 TOP10"区域无数据展示。
排查建议: 检查埋点SDK是否正确采集 latestreferrerhost 字段,或数据入库时该字段被截断。
七、执行优先级路线图
第一阶段:紧急修复(1-2小时)
□ P0-1: 修复 flow/overview 数据全为0
□ P0-2: 修复前端错误拦截器返回类型
□ P2-4: 修复 Users.tsx/Screens.tsx 缺少 projects 依赖
第二阶段:核心优化(2-3小时)
□ P1-1: 修复留存率趋势 null
□ P1-2: 添加参数校验
□ P1-3: 修复401响应Body为空
□ P2-1: 4个页面添加 Loading 状态
□ P2-2: 4个页面添加 Error 状态
□ P2-6: 分页参数 page/page_size 边界校验
第三阶段:体验优化(1-2小时)
□ P2-3: 处理 Promise.allSettled 失败
□ P2-5: 统一401处理
□ P3-1: CSV导出限制优化
□ P3-2: csv_escape 使用标准库
□ P3-5: alert 改为 toast
□ P3-6: 导出使用 blob 下载
第四阶段:代码清理(1小时)
□ P3-3: 删除 decorators.py 死代码
□ P3-4: 事件类型动态加载
□ P3-7: health 接口格式统一
□ P3-8: 过滤搜索词中的错误数据
□ P3-9: 排查来源网站字段为空问题
七、验证清单
修复完成后,按以下清单逐一验证:
- [ ] 概览看板 → 流量概览区域显示正常数值(PV/UV/IP等)
- [ ] 概览看板 → 其他KPI卡片正常显示
- [ ] 事件分析 → 事件分布图、趋势对比图正常渲染
- [ ] 事件分析 → 事件列表分页正常
- [ ] 事件分析 → 筛选标签切换正常
- [ ] 元素分析 → KPI卡片、趋势图、热力图正常(含 Loading 状态)
- [ ] 用户分析 → KPI卡片、留存率趋势箭头正常(含 Loading 状态)
- [ ] 用户分析 → 切换项目后数据刷新
- [ ] 页面路径 → 流程图、列表、入口/退出页面正常(含 Loading 状态)
- [ ] 页面路径 → 切换项目后数据刷新
- [ ] 数据明细 → 筛选、搜索、分页正常
- [ ] 数据明细 → 导出按钮触发CSV下载
- [ ] 数据明细 → 组合筛选(事件+省份+设备)正常
- [ ] 数据明细 → 用户ID搜索正常
- [ ] 事件分析 → 关键词搜索结果准确
- [ ] 事件分析 → event_type筛选结果准确
- [ ] 元素分析 → 关键词搜索结果准确
- [ ] 页面路径 → 关键词搜索结果准确
- [ ] 页面路径 → 大小写不敏感搜索正常
- [ ] 分页参数 → page=0时返回400或修正为1
- [ ] 分页参数 → page_size=0时返回400或修正为1
- [ ] SQL注入 → 搜索参数被参数化查询阻止
- [ ] 无Token访问 → 返回401 + JSON Body
- [ ] 缺少日期参数 → 返回400错误
- [ ] 无效日期格式 → 返回400错误
- [ ] 环境切换 → toast提示而非alert弹窗
- [ ] 登出/登录 → 正常跳转
文档生成时间: 2026-07-30
测试覆盖: 6个前端页面、20个API端点、14个后端Python文件、10个前端TSX文件、33个搜索接口测试用例
问题总计: P0×2 + P1×3 + P2×6 + P3×9 = 20个问题