2026-09-15 10:17:52 +08:00
# M1 数据采集底座 · 实施计划(JS SDK + Django 采集后端 + Art Design Pro 后台)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 打通端到端最小闭环——网站接入 JS SDK → 批量上报事件 → Django 采集入库(MySQL)→ Art Design Pro 后台展示访客跨天行为时间线。
**Architecture:** Monorepo 三组件:`server/` ( Django+DRF 单应用 `tracker` ,采集公开接口 + JWT 管理接口)、`sdk/` (原生 TS 写的浏览器 SDK, esbuild 打包 IIFE,≤15KB gzip)、`admin/` ( Art Design Pro 模板精简后开发 5 个页面)。事件全量入库(M1 不分区,PK/索引设计为将来按月分区留路),会话权威在 SDK、服务端兜底校验。
**Tech Stack:** Python 3.11+ / Django 5.x / DRF / MySQL 8(测试用 SQLite) / simplejwt / django-cors-headers / ip2region xdb; Node 20+ / TypeScript / esbuild / vitest + happy-dom; Vue3 + Element Plus( Art Design Pro 模板)。
**Spec:** `docs/superpowers/specs/2026-09-15-customer-behavior-platform-design.md` (M1 范围:§13 M1;红线:§4.6、§9)
---
## 文件结构总览
```
cms-cj/
├── server/ # Django 后端
│ ├── manage.py
│ ├── requirements.txt
│ ├── pytest.ini
│ ├── config/ # Django 工程(settings/urls/wsgi)
│ ├── tracker/ # 唯一业务应用
│ │ ├── models.py # Site/Visitor/VisitSession/Event/DailyStat
│ │ ├── constants.py # 事件名白名单、限制、敏感键黑名单
│ │ ├── services/
│ │ │ ├── validation.py # 事件结构/敏感键/时间窗校验
│ │ │ ├── ingest.py # 批量入库:幂等/访客/会话/首触
│ │ │ ├── geo.py # ip2region 离线解析
│ │ │ └── stats.py # 总览聚合查询
│ │ ├── api/
│ │ │ ├── collect.py # POST /api/collect/batch
│ │ │ ├── auth.py # JWT 登录/刷新/me
│ │ │ ├── sites.py # 站点 CRUD
│ │ │ ├── dashboard.py # 总览数据
│ │ │ └── visitors.py # 访客列表/详情/时间线/会话
│ │ ├── management/commands/refresh_stats.py
│ │ └── tests/ # pytest 测试
│ ├── Dockerfile
2026-09-15 11:02:50 +08:00
│ └── tracker/data/ip2region.xdb # 离线 IP 库(下载,不进 git,部署时 volume 挂载)
2026-09-15 10:17:52 +08:00
├── sdk/ # JS SDK
│ ├── package.json vitest.config.ts build.mjs
│ ├── src/{index,config,identity,context,transport,events,util}.ts
│ ├── test/*.test.ts
│ └── demo/index.html # 本地联调演示页
├── admin/ # Art Design Pro 前端(Task 14 克隆)
│ └── src/{api,views,router/modules}/...(新增 tracker 相关文件)
├── deploy/
│ ├── docker-compose.yml nginx.conf
│ └── README-deploy.md # 1Panel 部署步骤
└── docs/superpowers/{specs,plans}/
```
## 全局约定
- **响应包**:所有 `/api/admin/*` 返回 `{code: 200, data: ..., message: "ok"}` (适配 Art Design Pro 拦截器默认约定);错误 `code` 为业务码(401 未登录/403 无权/4xx 参数/500 内部)。
- **时间**:全库 UTC 存储(`USE_TZ=True` ),展示层按 `settings.TIME_ZONE='Asia/Shanghai'` 。
- **采集跨域**: SDK 用 `Content-Type: text/plain` 发 JSON(简单请求免预检),服务端用 `PlainJSONParser` 解析;`django-cors-headers` + 动态白名单(DB 中 Site.domain_whitelist, 60s 缓存)回 `Access-Control-Allow-Origin` 。
2026-09-15 17:38:09 +08:00
- **前端安全红线**: **业务代码(views/tracker/**、api/**)禁用 v-html**;不得向模板内置 v-html 组件(ArtTextScroll/ThemeSvg/v-highlight)传访客来源数据;访客来源数据(path/title/referrer/utm)不得直接绑定 `href/src` ——如需渲染成链接,协议白名单仅 http/https(Vue 不拦截 `javascript:` URI)。前端适配约定见 `admin/README-dev.md` ( config-object 签名/URL 无 /api 前缀/Bearer 不手拼)。
2026-09-15 10:17:52 +08:00
- **测试**:后端 `pytest` ( SQLite 内存库,`pytest.ini` 指定 `--ds=config.settings_test` );SDK `vitest` + happy-dom + fake timers。
- **提交**:每任务一提交,`feat:/test:/chore:` 前缀。
- **M1 明确不做**(防执行者自作主张加码):SPA 路由、点击/滚动/表单/意图事件、客户(contact)体系、Redis、事件表分区迁移、npm 发布。
---
# Phase 0 · 骨架
### Task 1: Django 工程骨架
**Files:**
- Create: `server/requirements.txt` , `server/pytest.ini` , `server/manage.py` , `server/config/__init__.py` , `server/config/settings.py` , `server/config/urls.py` , `server/config/wsgi.py` , `server/tracker/__init__.py` , `server/tracker/apps.py` , `server/tracker/constants.py`
- [ ] **Step 1: 建目录与依赖文件**
`server/requirements.txt` :
```
Django>=5.1,<5.3
djangorestframework>=3.15
djangorestframework-simplejwt>=5.3
django-cors-headers>=4.4
PyMySQL>=1.1
2026-09-15 11:02:50 +08:00
py-ip2region>=3.0
2026-09-15 10:17:52 +08:00
gunicorn>=22.0
```
`server/config/__init__.py` ( MySQL 驱动适配,Windows/容器免编译):
```python
try :
import pymysql
pymysql . install_as_MySQLdb ()
except ImportError :
pass
```
`server/pytest.ini` :
```ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings_test
python_files = test_*.py
addopts = -q
```
- [ ] **Step 2: 写 settings**
`server/config/settings.py` 关键内容(完整写出):
```python
from pathlib import Path
import os
BASE_DIR = Path ( __file__ ) . resolve () . parent . parent
SECRET_KEY = os . environ . get ( "DJANGO_SECRET_KEY" , "dev-insecure" )
2026-09-15 11:02:50 +08:00
DEBUG = os . environ . get ( "DJANGO_DEBUG" , "0" if os . environ . get ( "DJANGO_ENV" ) == "prod" else "1" ) == "1"
2026-09-15 10:17:52 +08:00
ALLOWED_HOSTS = os . environ . get ( "DJANGO_ALLOWED_HOSTS" , "*" ) . split ( "," )
2026-09-15 11:02:50 +08:00
if os . environ . get ( "DJANGO_ENV" ) == "prod" : # 生产 fail-fast:漏配即启动崩溃,不带病上线
assert SECRET_KEY != "dev-insecure" , "生产必须配置 DJANGO_SECRET_KEY"
assert os . environ . get ( "DJANGO_ALLOWED_HOSTS" ), "生产必须显式配置 DJANGO_ALLOWED_HOSTS"
2026-09-15 10:17:52 +08:00
INSTALLED_APPS = [
"django.contrib.contenttypes" ,
"django.contrib.auth" ,
"corsheaders" ,
"rest_framework" ,
"tracker" ,
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware" ,
"django.middleware.common.CommonMiddleware" ,
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [{
"BACKEND" : "django.template.backends.django.DjangoTemplates" ,
"DIRS" : [], "APP_DIRS" : True , "OPTIONS" : { "context_processors" : []},
}]
2026-09-15 10:32:41 +08:00
if os . environ . get ( "DB_ENGINE" , "mysql" ) == "sqlite" : # 本地无 MySQL 的开发模式
DATABASES = { "default" : { "ENGINE" : "django.db.backends.sqlite3" ,
"NAME" : BASE_DIR / "db.sqlite3" }}
else :
DATABASES = {
"default" : {
"ENGINE" : "django.db.backends.mysql" ,
"NAME" : os . environ . get ( "DB_NAME" , "tracker" ),
"USER" : os . environ . get ( "DB_USER" , "root" ),
"PASSWORD" : os . environ . get ( "DB_PASSWORD" , "" ),
"HOST" : os . environ . get ( "DB_HOST" , "127.0.0.1" ),
"PORT" : os . environ . get ( "DB_PORT" , "3306" ),
"OPTIONS" : { "charset" : "utf8mb4" },
}
2026-09-15 10:17:52 +08:00
}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES" : ( "rest_framework_simplejwt.authentication.JWTAuthentication" ,),
"DEFAULT_PERMISSION_CLASSES" : ( "rest_framework.permissions.IsAuthenticated" ,),
2026-09-15 10:32:41 +08:00
"DEFAULT_THROTTLE_RATES" : { "collect" : "120/min" , "admin" : "600/min" , "login" : "10/min" },
2026-09-15 10:17:52 +08:00
"NUM_PROXIES" : 1 ,
}
SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME" : timedelta ( hours = 12 )}
CORS_ALLOW_ALL_ORIGINS = False
2026-09-15 11:02:50 +08:00
CACHES = { "default" : { "BACKEND" : "django.core.cache.backends.filebased.FileBasedCache" ,
"LOCATION" : str ( BASE_DIR / ".cache" )}} # 限流计数跨 gunicorn worker 共享(不共享则限额×3)
DATA_UPLOAD_MAX_MEMORY_SIZE = 262144 # 256KB 应用层请求体兜底
2026-09-15 10:17:52 +08:00
TIME_ZONE = "Asia/Shanghai"
USE_TZ = True
LANGUAGE_CODE = "zh-hans"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
IP2REGION_XDB = str ( BASE_DIR / "tracker" / "data" / "ip2region.xdb" )
```
( `from datetime import timedelta` 放文件头;无 admin/session/contrib.staticfiles——管理台是独立 SPA。)
`server/config/settings_test.py` :
```python
from .settings import *
DATABASES = { "default" : { "ENGINE" : "django.db.backends.sqlite3" , "NAME" : ":memory:" }}
```
`server/config/urls.py` :
```python
urlpatterns = [] # Task 7 起逐任务填充
```
`server/tracker/constants.py` :
```python
EVENT_NAMES = { "page_view" , "page_stay" , "sdk_loaded" }
MAX_EVENTS_PER_BATCH = 50
MAX_PROP_KEYS = 32
MAX_STR_LEN = 500
MAX_PATH_LEN = 500
MAX_TITLE_LEN = 200
CLOCK_SKEW_LATE_SEC = 120 # 超过即标 late
CLOCK_SKEW_REJECT_SEC = 86400 # 超过即拒绝
SENSITIVE_KEY_PATTERNS = ( "password" , "passwd" , "token" , "secret" , "card" , "verif" , "otp" , "idcard" )
URL_PARAM_DROP_PATTERNS = ( "email" , "phone" , "mobile" , "token" , "key" , "password" , "verif" )
SESSION_IDLE_MS = 30 * 60 * 1000
2026-09-15 10:32:41 +08:00
BOT_UA_KEYWORDS = ( "bot" , "spider" , "crawl" , "slurp" , "bingpreview" , "headless" , "lighthouse" )
2026-09-15 11:02:50 +08:00
EVENT_PROP_WHITELIST = { # spec §9.1:未知字段拒绝。免费文本/任意属性不得入库
"page_view" : { "referrer" , "referrer_domain" , "utm" },
"page_stay" : { "active_ms" , "path" },
"sdk_loaded" : { "sdk_version" },
}
MAX_PROP_DEPTH = 2
MAX_PROP_JSON_BYTES = 4096
2026-09-15 10:17:52 +08:00
```
- [ ] **Step 3: 建 Django 工程与 app 骨架**
按上面文件清单创建 `manage.py` (标准模板)、`config/wsgi.py` (标准)、`tracker/apps.py` ( name="tracker")。
- [ ] **Step 4: 验证**
```bash
cd server && python -m venv .venv && .venv/Scripts/pip install -r requirements.txt pytest pytest-django
.venv/Scripts/python manage.py check
```
Expected: `System check identified no issues` 。`pip freeze > requirements.lock.txt` (不含 pytest 两条则手工剔除 pytest 行,锁定运行依赖)。
- [ ] **Step 5: Commit** `chore: Django 工程骨架(config+tracker app+常量)`
### Task 2: SDK 工程骨架
**Files:**
- Create: `sdk/package.json` , `sdk/tsconfig.json` , `sdk/vitest.config.ts` , `sdk/build.mjs` , `sdk/src/util.ts` , `sdk/test/util.test.ts`
- [ ] **Step 1: 初始化**
`sdk/package.json` :
```json
{
"name" : "ctk-sdk" ,
"version" : "0.1.0" ,
"private" : true ,
"type" : "module" ,
"scripts" : {
"build" : "node build.mjs" ,
"test" : "vitest run" ,
2026-09-15 11:39:12 +08:00
"typecheck" : "tsc --noEmit" ,
"checksize" : "node build.mjs && node -e \"const z=require('zlib'),f=require('fs');console.log('gzip bytes:',z.gzipSync(f.readFileSync('dist/v1.js')).length)\""
2026-09-15 10:17:52 +08:00
},
"devDependencies" : {
"esbuild" : "^0.24.0" ,
"happy-dom" : "^15.0.0" ,
"typescript" : "^5.6.0" ,
"vitest" : "^2.1.0"
}
}
```
`sdk/vitest.config.ts` :
```ts
import { defineConfig } from "vitest/config" ;
export default defineConfig ({
test : { environment : "happy-dom" , setupFiles : [] },
});
```
`sdk/build.mjs` :
```js
import { build } from "esbuild" ;
await build ({
entryPoints : [ "src/index.ts" ],
bundle : true , minify : true , format : "iife" ,
outfile : "dist/v1.js" , target : [ "es2018" ],
});
```
`sdk/src/util.ts` :
```ts
export function uuid () : string {
2026-09-15 11:39:12 +08:00
const c = globalThis . crypto as Crypto | undefined ; // as 断言防 TS2774( lib.dom 定义 randomUUID 非可选)
if ( c ? . randomUUID ) return c . randomUUID ();
2026-09-15 10:17:52 +08:00
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" . replace ( /[xy]/g , ( c ) => {
const r = ( Math . random () * 16 ) | 0 ;
return ( c === "x" ? r : ( r & 0x3 ) | 0x8 ). toString ( 16 );
});
}
```
- [ ] **Step 2: TDD uuid**
`sdk/test/util.test.ts` :
```ts
2026-09-15 11:39:12 +08:00
import { describe , it , expect , vi } from "vitest" ;
2026-09-15 10:17:52 +08:00
import { uuid } from "../src/util" ;
describe ( "uuid" , () => {
it ( "生成 36 位且不重复" , () => {
const a = uuid (), b = uuid ();
expect ( a ). toMatch ( /^[0-9a-f-]{36}$/ );
expect ( a ). not . toBe ( b );
});
2026-09-15 11:39:12 +08:00
it ( "Math.random 降级分支:格式正确且唯一(非 https 站点走此路径)" , () => {
vi . stubGlobal ( "crypto" , {});
const a = uuid (), b = uuid ();
expect ( a ). toMatch ( /^[0-9a-f-]{36}$/ );
expect ( a ). not . toBe ( b );
vi . unstubAllGlobals ();
});
2026-09-15 10:17:52 +08:00
});
```
```bash
cd sdk && pnpm install && pnpm test
```
Expected: 1 passed。
- [ ] **Step 3: Commit** `chore: SDK 工程骨架(esbuild+vitest) `
---
# Phase 1 · 数据模型与采集后端
### Task 3: 五个核心模型
**Files:**
- Create: `server/tracker/models.py` , `server/tracker/tests/test_models.py` , `server/tracker/migrations/__init__.py`
- [ ] **Step 1: 写失败测试**
`server/tracker/tests/test_models.py` :
```python
import pytest
from tracker.models import Site , Visitor , VisitSession , Event
pytestmark = pytest . mark . django_db
def test_create_site_generates_public_site_id ():
site = Site . objects . create ( name = "官网" , domains = [ "www.example.com" ])
assert site . site_id . startswith ( "site_" ) and len ( site . site_id ) == 21
assert site . is_active is True
def test_visitor_first_touch_fields ():
site = Site . objects . create ( name = "s" , domains = [ "a.com" ])
v = Visitor . objects . create ( site = site , visitor_id = "vid1" ,
first_referrer_domain = "google.com" , first_utm = { "utm_source" : "google" },
first_landing_path = "/p" , country = "美国" , device_type = "PC" )
assert v . visit_count == 0 and v . active_days == 0
def test_event_unique_site_event_id ():
site = Site . objects . create ( name = "s" , domains = [ "a.com" ])
v = Visitor . objects . create ( site = site , visitor_id = "vid1" )
2026-09-15 11:46:14 +08:00
s = VisitSession . objects . create ( site = site , visitor = v , session_id = "sid1" ,
started_at = "2026-09-15T10:00:00Z" , ended_at = "2026-09-15T10:01:00Z" )
2026-09-15 10:17:52 +08:00
Event . objects . create ( site = site , visitor = v , session = s , event_id = "e1" ,
event_name = "page_view" , occurred_at = "2026-09-15T10:00:00Z" , path = "/" )
with pytest . raises ( Exception ):
Event . objects . create ( site = site , visitor = v , session = s , event_id = "e1" ,
event_name = "page_view" , occurred_at = "2026-09-15T10:01:00Z" , path = "/" )
```
- [ ] **Step 2: 跑测试确认失败** `cd server && .venv/Scripts/python -m pytest tracker/tests/test_models.py` Expected: ImportError( models 不存在)。
- [ ] **Step 3: 实现 models.py**
```python
from django.db import models
import secrets
def short_public_id () -> str :
return "site_" + secrets . token_urlsafe ( 12 )[: 16 ]
class Site ( models . Model ):
name = models . CharField ( max_length = 100 )
site_id = models . CharField ( max_length = 32 , unique = True , default = short_public_id )
domains = models . JSONField ( default = list ) # 域名白名单 ["www.example.com"]
allow_no_origin = models . BooleanField ( default = False ) # 本地/demo 调试用
is_active = models . BooleanField ( default = True )
timezone = models . CharField ( max_length = 64 , default = "Asia/Shanghai" )
created_at = models . DateTimeField ( auto_now_add = True )
updated_at = models . DateTimeField ( auto_now = True )
class Meta :
db_table = "site"
class Visitor ( models . Model ):
site = models . ForeignKey ( Site , on_delete = models . CASCADE , related_name = "visitors" )
visitor_id = models . CharField ( max_length = 64 )
first_seen_at = models . DateTimeField ( auto_now_add = True )
last_seen_at = models . DateTimeField ( auto_now_add = True )
first_referrer_domain = models . CharField ( max_length = 200 , blank = True , default = "" )
first_utm = models . JSONField ( default = dict , blank = True )
first_landing_path = models . CharField ( max_length = 500 , blank = True , default = "" )
device_type = models . CharField ( max_length = 16 , blank = True , default = "" )
os = models . CharField ( max_length = 64 , blank = True , default = "" )
browser = models . CharField ( max_length = 64 , blank = True , default = "" )
language = models . CharField ( max_length = 32 , blank = True , default = "" )
timezone_name = models . CharField ( max_length = 64 , blank = True , default = "" )
screen = models . CharField ( max_length = 32 , blank = True , default = "" )
last_ip = models . GenericIPAddressField ( null = True , blank = True )
country = models . CharField ( max_length = 64 , blank = True , default = "" )
province = models . CharField ( max_length = 64 , blank = True , default = "" )
city = models . CharField ( max_length = 64 , blank = True , default = "" )
visit_count = models . PositiveIntegerField ( default = 0 ) # 会话数(服务端按新会话递增)
active_days = models . PositiveIntegerField ( default = 0 ) # 活跃天数
last_active_date = models . DateField ( null = True , blank = True )
2026-09-15 10:32:41 +08:00
is_bot = models . BooleanField ( default = False ) # UA 爬虫标记,报表默认排除
2026-09-15 10:17:52 +08:00
class Meta :
db_table = "visitor"
constraints = [ models . UniqueConstraint ( fields = [ "site" , "visitor_id" ], name = "uq_site_visitor" )]
indexes = [
models . Index ( fields = [ "site" , "-last_seen_at" ]),
models . Index ( fields = [ "site" , "country" ]),
]
class VisitSession ( models . Model ):
site = models . ForeignKey ( Site , on_delete = models . CASCADE , related_name = "sessions" )
visitor = models . ForeignKey ( Visitor , on_delete = models . CASCADE , related_name = "sessions" )
session_id = models . CharField ( max_length = 64 )
started_at = models . DateTimeField ()
ended_at = models . DateTimeField ()
referrer_domain = models . CharField ( max_length = 200 , blank = True , default = "" )
utm = models . JSONField ( default = dict , blank = True )
landing_path = models . CharField ( max_length = 500 , blank = True , default = "" )
event_count = models . PositiveIntegerField ( default = 0 )
2026-09-15 10:32:41 +08:00
duration_ms = models . PositiveBigIntegerField ( default = 0 ) # 会话内事件时间跨度
stay_ms = models . PositiveBigIntegerField ( default = 0 ) # 有效停留累计(page_stay active_ms 之和)
2026-09-15 10:17:52 +08:00
class Meta :
db_table = "visit_session"
constraints = [ models . UniqueConstraint ( fields = [ "site" , "session_id" ], name = "uq_site_session" )]
indexes = [ models . Index ( fields = [ "visitor" , "-started_at" ])]
class Event ( models . Model ):
site = models . ForeignKey ( Site , on_delete = models . CASCADE , related_name = "events" )
visitor = models . ForeignKey ( Visitor , on_delete = models . CASCADE , related_name = "events" )
session = models . ForeignKey ( VisitSession , on_delete = models . CASCADE , related_name = "events" , null = True )
event_id = models . CharField ( max_length = 64 )
event_name = models . CharField ( max_length = 50 )
occurred_at = models . DateTimeField ()
received_at = models . DateTimeField ( auto_now_add = True )
path = models . CharField ( max_length = 500 , blank = True , default = "" )
title = models . CharField ( max_length = 200 , blank = True , default = "" )
properties = models . JSONField ( default = dict , blank = True )
ip = models . GenericIPAddressField ( null = True , blank = True )
country = models . CharField ( max_length = 64 , blank = True , default = "" )
province = models . CharField ( max_length = 64 , blank = True , default = "" )
city = models . CharField ( max_length = 64 , blank = True , default = "" )
late = models . BooleanField ( default = False )
2026-09-15 10:32:41 +08:00
is_bot = models . BooleanField ( default = False )
2026-09-15 10:17:52 +08:00
class Meta :
db_table = "event"
constraints = [ models . UniqueConstraint ( fields = [ "site" , "event_id" ], name = "uq_site_event" )]
indexes = [
models . Index ( fields = [ "site" , "visitor" , "-occurred_at" ]),
models . Index ( fields = [ "session" , "occurred_at" ]),
models . Index ( fields = [ "site" , "event_name" , "-occurred_at" ]),
]
class DailyStat ( models . Model ):
site = models . ForeignKey ( Site , on_delete = models . CASCADE , related_name = "daily_stats" )
date = models . DateField ()
pv = models . PositiveIntegerField ( default = 0 )
uv = models . PositiveIntegerField ( default = 0 )
sessions = models . PositiveIntegerField ( default = 0 )
new_visitors = models . PositiveIntegerField ( default = 0 )
class Meta :
db_table = "daily_stat"
constraints = [ models . UniqueConstraint ( fields = [ "site" , "date" ], name = "uq_site_date" )]
```
- [ ] **Step 4: 迁移并跑测试**
```bash
.venv/Scripts/python manage.py makemigrations tracker
.venv/Scripts/python -m pytest tracker/tests/test_models.py
```
Expected: 3 passed。
- [ ] **Step 5: Commit** `feat: 核心数据模型(Site/Visitor/VisitSession/Event/DailyStat) `
### Task 4: 事件校验服务 validation.py
**Files:**
- Create: `server/tracker/services/__init__.py` , `server/tracker/services/validation.py` , `server/tracker/tests/test_validation.py`
- [ ] **Step 1: 失败测试** (覆盖:白名单事件名/敏感键拒绝/属性数与长度截断/URL 参数清洗/时间窗)
`server/tracker/tests/test_validation.py` 核心用例:
```python
from datetime import datetime , timedelta , timezone as tz
from tracker.services.validation import validate_event , sanitize_url
from django.utils import timezone
NOW = timezone . now ()
def _ev ( ** kw ):
base = dict ( event_id = "e1" , event_name = "page_view" ,
occurred_at = NOW , path = "/p" , title = "t" , properties = {})
base . update ( kw )
return base
def test_ok_event ():
assert validate_event ( _ev ()) is None # None = 通过
def test_unknown_event_name ():
assert "event_name" in validate_event ( _ev ( event_name = "xxx" ))
def test_sensitive_property_rejected ():
r = validate_event ( _ev ( properties = { "user_password" : "1" }))
assert "sensitive" in r
2026-09-15 11:02:50 +08:00
def test_shape_type_and_whitelist ():
assert validate_event ([ "x" ]) == "event_invalid_shape"
assert validate_event ( _ev ( event_id = 123 )) == "invalid_event_id"
assert validate_event ( _ev ( properties = { "foo" : "bar" })) == "property_not_allowed"
2026-09-15 10:17:52 +08:00
def test_too_many_props_and_long_string ():
r = validate_event ( _ev ( properties = { f "k { i } " : "v" for i in range ( 40 )}))
assert "properties" in r
2026-09-15 12:05:31 +08:00
r = validate_event ( _ev ( properties = { "referrer" : "x" * 600 })) # 白名单内的键才能测到长度分支
2026-09-15 10:17:52 +08:00
assert "properties" in r
2026-09-15 12:05:31 +08:00
def test_nested_sensitive_key_and_naive_datetime ():
r = validate_event ( _ev ( properties = { "utm" : { "utm_a" : 1 , "user_password" : "x" }}))
assert r == "sensitive_field" # 嵌套层级的键也要扫
assert validate_event ( _ev ( occurred_at = NOW . replace ( tzinfo = None ))) == "invalid_occurred_at"
2026-09-15 10:17:52 +08:00
def test_future_and_stale_time ():
r = validate_event ( _ev ( occurred_at = NOW + timedelta ( days = 2 )))
assert "occurred_at" in r # 超窗拒绝
assert validate_event ( _ev ( occurred_at = NOW - timedelta ( hours = 2 ))) is None # 窗内迟到可收
def test_sanitize_url_drops_sensitive_params ():
2026-09-15 11:02:50 +08:00
u = sanitize_url ( "/p?email=a@b.com&token=x&id=3&utm_source=google&monkey=1" )
2026-09-15 10:17:52 +08:00
assert "email=" not in u and "token=" not in u and "utm_source=google" in u
2026-09-15 11:02:50 +08:00
assert "monkey=1" in u # fullmatch:含 key 子串的正常参数不误伤
2026-09-15 10:17:52 +08:00
```
- [ ] **Step 2: 跑测试确认失败** → ModuleNotFoundError。
- [ ] **Step 3: 实现**
```python
2026-09-15 11:02:50 +08:00
import json
2026-09-15 10:17:52 +08:00
import re
2026-09-15 12:05:31 +08:00
from datetime import datetime
2026-09-15 11:02:50 +08:00
from urllib.parse import parse_qsl , urlencode , urlparse
2026-09-15 10:17:52 +08:00
from django.utils import timezone
from tracker import constants as C
_SENSITIVE = re . compile ( "|" . join ( C . SENSITIVE_KEY_PATTERNS ), re . I )
2026-09-15 11:02:50 +08:00
_URL_DROP_KEY = re . compile ( "|" . join ( C . URL_PARAM_DROP_PATTERNS ), re . I )
2026-09-15 10:17:52 +08:00
def sanitize_url ( url : str ) -> str :
2026-09-15 11:02:50 +08:00
"""按参数名整键删除(fullmatch 防止误伤 monkey 这类含 key 子串的参数)。"""
p = urlparse ( url )
kept = [( k , v ) for k , v in parse_qsl ( p . query , keep_blank_values = True )
if not _URL_DROP_KEY . fullmatch ( k )]
return p . _replace ( query = urlencode ( kept )) . geturl ()
2026-09-15 12:05:31 +08:00
def _walk_props ( v , depth = 0 ):
"""一次递归同时做深度合规与敏感键扫描(任意层级)。返回 (depth_ok, sensitive_hit)。"""
2026-09-15 11:02:50 +08:00
if depth > C . MAX_PROP_DEPTH :
2026-09-15 12:05:31 +08:00
return False , False
2026-09-15 11:02:50 +08:00
if isinstance ( v , dict ):
2026-09-15 12:05:31 +08:00
for k , x in v . items ():
if _SENSITIVE . search ( str ( k )):
return True , True
ok , hit = _walk_props ( x , depth + 1 )
if hit :
return True , True
if not ok :
return False , False
return True , False
2026-09-15 11:02:50 +08:00
if isinstance ( v , list ):
2026-09-15 12:05:31 +08:00
for x in v :
ok , hit = _walk_props ( x , depth + 1 )
if hit :
return True , True
if not ok :
return False , False
return True , False
return True , False
2026-09-15 11:02:50 +08:00
def validate_event ( ev ) -> str | None :
"""返回 None 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。"""
if not isinstance ( ev , dict ):
return "event_invalid_shape"
2026-09-15 10:17:52 +08:00
if ev . get ( "event_name" ) not in C . EVENT_NAMES :
return "event_name_not_allowed"
2026-09-15 11:02:50 +08:00
eid = ev . get ( "event_id" )
if not isinstance ( eid , str ) or not eid or len ( eid ) > 64 :
2026-09-15 10:17:52 +08:00
return "invalid_event_id"
2026-09-15 11:02:50 +08:00
for k in ( "path" , "title" ):
if ev . get ( k ) is not None and not isinstance ( ev [ k ], str ):
return "invalid_field_type"
2026-09-15 10:17:52 +08:00
occ = ev . get ( "occurred_at" )
2026-09-15 12:05:31 +08:00
if not isinstance ( occ , datetime ) or timezone . is_naive ( occ ):
2026-09-15 10:17:52 +08:00
return "invalid_occurred_at"
skew = ( timezone . now () - occ ) . total_seconds ()
if abs ( skew ) > C . CLOCK_SKEW_REJECT_SEC :
return "occurred_at_out_of_window"
props = ev . get ( "properties" ) or {}
if not isinstance ( props , dict ) or len ( props ) > C . MAX_PROP_KEYS :
return "properties_invalid"
2026-09-15 12:05:31 +08:00
depth_ok , sensitive_hit = _walk_props ( props ) # 敏感键(任意层级)与深度先于白名单检查,
if sensitive_hit : # 否则白名单会把敏感码短路成 not_allowed
return "sensitive_field"
2026-09-15 11:02:50 +08:00
if not set ( props ) <= C . EVENT_PROP_WHITELIST . get ( ev [ "event_name" ], set ()):
return "property_not_allowed"
2026-09-15 12:05:31 +08:00
if not depth_ok :
2026-09-15 11:02:50 +08:00
return "properties_depth"
if len ( json . dumps ( props , ensure_ascii = False , default = str )) > C . MAX_PROP_JSON_BYTES :
return "properties_too_large"
2026-09-15 12:05:31 +08:00
for v in props . values ():
2026-09-15 10:17:52 +08:00
if isinstance ( v , str ) and len ( v ) > C . MAX_STR_LEN :
return "properties_too_long"
if len ( ev . get ( "path" ) or "" ) > C . MAX_PATH_LEN :
ev [ "path" ] = ev [ "path" ][: C . MAX_PATH_LEN ]
if len ( ev . get ( "title" ) or "" ) > C . MAX_TITLE_LEN :
ev [ "title" ] = ev [ "title" ][: C . MAX_TITLE_LEN ]
if ev . get ( "path" ):
ev [ "path" ] = sanitize_url ( ev [ "path" ])
return None
```
- [ ] **Step 4: 跑测试通过** → 6 passed。
2026-09-15 12:22:22 +08:00
(**执行注记**:实际交付经 4 轮审查加固,最终实现以提交 7b26c7e 为准——含敏感键先于白名单、递归深度/敏感/长度三合一扫描、字节数守门、孤代理 `_utf8_ok` 三入口防护、path 先清洗后截断;上方代码为初版基线,17 个校验测试锁定全部 12 个原因码。)
2026-09-15 10:17:52 +08:00
- [ ] **Step 5: Commit** `feat: 事件校验服务(白名单/敏感键/时间窗/URL清洗)`
### Task 5: geo.py 离线 IP 解析
**Files:**
- Create: `server/tracker/services/geo.py` , `server/tracker/tests/test_geo.py`
- Download: `server/tracker/data/ip2region.xdb` ( [ip2region 官方仓库 ](https://github.com/lionsoul2014/ip2region ) data/ip2region.xdb, ~11MB,加入 .gitignore)
- [ ] **Step 1: 失败测试**
```python
from tracker.services.geo import GeoResolver
def test_private_ip_returns_local ():
r = GeoResolver ()
assert r . resolve ( "192.168.1.5" ) is None # 内网不解析(后台显示"本地")
assert r . resolve ( "127.0.0.1" ) is None
def test_public_ip_parsed_or_none ( monkeypatch ):
r = GeoResolver ()
# xdb 未下载/解析失败时优雅降级返回 None,不抛异常
out = r . resolve ( "8.8.8.8" )
assert out is None or set ( out ) == { "country" , "province" , "city" }
```
- [ ] **Step 2: 实现**
```python
import functools , ipaddress
from django.conf import settings
class GeoResolver :
2026-09-15 11:02:50 +08:00
"""进程级单例:xdb 一次读入内存(官方 buffer 模式声明线程安全)。
pip 包名 py-ip2region(模块名 ip2region);包缺失或 xdb 缺失时优雅降级返回 None。"""
2026-09-15 10:17:52 +08:00
def __init__ ( self ):
self . _searcher = None
try :
2026-09-15 11:02:50 +08:00
from ip2region import searcher as ip2region_searcher
with open ( settings . IP2REGION_XDB , "rb" ) as f :
self . _searcher = ip2region_searcher . new_with_buffer ( f . read ())
2026-09-15 10:17:52 +08:00
except Exception :
2026-09-15 11:02:50 +08:00
self . _searcher = None
2026-09-15 10:17:52 +08:00
@functools.lru_cache ( maxsize = 10000 )
def resolve ( self , ip : str ):
if not ip :
return None
try :
if ipaddress . ip_address ( ip ) . is_private :
return None
except ValueError :
return None
2026-09-15 11:02:50 +08:00
if not self . _searcher :
2026-09-15 10:17:52 +08:00
return None
try :
2026-09-15 11:02:50 +08:00
raw = self . _searcher . search ( ip ) # "国家|区域|省|市|ISP"
2026-09-15 10:17:52 +08:00
parts = ( raw or "" ) . split ( "|" )
return { "country" : parts [ 0 ], "province" : parts [ 2 ], "city" : parts [ 3 ]}
except Exception :
return None
```
2026-09-15 11:02:50 +08:00
注:py-ip2region 的实际调用以其 README 为准(模块 `ip2region` ,入口 `searcher.new_with_buffer(buff)` 后 `search(ip)` ;**不支持 with 上下文管理器**——用错会被 except 静默吞掉、地理功能永久失效且测试照样绿,务必按此实现)。解析结果格式 `国家|区域|省|市|ISP` ,如 `中国|0|广东省|深圳市|电信` 、`美国|0|加利福尼亚|旧金山|0` 。
2026-09-15 12:38:04 +08:00
(**执行注记**:实际交付以提交为准——py-ip2region 3.0.4 `new_with_buffer(version, c_buffer)` 双参、现行 GitHub 库已改名 ip2region_v4.xdb 且五段布局为 `国家|省|城市|ISP|国家代码` (旧映射会把 ISP 串进 city),实现按 xdb 头版本分派新旧布局;xdb 外国条目为英文;"0" 为缺失占位(Task 6 写库时归一为空串);模块级导出 `resolver` 单例;**IPv6 M1 显示"未知"( v4-only 库,已接受)**; Task 7 的 `_client_ip` 需归一 `::ffff:` 前缀的 IPv4 映射地址防地理丢失。)
2026-09-15 10:17:52 +08:00
- [ ] **Step 3: 跑测试通过** ( `server/.gitignore` 加 `tracker/data/*.xdb` )
- [ ] **Step 4: Commit** `feat: ip2region 离线地理解析(优雅降级)`
### Task 6: ingest.py 批量入库服务
**Files:**
- Create: `server/tracker/services/ingest.py` , `server/tracker/tests/test_ingest.py`
**职责** :对已通过校验的批次执行——访客 get_or_create(首触来源只写一次)、会话 get_or_create(30 分钟兜底校验)、`bulk_create(ignore_conflicts=True)` 幂等写事件、更新访客聚合(visit_count/active_days/last_seen/last_ip/geo)。**测试用 SQLite,忽略 MySQL 差异。**
- [ ] **Step 1: 失败测试** (核心场景全列):
```python
from datetime import timedelta
from django.utils import timezone
from tracker.models import Site , Visitor , VisitSession , Event
from tracker.services.ingest import ingest_batch
NOW = timezone . now ()
def _batch ( events , vid = "v1" , sid = "s1" ):
return { "visitor_id" : vid , "session_id" : sid , "context" : {
"referrer_domain" : "google.com" , "utm" : { "utm_source" : "google" },
"path" : "/landing" , "device_type" : "PC" , "os" : "Windows" ,
"browser" : "Chrome" , "language" : "en-US" , "screen" : "1920x1080" },
"events" : events }
def _ev ( i , name = "page_view" , ** kw ):
d = dict ( event_id = f "e { i } " , event_name = name , occurred_at = NOW ,
path = "/p" , title = "T" , properties = {})
d . update ( kw )
return d
def test_first_batch_creates_visitor_session_events ():
site = Site . objects . create ( name = "s" , domains = [ "a.com" ])
r = ingest_batch ( site , _batch ([ _ev ( 1 ), _ev ( 2 )]), ip = "8.8.8.8" )
assert ( r [ "accepted" ], r [ "duplicated" ], r [ "rejected" ]) == ( 2 , 0 , [])
v = Visitor . objects . get ( site = site , visitor_id = "v1" )
assert v . first_referrer_domain == "google.com" and v . visit_count == 1
assert v . active_days == 1 and v . last_ip == "8.8.8.8"
assert Event . objects . filter ( site = site ) . count () == 2
def test_duplicate_event_ids_counted_not_rewritten ():
site = Site . objects . create ( name = "s" , domains = [ "a.com" ])
ingest_batch ( site , _batch ([ _ev ( 1 )]))
r = ingest_batch ( site , _batch ([ _ev ( 1 )])) # 重放
assert r [ "duplicated" ] == 1 and Event . objects . count () == 1
def test_new_session_increments_visit_count ():
site = Site . objects . create ( name = "s" , domains = [ "a.com" ])
ingest_batch ( site , _batch ([ _ev ( 1 )], sid = "s1" ))
ingest_batch ( site , _batch ([ _ev ( 2 )], sid = "s2" ))
v = Visitor . objects . get ( visitor_id = "v1" )
assert v . visit_count == 2 and VisitSession . objects . count () == 2
def test_server_session_backstop_splits_idle_gap ():
# SDK 权威;服务端仅当同一 session_id 事件间隔>30min 时切分出后续段(记新 session_id 后缀)
site = Site . objects . create ( name = "s" , domains = [ "a.com" ])
ingest_batch ( site , _batch ([ _ev ( 1 , occurred_at = NOW - timedelta ( minutes = 90 ))], sid = "s1" ))
ingest_batch ( site , _batch ([ _ev ( 2 , occurred_at = NOW )], sid = "s1" ))
assert VisitSession . objects . count () == 2 # s1 与 s1#2
def test_rejected_events_reported_with_reason ():
site = Site . objects . create ( name = "s" , domains = [ "a.com" ])
r = ingest_batch ( site , _batch ([ _ev ( 1 , event_name = "bad" ), _ev ( 2 )]))
assert r [ "accepted" ] == 1 and r [ "rejected" ][ 0 ][ "reason" ] == "event_name_not_allowed"
```
- [ ] **Step 2: 跑测试确认失败** → ImportError。
- [ ] **Step 3: 实现**
```python
from datetime import timedelta
from django.db import transaction
2026-09-15 11:02:50 +08:00
from django.db.models import F
2026-09-15 10:17:52 +08:00
from django.utils import timezone
from tracker import constants as C
from tracker.models import Visitor , VisitSession , Event
from tracker.services.validation import validate_event
2026-09-15 12:38:04 +08:00
from tracker.services.geo import resolver as _geo # 模块级单例(勿再实例化,11MB/份)
# geo 返回值缺失段为 "0" 占位:写库前归一 x if x not in ("", "0") else ""
2026-09-15 10:17:52 +08:00
2026-09-15 11:02:50 +08:00
_FIELD_LEN = { "device_type" : 16 , "os" : 64 , "browser" : 64 , "language" : 32 , "screen" : 32 }
def _clean_utm ( d ):
"""utm 键白名单(utm_* 前缀)+ 截断——任意字符串不得经 utm 入库渲染。"""
return { k [: 50 ]: str ( v )[: 100 ] for k , v in ( d or {}) . items ()
if isinstance ( k , str ) and k . startswith ( "utm_" )}
2026-09-15 10:17:52 +08:00
def _touch_visitor ( site , vid , ctx , ip ):
2026-09-15 11:02:50 +08:00
env = { k : str ( ctx . get ( k , "" ) or "" )[: n ] for k , n in _FIELD_LEN . items ()}
2026-09-15 10:17:52 +08:00
v , created = Visitor . objects . get_or_create (
2026-09-15 11:02:50 +08:00
site = site , visitor_id = vid , defaults = { ** env , "last_ip" : ip })
2026-09-15 10:17:52 +08:00
now = timezone . now ()
2026-09-15 11:02:50 +08:00
upd = { "last_seen_at" : now } # 聚合字段一律 filter().update(),避免并发下全量 save() 用旧值覆盖赢家
if created :
upd . update ( first_referrer_domain = ( ctx . get ( "referrer_domain" ) or "" )[: 200 ],
first_utm = _clean_utm ( ctx . get ( "utm" )),
first_landing_path = ( ctx . get ( "path" ) or "" )[: 500 ],
last_active_date = now . date (), active_days = 1 )
elif not Visitor . objects . filter ( pk = v . pk , last_active_date = now . date ()) . exists ():
upd . update ( last_active_date = now . date (), active_days = F ( "active_days" ) + 1 )
2026-09-15 10:17:52 +08:00
if ip :
2026-09-15 11:02:50 +08:00
upd [ "last_ip" ] = ip
2026-09-15 10:17:52 +08:00
g = _geo . resolve ( ip )
if g :
2026-09-15 11:02:50 +08:00
upd . update ( country = g [ "country" ][: 64 ], province = g [ "province" ][: 64 ],
city = g [ "city" ][: 64 ])
Visitor . objects . filter ( pk = v . pk ) . update ( ** upd )
v . refresh_from_db ()
2026-09-15 10:17:52 +08:00
return v
def _get_session ( site , visitor , sid , ctx , occurred_at ):
"""会话权威在 SDK;服务端兜底:同 session_id 距上事件>30min 切分后缀段。"""
idle = timedelta ( milliseconds = C . SESSION_IDLE_MS )
sess = VisitSession . objects . filter ( site = site , session_id = sid ) . order_by ( "-started_at" ) . first ()
if sess and occurred_at - sess . ended_at > idle :
suffix = 2 + VisitSession . objects . filter (
site = site , session_id__startswith = sid + "#" ) . count ()
sid = f " { sid } # { suffix } "
sess = None
if not sess :
2026-09-15 11:02:50 +08:00
try :
sess = VisitSession . objects . create (
site = site , visitor = visitor , session_id = sid ,
started_at = occurred_at , ended_at = occurred_at ,
referrer_domain = ( ctx . get ( "referrer_domain" ) or "" )[: 200 ],
utm = _clean_utm ( ctx . get ( "utm" )),
landing_path = ( ctx . get ( "path" ) or "" )[: 500 ])
except IntegrityError : # 并发同 sid 同时切分出相同后缀撞唯一键——重查复用
from django.db import IntegrityError as _IE # 顶部统一 import IntegrityError
sess = VisitSession . objects . filter ( site = site , session_id = sid ) . first ()
if not sess :
raise
Visitor . objects . filter ( pk = visitor . pk ) . update ( visit_count = F ( "visit_count" ) + 1 )
2026-09-15 10:17:52 +08:00
return sess
@transaction.atomic
2026-09-15 10:32:41 +08:00
def ingest_batch ( site , payload : dict , ip : str | None , ua : str = "" ) -> dict :
2026-09-15 10:17:52 +08:00
now = timezone . now ()
ctx = payload . get ( "context" ) or {}
events = payload . get ( "events" ) or []
result = { "accepted" : 0 , "duplicated" : 0 , "rejected" : []}
if not events or len ( events ) > C . MAX_EVENTS_PER_BATCH :
return { "accepted" : 0 , "duplicated" : 0 ,
"rejected" : [{ "reason" : "batch_size_invalid" }]}
visitor = _touch_visitor ( site , payload [ "visitor_id" ], ctx , ip )
geo = _geo . resolve ( ip ) if ip else None
2026-09-15 10:32:41 +08:00
is_bot = any ( k in ( ua or "" ) . lower () for k in C . BOT_UA_KEYWORDS )
if is_bot :
Visitor . objects . filter ( pk = visitor . pk ) . update ( is_bot = True )
2026-09-15 10:17:52 +08:00
ready , session_cache = [], {}
for i , ev in enumerate ( events ):
reason = validate_event ( ev )
if reason :
result [ "rejected" ] . append ({ "index" : i , "event_id" : ev . get ( "event_id" , "" ),
"reason" : reason })
continue
occ = ev [ "occurred_at" ]
sid = payload [ "session_id" ]
sess = session_cache . get ( sid )
if not sess :
sess = _get_session ( site , visitor , sid , ctx , occ )
2026-09-15 11:02:50 +08:00
session_cache [ sid ] = sess # 必须按原始 sid 存:切分兜底后 sess.session_id 是 "s1#2",
# 若按它存,同批后续事件按 "s1" 查会 miss → 每条再切一个新会话
2026-09-15 10:17:52 +08:00
ready . append ( Event (
site = site , visitor = visitor , session = sess ,
event_id = ev [ "event_id" ], event_name = ev [ "event_name" ],
occurred_at = occ , path = ev . get ( "path" , "" )[: 500 ],
title = ev . get ( "title" , "" )[: 200 ],
properties = ev . get ( "properties" ) or {},
ip = ip , late = abs (( now - occ ) . total_seconds ()) > C . CLOCK_SKEW_LATE_SEC ,
country = geo [ "country" ] if geo else "" ,
province = geo [ "province" ] if geo else "" ,
2026-09-15 10:32:41 +08:00
city = geo [ "city" ] if geo else "" ,
is_bot = is_bot ))
2026-09-15 10:17:52 +08:00
if ready :
before = Event . objects . filter ( site = site ,
event_id__in = [ e . event_id for e in ready ]) . values_list ( "event_id" , flat = True )
existed = set ( before )
created = Event . objects . bulk_create (
[ e for e in ready if e . event_id not in existed ], ignore_conflicts = True )
# bulk_create ignore_conflicts 不回传;用存在集 + 计数差算 duplicated
result [ "accepted" ] = len ( ready ) - ( len ([ e for e in ready if e . event_id in existed ]))
result [ "duplicated" ] = len ([ e for e in ready if e . event_id in existed ])
for sess in set ( e . session for e in ready ):
n = len ([ e for e in ready if e . session == sess ])
VisitSession . objects . filter ( pk = sess . pk ) . update (
ended_at = max ( e . occurred_at for e in ready if e . session == sess ),
event_count = sess . event_count + n ,
duration_ms = int ( sum (
( e . ended and 0 ) or 0 for e in [] ) or
max ( 0 , ( max ( x . occurred_at for x in ready if x . session == sess )
- min ( x . occurred_at for x in ready if x . session == sess )
) . total_seconds () * 1000 ))
return result
```
2026-09-15 10:32:41 +08:00
**注意(执行者必读——上面代码 `duration_ms` 段是占位,按以下正确版本实现)** :
2026-09-15 10:17:52 +08:00
```python
2026-09-15 10:32:41 +08:00
from django.db.models import F , Greatest
2026-09-15 11:02:50 +08:00
new_events = [ e for e in ready if e . event_id not in existed ] # 重放命中的不参与聚合!
for sess in { e . session for e in new_events }:
se = [ e for e in new_events if e . session . pk == sess . pk ]
2026-09-15 10:32:41 +08:00
new_end = max ( x . occurred_at for x in se )
span_ms = int (( new_end - min ( x . occurred_at for x in se )) . total_seconds () * 1000 )
stay_add = sum ( int ( e . properties . get ( "active_ms" , 0 ) or 0 )
for e in se if e . event_name == "page_stay" )
VisitSession . objects . filter ( pk = sess . pk ) . update (
ended_at = Greatest ( F ( "ended_at" ), new_end ), # 只前进不回退,防乱序批次改早结束时间
event_count = F ( "event_count" ) + len ( se ),
duration_ms = F ( "duration_ms" ) + span_ms ,
stay_ms = F ( "stay_ms" ) + stay_add ) # 有效停留单独累计,供访客"总停留"取数
2026-09-15 10:17:52 +08:00
```
2026-09-15 11:02:50 +08:00
- **重放批不得抬高会话聚合**: event_count/stay_ms/duration_ms 只用 `new_events` (否则重试/重放被幂等去重后仍把会话计数刷高——`test_replay_duplicated` 需补断言 `VisitSession.event_count == 1` )。
2026-09-15 10:32:41 +08:00
- 同批多会话各自按上式计算。
- `duplicated` 计数允许并发近似(唯一约束保证绝不重复入库,仅统计口径可能差 1-2,M1 接受)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 4: 跑测试通过** → 5 passed。修到绿为止。
2026-09-15 13:01:31 +08:00
(**执行注记**:最终实现以提交 173d13d 为准,含审查修复——同批 event_id 去重(防匿名端点灌倍 stay_ms)、会话族切分用 `Q(exact)|Q(startswith=sid+"#")` 取族内最大 ended_at 比较(**原计划代码比的是陈旧根段,首切后每批膨胀 visit_count,勿照抄**)、Greatest 从 `django.db.models.functions` 导入、geo "0" 占位归一、TOCTOU 毫秒窗口为接受项。13 个 ingest 测试。)
2026-09-15 10:17:52 +08:00
- [ ] **Step 5: Commit** `feat: 批量入库服务(幂等/首触/会话兜底/聚合更新)`
### Task 7: 采集接口 POST /api/collect/batch
**Files:**
- Create: `server/tracker/api/__init__.py` , `server/tracker/api/collect.py` , `server/tracker/tests/test_collect.py` , Modify: `server/config/urls.py` , `server/config/settings.py` ( CORS 动态白名单)
- [ ] **Step 1: 失败测试** (要点:白名单域放行/非白名单 403/限流触发 429/textplain 解析/响应最小化/幂等重放)
```python
import json
2026-09-15 11:02:50 +08:00
import pytest
from django.core.cache import cache
2026-09-15 10:17:52 +08:00
from django.utils import timezone
from tracker.models import Site
NOW = timezone . now ()
2026-09-15 11:02:50 +08:00
@pytest.fixture ( autouse = True )
def _clear_cache ():
cache . clear () # locmem 限流历史跨测试残留,会让 429 用例假红
2026-09-15 10:17:52 +08:00
def _post ( client , site , body , origin = "https://www.a.com" , extra = None ):
return client . post ( "/api/collect/batch" , data = json . dumps ( body ),
content_type = "text/plain" , HTTP_ORIGIN = origin , ** ( extra or {}))
def _body ( n = 1 , sid = "s1" , vid = "v1" ):
return { "site_id" : "site_x" , "visitor_id" : vid , "session_id" : sid ,
"context" : { "referrer_domain" : "" , "path" : "/" , "device_type" : "PC" },
"events" : [{ "event_id" : f "e { i } " , "event_name" : "page_view" ,
"occurred_at" : NOW . isoformat (), "path" : "/" , "title" : "t" ,
"properties" : {}} for i in range ( n )]}
def test_collect_ok_creates_events ( db , client ):
Site . objects . create ( name = "s" , domains = [ "www.a.com" ], site_id = "site_x" )
r = _post ( client , None , _body ( 2 ))
assert r . status_code == 200 and r . json () == { "code" : 200 , "data" : { "accepted" : 2 , "duplicated" : 0 }, "message" : "ok" }
def test_origin_not_whitelisted_rejected ( db , client ):
Site . objects . create ( name = "s" , domains = [ "www.a.com" ], site_id = "site_x" )
r = _post ( client , None , _body ( 1 ), origin = "https://evil.com" )
assert r . status_code == 403 and r . json ()[ "code" ] == 403
def test_inactive_site_rejected ( db , client ):
Site . objects . create ( name = "s" , domains = [ "www.a.com" ], site_id = "site_x" , is_active = False )
assert _post ( client , None , _body ( 1 )) . status_code == 403
def test_unknown_site_403_not_404 ( db , client ): # 不泄露站点是否存在
r = _post ( client , None , _body ( 1 ))
assert r . status_code == 403
def test_replay_duplicated ( db , client ):
Site . objects . create ( name = "s" , domains = [ "www.a.com" ], site_id = "site_x" )
_post ( client , None , _body ( 1 ))
r = _post ( client , None , _body ( 1 ))
assert r . json ()[ "data" ][ "duplicated" ] == 1
def test_throttle_429 ( db , client , settings ):
settings . REST_FRAMEWORK = { ** settings . REST_FRAMEWORK ,
"DEFAULT_THROTTLE_RATES" : { "collect" : "3/min" }}
Site . objects . create ( name = "s" , domains = [ "www.a.com" ], site_id = "site_x" )
for _ in range ( 3 ):
assert _post ( client , None , _body ( 1 )) . status_code == 200
assert _post ( client , None , _body ( 1 )) . status_code == 429
```
- [ ] **Step 2: 确认失败** → 404(无路由)。
- [ ] **Step 3: 实现**
`server/tracker/api/collect.py` :
```python
import json
2026-09-15 11:02:50 +08:00
from datetime import datetime , timezone as dt_tz
2026-09-15 10:17:52 +08:00
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import AnonRateThrottle
from rest_framework.parsers import BaseParser
from tracker.models import Site
from tracker.services.ingest import ingest_batch
class PlainJSONParser ( BaseParser ):
media_type = "text/plain"
def parse ( self , stream , * args , ** kwargs ):
2026-09-15 11:02:50 +08:00
try :
return json . loads ( stream . read () . decode ( "utf-8" ) or " {} " )
except ( ValueError , UnicodeDecodeError ):
from rest_framework.exceptions import ParseError
raise ParseError ( "invalid_json" ) # DRF 对 parser 异常是 re-raise 不是包装,不自包就是 500
2026-09-15 10:17:52 +08:00
class CollectThrottle ( AnonRateThrottle ):
scope = "collect"
def _iso ( dt ):
if isinstance ( dt , str ):
s = dt . replace ( "Z" , "+00:00" )
d = datetime . fromisoformat ( s )
2026-09-15 11:02:50 +08:00
return d if d . tzinfo else d . replace ( tzinfo = dt_tz . utc ) # Django≥5 已删除 django.utils.timezone.utc
2026-09-15 10:17:52 +08:00
return dt
class CollectBatchView ( APIView ):
permission_classes = [ AllowAny ]
parser_classes = [ PlainJSONParser ]
throttle_classes = [ CollectThrottle ]
authentication_classes = []
def post ( self , request ):
origin = request . headers . get ( "Origin" ) or request . headers . get ( "Referer" ) or ""
2026-09-15 11:02:50 +08:00
if int ( request . headers . get ( "Content-Length" ) or 0 ) > 262144 :
return self . _deny ( 400 , "payload_too_large" ) # 应用层兜底(nginx 另设 client_max_body_size)
2026-09-15 10:17:52 +08:00
site = self . _match_site ( request )
if site is None or not site . is_active :
2026-09-15 11:02:50 +08:00
return self . _deny ( 403 , "not_allowed" ) # 三种拒绝统一 reason,防 site_id 枚举探测
2026-09-15 10:17:52 +08:00
host = ( origin . split ( "//" )[ - 1 ] . split ( "/" )[ 0 ] . split ( ":" )[ 0 ] . lower ()
if origin else "" )
if host and host not in [ d . lower () for d in site . domains ]:
2026-09-15 11:02:50 +08:00
return self . _deny ( 403 , "not_allowed" )
2026-09-15 10:17:52 +08:00
if not host and not site . allow_no_origin :
2026-09-15 11:02:50 +08:00
return self . _deny ( 403 , "not_allowed" )
2026-09-15 10:17:52 +08:00
payload = request . data or {}
for ev in payload . get ( "events" ) or []:
2026-09-15 11:02:50 +08:00
if isinstance ( ev , dict ) and "occurred_at" in ev :
2026-09-15 10:17:52 +08:00
try :
ev [ "occurred_at" ] = _iso ( ev [ "occurred_at" ])
except ValueError :
return self . _deny ( 400 , "invalid_occurred_at" )
if not payload . get ( "visitor_id" ) or not payload . get ( "session_id" ):
return self . _deny ( 400 , "invalid_payload" )
2026-09-15 10:32:41 +08:00
ip = self . _client_ip ( request )
r = ingest_batch ( site , payload , ip , request . headers . get ( "User-Agent" , "" ))
2026-09-15 10:17:52 +08:00
return Response ({ "code" : 200 ,
"data" : { "accepted" : r [ "accepted" ], "duplicated" : r [ "duplicated" ]},
"message" : "ok" })
def _match_site ( self , request ):
sid = ( request . data or {}) . get ( "site_id" ) if isinstance ( request . data , dict ) else None
# site_id 在 body;为匹配 Origin 需先读 body——parser 已解析到 request.data
from tracker.models import Site as S
return S . objects . filter ( site_id = sid ) . first () if sid else None
2026-09-15 10:32:41 +08:00
def _client_ip ( self , request ):
"""nginx 前置时取 X-Forwarded-For 最后一段(与 DRF NUM_PROXIES=1 一致;套 CDN 时按层数前移)。"""
xff = request . META . get ( "HTTP_X_FORWARDED_FOR" , "" )
2026-09-15 12:38:04 +08:00
ip = xff . split ( "," )[ - 1 ] . strip () if xff else request . META . get ( "REMOTE_ADDR" )
# 归一 IPv4 映射地址(::ffff:1.2.3.4 → 1.2.3.4),防地理解析丢失
if ip and ip . lower () . startswith ( "::ffff:" ):
ip = ip [ 7 :]
return ip
2026-09-15 10:32:41 +08:00
2026-09-15 10:17:52 +08:00
def _deny ( self , code , reason ):
2026-09-15 11:02:50 +08:00
resp = Response ({ "code" : code , "data" : None , "message" : reason }, status = code )
origin = self . request . headers . get ( "Origin" )
if origin : # 拒绝也要回 ACAO,否则浏览器读不到错误体、SDK 误判为网络错误走重试
resp [ "Access-Control-Allow-Origin" ] = origin
return resp
2026-09-15 10:17:52 +08:00
```
`server/config/urls.py` :
```python
from django.urls import path
from tracker.api.collect import CollectBatchView
urlpatterns = [
path ( "api/collect/batch" , CollectBatchView . as_view ()),
]
```
CORS 动态白名单(`settings.py` 追加 + MIDDLEWARE 顺序保持 corsheaders 在前):
```python
CORS_ALLOWED_ORIGIN_REGEXES = [] # 不用全局
```
并在 `tracker/api/collect.py` 出口处手动加头(简单直接,绕开 cors-headers 的静态配置):
```python
# post() 成功分支 Response 前:
resp = Response ({ ... })
if origin :
resp [ "Access-Control-Allow-Origin" ] = origin
resp [ "Access-Control-Allow-Headers" ] = "content-type"
return resp
```
另加 OPTIONS 预检支持:`CollectBatchView.options()` 返回同头 200。(SDK 用 text/plain 免预检,此处为兜底。)
- [ ] **Step 4: 跑测试通过** → 6 passed。
2026-09-15 13:30:54 +08:00
(**执行注记**:最终实现以提交 678d215 为准,含审查修复——Origin 提取改 `urlsplit().hostname` (原 split("//") 可被查询串绕过白名单)、`finalize_response` 统一 ACAO+{code,data,message} 信封(覆盖 429/ParseError, SDK 跨域可读错误体)、Content-Length `isdecimal()` 防御、CollectThrottle.get_rate 读实时 api_settings( DRF 类属性 import 时固化)。11 个 collect 测试。备注:真实浏览器预检会被 corsheaders 中间件短路(SDK 简单请求不触发,无影响)。)
2026-09-15 10:17:52 +08:00
- [ ] **Step 5: Commit** `feat: 采集接口(域名白名单/限流/textplain解析/幂等)`
### Task 8: refresh_stats 命令 + stats.py
**Files:**
- Create: `server/tracker/services/stats.py` , `server/tracker/management/__init__.py` , `server/tracker/management/commands/__init__.py` , `server/tracker/management/commands/refresh_stats.py` , `server/tracker/tests/test_stats.py`
- [ ] **Step 1: 失败测试** :造 2 天数据(访客 v1 两天访问、v2 一天)→ `refresh(site, date)` 后 DailyStat 的 pv/uv/sessions/new_visitors 正确;uv 按访客去重、new_visitors 按 first_seen_at 当日计数。
2026-09-15 11:56:44 +08:00
- [ ] **Step 1b: 索引迁移打包(Task 3 质量审查结论)** :本任务顺带一个迁移——`Event` 加 `Index(fields=["site", "occurred_at"])` (按日聚合查询否则只能前缀扫全站)、`VisitSession` 加 `Index(fields=["site", "started_at"])` 、FK 冗余单列索引清理(`event.site` /`event.session` /`visit_session.site` /`visit_session.visitor` /`daily_stat.site` 的 FK 加 `db_index=False` , **`event.visitor` 保留**——级联删除需要);同时收紧 `test_models.py` 断言(`pytest.raises(IntegrityError)` )并补一条删站级联测试(删 site → 三张子表全清)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 2: 实现** `stats.py` :
```python
2026-09-15 11:02:50 +08:00
from datetime import datetime , time , timedelta
from django.utils import timezone
from tracker.models import DailyStat , Event , VisitSession
2026-09-15 10:17:52 +08:00
def refresh_daily ( site , date ) -> DailyStat :
day_start = timezone . make_aware ( datetime . combine ( date , time . min ))
day_end = day_start + timedelta ( days = 1 )
2026-09-15 10:32:41 +08:00
pv = Event . objects . filter ( site = site , is_bot = False ,
occurred_at__gte = day_start , occurred_at__lt = day_end ) . count ()
uv = Event . objects . filter ( site = site , is_bot = False , occurred_at__gte = day_start ,
occurred_at__lt = day_end ) . values ( "visitor" ) . distinct () . count ()
2026-09-15 11:02:50 +08:00
sessions = VisitSession . objects . filter ( site = site , visitor__is_bot = False ,
started_at__gte = day_start ,
2026-09-15 10:17:52 +08:00
started_at__lt = day_end ) . count ()
new_v = Visitor . objects . filter ( site = site , first_seen_at__gte = day_start ,
first_seen_at__lt = day_end ) . count ()
obj , _ = DailyStat . objects . update_or_create ( site = site , date = date ,
defaults = { "pv" : pv , "uv" : uv , "sessions" : sessions , "new_visitors" : new_v })
return obj
```
`refresh_stats.py` 命令:`python manage.py refresh_stats --days 3` (回刷近 N 天,含今天)。
**调度** :生产环境由 1Panel 计划任务每日 00:10 执行 `docker exec <web容器> python manage.py refresh_stats --days 2` (写入 Task 22 的 README-deploy;不做每日调度则趋势图历史全为零)。
- [ ] **Step 3: 测试通过,Commit** `feat: 日聚合刷新命令`
### Task 9: JWT 认证接口
**Files:**
- Create: `server/tracker/api/auth.py` , `server/tracker/tests/test_auth.py` , Modify: `config/urls.py`
- [ ] **Step 1: 失败测试** : `/api/admin/auth/token` 用户名密码 → access/refresh;错密码 401; `/api/admin/auth/me` 带 token 返回 username/is_admin( is_staff);无 token 401; `/api/admin/users` 仅 is_staff 可 POST 建普通成员。
2026-09-15 11:02:50 +08:00
- [ ] **Step 2: 实现** : `TokenObtainPairView` 子类(返回 `{code:200,data:{access,refresh},message:"ok"}` 包一层),`me` 返回 `{username, is_admin: user.is_staff}` ; `UserCreateView` ( IsAdminUser) `username/password` 创建 `is_staff=False` 用户。登录接口挂 `ScopedRateThrottle` scope=`login` ( 10/min/IP,防爆破;settings 已含 rate)。**JWT 取舍(M1 决策,README-deploy 写明)**: access 12h、refresh 显式配置 24h、无黑名单/轮换——token 泄露的唯一应急是更换 SECRET_KEY(全体登出)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 3: 测试通过,Commit** `feat: JWT 登录与用户接口`
### Task 10: 站点管理 API
2026-09-15 14:16:20 +08:00
**开工首件事(Task 9 审查结论)** :把 `tracker/api/auth.py` 的 `_EnvelopeMixin` 与 `_ok` 提取到 `tracker/api/envelope.py` 共享模块(auth 与 collect 的 finalize_response 是两处相似实现,Task 10-12 的 admin 视图都要用;collect 版含 ACAO 保留在自己视图内,仅信封逻辑共享)。
2026-09-15 10:17:52 +08:00
**Files:**
- Create: `server/tracker/api/sites.py` , `server/tracker/tests/test_sites.py` , Modify: `config/urls.py`
2026-09-15 11:56:44 +08:00
- [ ] **Step 1: 失败测试** :CRUD(列表/新建自动生成 site_id/更新域名白名单/停用);仅登录成员可访问,删除仅 admin;域名校验(去协议、转小写、非空列表)。**删除走软路径**:默认 `is_active=False` 停用;物理删除为管理命令分批删子表(大站单语句级联删除有锁/binlog 风暴风险,CASCADE 只作完整性兜底)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 2: 实现** : DRF `APIView` 或轻量 `ModelViewSet` + 自行包 `{code,data,message}` 。序列化输出含 `site_id/domains/is_active/allow_no_origin` 。
- [ ] **Step 3: 测试通过,Commit** `feat: 站点管理接口`
### Task 11: 总览 Dashboard API
**Files:**
- Create: `server/tracker/api/dashboard.py` , `server/tracker/tests/test_dashboard.py` , Modify: `config/urls.py`
- [ ] **Step 1: 失败测试** (造数后断言):
```python
def test_overview_shape ( db , client , django_user_model ):
# 造 2 访客/3 会话/10 事件,1 条在 5 分钟内(active_now=1)
r = client . get ( "/api/admin/dashboard/overview" )
assert r . json ()[ "data" ][ "today" ] == { "pv" : 10 , "uv" : 2 , "sessions" : 3 ,
"new_visitors" : 2 , "active_now" : 1 }
assert len ( r . json ()[ "data" ][ "trend" ]) == 30
assert r . json ()[ "data" ][ "sources" ][ 0 ][ "source" ] == "google.com"
assert r . json ()[ "data" ][ "top_pages" ][ 0 ][ "path" ] == "/pricing"
```
2026-09-15 13:53:06 +08:00
- [ ] **Step 2: 实现** : `today` 用事件/会话表实时聚合(当天 Asia/Shanghai 边界);`trend` 取 DailyStat 近 30 天(缺日补零;今天用实时值覆盖最后一位);`sources` = 当天 VisitSession group by `referrer_domain` (空→"直接访问") top8; `top_pages` = 当天 page_view group by path top8; `active_now` = `Visitor.last_seen_at > now-5min` 计数(**不要用** `Event.received_at` ——无索引会全表扫;last_seen_at 每批刷新且已有索引,语义等价)。认证 + 成员权限。**所有聚合排除 `is_bot=True` (爬虫不进 Dashboard) **。**实现约束(Task 8 审查结论):pv/uv/sessions/new_visitors 提取为 stats.py 共享 `daily_counts(site, date)` , refresh_daily 与 dashboard today 共用,防两处口径漂移(Task 8 的 new_visitors 爬虫口径问题正是反例)。**
2026-09-15 10:17:52 +08:00
- [ ] **Step 3: 测试通过,Commit** `feat: 总览聚合接口`
### Task 12: 访客列表/详情/时间线/会话 API
**Files:**
- Create: `server/tracker/api/visitors.py` , `server/tracker/tests/test_visitors.py` , Modify: `config/urls.py`
路由:
```
GET /api/admin/visitors 列表(分页/筛选/搜索)
GET /api/admin/visitors/<visitor_id> 详情(档案+环境+统计+会话摘要列表)
GET /api/admin/visitors/<visitor_id>/timeline 跨天时间线(按日分组→会话→事件)
GET /api/admin/visitors/<visitor_id>/sessions/<session_id> 单会话页面路径
```
- [ ] **Step 1: 失败测试** :
- 列表字段齐全:visitor_id/country/province/city/device_type/os/browser/first_seen_at/last_seen_at/visit_count/active_days/session_count/total_stay_ms/first_source/last_source/online;筛选 country/device_type/online=true/search=访客ID或IP前缀;按 last_seen_at 倒序分页(page_size 20)。
- timeline 结构:`[{date:"2026-09-15", sessions:[{session_id, started_at, events:[{time,event_name,path,title,active_ms}]}]}]` ,多天数据按日倒序、日内时间正序;默认近 30 天,`?days=` 可调,单次上限 1000 事件。
- 会话详情:事件按 occurred_at 正序,含 active_ms( page_stay 的 properties.active_ms 就地并入相邻 page_view 或单列,见实现说明)。
2026-09-15 11:02:50 +08:00
- [ ] **Step 2: 实现** : ORM 查询 + Python 分组;`total_stay_ms` = `Sum("sessions__stay_ms")` 注解(**有效停留口径,不用 duration_ms 跨度**);`first_source` = `first_referrer_domain or first_utm.utm_source or "直接访问"` ; `last_source` 取最近会话同逻辑;`online` = last_seen_at > now-5min;列表默认过滤 `is_bot=False` ( `?show_bot=1` 可查爬虫)。**page_view 与 page_stay 合并展示**:时间线返回时若 page_stay 的 path 与前一条 page_view 相同,则把 `active_ms` 合入该 page_view(服务端做,前端省事);同 path 连续多段 page_stay(多次隐藏/恢复)的 active_ms **累加**后合入。会话详情展示"时长"统一用 `stay_ms` 口径(`duration_ms` 是批内跨度累加,仅内部字段不展示)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 3: 测试通过,Commit** `feat: 访客列表/详情/时间线/会话接口`
---
# Phase 2 · JS SDK( M1:多页网站)
### Task 13: identity.ts + context.ts
**Files:**
- Create: `sdk/src/identity.ts` , `sdk/src/context.ts` , `sdk/test/identity.test.ts` , `sdk/test/context.test.ts`
- [ ] **Step 1: 失败测试** ( happy-dom 提供 localStorage/document; fake timers 测 30 分钟切会话):
```ts
import { describe , it , expect , beforeEach , vi } from "vitest" ;
import { getVisitorId , getSessionId , touchActivity } from "../src/identity" ;
describe ( "identity" , () => {
beforeEach (() => localStorage . clear ());
it ( "visitor_id 持久化" , () => {
expect ( getVisitorId ()). toBe ( getVisitorId ());
expect ( getVisitorId ()). toMatch ( /^[0-9a-f-]{36}$/ );
});
it ( "30 分钟无活动切新会话" , () => {
vi . useFakeTimers ();
const s1 = getSessionId ();
vi . advanceTimersByTime ( 10 * 60 _000 ); touchActivity ();
vi . advanceTimersByTime ( 10 * 60 _000 );
expect ( getSessionId ()). toBe ( s1 ); // 20min 内有活动→同会话
vi . advanceTimersByTime ( 31 * 60 _000 ); // 再过 31min 无活动
expect ( getSessionId ()). not . toBe ( s1 );
});
});
```
context 测试:`parseUtm("?utm_source=google&utm_medium=cpc&other=1")` 只留 utm_*; `sanitizeUrl` 删 email/token 参数;`buildEnvContext()` 返回 device_type( UA 含 "Mobile" → "手机") /os/browser/language/timezone/screen 键。
- [ ] **Step 2: 实现**
```ts
// identity.ts
import { uuid } from "./util" ;
const K = { vid : "ctk_vid" , sid : "ctk_sid" , act : "ctk_act" };
const IDLE = 30 * 60 _000 ;
2026-09-15 11:02:50 +08:00
// localStorage 可能抛异常(隐私模式/禁存储,Safari ITP):统一走带内存降级的 store,
// 任何路径都不得让异常冒泡到宿主页(spec §4.5 红线)
const mem : Record < string , string > = {};
const store = {
get ( k : string ) : string | null {
try { return localStorage . getItem ( k ); } catch { return mem [ k ] ?? null ; }
},
set ( k : string , v : string ) {
try { localStorage . setItem ( k , v ); } catch { mem [ k ] = v ; }
},
};
2026-09-15 10:17:52 +08:00
export function getVisitorId () : string {
2026-09-15 11:02:50 +08:00
let v = store . get ( K . vid );
if ( ! v ) { v = uuid (); store . set ( K . vid , v ); }
2026-09-15 10:17:52 +08:00
return v ;
}
2026-09-15 11:02:50 +08:00
export function touchActivity() { store . set ( K . act , String ( Date . now ())); }
2026-09-15 10:17:52 +08:00
export function getSessionId () : string {
2026-09-15 11:02:50 +08:00
const last = + ( store . get ( K . act ) || 0 );
let s = store . get ( K . sid );
if ( ! s || Date . now () - last > IDLE ) { s = uuid (); store . set ( K . sid , s ); }
2026-09-15 10:17:52 +08:00
touchActivity ();
return s ;
}
```
`context.ts` : `buildEnvContext()` ( UA 正则判 device_type/os/browser; `navigator.language` 、`Intl.DateTimeFormat().resolvedOptions().timeZone` 、`screen.width+"x"+screen.height` 、`navigator.hardwareConcurrency` 、`navigator.connection?.effectiveType` );`parseUtm(search)` ; `sanitizeUrl(path)` ; `pageContext()` → `{path: sanitized, title: document.title, referrer: document.referrer, referrer_domain, utm}` 。
2026-09-15 15:57:57 +08:00
**UA 检测顺序(规格修订)** : device_type 先判 iPad/Tablet 再判 Mobile/Android/iPhone(真实 iPad UA 恒含 Mobile 串,后判必误判手机;Firefox Android 平板显式 "; Tablet;" 同修);browser 顺序 MicroMessenger → Edg → Firefox/FxiOS → Chrome → Safari(微信/iOS-Firefox 的 UA 必含引擎串,放后面是死代码)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 3: 测试通过,Commit** `feat(sdk): 身份与环境上下文模块`
### Task 14: transport.ts 批量传输
**Files:**
- Create: `sdk/src/transport.ts` , `sdk/src/config.ts` , `sdk/test/transport.test.ts`
- [ ] **Step 1: 失败测试** ( vi.stubGlobal fetch; fake timers):
- 满 10 条即发;15 秒定时器到即发;payload 含 batch_id/site_id/visitor_id/session_id/events。
- 500 失败→退避重试(500ms×2^n 抖动)最多 3 次→丢弃计数;400 不重试直接丢。
2026-09-15 11:02:50 +08:00
- 429 → 重试;403/400 → 不重试直接丢。
- `flushBeacon()` 调 `navigator.sendBeacon` ( spy 断言 URL 与 body)。**happy-dom 没有 sendBeacon,用 `vi.stubGlobal("navigator", { sendBeacon: spy })` ,测完 restore**。
- 队列上限 200(用 `batchSize > queueMax` 的自定义 cfg 测,默认 batchSize=10 到不了 200),超限丢最旧并计数。
- **测试技巧**:异步断言用 `await vi.advanceTimersByTimeAsync()` ( flush/send 是 async);fake timers 需含 Date( vitest 默认 toFake 即含);`document.hidden` 是 getter,用 `Object.defineProperty(document, "hidden", { value: true, configurable: true })` + `dispatchEvent(new Event("visibilitychange"))` 模拟;每个用例后 `vi.useRealTimers()` 。
2026-09-15 10:17:52 +08:00
- [ ] **Step 2: 实现**
```ts
import { uuid } from "./util" ;
import { getVisitorId , getSessionId } from "./identity" ;
export interface CtkConfig { siteId : string ; endpoint : string ;
batchSize? : number ; flushMs? : number ; maxRetry? : number ; queueMax? : number }
export class Transport {
private q : any [] = []; private timer : any = null ;
private retry = 0 ; dropped = 0 ;
constructor ( public cfg : Required < CtkConfig >) {}
push ( ev : object ) {
if ( this . q . length >= this . cfg . queueMax ) { this . q . shift (); this . dropped ++ ; }
this . q . push ( ev );
if ( this . q . length >= this . cfg . batchSize ) this . flush ();
else if ( ! this . timer )
this . timer = setTimeout (() => this . flush (), this . cfg . flushMs );
}
flush ( beacon = false ) {
if ( this . timer ) { clearTimeout ( this . timer ); this . timer = null ; }
if ( ! this . q . length ) return ;
2026-09-15 11:02:50 +08:00
try {
const events = this . q ; this . q = [];
const body = JSON . stringify ({ batch_id : uuid (), site_id : this.cfg.siteId ,
visitor_id : getVisitorId (), session_id : getSessionId (),
sent_at : new Date (). toISOString (), context : ( globalThis as any ). __ctk_ctx ,
events });
if ( beacon && navigator . sendBeacon ) {
navigator . sendBeacon ( this . cfg . endpoint , new Blob ([ body ], { type : "text/plain" }));
return ;
}
this . send ( body , events . length );
} catch { /* 定时器/事件回调内的任何异常不得冒泡到宿主页 */ }
2026-09-15 10:17:52 +08:00
}
2026-09-15 11:02:50 +08:00
private async send ( body : string , count : number ) {
2026-09-15 10:17:52 +08:00
try {
const res = await fetch ( this . cfg . endpoint , { method : "POST" ,
keepalive : true , headers : { "Content-Type" : "text/plain" }, body });
2026-09-15 11:02:50 +08:00
if ( res . status >= 400 && res . status < 500 && res . status !== 429 ) {
this . retry = 0 ; return ; // 4xx 参数/权限错不重试(403 重试只会掩盖配置错误)
}
2026-09-15 10:17:52 +08:00
if ( ! res . ok ) throw new Error ( String ( res . status ));
this . retry = 0 ;
} catch {
if ( this . retry ++ < this . cfg . maxRetry ) {
const delay = 500 * 2 ** this . retry * ( 0.8 + Math . random () * 0.4 );
2026-09-15 11:02:50 +08:00
setTimeout (() => this . send ( body , count ). catch (() => {}), delay );
} else { this . retry = 0 ; this . dropped += count ; } // 按事件数计丢弃
2026-09-15 10:17:52 +08:00
}
}
}
```
`config.ts` : `mergeConfig(opts)` 填默认 `{batchSize:10, flushMs:15000, maxRetry:3, queueMax:200}` 。
- [ ] **Step 3: 测试通过,Commit** `feat(sdk): 批量传输(批量/退避/信标/丢包计数)`
### Task 15: events.ts + index.ts 入口与打包
**Files:**
- Create: `sdk/src/events.ts` , `sdk/src/index.ts` , `sdk/test/events.test.ts` , `sdk/demo/index.html` , Modify: `.gitignore` ( `sdk/node_modules` , `sdk/dist` )
- [ ] **Step 1: 失败测试** :
2026-09-15 11:02:50 +08:00
- init 后 `sdk_loaded` 事件入队;`page_view` 含 path/title/referrer_domain/utm( utm 随每页上报,服务端只写首个,可接受冗余)。
- 批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路,断言 `__ctk_ctx` 字段齐全)。
2026-09-15 10:17:52 +08:00
- 有效停留:fake timers 下 `visible` 且有 pointerdown → 3 秒后 active_ms≈3000; `document.hidden=true` 后不再累计;隐藏时自动 flush beacon( page_stay 已入队)。
2026-09-15 11:02:50 +08:00
- **localStorage 抛异常时 15 秒定时 flush 不产生未捕获异常**( stub localStorage 全方法抛错 + `await vi.advanceTimersByTimeAsync(15000)` ,配 `vi.onUnhandledError` 断言;且身份降级到内存后事件照常发出)。
- 隐藏→恢复→再隐藏产生两段 page_stay,active_ms 各自正确(第二段从 0 重新累计)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 2: 实现**
```ts
// events.ts
import { Transport } from "./transport" ;
import { pageContext } from "./context" ;
import { uuid } from "./util" ;
export function startPageTracking ( t : Transport ) {
let activeMs = 0 , lastAct = Date . now ();
const act = () => { lastAct = Date . now (); };
[ "pointerdown" , "keydown" , "wheel" , "touchstart" ]. forEach ( e =>
document . addEventListener ( e , act , { passive : true , capture : true }));
2026-09-15 11:02:50 +08:00
setInterval (() => { try { if ( ! document . hidden && Date . now () - lastAct < 30 _000 ) activeMs += 1000 ; } catch { /* 静默 */ } }, 1000 );
2026-09-15 10:17:52 +08:00
const emit = ( event_name : string , properties : any = {}) => {
const pc = pageContext ();
t . push ({ event_id : uuid (), event_name , occurred_at : new Date (). toISOString (),
path : pc.path , title : pc.title , properties });
};
emit ( "page_view" , { referrer : document.referrer ,
referrer_domain : pageContext (). referrer_domain , utm : pageContext (). utm });
const close = () => {
2026-09-15 11:02:50 +08:00
try {
// path 必须与 page_view 同源同清洗(pageContext().path 含 query),
// 否则带 UTM 进落地页时服务端"page_stay 合并进相邻 page_view"匹配失败
if ( activeMs > 0 ) emit ( "page_stay" , { active_ms : activeMs , path : pageContext (). path });
activeMs = 0 ; t . flush ( true );
} catch { /* 静默 */ }
2026-09-15 10:17:52 +08:00
};
document . addEventListener ( "visibilitychange" , () => { if ( document . hidden ) close (); });
window . addEventListener ( "pagehide" , close );
}
```
```ts
// index.ts —— 打包入口(IIFE 副作用)
import { mergeConfig } from "./config" ;
import { Transport } from "./transport" ;
import { startPageTracking } from "./events" ;
2026-09-15 11:02:50 +08:00
import { buildEnvContext , pageContext } from "./context" ;
2026-09-15 10:17:52 +08:00
const g = globalThis as any ;
const q : any [][] = g . ctk ? . q || [];
let transport : Transport | null = null ;
function dispatch ( cmd : string , arg? : any ) {
if ( cmd === "init" && ! transport ) {
const cfg = mergeConfig ( arg );
2026-09-15 11:02:50 +08:00
// 首触归因修复:批次 context 除设备环境外必须带 referrer_domain/utm/path——
// 服务端 _touch_visitor/_get_session 只从 context 读首触,缺了则首触/会话来源恒空
const pc = pageContext ();
( globalThis as any ). __ctk_ctx = { ... buildEnvContext (),
referrer_domain : pc.referrer_domain , utm : pc.utm , path : pc.path };
2026-09-15 10:17:52 +08:00
transport = new Transport ( cfg );
transport . push ({ event_id : uuid (), event_name : "sdk_loaded" ,
occurred_at : new Date (). toISOString (),
path : location.pathname , title : document.title ,
properties : { sdk_version : "0.1.0" } });
startPageTracking ( transport );
} else if ( cmd === "track" && transport ) transport . push ( arg );
else if ( cmd === "flush" && transport ) transport . flush ();
}
g . ctk = dispatch ;
q . forEach (([ c , a ]) => dispatch ( c , a ));
```
( `uuid` import 补上;`sdk_loaded` 后 init 前队列已重放。**整个 dispatch 与 startPageTracking 必须包在 try/catch 中,任何异常静默吞掉(console.debug 级别记录,绝不冒泡到宿主页面)**——这是 spec §4.5 的硬性要求,Task 15 测试补一条:让 localStorage 抛异常时 init 不抛。)
`sdk/demo/index.html` :
```html
<!doctype html> < html >< head >< meta charset = "utf-8" >< title > CTK Demo</ title >
< script > window . ctk = window . ctk || function (){( ctk . q = ctk . q || []). push ( arguments )};
ctk ( "init" , { siteId : "site_x" , endpoint : "http://127.0.0.1:8000/api/collect/batch" });</ script >
< script async src = "../dist/v1.js" ></ script ></ head >
< body >< h1 > Demo 页 1</ h1 >< a href = "page2.html" > 去第 2 页</ a ></ body ></ html >
```
( `page2.html` 同构再建一份,标题"Demo 页 2",验证多页跳转的会话连续性。)
```
- [ ] **Step 3: 构建 + 体积门禁**
```bash
2026-09-15 11:39:12 +08:00
cd sdk && pnpm build && pnpm test && pnpm typecheck
pnpm checksize # 输出 gzip 字节数,必须 ≤ 15360( 15KB),超标则精简(去 polyfill/减正则)
2026-09-15 10:17:52 +08:00
```
- [ ] **Step 4: Commit** `feat(sdk): 页面浏览/有效停留/事件入口,打包≤15KB`
### Task 16: SDK ↔ 后端本地集成验证(手动脚本)
2026-09-15 11:02:50 +08:00
- [ ] **Step 1** : 后端起服务(本地开发):`DB_ENGINE=sqlite python manage.py migrate && DJANGO_DEBUG=1 DB_ENGINE=sqlite python manage.py runserver` (生成 db.sqlite3,后续 Task 19-21 验证复用同一环境)。`createsuperuser` 建 admin。**demo 站点必须写死 site_id**( demo HTML 硬编码):`python manage.py shell` 执行 `Site.objects.create(name="demo", site_id="site_x", domains=["127.0.0.1"], allow_no_origin=True)` 。
2026-09-15 10:17:52 +08:00
- [ ] **Step 2** : `cd sdk && python -m http.server 8080` ,浏览器开 `http://127.0.0.1:8080/demo/` ,点击、停留、切后台、跳 page2。
- [ ] **Step 3** : 断言:`Event` 表出现 page_view/page_stay/sdk_loaded; Visitor.visit_count/active_days 正确;重复刷新不产生重复 event_id;后台 Django shell 查询验证(截图/记录到 PR 描述)。
- [ ] **Step 4: Commit** (如有修复)`fix: SDK↔后端联调修复`
---
# Phase 3 · Art Design Pro 管理后台
### Task 17: 模板落地与请求层适配
**Files:**
2026-09-15 11:02:50 +08:00
- Create: `admin/` (模板克隆),Modify: `admin/.env.development` , `admin/.env.production` , `admin/vite.config.ts` , `admin/src/utils/request/*` (按模板实际结构定位)
2026-09-15 10:17:52 +08:00
2026-09-15 11:02:50 +08:00
- [ ] **Step 1** : 克隆模板(大陆网络备用:gitee 镜像 `https://gitee.com/lingchen163/art-design-pro.git` ; npm 慢加 `--registry=https://registry.npmmirror.com` )
2026-09-15 10:17:52 +08:00
```bash
git clone --depth 1 https://github.com/Daymychen/art-design-pro.git admin
cd admin && rm -rf .git && pnpm install && pnpm dev # 确认模板可跑
pnpm clean:dev # 一键精简演示页(按提示输入 yes)
```
2026-09-15 11:02:50 +08:00
- [ ] **Step 2** : `.env.development` 设 `VITE_API_URL=/api` ; **`.env.production` 同样设 `VITE_API_URL=/api` (模板默认指向 Apifox Mock 域名,漏改则 `pnpm build` 的产物请求 mock,部署必挂)**;`vite.config.ts` 加 proxy `"/api": { target: "http://127.0.0.1:8000", changeOrigin: true }` 。
2026-09-15 10:17:52 +08:00
- [ ] **Step 3** : 改造模板 `src/utils/request` (或等价拦截器文件):响应成功判定改为 `res.data.code === 200` 时 `return res.data.data` ; 401 跳登录页携带 redirect;token 从登录响应存入(沿用模板的 token 管理,替换其 mock 登录为真实 API)。
- [ ] **Step 4** : 登录页接 `/api/admin/auth/token` (用户名/密码 → access/refresh),登录成功进布局页。手动验证:错误密码报错、正确 token 进入首页。
- [ ] **Step 5: Commit** `feat(admin): 模板精简+请求层适配 Django JWT`
### Task 18: api 模块 + 路由注册
**Files:**
- Create: `admin/src/api/tracker/{auth,dashboard,visitors,sites}.ts` , `admin/src/router/modules/tracker.ts` (命名按模板路由规范)
- [ ] **Step 1** : api 模块(示例 visitors.ts,其余同构):
```ts
import request from "@/utils/request" ;
export interface VisitorRow { visitor_id : string ; country : string ; province : string ;
city : string ; device_type : string ; os : string ; browser : string ;
first_seen_at : string ; last_seen_at : string ; visit_count : number ;
active_days : number ; session_count : number ; total_stay_ms : number ;
first_source : string ; last_source : string ; online : boolean }
export const getVisitors = ( params : any ) =>
request . get < { list : VisitorRow []; total : number } > ( "/admin/visitors" , { params });
export const getVisitor = ( vid : string ) => request . get ( `/admin/visitors/ ${ vid } ` );
export const getTimeline = ( vid : string , days = 30 ) =>
request . get ( `/admin/visitors/ ${ vid } /timeline` , { params : { days } });
export const getSession = ( vid : string , sid : string ) =>
request . get ( `/admin/visitors/ ${ vid } /sessions/ ${ sid } ` );
```
- [ ] **Step 2** : 路由模块注册 4 页(总览/访客列表/访客详情(hidden)/站点管理),菜单名中文,icon 任选模板内置。
- [ ] **Step 3: Commit** `feat(admin): tracker api 模块与路由`
### Task 19: 站点管理页 + 总览 Dashboard 页
**Files:**
- Create: `admin/src/views/tracker/site/index.vue` , `admin/src/views/tracker/dashboard/index.vue`
- [ ] **Step 1** : 站点页:Element Plus 表格(名称/site_id 复制按钮/域名列表 tag/状态开关)+ 新建编辑 Dialog(域名校验转小写)+ 停用开关调 PATCH。手动验证增删改。
2026-09-15 11:02:50 +08:00
- [ ] **Step 2** : Dashboard 页:5 张统计卡(今日 PV/UV/会话/新访客/在线)、ECharts 30 天趋势折线(pv/uv/sessions 三线)、来源饼图 top8、热门页面表格。用模板自带图表组件或直接 ECharts。手动对拍后端返回(后端以 `DJANGO_DEBUG=1 DB_ENGINE=sqlite python manage.py runserver` 起,复用 Task 16 的 db.sqlite3)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 3: Commit** `feat(admin): 站点管理与总览页`
### Task 20: 访客列表页
2026-09-15 18:48:32 +08:00
**执行约定(Task 18/19 审查沉淀)** :① 详情路由 keepAlive=true——detail.vue 必须在 `onActivated` 或 watch `$route.query.visitor_id` 里响应访客切换,不能只在 setup 读一次;② 访客列表是分页表——useTable 用语义化方法(`refreshCreate` /`refreshUpdate` )而非裸 `refreshData` ;③ `formatDateTime` 从 site/index.vue 提取到共享 util( `admin/src/utils/` 下 tracker 模块)供列表/详情复用;④ 后端时间戳已统一 `timezone.localtime().isoformat()` (上海时间),前端直接展示;⑤ 筛选请求带竞态守卫(reqSeq 令牌,参照 dashboard 修法)。
2026-09-15 10:17:52 +08:00
**Files:**
- Create: `admin/src/views/tracker/visitor/index.vue`
2026-09-15 20:16:27 +08:00
- [ ] **Step 1** : 表格列:访客ID(截断+复制)/国旗 emoji+国家·省市/设备(PC·Win·Chrome 一行小字)/首访/最近访问/访问次数/活跃天数/总停留(格式化 x分x秒)/首触来源/最近来源/在线绿点。筛选栏:国家输入/设备下拉/在线开关/关键词(访客ID·IP 前缀)/日期范围/显示爬虫开关。(**执行注记**:原"来源输入"筛选无后端 API 支持(VisitorListParams 无 source 字段),M1 以"显示爬虫"开关替代;来源筛选 M2 随后端支持再补。)分页 20/页,后端排序 last_seen_at desc。行点击 → 详情页。
2026-09-15 10:17:52 +08:00
- [ ] **Step 2** : 手动验证(用 Task 16 联调产生的真实数据)。
- [ ] **Step 3: Commit** `feat(admin): 访客列表页`
### Task 21: 访客详情页(时间线)
**Files:**
- Create: `admin/src/views/tracker/visitor/detail.vue`
- [ ] **Step 1** : 布局:顶部档案卡(访客ID/国家·城市/IP/设备环境 grid/首触来源 utm 链/统计 4 数字)+ 时间线主体(`el-timeline` ,日期分组倒序;组内会话块:起止时间+来源+时长,事件行:`10:24 · 看了 /pricing · 停留 2分18秒` ,事件 icon 按类型区分 page_view/page_stay 合并行/sdk_loaded) + 顶部"会话"Tab 切换会话列表(含页面路径视图:路径序列 + 各页停留)。
- [ ] **Step 2** : 停留格式化工具(`formatStay(ms)` );空态/加载态;days 切换(7/30/90)。
2026-09-15 11:02:50 +08:00
- [ ] **Step 3** : 手动验证:当天数据即可验收交互;跨天分组用 Django shell 直插一条昨日事件验证分组渲染(`Event.objects.create(..., occurred_at=昨天时间)` 绕过 ingest 时间窗),真实跨天效果并入 Task 23 次日复验。**这是 M1 验收页面。**
2026-09-15 10:17:52 +08:00
- [ ] **Step 4: Commit** `feat(admin): 访客详情跨天时间线`
---
# Phase 4 · 部署与验收
### Task 22: Docker 部署产物
**Files:**
- Create: `server/Dockerfile` , `deploy/docker-compose.yml` , `deploy/nginx.conf` , `deploy/README-deploy.md` , Modify: `.gitignore`
- [ ] **Step 1** : `server/Dockerfile` :
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
COPY . .
EXPOSE 8000
CMD [ "sh" , "-c" , "python manage.py migrate && gunicorn config.wsgi:application -b 0.0.0.0:8000 -w 3 --forwarded-allow-ips=*" ]
```
2026-09-15 11:02:50 +08:00
- [ ] **Step 2** : `deploy/nginx.conf` 关键段(compose 服务名统一为 `web` ):`location /api/ { client_max_body_size 256k; proxy_pass http://web:8000; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }` (体积上限第一道 + 配合 DRF `NUM_PROXIES=1` 取真实 IP);`location /sdk/ { alias /var/www/sdk/; }` ( **静态别名**,放 `sdk/dist/v1.js` ,不走代理);`location / { root /var/www/admin; try_files $uri $uri/ /index.html; }` ( SPA fallback)。
- [ ] **Step 3** : `deploy/docker-compose.yml` (全量写出)+ `server/.dockerignore` (内容:`.venv/` 、`db.sqlite3` 、`__pycache__/` 、`.cache/` ——否则 Windows 的 .venv(数百 MB)会被 `COPY . .` 打进镜像):
```yaml
services :
web :
build : ../server
env_file : .env # 必含 DJANGO_ENV=prod DJANGO_DEBUG=0 DJANGO_SECRET_KEY DJANGO_ALLOWED_HOSTS DB_*
volumes :
- ./data/ip2region.xdb:/app/tracker/data/ip2region.xdb:ro # xdb 挂载进容器(不是 docker cp,重建不丢)
extra_hosts :
- "host.docker.internal:host-gateway"
nginx :
image : nginx:alpine
ports : [ "80:80" , "443:443" ]
volumes :
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../admin/dist:/var/www/admin:ro
- ../sdk/dist:/var/www/sdk:ro
depends_on : [ web]
```
**连 1Panel 已有 MySQL 的三条路** (任选其一,README 写清排查步骤):
1. 上面已配的 `extra_hosts: host-gateway` + `DB_HOST=host.docker.internal` ——前提:MySQL 端口发布在 `0.0.0.0` 且账号 host 为 `%` ;
2. **推荐** :加入 1Panel 网络——compose 末尾加 `networks: { default: { external: true, name: 1panel-network } }` , `DB_HOST=<MySQL 容器名>` ;
3. `DB_HOST=172.17.0.1` ( docker0 网关,MySQL 需监听可达)。
- [ ] **Step 4** : `README-deploy.md` : **前置构建**( `cd admin && pnpm build` 、`cd sdk && pnpm build` 产出 dist 供挂载)→ 1Panel 步骤(建 MySQL 库 → Compose 部署 → 进容器 `createsuperuser` → xdb 放宿主 `deploy/data/` 由 volume 挂载 → 1Panel 网站功能给 API 域名挂 HTTPS 证书 → 建站点拿 site_id → 目标网站贴接入代码)→ 目标网站接入片段(同 sdk/demo,endpoint 换正式域名)→ **生产 .env 必填项清单** ( `DJANGO_ENV=prod` 、随机 `SECRET_KEY` : `python -c "import secrets; print(secrets.token_urlsafe(48))"` 、实际域名的 `ALLOWED_HOSTS` ——漏配启动即崩,这是设计好的 fail-fast)→ **JWT 应急预案** (token 泄露唯一吊销手段 = 换 SECRET_KEY,全体登出)→ **大陆网络备用源** (模板 gitee 镜像、xdb gitee 下载、`pip -i 清华源` 、`pnpm --registry=https://registry.npmmirror.com` )→ **若目标站套 CDN/WAF, X-Forwarded-For 层数变化需同步调整 `_client_ip` 与 `NUM_PROXIES`** 。
2026-09-15 10:17:52 +08:00
- [ ] **Step 5: Commit** `chore: Docker/Nginx 部署产物与 1Panel 指南`
### Task 23: M1 端到端验收(真实网站)
2026-09-15 11:02:50 +08:00
- [ ] **Step 1** : 选一个真实网站贴接入代码(白名单填其域名)。**前置(spec 治理底线,不装不验收):目标站先上线隐私告知**——一段话说明"本站使用第一方分析存储收集访问行为与设备信息,用于改进服务",附联系方式(文案模板取 spec §9.4)。
2026-09-15 10:17:52 +08:00
- [ ] **Step 2** : 按验收清单逐项过(写入 PR/交付记录):
- [ ] 新访客首次访问 → 后台访客列表出现,环境/来源/地理正确
- [ ] 浏览 3 个页面各停留可感知时长 → 时间线 3 条 page_view 且 active_ms 合理
2026-09-15 10:32:41 +08:00
- [ ] 隔天再访(**需真实次日复验**——服务端拒绝 >24h 时间偏移事件,无法伪造跨天数据,这是防篡改设计;当天先验证:等 30 分钟后新访问使 visit_count+1、时间线新增会话组)→ 同 visitor_id、visit_count+1、时间线新增日期组
2026-09-15 11:02:50 +08:00
- [ ] 首触来源 Google/带 UTM 链接进入 → first_referrer_domain/first_utm 正确(无 Google 环境时:无痕窗口 + `?utm_source=google&utm_medium=test` 链接造新访客验证)
- [ ] 直接刷新/断网重试/重复上报 → 无重复事件(event_id 幂等)(断网用 DevTools Network→Offline 再恢复)
- [ ] curl 伪造非白名单 Origin 上报 → 403;超频 → 429(注意:白名单 Origin 本身可被伪造,这是归属控制不是认证——spec §11 定位)
2026-09-15 10:17:52 +08:00
- [ ] Dashboard 数字与访客明细对得上;总停留=各会话之和
- [ ] SDK 对页面加载性能无可感知影响(DevTools Network 确认 async + ≤15KB)
2026-09-15 11:02:50 +08:00
- [ ] **Step 3** : 全部通过 → `git tag m1-beta` ;验收记录(含截图与数据对拍)写入 `docs/验收记录-m1.md` , Commit `docs: M1 验收通过` 。**同时记录采集开始日期:M2 的 IP 截断任务必须在其 180 天内上线,否则先跑截断脚本再继续采集。**
2026-09-15 10:17:52 +08:00
---
## 执行注意
2026-09-15 11:02:50 +08:00
- Task 6 ingest 的 `duration_ms` 以"计划内正确版本"实现(F 表达式 + new_events 排除重放),不要照抄占位伪码。
- geo.py 是进程级单例(xdb 一次读入 buffer);dropped 统一按事件数计。
- MySQL 的 `INSERT IGNORE` ( ignore_conflicts)会连字段截断类错误一起吞——`_FIELD_LEN` 已按模型长度截断,属双保险;导入/迁移后抽查字段长度。
2026-09-15 11:56:44 +08:00
- **event 表按月分区预案(触发阈值:行数 >5000 万或分区维护成为瓶颈时启动)**,迁移日必做四件事:① PK `(id)` → `(id, occurred_at)` ( Django 5.2 `CompositePrimaryKey` 可表达);② `uq_site_event` 扩为 `(site, event_id, occurred_at)` ——去重从全局弱化为时间作用域,SDK 重放携带同 occurred_at 仍撞键 + ingest 应用层预查双保险在;③ event 的 3 个 FK 改 `db_constraint=False` (InnoDB 分区表不支持外键),站点清除改管理命令分批 DELETE;④ `ALTER ... PARTITION BY` 是 COPY 重建(锁写、磁盘翻倍)——要么趁数据小做,要么 gh-ost/pt-osc 切换。
2026-09-15 11:02:50 +08:00
- Art Design Pro 模板内部组件 API 以克隆下来的实际代码为准(Task 17 后先读 `src/views` 现存示例再写页面,注意模板 request 封装签名可能是 `request.get({url, params})` );本计划页面代码是基线,允许按模板约定适配,**不允许**砍功能。
2026-09-15 10:17:52 +08:00
- ip2region pip 包若与计划 API 不符,参照其 README 调整 `geo.py` ,测试断言不变。
- 所有中文文案直接用于界面;代码注释保持中文、少量、说明意图。
- 每任务完成即提交;测试红→绿→提交,禁止跳测试。