Files
fangke/docs/superpowers/plans/2026-09-15-m1-data-collection-foundation.md
T
浅念 8af480e4a4 docs: M1 计划四维审查修复(30+ 处)
- 后端: py-ip2region 正确包名与API/会话缓存键/并发F表达式/重放聚合排除/parser与Django5时区500/类型与体积深度白名单/限流测试夹具/字段截断
- SDK: localStorage内存降级/异步路径try-catch/批次context补首触字段/page_stay同源path/4xx不重试/测试技巧
- 安全: 生产fail-fast/请求体双层上限/deny统一防枚举/拒绝回ACAO/文件缓存共享限流/前端禁v-html/JWT取舍明示
- 交付: .env.production/xdb volume挂载/compose全量与MySQL三路径/.dockerignore/大陆镜像源/隐私告知前置/跨天验证指引

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 11:02:50 +08:00

1511 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
│ └── tracker/data/ip2region.xdb # 离线 IP 库(下载,不进 git,部署时 volume 挂载)
├── 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`。
- **前端安全红线**:全项目禁用 `v-html`;访客来源数据(path/title/referrer/utm)不得直接绑定 `href/src`——如需渲染成链接,协议白名单仅 http/https(Vue 不拦截 `javascript:` URI)。
- **测试**:后端 `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
py-ip2region>=3.0
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")
DEBUG = os.environ.get("DJANGO_DEBUG", "0" if os.environ.get("DJANGO_ENV") == "prod" else "1") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")
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"
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": []},
}]
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"},
}
}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_THROTTLE_RATES": {"collect": "120/min", "admin": "600/min", "login": "10/min"},
"NUM_PROXIES": 1,
}
SIMPLE_JWT = {"ACCESS_TOKEN_LIFETIME": timedelta(hours=12)}
CORS_ALLOW_ALL_ORIGINS = False
CACHES = {"default": {"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": str(BASE_DIR / ".cache")}} # 限流计数跨 gunicorn worker 共享(不共享则限额×3)
DATA_UPLOAD_MAX_MEMORY_SIZE = 262144 # 256KB 应用层请求体兜底
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
BOT_UA_KEYWORDS = ("bot", "spider", "crawl", "slurp", "bingpreview", "headless", "lighthouse")
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
```
- [ ] **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",
"checksize": "node build.mjs && gzip -c dist/v1.js | wc -c"
},
"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 {
if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
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
import { describe, it, expect } from "vitest";
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);
});
});
```
```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")
s = VisitSession.objects.create(site=site, visitor=v, session_id="sid1")
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)
is_bot = models.BooleanField(default=False) # UA 爬虫标记,报表默认排除
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)
duration_ms = models.PositiveBigIntegerField(default=0) # 会话内事件时间跨度
stay_ms = models.PositiveBigIntegerField(default=0) # 有效停留累计(page_stay active_ms 之和)
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)
is_bot = models.BooleanField(default=False)
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
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"
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
r = validate_event(_ev(properties={"a": "x" * 600}))
assert "properties" in r
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():
u = sanitize_url("/p?email=a@b.com&token=x&id=3&utm_source=google&monkey=1")
assert "email=" not in u and "token=" not in u and "utm_source=google" in u
assert "monkey=1" in u # fullmatch:含 key 子串的正常参数不误伤
```
- [ ] **Step 2: 跑测试确认失败** → ModuleNotFoundError。
- [ ] **Step 3: 实现**
```python
import json
import re
from datetime import datetime, timedelta
from urllib.parse import parse_qsl, urlencode, urlparse
from django.utils import timezone
from tracker import constants as C
_SENSITIVE = re.compile("|".join(C.SENSITIVE_KEY_PATTERNS), re.I)
_URL_DROP_KEY = re.compile("|".join(C.URL_PARAM_DROP_PATTERNS), re.I)
def sanitize_url(url: str) -> str:
"""按参数名整键删除(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()
def _depth_ok(v, depth=0) -> bool:
if depth > C.MAX_PROP_DEPTH:
return False
if isinstance(v, dict):
return all(_depth_ok(x, depth + 1) for x in v.values())
if isinstance(v, list):
return all(_depth_ok(x, depth + 1) for x in v)
return True
def validate_event(ev) -> str | None:
"""返回 None 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。"""
if not isinstance(ev, dict):
return "event_invalid_shape"
if ev.get("event_name") not in C.EVENT_NAMES:
return "event_name_not_allowed"
eid = ev.get("event_id")
if not isinstance(eid, str) or not eid or len(eid) > 64:
return "invalid_event_id"
for k in ("path", "title"):
if ev.get(k) is not None and not isinstance(ev[k], str):
return "invalid_field_type"
occ = ev.get("occurred_at")
if not isinstance(occ, datetime):
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"
if not set(props) <= C.EVENT_PROP_WHITELIST.get(ev["event_name"], set()):
return "property_not_allowed"
if not _depth_ok(props):
return "properties_depth"
if len(json.dumps(props, ensure_ascii=False, default=str)) > C.MAX_PROP_JSON_BYTES:
return "properties_too_large"
for k, v in props.items():
if _SENSITIVE.search(str(k)):
return "sensitive_field"
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。
- [ ] **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:
"""进程级单例:xdb 一次读入内存(官方 buffer 模式声明线程安全)。
pip 包名 py-ip2region(模块名 ip2region);包缺失或 xdb 缺失时优雅降级返回 None。"""
def __init__(self):
self._searcher = None
try:
from ip2region import searcher as ip2region_searcher
with open(settings.IP2REGION_XDB, "rb") as f:
self._searcher = ip2region_searcher.new_with_buffer(f.read())
except Exception:
self._searcher = None
@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
if not self._searcher:
return None
try:
raw = self._searcher.search(ip) # "国家|区域|省|市|ISP"
parts = (raw or "").split("|")
return {"country": parts[0], "province": parts[2], "city": parts[3]}
except Exception:
return None
```
注:py-ip2region 的实际调用以其 README 为准(模块 `ip2region`,入口 `searcher.new_with_buffer(buff)` 后 `search(ip)`;**不支持 with 上下文管理器**——用错会被 except 静默吞掉、地理功能永久失效且测试照样绿,务必按此实现)。解析结果格式 `国家|区域|省|市|ISP`,如 `中国|0|广东省|深圳市|电信`、`美国|0|加利福尼亚|旧金山|0`。
- [ ] **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
from django.db.models import F
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
from tracker.services.geo import GeoResolver
_geo = GeoResolver()
_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_")}
def _touch_visitor(site, vid, ctx, ip):
env = {k: str(ctx.get(k, "") or "")[:n] for k, n in _FIELD_LEN.items()}
v, created = Visitor.objects.get_or_create(
site=site, visitor_id=vid, defaults={**env, "last_ip": ip})
now = timezone.now()
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)
if ip:
upd["last_ip"] = ip
g = _geo.resolve(ip)
if g:
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()
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:
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)
return sess
@transaction.atomic
def ingest_batch(site, payload: dict, ip: str | None, ua: str = "") -> dict:
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
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)
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)
session_cache[sid] = sess # 必须按原始 sid 存:切分兜底后 sess.session_id 是 "s1#2",
# 若按它存,同批后续事件按 "s1" 查会 miss → 每条再切一个新会话
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 "",
city=geo["city"] if geo else "",
is_bot=is_bot))
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
```
**注意(执行者必读——上面代码 `duration_ms` 段是占位,按以下正确版本实现)**:
```python
from django.db.models import F, Greatest
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]
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) # 有效停留单独累计,供访客"总停留"取数
```
- **重放批不得抬高会话聚合**:event_count/stay_ms/duration_ms 只用 `new_events`(否则重试/重放被幂等去重后仍把会话计数刷高——`test_replay_duplicated` 需补断言 `VisitSession.event_count == 1`)。
- 同批多会话各自按上式计算。
- `duplicated` 计数允许并发近似(唯一约束保证绝不重复入库,仅统计口径可能差 1-2,M1 接受)。
- [ ] **Step 4: 跑测试通过** → 5 passed。修到绿为止。
- [ ] **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
import pytest
from django.core.cache import cache
from django.utils import timezone
from tracker.models import Site
NOW = timezone.now()
@pytest.fixture(autouse=True)
def _clear_cache():
cache.clear() # locmem 限流历史跨测试残留,会让 429 用例假红
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
from datetime import datetime, timezone as dt_tz
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):
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
class CollectThrottle(AnonRateThrottle):
scope = "collect"
def _iso(dt):
if isinstance(dt, str):
s = dt.replace("Z", "+00:00")
d = datetime.fromisoformat(s)
return d if d.tzinfo else d.replace(tzinfo=dt_tz.utc) # Django≥5 已删除 django.utils.timezone.utc
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 ""
if int(request.headers.get("Content-Length") or 0) > 262144:
return self._deny(400, "payload_too_large") # 应用层兜底(nginx 另设 client_max_body_size)
site = self._match_site(request)
if site is None or not site.is_active:
return self._deny(403, "not_allowed") # 三种拒绝统一 reason,防 site_id 枚举探测
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]:
return self._deny(403, "not_allowed")
if not host and not site.allow_no_origin:
return self._deny(403, "not_allowed")
payload = request.data or {}
for ev in payload.get("events") or []:
if isinstance(ev, dict) and "occurred_at" in ev:
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")
ip = self._client_ip(request)
r = ingest_batch(site, payload, ip, request.headers.get("User-Agent", ""))
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
def _client_ip(self, request):
"""nginx 前置时取 X-Forwarded-For 最后一段(与 DRF NUM_PROXIES=1 一致;套 CDN 时按层数前移)。"""
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
if xff:
return xff.split(",")[-1].strip()
return request.META.get("REMOTE_ADDR")
def _deny(self, code, reason):
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
```
`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。
- [ ] **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 当日计数。
- [ ] **Step 2: 实现** `stats.py`:
```python
from datetime import datetime, time, timedelta
from django.utils import timezone
from tracker.models import DailyStat, Event, VisitSession
def refresh_daily(site, date) -> DailyStat:
day_start = timezone.make_aware(datetime.combine(date, time.min))
day_end = day_start + timedelta(days=1)
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()
sessions = VisitSession.objects.filter(site=site, visitor__is_bot=False,
started_at__gte=day_start,
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 建普通成员。
- [ ] **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(全体登出)。
- [ ] **Step 3: 测试通过,Commit** `feat: JWT 登录与用户接口`
### Task 10: 站点管理 API
**Files:**
- Create: `server/tracker/api/sites.py`, `server/tracker/tests/test_sites.py`, Modify: `config/urls.py`
- [ ] **Step 1: 失败测试**:CRUD(列表/新建自动生成 site_id/更新域名白名单/停用);仅登录成员可访问,删除仅 admin;域名校验(去协议、转小写、非空列表)。
- [ ] **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"
```
- [ ] **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` = Event `received_at > now-5min` distinct visitor。认证 + 成员权限。**所有聚合排除 `is_bot=True`(爬虫不进 Dashboard)。**
- [ ] **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 或单列,见实现说明)。
- [ ] **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` 是批内跨度累加,仅内部字段不展示)。
- [ ] **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;
// 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; }
},
};
export function getVisitorId(): string {
let v = store.get(K.vid);
if (!v) { v = uuid(); store.set(K.vid, v); }
return v;
}
export function touchActivity() { store.set(K.act, String(Date.now())); }
export function getSessionId(): string {
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); }
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}`。
- [ ] **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 不重试直接丢。
- 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()`。
- [ ] **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;
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 { /* 定时器/事件回调内的任何异常不得冒泡到宿主页 */ }
}
private async send(body: string, count: number) {
try {
const res = await fetch(this.cfg.endpoint, { method: "POST",
keepalive: true, headers: { "Content-Type": "text/plain" }, body });
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
this.retry = 0; return; // 4xx 参数/权限错不重试(403 重试只会掩盖配置错误)
}
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);
setTimeout(() => this.send(body, count).catch(() => {}), delay);
} else { this.retry = 0; this.dropped += count; } // 按事件数计丢弃
}
}
}
```
`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: 失败测试**:
- init 后 `sdk_loaded` 事件入队;`page_view` 含 path/title/referrer_domain/utm(utm 随每页上报,服务端只写首个,可接受冗余)。
- 批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路,断言 `__ctk_ctx` 字段齐全)。
- 有效停留:fake timers 下 `visible` 且有 pointerdown → 3 秒后 active_ms≈3000;`document.hidden=true` 后不再累计;隐藏时自动 flush beacon(page_stay 已入队)。
- **localStorage 抛异常时 15 秒定时 flush 不产生未捕获异常**(stub localStorage 全方法抛错 + `await vi.advanceTimersByTimeAsync(15000)`,配 `vi.onUnhandledError` 断言;且身份降级到内存后事件照常发出)。
- 隐藏→恢复→再隐藏产生两段 page_stay,active_ms 各自正确(第二段从 0 重新累计)。
- [ ] **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 }));
setInterval(() => { try { if (!document.hidden && Date.now() - lastAct < 30_000) activeMs += 1000; } catch { /* 静默 */ } }, 1000);
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 = () => {
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 { /* 静默 */ }
};
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";
import { buildEnvContext, pageContext } from "./context";
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);
// 首触归因修复:批次 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 };
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
cd sdk && pnpm build && pnpm test
gzip -c dist/v1.js | wc -c # 必须 ≤ 15360(15KB),超标则精简(去 polyfill/减正则)
```
- [ ] **Step 4: Commit** `feat(sdk): 页面浏览/有效停留/事件入口,打包≤15KB`
### Task 16: SDK ↔ 后端本地集成验证(手动脚本)
- [ ] **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)`。
- [ ] **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:**
- Create: `admin/`(模板克隆),Modify: `admin/.env.development`, `admin/.env.production`, `admin/vite.config.ts`, `admin/src/utils/request/*`(按模板实际结构定位)
- [ ] **Step 1**: 克隆模板(大陆网络备用:gitee 镜像 `https://gitee.com/lingchen163/art-design-pro.git`;npm 慢加 `--registry=https://registry.npmmirror.com`)
```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)
```
- [ ] **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 }`。
- [ ] **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。手动验证增删改。
- [ ] **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)。
- [ ] **Step 3: Commit** `feat(admin): 站点管理与总览页`
### Task 20: 访客列表页
**Files:**
- Create: `admin/src/views/tracker/visitor/index.vue`
- [ ] **Step 1**: 表格列:访客ID(截断+复制)/国旗 emoji+国家·省市/设备(PC·Win·Chrome 一行小字)/首访/最近访问/访问次数/活跃天数/总停留(格式化 x分x秒)/首触来源/最近来源/在线绿点。筛选栏:国家输入/设备下拉/来源输入/在线开关/关键词(访客ID·IP 前缀)/日期范围。分页 20/页,后端排序 last_seen_at desc。行点击 → 详情页。
- [ ] **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)。
- [ ] **Step 3**: 手动验证:当天数据即可验收交互;跨天分组用 Django shell 直插一条昨日事件验证分组渲染(`Event.objects.create(..., occurred_at=昨天时间)` 绕过 ingest 时间窗),真实跨天效果并入 Task 23 次日复验。**这是 M1 验收页面。**
- [ ] **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=*"]
```
- [ ] **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`**。
- [ ] **Step 5: Commit** `chore: Docker/Nginx 部署产物与 1Panel 指南`
### Task 23: M1 端到端验收(真实网站)
- [ ] **Step 1**: 选一个真实网站贴接入代码(白名单填其域名)。**前置(spec 治理底线,不装不验收):目标站先上线隐私告知**——一段话说明"本站使用第一方分析存储收集访问行为与设备信息,用于改进服务",附联系方式(文案模板取 spec §9.4)。
- [ ] **Step 2**: 按验收清单逐项过(写入 PR/交付记录):
- [ ] 新访客首次访问 → 后台访客列表出现,环境/来源/地理正确
- [ ] 浏览 3 个页面各停留可感知时长 → 时间线 3 条 page_view 且 active_ms 合理
- [ ] 隔天再访(**需真实次日复验**——服务端拒绝 >24h 时间偏移事件,无法伪造跨天数据,这是防篡改设计;当天先验证:等 30 分钟后新访问使 visit_count+1、时间线新增会话组)→ 同 visitor_id、visit_count+1、时间线新增日期组
- [ ] 首触来源 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 定位)
- [ ] Dashboard 数字与访客明细对得上;总停留=各会话之和
- [ ] SDK 对页面加载性能无可感知影响(DevTools Network 确认 async + ≤15KB)
- [ ] **Step 3**: 全部通过 → `git tag m1-beta`;验收记录(含截图与数据对拍)写入 `docs/验收记录-m1.md`,Commit `docs: M1 验收通过`。**同时记录采集开始日期:M2 的 IP 截断任务必须在其 180 天内上线,否则先跑截断脚本再继续采集。**
---
## 执行注意
- Task 6 ingest 的 `duration_ms` 以"计划内正确版本"实现(F 表达式 + new_events 排除重放),不要照抄占位伪码。
- geo.py 是进程级单例(xdb 一次读入 buffer);dropped 统一按事件数计。
- MySQL 的 `INSERT IGNORE`(ignore_conflicts)会连字段截断类错误一起吞——`_FIELD_LEN` 已按模型长度截断,属双保险;导入/迁移后抽查字段长度。
- Art Design Pro 模板内部组件 API 以克隆下来的实际代码为准(Task 17 后先读 `src/views` 现存示例再写页面,注意模板 request 封装签名可能是 `request.get({url, params})`);本计划页面代码是基线,允许按模板约定适配,**不允许**砍功能。
- ip2region pip 包若与计划 API 不符,参照其 README 调整 `geo.py`,测试断言不变。
- 所有中文文案直接用于界面;代码注释保持中文、少量、说明意图。
- 每任务完成即提交;测试红→绿→提交,禁止跳测试。