埋点分析系统 — 优化任务清单

📑 目录
  1. P0 — 安全与正确性(立即修复)
  2. P1 — 代码正确性(本周内)
  3. P2 — 重构与质量提升(两周内)
  4. P3 — 工程化建设(本月内)
  5. 附录:快速索引

埋点分析系统 — 优化任务清单

本文档面向 AI Agent,每个任务均给出目标文件、问题定位、修改指令与代码示例。按 P0→P3 优先级执行。


P0 — 安全与正确性(立即修复)

任务 1:全面消除 SQL 注入风险

问题:后端所有 API 模块均通过 f-string 拼接用户输入到 SQL,存在注入风险。

涉及文件

  • backend/api/detail.py
  • backend/api/events.py
  • backend/api/screens.py
  • backend/api/users.py
  • backend/utils.py

修改方案

  1. 修改 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)}
  1. 修改 backend/database.py,确保 executequeryqueryto_dicts 支持 params 透传(当前已有 params 参数,但各 API 未使用)。
  1. 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)
  1. 修改 backend/api/events.pyscreens.pyusers.py 中所有 f-string 拼接 keywordeventtypeuserid 等处,统一改为参数化查询。
  1. 注意 backend/api/screens.py L39 的 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.pyL77-L89totalusers.trend: 8.2retentionrate: 68.4 硬编码改为 None 或从真实数据计算
backend/api/users.pyL123running_total + 3500移除 + 3500,返回真实累计值
backend/api/users.pyL200-202sources 为空时返回假数据返回 {'labels': [], 'data': []}
backend/api/screens.pyL49-58flow 失败返回假数据返回 {'labels': [], 'data': []}
backend/api/screens.pyL216-222entry 失败返回假数据返回空数组
backend/api/screens.pyL270-276exit 失败返回假数据返回空数组
backend/api/detail.pyL197-218filters 失败返回假数据返回对应空数组

修改示例(以 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.pygettrendcompare 对每个事件单独发一次查询。

修改

# 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 单向数据流。

修改方案

  1. 删除 api/index.ts 中的 selectedProjects 模块变量和 setSelectedProjects 导出。
  2. 所有 API 函数改为接受 projects?: string[] 参数,请求时注入。
  3. 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.ts
  • frontend/src/style.css
  • frontend/src/assets/typescript.svg
  • frontend/src/assets/vite.svg
  • frontend/src/assets/hero.png
  • frontend/colorsandtype.css(根目录)

检查:确认 main.tsxindex.html 中未引用上述文件后再删除。


任务 13:统一 formatDate 工具函数

问题frontend/src/utils/index.tsfrontend/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.cjsfrontend/.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 参数化P05+2h
移除硬编码凭据P015min
收紧 CORSP0110min
清除 Mock 数据P141h
修复具体 BugP1330min
添加 .gitignoreP1110min
抽取公共装饰器P251.5h
合并 N+1 查询P2130min
消除全局可变状态P231h
补全 TS 类型P231h
移除 refreshKeyP2220min
删除遗留文件P2610min
README + DockerP341h
WSGI 生产部署P3330min
代码规范工具P341h
路由懒加载P3115min