docs: 统计分析页入 spec(漏斗/趋势/分布/推送健康,逐库聚合无新表)+ 计划修 3红3黄

- Task3 TenantConfig 站点化(Site dataclass,消除与 Task5 的结构矛盾)
- Task5 拆 5a(Registry 单元级)/5b(装配切换+conftest 迁移+默认租户过渡兜底)
- conftest teardown dispose_all(Windows 文件锁)
- 提取脚本改 AST 解析(不 import 源模块);REST session 收 ?site=
- resolve=resolve_site 便捷形式注明

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 15:52:59 +08:00
parent 9cb6c095c9
commit befdd48c63
2 changed files with 67 additions and 20 deletions
@@ -96,7 +96,7 @@ jc/pytest.ini 的 testpaths 已在 Task 1 建
- [ ] **Step 3: 过渡版 app/main.py —— 照抄 jc main.py 全文(含模块级 engine/async_session/Base 与 lifespan),只改 import 路径与去掉 admin 路由** - [ ] **Step 3: 过渡版 app/main.py —— 照抄 jc main.py 全文(含模块级 engine/async_session/Base 与 lifespan),只改 import 路径与去掉 admin 路由**
⚠️ **不要写"最小 main"**:`chat.py`/`chat_rest.py`/`lead_push_service.py` 都 `from app.main import async_session`,jc 的 conftest 还 `from app.main import engine`——jc conftest 里文档化过 main→chat_rest 的循环导入次序坑。过渡期保留 jc main.py 的全局 engine/async_session 结构(Task 5 才由 Registry 接管),admin 路由 import 行删除。同时先把 `jc/static/` 整目录拷到 `static/`(删掉 admin.html——旧单文件后台,Phase 2 重写;Task 10 才改造其余文件,但 `StaticFiles(directory="static")` 需要目录先存在)。 ⚠️ **不要写"最小 main"**:`chat.py`/`chat_rest.py`/`lead_push_service.py` 都 `from app.main import async_session`,jc 的 conftest 还 `from app.main import engine`——jc conftest 里文档化过 main→chat_rest 的循环导入次序坑。过渡期保留 jc main.py 的全局 engine/async_session 结构(Task 5b 才由 Registry 接管),admin 路由 import 行删除。同时先把 `jc/static/` 整目录拷到 `static/`(删掉 admin.html——旧单文件后台,Phase 2 重写;Task 10 才改造其余文件,但 `StaticFiles(directory="static")` 需要目录先存在)。
- [ ] **Step 4: 跑全量测试建立基线** - [ ] **Step 4: 跑全量测试建立基线**
@@ -184,21 +184,32 @@ def deep_merge(base: dict, override: dict) -> dict:
```python ```python
# app/platform/tenant_config.py # app/platform/tenant_config.py
from dataclasses import dataclass from dataclasses import dataclass, field
from app.platform.defaults import DEFAULT_CONFIG, deep_merge from app.platform.defaults import DEFAULT_CONFIG, deep_merge
@dataclass(frozen=True)
class Site:
slug: str
name: str
domains: tuple[str, ...]
brand: dict # 站点门面覆盖层 ⊕ 租户默认 brand(已深合并)
@dataclass(frozen=True) @dataclass(frozen=True)
class TenantConfig: class TenantConfig:
slug: str slug: str
name: str name: str
domains: tuple[str, ...]
status: str # active | disabled status: str # active | disabled
data: dict # deep_merge(DEFAULT_CONFIG, snapshot) data: dict # deep_merge(DEFAULT_CONFIG, snapshot)
sites: tuple[Site, ...] = ()
@classmethod @classmethod
def from_snapshot(cls, slug, name, domains, snapshot, status="active"): def from_snapshot(cls, slug, name, snapshot, sites=(), status="active"):
return cls(slug=slug, name=name, domains=tuple(domains), merged = deep_merge(DEFAULT_CONFIG, snapshot)
status=status, data=deep_merge(DEFAULT_CONFIG, snapshot)) resolved = tuple(
Site(s["slug"], s.get("name", s["slug"]), tuple(s.get("domains", ())),
deep_merge(merged["brand"], s.get("brand", {})))
for s in sites)
return cls(slug=slug, name=name, status=status, data=merged, sites=resolved)
``` ```
- [ ] **Step 4: 跑测试确认通过** - [ ] **Step 4: 跑测试确认通过**
@@ -224,7 +235,7 @@ git add app/platform tests/test_config_merge.py && git commit -m "feat: 出厂
- [ ] **Step 0: 先写 scripts/extract_config.py(配置内容机器提取,不手抄)** - [ ] **Step 0: 先写 scripts/extract_config.py(配置内容机器提取,不手抄)**
从两个老项目确定性生成 seed_profiles 骨架:wall.json(正则/话术从 `boundary_filter.py`/`conversation.py` 的常量提取——import 源模块直接读常量值再 dump,别用文本匹配硬解析)、品牌值(chat.html / widget.js / chat.js 三处硬编码 + .env 的 NOTIFY_EMAIL 等归一)、提示词 md(直接拷贝)。脚本幂等可重跑。Task 5/6 直接消费其产出——词表正确性由 Step 7 等价回归兜底,品牌值从此有单一可信来源(Phase 4 的 extract 职责就此完成,只剩 import_legacy.py)。 从两个老项目确定性生成 seed_profiles 骨架:wall.json(正则/话术从 `boundary_filter.py`/`conversation.py` 的常量提取——**用 AST 解析源码取常量赋值,不要 import 源模块**:conversation.py 的 import 链会拉起 ai_service(它 import 时读提示词文件),脚本本身会挂)、品牌值(chat.html / widget.js / chat.js 三处硬编码 + .env 的 NOTIFY_EMAIL 等归一)、提示词 md(直接拷贝)。脚本幂等可重跑。Task 5a/6 直接消费其产出——词表正确性由 Step 7 等价回归兜底,品牌值从此有单一可信来源(Phase 4 的 extract 职责就此完成,只剩 import_legacy.py)。
- [ ] **Step 1: 生成 jiancai 词表**(用 Step 0 脚本产出,按下述 schema 校对): - [ ] **Step 1: 生成 jiancai 词表**(用 Step 0 脚本产出,按下述 schema 校对):
@@ -325,11 +336,12 @@ git add -A && git commit -m "feat: 词表数据化——boundary/conversation
--- ---
### Task 5: TenantRuntime + Registry(文件加载器) ### Task 5a: TenantRuntime + Registry(文件加载器,纯单元级)
**Files:** **Files:**
- Create: `app/platform/registry.py`、`seed_profiles/jiancai/tenant.json`、`seed_profiles/auto/tenant.json` - Create: `app/platform/registry.py`、`seed_profiles/jiancai/tenant.json`、`seed_profiles/auto/tenant.json`
- Modify: `app/main.py`(去掉过渡单例,启动装配 Registry)
(app 装配与服务构造线改造拆到 Task 5b——本任务只做 Registry 本体,单元测试验证,不碰 app 与既有测试。)
- [ ] **Step 1: 写两个 tenant.json** - [ ] **Step 1: 写两个 tenant.json**
@@ -431,19 +443,38 @@ class TenantRegistry:
def registry_reload(self, slug): ... def registry_reload(self, slug): ...
``` ```
(实现要点:懒创建用 `_lock` 串行;首次 resolve 执行 `run_sync(Base.metadata.create_all)` 与 jc `_migrate` 同等加列逻辑;ConversationManager 构造参数改为接收 session_factory + config + prompts + wall。) (实现要点:懒创建用 `_lock` 串行;首次 resolve 执行 `run_sync(Base.metadata.create_all)` 与 jc `_migrate` 同等加列逻辑;ConversationManager 构造参数改为接收 session_factory + config + prompts + wall;`resolve(host)` 只是 `resolve_site(host)[0]` 的便捷形式——双 API 一层实现防漂移;registry 提供 `dispose_all()` 关闭全部 engine——**测试 teardown 必须调它,Windows 上连接不关文件锁着、tmp 目录删不掉**。)
- [ ] **Step 5: main.py 装配**:lifespan 里 `registry = TenantRegistry.load_from_dir("seed_profiles", data_dir=Path("tenants"))`,挂到 `app.state.registry`;逐租户启动 `sweep_stale_pending`。 - [ ] **Step 5: 跑单元测试 + 提交**
- [ ] **Step 6: 跑新测试 + 全量回归** Run: `python -m pytest tests/test_registry.py -q`
Expected: 5 项 PASS(不动全量——装配在 5b)
Run: `python -m pytest tests/test_registry.py -q && python -m pytest -q`
Expected: 新 4 项 + 全量绿。此时 conversation/ai_service 的构造已改为注入式(Task 5 一并完成 services 构造线改造:manager 由 registry 造,routes 从 request.state.tenant 取)。
- [ ] **Step 7: 提交**
```bash ```bash
git add -A && git commit -m "feat: TenantRuntime/Registry——每租户懒建 engine(WAL/pool2)+文件加载器" git add -A && git commit -m "feat: TenantRuntime/Registry——每租户懒建 engine(WAL/pool2)+文件加载器+站点解析"
```
### Task 5b: 装配切换(main 重装配 + 服务构造线 + conftest 全量迁移)
**Files:**
- Modify: `app/main.py`(Registry 装配,去全局单例)、`app/engine/routes/chat.py`、`chat_rest.py`、各 services 构造线、`tests/conftest.py`
- Test: 全量回归(本任务的验收就是 315+5 全绿)
- [ ] **Step 1: main.py 装配**:lifespan 里 `registry = TenantRegistry.load_from_dir("seed_profiles", data_dir=Path("tenants"))` 挂 `app.state.registry`;逐租户启动 `sweep_stale_pending`;删除全局 engine/async_session。**过渡兜底**:中间件(Task 7)上线前,路由取租户处加"state 无租户 → 默认取 jiancai"的 fallback(Task 7 完成后删除,避免 API 测试在 5b→7 之间拿不到 state 挂掉)
- [ ] **Step 2: 服务构造线注入**:ConversationManager 由 registry 造(session_factory + config + prompts + wall);WS/REST 路由从 `request.state.tenant` / `websocket.state.tenant` 取 runtime(经 Step 1 兜底)
- [ ] **Step 3: conftest 全量迁移**:每测试 tmp `data_dir` + registry 夹具(含 **teardown 调 `registry.dispose_all()`** 再清目录——Windows 文件锁);jc 原先 `from app.main import engine` 的 autouse 夹具改从 registry 取
- [ ] **Step 4: 全量回归**
Run: `python -m pytest -q`
Expected: 315 + 5 全绿。红了逐个修夹具,不改产品断言
- [ ] **Step 5: 提交**
```bash
git add -A && git commit -m "feat: 装配切换——main/服务构造线/conftest 迁至 Registry(含默认租户过渡兜底)"
``` ```
--- ---
@@ -605,7 +636,7 @@ def _cfg(over=None):
"prefix": "t1", "timeout": 5, "max_attempts": 1, "prefix": "t1", "timeout": 5, "max_attempts": 1,
"retry_backoff_base": 1.0}} "retry_backoff_base": 1.0}}
snap.update(over or {}) snap.update(over or {})
return TenantConfig.from_snapshot("t1", "T", ["t1.local"], snap) return TenantConfig.from_snapshot("t1", "T", snap)
def test_email_reads_tenant_smtp(monkeypatch): def test_email_reads_tenant_smtp(monkeypatch):
import app.engine.config as cfg_mod import app.engine.config as cfg_mod
@@ -719,7 +750,7 @@ def test_widget_keys_namespaced_by_slug():
- [ ] **Step 2: 实现 public.py + 前端改造** - [ ] **Step 2: 实现 public.py + 前端改造**
- `/api/public/brand?site=<slug>`:站点覆盖层 ⊕ 租户默认门面,显式字段白名单序列化 + CORS `*`;未带/未知 site 回退租户默认 - `/api/public/brand?site=<slug>`:站点覆盖层 ⊕ 租户默认门面,显式字段白名单序列化 + CORS `*`;未带/未知 site 回退租户默认
- `/api/public/avatar`:按当前租户解析 `seed_profiles/<slug>/assets/<brand.avatar 文件名>`,缺省回退 `static/kefu-small.png`(Phase 2 起改读 uploads 卷) - `/api/public/avatar`:按当前租户解析 `seed_profiles/<slug>/assets/<brand.avatar 文件名>`,缺省回退 `static/kefu-small.png`(Phase 2 起改读 uploads 卷)
- widget.js:读自身 `data-site` 属性,fetch brand 时带 `?site=`;所有默认值改读 brand;host id / sessionStorage / localStorage key 拼租户 slug(同租户多站点靠页面 path 天然隔离);fetch 失败退 data-*/中性默认;**WS init 消息带 site**,服务端记入 client_info(会话打站点标,后台可按站筛线索) - widget.js:读自身 `data-site` 属性,fetch brand 时带 `?site=`;所有默认值改读 brand;host id / sessionStorage / localStorage key 拼租户 slug(同租户多站点靠页面 path 天然隔离);fetch 失败退 data-*/中性默认;**WS init 消息带 site**,服务端记入 client_info(会话打站点标,后台可按站筛线索);**REST `/api/chat/session` 同样收 `?site=` 记入 client_info**(与 WS 对齐,App/REST 来源不缺站标)
- chat.html/chat.js 同法(fetch 统一) - chat.html/chat.js 同法(fetch 统一)
- [ ] **Step 3: 手动冒烟**:`uvicorn app.main:app --port 8018` 起服务,改本机 hosts 把两个测试域名指 127.0.0.1,浏览器开 `static/widget-test.html` 验证两租户品牌与同页共存。 - [ ] **Step 3: 手动冒烟**:`uvicorn app.main:app --port 8018` 起服务,改本机 hosts 把两个测试域名指 127.0.0.1,浏览器开 `static/widget-test.html` 验证两租户品牌与同页共存。
@@ -243,6 +243,7 @@ all-kefu/
| 会话与线索 | 顶部租户切换器,看所有租户会话/画像/推送日志 | 只看自己的 | | 会话与线索 | 顶部租户切换器,看所有租户会话/画像/推送日志 | 只看自己的 |
| 配置中心 | 编辑任何租户全部配置组 | 编辑自己全部配置组(全开) | | 配置中心 | 编辑任何租户全部配置组 | 编辑自己全部配置组(全开) |
| 站点管理 | 租户下站点增删/停用、域名绑定、门面编辑(覆盖层+继承项提示+嵌入代码含 data-site) | 同左(限自己租户) | | 站点管理 | 租户下站点增删/停用、域名绑定、门面编辑(覆盖层+继承项提示+嵌入代码含 data-site) | 同左(限自己租户) |
| 统计分析 | 全租户对比 + 单租户下钻 | 只看自己租户 |
| 版本历史 | 快照列表、两版对比、一键回滚(回滚生成新版本) | 同 | | 版本历史 | 快照列表、两版对比、一键回滚(回滚生成新版本) | 同 |
| 平台配置 | 集团默认 AI 凭据、默认 SMTP、超管改密 | ✗ | | 平台配置 | 集团默认 AI 凭据、默认 SMTP、超管改密 | ✗ |
@@ -259,6 +260,21 @@ all-kefu/
`/api/public/brand` 用**显式字段白名单**序列化(只吐品牌字段,绝不序列化含密钥的整个配置 JSON);支持 `?site=<slug>` 返回"站点覆盖层 ⊕ 租户默认门面"的合并结果,未带或未知 site 回退租户默认;该端点被客户官网跨域 fetch,响应需带 `Access-Control-Allow-Origin: *`(与 widget.js 现行 CORS 策略一致)。 `/api/public/brand` 用**显式字段白名单**序列化(只吐品牌字段,绝不序列化含密钥的整个配置 JSON);支持 `?site=<slug>` 返回"站点覆盖层 ⊕ 租户默认门面"的合并结果,未带或未知 site 回退租户默认;该端点被客户官网跨域 fetch,响应需带 `Access-Control-Allow-Origin: *`(与 widget.js 现行 CORS 策略一致)。
### 7.6 统计分析页(数据全部来自现有五张表,无新表)
仪表盘是速览卡片,统计页是下钻分析。内容:
- **核心卡片**:会话数、线索数(已留联系方式的会话)、高意向数、推送成功率
- **转化漏斗**:会话 → 留资 → 完成评估 → 推送成功(每级流失可见)
- **趋势图**:按日会话量/线索量(近 7/30/90 天 + 自定义区间)
- **分布**:意向三级占比、来源(widget/app)、**站点(哪个官网带来的量)**、国家 Top10(IP 归国)
- **推送健康**:success / failed / skipped / duplicated 计数
- **筛选**:时间范围 + 站点;超管可跨租户对比与单租户下钻
实现:`/api/platform/stats` 聚合端点(**逐库 SQL 聚合 + 应用层合并**,沿用"不用 ATTACH"的决定);前端用 ECharts(Element Plus 生态标配)。统计 API 归 Phase 2 平台层、页面归 Phase 3 后台——写那两份计划时据此展开任务。
二期不做:墙拦截率统计(需在消息层给墙命中做埋点,现阶段继续用 `scripts/breach_rate.py` 离线观察)。
## 8. 数据迁移 ## 8. 数据迁移
**scripts/extract_config.py**:从两个老项目提取首版配置快照,生成 seed_profiles/auto/ 与 seed_profiles/jiancai/(提示词拷 md;词表从 boundary_filter.py/conversation.py 提取正则话术转 JSON;品牌从 chat.html/widget.js/chat.js 三处硬编码 + .env 归一)。此后两个老项目只读。 **scripts/extract_config.py**:从两个老项目提取首版配置快照,生成 seed_profiles/auto/ 与 seed_profiles/jiancai/(提示词拷 md;词表从 boundary_filter.py/conversation.py 提取正则话术转 JSON;品牌从 chat.html/widget.js/chat.js 三处硬编码 + .env 归一)。此后两个老项目只读。