埋点分析系统 — 优化任务清单
本文档面向 AI Agent,每个任务均给出目标文件、问题定位、修改指令与代码示例。按 P0→P3 优先级执行。
P0 — 安全与正确性(立即修复)
任务 1:全面消除 SQL 注入风险
问题:后端所有 API 模块均通过 f-string 拼接用户输入到 SQL,存在注入风险。
涉及文件:
backend/api/detail.pybackend/api/events.pybackend/api/screens.pybackend/api/users.pybackend/utils.py
修改方案:
- 修改
backend/utils.py中的getprojectfilter(),使其返回参数占位符和参数字典,而非直接拼接 SQL 字符串。
# backend/utils.py — 新增函数
from flask import request
def get_project_filter_params():
"""返回 (sql_fragment, params_dict)
sql_fragment 中项目占位符使用 %(projects)s
"""
projects_str = request.args.get('projects', '')
if not projects_str:
return '', {}
projects = [p.strip() for p in projects_str.split(',') if p.strip()]
if not projects:
return '', {}
# ClickHouse 参数化查询中 IN 使用数组
return 'AND project_name IN %(projects)s', {'projects': tuple(projects)}
- 修改
backend/database.py,确保executequery和queryto_dicts支持params透传(当前已有params参数,但各 API 未使用)。
- 以
backend/api/detail.py为例重写过滤逻辑(其余模块同理):
# backend/api/detail.py — get_events 中
# 替换原有的 where_clause 拼接方式
pf_sql, pf_params = get_project_filter_params()
params = {**pf_params}
conditions = ['stat_date BETWEEN %(start)s AND %(end)s']
params['start'] = start_str
params['end'] = end_str
if pf_sql:
conditions.append(pf_sql.lstrip('AND '))
if event_type and event_type != '全部':
conditions.append('event = %(event_type)s')
params['event_type'] = event_type
if user_id:
conditions.append('distinct_id LIKE %(user_id)s')
params['user_id'] = f'%{user_id}%'
if device:
conditions.append('model LIKE %(device)s')
params['device'] = f'%{device}%'
if os_name:
conditions.append('(os LIKE %(os)s OR os_version LIKE %(os)s)')
params['os'] = f'%{os_name}%'
if province:
conditions.append('province = %(province)s')
params['province'] = province
if keyword:
conditions.append('(event LIKE %(kw)s OR screen_name LIKE %(kw)s OR element_name LIKE %(kw)s OR distinct_id LIKE %(kw)s)')
params['kw'] = f'%{keyword}%'
where_clause = ' AND '.join(conditions)
count_query = f"SELECT count(*) FROM {table} WHERE {where_clause}"
total_result = execute_query(count_query, params)
- 修改
backend/api/events.py、screens.py、users.py中所有 f-string 拼接keyword、eventtype、userid等处,统一改为参数化查询。
- 注意
backend/api/screens.pyL39 的GROUP BY prevscreen, prevscreen, nextscreen应修正为GROUP BY prevscreen, next_screen。
任务 2:移除硬编码凭据
问题:backend/config.py 中数据库密码默认值为 123456。
修改:
# backend/config.py
class Config:
CLICKHOUSE_HOST = os.environ.get('CLICKHOUSE_HOST', '192.168.20.147')
CLICKHOUSE_PORT = int(os.environ.get('CLICKHOUSE_PORT', 9000))
CLICKHOUSE_USER = os.environ.get('CLICKHOUSE_USER', 'default')
CLICKHOUSE_PASSWORD = os.environ.get('CLICKHOUSE_PASSWORD', '') # 移除默认值
CLICKHOUSE_DATABASE = os.environ.get('CLICKHOUSE_DATABASE', 'clklog')
CLICKHOUSE_TABLE = os.environ.get('CLICKHOUSE_TABLE', 'log_analysis')
FLASK_HOST = '0.0.0.0'
FLASK_PORT = 5000
FLASK_DEBUG = False
任务 3:收紧 CORS 策略
问题:当前 CORS(app, supports_credentials=True) 允许所有来源携带凭证,存在 CSRF 风险。
修改:
# backend/app.py
from flask_cors import CORS
from config import Config
# 只允许前端开发/生产域名
CORS(app, resources={
r"/api/*": {
"origins": os.environ.get('CORS_ORIGINS', 'http://localhost:3000').split(','),
"supports_credentials": True
}
})
P1 — 代码正确性(本周内)
任务 4:清除所有 Mock / 假数据 fallback
问题:多处接口在查询失败时返回硬编码假数据,会误导业务决策。
涉及位置:
| 文件 | 行号 | 当前行为 | 修改为 |
|---|---|---|---|
backend/api/users.py | L77-L89 | totalusers.trend: 8.2、retentionrate: 68.4 硬编码 | 改为 None 或从真实数据计算 |
backend/api/users.py | L123 | running_total + 3500 | 移除 + 3500,返回真实累计值 |
backend/api/users.py | L200-202 | sources 为空时返回假数据 | 返回 {'labels': [], 'data': []} |
backend/api/screens.py | L49-58 | flow 失败返回假数据 | 返回 {'labels': [], 'data': []} |
backend/api/screens.py | L216-222 | entry 失败返回假数据 | 返回空数组 |
backend/api/screens.py | L270-276 | exit 失败返回假数据 | 返回空数组 |
backend/api/detail.py | L197-218 | filters 失败返回假数据 | 返回对应空数组 |
修改示例(以 users.py sources 为例):
# backend/api/users.py — get_sources
# 删除以下 fallback 代码块:
# if not labels:
# labels = ['自然流量', '应用市场', '广告投放', '分享', '其他']
# data = [45, 28, 15, 8, 4]
# 保留:
return success_response({'labels': labels, 'data': data})
修改示例(以 users.py total_users trend 为例):
# backend/api/users.py — get_kpi
# 移除硬编码,改为从 prev 数据计算(类似 new_users / active_users)
prev_total_query = f"""
SELECT count(distinct distinct_id) as total_users
FROM {table}
WHERE stat_date <= '{format_date(prev_end)}'
{pf}
"""
prev_total_result = query_to_dicts(prev_total_query)
prev_total = prev_total_result[0]['total_users'] if prev_total_result else 0
total_trend = format_percent(safe_divide(total_users - prev_total, max(prev_total, 1)))
# retention_rate 当前无真实计算逻辑,可暂时返回 None,或补充留存计算
return success_response({
'total_users': {
'value': total_users,
'trend': total_trend,
},
'retention_rate': {
'value': None,
'trend': None,
}
})
任务 5:修复具体 Bug
5a — screens.py GROUP BY 重复列
# backend/api/screens.py — L39
# 从:
GROUP BY prev_screen, prev_screen, next_screen
# 改为:
GROUP BY prev_screen, next_screen
5b — overview.py 硬编码年份
# backend/api/overview.py — L115
# 当前代码:
label_full = f"2026-{label}" if len(label) == 5 else label
# 改为动态获取当前年份:
from datetime import datetime
label_full = f"{datetime.now().year}-{label}" if len(label) == 5 else label
5c — detail.py CSV 导出不转义逗号
# backend/api/detail.py — export_events
# 当前直接 ','.join(row),字段含逗号会破坏 CSV
# 替换为:
def csv_escape(value):
s = str(value).replace('"', '""')
if ',' in s or '\n' in s or '"' in s:
s = f'"{s}"'
return s
for r in result:
row = [csv_escape(v) for v in row_values]
csv_lines.append(','.join(row))
任务 6:添加 .gitignore 并清理已提交缓存
新增文件:项目根目录 e:\AI工作台\埋点分析系统\.gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
*.egg-info/
dist/
build/
# Frontend
node_modules/
frontend/.vite/
frontend/dist/
# IDE
.vscode/
.idea/
*.swp
# Environment
.env
.env.local
# OS
.DS_Store
Thumbs.db
执行命令:
git rm -r --cached backend/__pycache__ backend/api/__pycache__
git add .gitignore
git commit -m "chore: add .gitignore and remove cached pyc files"
P2 — 重构与质量提升(两周内)
任务 7:抽取后端公共请求解析逻辑
问题:每个端点都重复解析日期、项目过滤、表名。
新增文件:backend/api/decorators.py
from functools import wraps
from flask import request
from database import get_table_name
from utils import get_date_range, format_date, get_project_filter_params
def with_date_range_and_project(f):
@wraps(f)
def wrapper(*args, **kwargs):
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
start, end = get_date_range(start_date, end_date, 7)
pf_sql, pf_params = get_project_filter_params()
kwargs['date_ctx'] = {
'start': start,
'end': end,
'start_str': format_date(start),
'end_str': format_date(end),
'prev_start': start - (end - start) - __import__('datetime').timedelta(days=1),
'prev_end': start - __import__('datetime').timedelta(days=1),
}
kwargs['table'] = get_table_name()
kwargs['pf_sql'] = pf_sql
kwargs['pf_params'] = pf_params
return f(*args, **kwargs)
return wrapper
使用方式(以 overview kpi 为例):
# backend/api/overview.py
from .decorators import with_date_range_and_project
@overview_bp.route('/kpi')
@with_date_range_and_project
def get_kpi(date_ctx, table, pf_sql, pf_params):
start_str = date_ctx['start_str']
end_str = date_ctx['end_str']
# ... 使用 date_ctx、pf_sql、pf_params 替代重复代码
任务 8:合并 events trend-compare 的 N+1 查询
问题:events.py 的 gettrendcompare 对每个事件单独发一次查询。
修改:
# backend/api/events.py — get_trend_compare
event_list = [e.strip() for e in events_param.split(',') if e.strip()]
pf_sql, pf_params = get_project_filter_params()
params = {**pf_params, 'start': start_str, 'end': end_str, 'events': tuple(event_list)}
query = f"""
SELECT
stat_date,
event,
count(*) as cnt
FROM {table}
WHERE stat_date BETWEEN %(start)s AND %(end)s
{pf_sql}
AND event IN %(events)s
GROUP BY stat_date, event
ORDER BY stat_date, event
"""
result = query_to_dicts(query, params)
# 按 event 分组
from collections import defaultdict
event_data_map = defaultdict(dict)
for r in result:
label = format_date(r['stat_date'])[-5:]
event_data_map[r['event']][label] = r['cnt']
colors = ['#4f46e5', '#818cf8', '#a5b4fc', '#c7d2fe', '#e0e7ff']
datasets = []
for idx, event_name in enumerate(event_list):
data = [event_data_map[event_name].get(label, 0) for label in labels]
datasets.append({
'label': event_name,
'data': data,
'borderColor': colors[idx % len(colors)],
'backgroundColor': f"rgba(79,70,229,0.08)",
})
return success_response({'labels': labels, 'datasets': datasets})
任务 9:消除前端全局可变状态
问题:frontend/src/api/index.ts 使用模块级变量 selectedProjects 传递状态,违背 React 单向数据流。
修改方案:
- 删除
api/index.ts中的selectedProjects模块变量和setSelectedProjects导出。 - 所有 API 函数改为接受
projects?: string[]参数,请求时注入。 App.tsx中不再调用setApiProjects,而是直接把selectedProjects传给各 Page 组件。
// frontend/src/api/index.ts — 删除以下内容:
let selectedProjects: string[] = []
export const setSelectedProjects = (projects: string[]) => { ... }
// 以及 interceptor 中自动注入 projects 的逻辑
// 保留所有 API 对象,但每个方法签名增加 projects 参数
export const overviewApi = {
getKpi: (params?: { start_date?: string; end_date?: string; projects?: string[] }) =>
api.get('/overview/kpi', { params }),
// ... 其余方法同理
}
// frontend/src/App.tsx
// 删除:import { setSelectedProjects as setApiProjects } from './api'
// 删除:setApiProjects(all)
// 删除:setApiProjects(selected)
// Routes 中把 selectedProjects 传给页面:
<Route path="/" element={<Overview startDate={startDate} endDate={endDate} projects={selectedProjects} />} />
// frontend/src/pages/Overview.tsx
interface OverviewProps {
startDate: string
endDate: string
projects?: string[]
}
const Overview = ({ startDate, endDate, projects }: OverviewProps) => {
const params = { start_date: startDate, end_date: endDate, projects }
// ...
}
任务 10:前端补全 TypeScript 类型
新增文件:frontend/src/types/api.ts
export interface ApiResponse<T> {
code: number
message: string
data: T
}
export interface KpiData {
total_events: { value: number; trend?: number }
active_users: { value: number; trend?: number }
pv: { value: number; trend?: number }
avg_session_duration: { value: number; formatted: string }
}
export interface TrendData {
labels: string[]
events: number[]
users: number[]
}
export interface EventTypeData {
labels: string[]
data: number[]
}
export interface TopEventItem {
event: string
type: string
screen_name: string
trigger_count: number
user_count: number
avg_per_user: number
ratio: number
trend: number
}
export interface TopEventsData {
list: TopEventItem[]
total: number
}
export interface DevicesData {
labels: string[]
data: number[]
}
export interface RegionItem {
province: string
user_count: number
ratio: number
}
修改 frontend/src/api/index.ts:
import type { KpiData, TrendData, EventTypeData, TopEventsData, DevicesData, RegionItem } from '../types/api'
export const overviewApi = {
getKpi: (params?: ...) => api.get<KpiData>('/overview/kpi', { params }),
getTrend: (params?: ...) => api.get<TrendData>('/overview/trend', { params }),
// ... 其余同理
}
修改 frontend/src/pages/Overview.tsx:
import type { KpiData, TrendData, EventTypeData, TopEventsData, DevicesData, RegionItem } from '../types/api'
const [kpi, setKpi] = useState<KpiData | null>(null)
const [trend, setTrend] = useState<TrendData | null>(null)
// ... 其余 state 同理
任务 11:移除 refreshKey 强制重渲染
问题:App.tsx 通过改变 key 来强制页面组件重新挂载,导致状态丢失和性能浪费。
修改:
// frontend/src/App.tsx
// 删除:const [refreshKey, setRefreshKey] = useState(0)
// 删除:setRefreshKey((k) => k + 1)
// 页面组件不应使用 key={...},而是把 startDate / endDate / projects 作为 props 传入,
// 各页面内部的 useEffect 依赖这些 props 即可自动刷新。
<Route path="/" element={<Overview startDate={startDate} endDate={endDate} projects={selectedProjects} />} />
// 其余页面同理
任务 12:删除前端遗留模板文件
删除文件:
frontend/src/counter.tsfrontend/src/style.cssfrontend/src/assets/typescript.svgfrontend/src/assets/vite.svgfrontend/src/assets/hero.pngfrontend/colorsandtype.css(根目录)
检查:确认 main.tsx、index.html 中未引用上述文件后再删除。
任务 13:统一 formatDate 工具函数
问题:frontend/src/utils/index.ts 和 frontend/src/components/Header.tsx 各自定义了 formatDate。
修改:
// frontend/src/components/Header.tsx
// 删除本地 formatDate 定义,改为:
import { formatDate } from '../utils'
P3 — 工程化建设(本月内)
任务 14:补充项目 README
新增文件:e:\AI工作台\埋点分析系统\README.md
# 埋点分析系统
前后端分离的埋点数据分析平台,对接 ClickHouse。
## 技术栈
- 后端:Python 3.10 + Flask + clickhouse-driver
- 前端:React 18 + TypeScript + Vite + Chart.js
## 快速启动
### 后端
cd backend python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt
export CLICKHOUSEHOST=192.168.20.147 export CLICKHOUSEPASSWORD=your_password python app.py
### 前端
cd frontend npm install npm run dev
## 环境变量
| 变量 | 说明 | 默认值 |
|---|---|---|
| CLICKHOUSE_HOST | ClickHouse 地址 | 192.168.20.147 |
| CLICKHOUSE_PORT | 端口 | 9000 |
| CLICKHOUSE_USER | 用户名 | default |
| CLICKHOUSE_PASSWORD | 密码 | (必填) |
| CLICKHOUSE_DATABASE | 数据库 | clklog |
| CLICKHOUSE_TABLE | 表名 | log_analysis |
| CORS_ORIGINS | 允许的跨域来源 | http://localhost:3000 |
任务 15:添加 .env.example
新增文件:e:\AI工作台\埋点分析系统\.env.example
CLICKHOUSE_HOST=192.168.20.147
CLICKHOUSE_PORT=9000
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_DATABASE=clklog
CLICKHOUSE_TABLE=log_analysis
CORS_ORIGINS=http://localhost:3000
任务 16:添加 Dockerfile
新增文件:e:\AI工作台\埋点分析系统\backend\Dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
新增文件:e:\AI工作台\埋点分析系统\frontend\Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
新增文件:e:\AI工作台\埋点分析系统\docker-compose.yml
version: '3.8'
services:
backend:
build: ./backend
ports:
- "5000:5000"
env_file:
- .env
networks:
- app
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- app
networks:
app:
driver: bridge
任务 17:生产环境使用 WSGI 服务器
修改:backend/app.py
# 替换末尾的 app.run(...)
if __name__ == '__main__':
print(f"Starting server on {Config.FLASK_HOST}:{Config.FLASK_PORT}")
# 开发环境
app.run(host=Config.FLASK_HOST, port=Config.FLASK_PORT, debug=Config.FLASK_DEBUG)
新增:backend/gunicorn.conf.py
bind = "0.0.0.0:5000"
workers = 4
worker_class = "sync"
timeout = 60
keepalive = 5
errorlog = "-"
accesslog = "-"
修改 requirements.txt:
Flask==3.0.0
flask-cors==4.0.0
gunicorn==23.0.0
clickhouse-driver==0.2.6
python-dateutil==2.8.2
任务 18:引入代码规范工具
前端:
cd frontend
npm install -D eslint prettier eslint-plugin-react-hooks @typescript-eslint/parser @typescript-eslint/eslint-plugin
新增 frontend/.eslintrc.cjs 和 frontend/.prettierrc,并在 package.json scripts 中添加:
"lint": "eslint src --ext ts,tsx",
"format": "prettier --write src/**/*.{ts,tsx}"
后端:
cd backend
pip install ruff
ruff check .
ruff format .
任务 19:前端路由懒加载
修改:frontend/src/App.tsx
import { lazy, Suspense } from 'react'
const Overview = lazy(() => import('./pages/Overview'))
const Events = lazy(() => import('./pages/Events'))
const Users = lazy(() => import('./pages/Users'))
const Screens = lazy(() => import('./pages/Screens'))
const Detail = lazy(() => import('./pages/Detail'))
// 在 Routes 外包裹 Suspense
<Suspense fallback={<PageLoading />}>
<Routes>
<Route path="/" element={<Overview ... />} />
{/* ... */}
</Routes>
</Suspense>
附录:快速索引
| 任务 | 优先级 | 涉及文件数 | 预估工作量 |
|---|---|---|---|
| SQL 参数化 | P0 | 5+ | 2h |
| 移除硬编码凭据 | P0 | 1 | 5min |
| 收紧 CORS | P0 | 1 | 10min |
| 清除 Mock 数据 | P1 | 4 | 1h |
| 修复具体 Bug | P1 | 3 | 30min |
| 添加 .gitignore | P1 | 1 | 10min |
| 抽取公共装饰器 | P2 | 5 | 1.5h |
| 合并 N+1 查询 | P2 | 1 | 30min |
| 消除全局可变状态 | P2 | 3 | 1h |
| 补全 TS 类型 | P2 | 3 | 1h |
| 移除 refreshKey | P2 | 2 | 20min |
| 删除遗留文件 | P2 | 6 | 10min |
| README + Docker | P3 | 4 | 1h |
| WSGI 生产部署 | P3 | 3 | 30min |
| 代码规范工具 | P3 | 4 | 1h |
| 路由懒加载 | P3 | 1 | 15min |