From befdd48c63af889a9002b0ad96a0a3496510bf36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 12 Oct 2026 15:52:59 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=BB=9F=E8=AE=A1=E5=88=86=E6=9E=90?= =?UTF-8?q?=E9=A1=B5=E5=85=A5=20spec=EF=BC=88=E6=BC=8F=E6=96=97/=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF/=E5=88=86=E5=B8=83/=E6=8E=A8=E9=80=81=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=EF=BC=8C=E9=80=90=E5=BA=93=E8=81=9A=E5=90=88=E6=97=A0?= =?UTF-8?q?=E6=96=B0=E8=A1=A8=EF=BC=89+=20=E8=AE=A1=E5=88=92=E4=BF=AE=203?= =?UTF-8?q?=E7=BA=A23=E9=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...10-12-phase1-engine-merge-tenantization.md | 71 +++++++++++++------ ...2026-10-12-unified-kefu-platform-design.md | 16 +++++ 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md b/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md index 4ff3ece..26db8a8 100644 --- a/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md +++ b/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md @@ -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 路由** -⚠️ **不要写"最小 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: 跑全量测试建立基线** @@ -184,21 +184,32 @@ def deep_merge(base: dict, override: dict) -> dict: ```python # app/platform/tenant_config.py -from dataclasses import dataclass +from dataclasses import dataclass, field 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) class TenantConfig: slug: str name: str - domains: tuple[str, ...] status: str # active | disabled data: dict # deep_merge(DEFAULT_CONFIG, snapshot) + sites: tuple[Site, ...] = () @classmethod - def from_snapshot(cls, slug, name, domains, snapshot, status="active"): - return cls(slug=slug, name=name, domains=tuple(domains), - status=status, data=deep_merge(DEFAULT_CONFIG, snapshot)) + def from_snapshot(cls, slug, name, snapshot, sites=(), status="active"): + merged = 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: 跑测试确认通过** @@ -224,7 +235,7 @@ git add app/platform tests/test_config_merge.py && git commit -m "feat: 出厂 - [ ] **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 校对): @@ -325,11 +336,12 @@ git add -A && git commit -m "feat: 词表数据化——boundary/conversation --- -### Task 5: TenantRuntime + Registry(文件加载器) +### Task 5a: TenantRuntime + Registry(文件加载器,纯单元级) **Files:** - 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** @@ -431,19 +443,38 @@ class TenantRegistry: 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 && python -m pytest -q` -Expected: 新 4 项 + 全量绿。此时 conversation/ai_service 的构造已改为注入式(Task 5 一并完成 services 构造线改造:manager 由 registry 造,routes 从 request.state.tenant 取)。 - -- [ ] **Step 7: 提交** +Run: `python -m pytest tests/test_registry.py -q` +Expected: 5 项 PASS(不动全量——装配在 5b) ```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, "retry_backoff_base": 1.0}} 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): import app.engine.config as cfg_mod @@ -719,7 +750,7 @@ def test_widget_keys_namespaced_by_slug(): - [ ] **Step 2: 实现 public.py + 前端改造** - `/api/public/brand?site=`:站点覆盖层 ⊕ 租户默认门面,显式字段白名单序列化 + CORS `*`;未带/未知 site 回退租户默认 - `/api/public/avatar`:按当前租户解析 `seed_profiles//assets/`,缺省回退 `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 统一) - [ ] **Step 3: 手动冒烟**:`uvicorn app.main:app --port 8018` 起服务,改本机 hosts 把两个测试域名指 127.0.0.1,浏览器开 `static/widget-test.html` 验证两租户品牌与同页共存。 diff --git a/docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md b/docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md index 9a23a67..42ad92a 100644 --- a/docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md +++ b/docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md @@ -243,6 +243,7 @@ all-kefu/ | 会话与线索 | 顶部租户切换器,看所有租户会话/画像/推送日志 | 只看自己的 | | 配置中心 | 编辑任何租户全部配置组 | 编辑自己全部配置组(全开) | | 站点管理 | 租户下站点增删/停用、域名绑定、门面编辑(覆盖层+继承项提示+嵌入代码含 data-site) | 同左(限自己租户) | +| 统计分析 | 全租户对比 + 单租户下钻 | 只看自己租户 | | 版本历史 | 快照列表、两版对比、一键回滚(回滚生成新版本) | 同 | | 平台配置 | 集团默认 AI 凭据、默认 SMTP、超管改密 | ✗ | @@ -259,6 +260,21 @@ all-kefu/ `/api/public/brand` 用**显式字段白名单**序列化(只吐品牌字段,绝不序列化含密钥的整个配置 JSON);支持 `?site=` 返回"站点覆盖层 ⊕ 租户默认门面"的合并结果,未带或未知 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. 数据迁移 **scripts/extract_config.py**:从两个老项目提取首版配置快照,生成 seed_profiles/auto/ 与 seed_profiles/jiancai/(提示词拷 md;词表从 boundary_filter.py/conversation.py 提取正则话术转 JSON;品牌从 chat.html/widget.js/chat.js 三处硬编码 + .env 归一)。此后两个老项目只读。