docs: 站点模型(一租户多官网多门面)+ 6 项缺口修正落档 spec 与 Phase 1 计划

- spec:域名→站点→租户解析链、sites/site_domains 表、站点管理页、brand 接口 ?site=、
  词表补轮中提醒话术、头像按(租户,站点)解析
- 计划:Task4 加 extract_config.py 前置与 reminders 组、Task5 tenant.json 站点化、
  Task7 中间件带站点、Task9 扩为七类接线(AI凭据/行为开关/映射表/种子键)、
  Task10 站点覆盖下发+每租户头像资产+data-site+会话站点标

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 15:43:17 +08:00
parent daccc37519
commit 9cb6c095c9
2 changed files with 99 additions and 26 deletions
@@ -222,7 +222,11 @@ git add app/platform tests/test_config_merge.py && git commit -m "feat: 出厂
**思路:** 把 jc `boundary_filter.py` 和 `conversation.py` 里所有正则/话术常量提取成 `wall.json`;`compile_wall(json) → CompiledWall`;boundary_filter 函数加 `wall: CompiledWall | None = None` 参数(None=用模块级默认,默认=jiancai 出厂)——**既有 320 测试零修改**。
- [ ] **Step 1: 提取 jiancai 词表**。逐常量从 `app/engine/services/boundary_filter.py`(PRICE_PATTERNS、DEFLECTIONS_EN/ZH/ES、NO_CONTACT_*)和 `app/engine/services/conversation.py`(BOUNDARY_TRIGGER_RE、BUDGET_TRIGGER_RE、CONTACT_ASK_TRIGGER_RE 的模式串)抄进 JSON:
- [ ] **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)。
- [ ] **Step 1: 生成 jiancai 词表**(用 Step 0 脚本产出,按下述 schema 校对):
```json
{
@@ -231,6 +235,8 @@ git add app/platform tests/test_config_merge.py && git commit -m "feat: 出厂
"deflections_contact": {"en": ["<DEFLECTIONS_*_CONTACT 已留资变体池>"], "zh": ["..."], "es": ["..."]},
"no_contact": {"web": "<NO_CONTACT_WEB>", "app": "<NO_CONTACT_APP>", "already": "<NO_CONTACT_ALREADY>"},
"triggers": {"boundary": ["..."], "budget": ["..."], "contact_ask": ["..."]},
"reminders": {"boundary": "<边界提醒>", "contact_ask": "<索要提醒>", "language_follow": "<语言跟随提醒>",
"lead_given": "<已留资禁令>", "cc_guide": "<CC 确认指引>", "email_light": "<邮箱轻补>"},
"cc_templates": {"en": ["<回显确认模板 EN>"], "zh": ["<ZH>"], "es": ["<ES>"]}
}
```
@@ -281,6 +287,7 @@ class CompiledWall:
deflections_contact: dict
no_contact: dict
triggers: dict # 每值 tuple[re.Pattern]
reminders: dict # 六类轮中提醒话术(_build_context_reminder 的数据源)
cc_templates: dict
def compile_wall(cfg: dict) -> CompiledWall:
@@ -296,13 +303,14 @@ def compile_wall(cfg: dict) -> CompiledWall:
deflections_contact={k: list(v) for k, v in cfg.get("deflections_contact", {}).items()},
no_contact=dict(cfg.get("no_contact", {})),
triggers=triggers,
reminders=dict(cfg.get("reminders", {})),
cc_templates={k: list(v) for k, v in cfg.get("cc_templates", {}).items()},
)
```
- [ ] **Step 5: boundary_filter 纯函数化**。函数签名改为接收 `wall: CompiledWall | None = None`,模块顶部 `DEFAULT_WALL = compile_wall(json.loads(_default_wall_json))`(`_default_wall_json` 内联读 `seed_profiles/jiancai/wall.json`);函数体内所有模块常量引用改为 `w = wall or DEFAULT_WALL`。**函数逻辑一行不动**,只换数据来源。
- [ ] **Step 6: conversation.py 三组触发正则同法**:`BOUNDARY_TRIGGER_RE` 等 → 由 `config.wall.triggers` 编译产物替代,`_build_context_reminder` 里的逐字话术改为从 `config` 取(jc 原文进 jiancai 词表 JSON 的对应字段)。
- [ ] **Step 6: conversation.py 三组触发正则同法**:`BOUNDARY_TRIGGER_RE` 等 → 由 `config.wall.triggers` 编译产物替代;`_build_context_reminder` 的六类逐字话术改为从 `config.wall.reminders` 取(jc 原文进 jiancai 的 reminders 字段——schema 已定义,见 Step 1)。
- [ ] **Step 7: 跑全量回归**
@@ -325,11 +333,15 @@ git add -A && git commit -m "feat: 词表数据化——boundary/conversation
- [ ] **Step 1: 写两个 tenant.json**
**seed_profiles/jiancai/tenant.json**(纯 JSON,无注释):
**seed_profiles/jiancai/tenant.json**(纯 JSON,无注释;**站点模型**——域名挂在站点上,站点属于租户):
```json
{ "slug": "jiancai", "name": "HESUPIPE 建材", "status": "active",
"domains": ["chat2.agent.hnxinyouhe.com", "localhost.jc"],
"sites": [
{ "slug": "main", "domains": ["chat2.agent.hnxinyouhe.com", "localhost.jc"] },
{ "slug": "es", "domains": [],
"brand": {"title": "HESUPIPE Soporte", "welcome": "¡Hola! ¿En qué podemos ayudarle?"} }
],
"config": { "brand": {"title": "HESUPIPE Support", "color": "#066fc0",
"whatsapp": "+8615937108317", "email": "hesu@hesuu.com",
"privacy_url": "https://hesupipe.com/privacy-policy"},
@@ -338,7 +350,7 @@ git add -A && git commit -m "feat: 词表数据化——boundary/conversation
"product_id": "app_product_id"}} } }
```
auto 同理(domains: chat.agent.hnxinyouhe.com,prefix ai-kefu,context_seed_keys 用 vehicle_*,品牌橙 #f08a31)。`config` 内**不含** prompts(prompts 由目录内 md 提供,见 Task 6)与 wall(同目录 wall.json)。
规则:每租户**至少一个站点**;`site.brand` 是覆盖层(缺省项继承 `config.brand`,用 deep_merge);`site.domains` 非空的站点按 Host 识别,空域名的站点(如 es)靠嵌入代码 `data-site="es"` 接入(共用挂件域名);site slug 不可变。auto 同理(main 站 domains: chat.agent.hnxinyouhe.com,prefix ai-kefu,context_seed_keys 用 vehicle_*,品牌橙 #f08a31)。`config` 内**不含** prompts(prompts 由目录内 md 提供,见 Task 6)与 wall(同目录 wall.json)。
- [ ] **Step 2: 写失败测试**
@@ -368,6 +380,14 @@ def test_reload_swaps_config_atomically(tmp_path):
rt = reg.resolve("chat2.agent.hnxinyouhe.com")
reg.registry_reload("jiancai") # 重新读盘
assert reg.resolve("chat2.agent.hnxinyouhe.com") is not rt
def test_site_brand_overlay_and_inheritance(tmp_path):
reg = TenantRegistry.load_from_dir("seed_profiles", data_dir=tmp_path)
rt, site = reg.resolve_site("chat2.agent.hnxinyouhe.com")
assert site.slug == "main"
es = rt.site_by_slug("es") # 空域名站点按 slug 取
assert es.brand["title"] == "HESUPIPE Soporte" # 覆盖生效
assert es.brand["color"] == rt.config.data["brand"]["color"] # 未覆盖项继承租户默认
```
- [ ] **Step 3: 跑测试确认失败**
@@ -398,6 +418,7 @@ def make_tenant_engine(db_path: pathlib.Path):
class TenantRuntime: ...
# dataclass: config, db_path, engine, session_factory, manager, prompts, compiled_wall
# + sites: tuple[Site](slug/domains/brand 覆盖层已 deep_merge)+ site_by_slug(slug)
class TenantRegistry:
def __init__(self): self._by_host, self._by_slug = {}, {}
@@ -511,7 +532,7 @@ class TenantHostMiddleware:
return await self.app(scope, receive, send)
host = dict(scope.get("headers", {})).get(b"host", b"").decode().split(":")[0].lower()
try:
rt = self.registry.resolve(host)
rt, site = self.registry.resolve_site(host) # 域名→站点→租户
except UnknownTenantError:
if scope["type"] == "http":
resp = PlainTextResponse("not found", status_code=404)
@@ -521,7 +542,7 @@ class TenantHostMiddleware:
# 503 中性暂停页(同上双通道处理)
...
scope["state"] = scope.get("state") or {}
scope["state"]["tenant"] = rt
scope["state"]["tenant"], scope["state"]["site"] = rt, site
await self.app(scope, receive, send)
```
@@ -560,14 +581,14 @@ def test_limit_per_ip():
---
### Task 9: email/lead_push/rate_limit 租户配置接线
### Task 9: 服务层配置接线(email/push/rate_limit/AI 凭据/行为开关/映射表/种子键)
**背景(评审核实的事实):** jc 现状——email_service 读全局 settings(SMTP_*/NOTIFY_EMAIL,发件名硬编码【HESUPIPE】);lead_push_service 读 settings.PUSH_*;rate_limit 的 key=(ip, device)。既有测试 monkeypatch 的都是 settings,所以这个缺口**测试暴露不了**——必须新增接线测试逼出来。不接线的话,Phase 1 结束时两租户联调会静默错配(auto 用上 jc 的 SMTP/推送目标)。
**背景(评审核实 + 缺口分析):** jc 现状——email_service 读全局 settings(SMTP_*/NOTIFY_EMAIL,发件名硬编码【HESUPIPE】);lead_push_service 读 settings.PUSH_*;rate_limit 的 key=(ip, device);glm provider 实例化读 settings 的智谱凭据;cc_completion 的 NAME_TO_REGION 与 chat_rest 的种子键白名单是代码常量。DEFAULT_CONFIG 里定义的 ai/contact/misc/engine 组**没人读**——既有测试 monkeypatch 的都是 settings,缺口测试暴露不了,必须新增接线测试逼出来。不接线的话:两租户联调静默错配(auto 用上 jc 的 SMTP/推送目标),且"租户用自己的 AI key""关掉邮箱轻补"是摆设开关。
另:jc 的 lead_push 已是 httpx.AsyncClient(`_do_post`)、glm 已走 executor,零同步 IO 纪律在主线已满足,无需改造(Task 12 做审计确认)。
**Files:**
- Modify: `app/engine/services/email_service.py`、`lead_push_service.py`、`rate_limit.py`
- Modify: `app/engine/services/email_service.py`、`lead_push_service.py`、`rate_limit.py`、`ai_service.py`(provider 工厂)、`cc_completion.py`(映射表合并)、`app/engine/routes/chat_rest.py`(种子键白名单)
- Test: `tests/test_tenant_config_wiring.py`
- [ ] **Step 1: 写失败测试**
@@ -577,12 +598,13 @@ def test_limit_per_ip():
from types import SimpleNamespace
from app.platform.tenant_config import TenantConfig
def _cfg():
def _cfg(over=None):
snap = {"email": {"smtp": {"host": "s", "port": 465, "user": "u", "pass": "p"},
"sender_name": "【T】", "notify_emails": ["a@b.c"]},
"push": {"enabled": True, "url": "http://x", "api_key": "k",
"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)
def test_email_reads_tenant_smtp(monkeypatch):
@@ -603,6 +625,30 @@ def test_rate_limit_key_includes_tenant():
assert lim.check("t1", "1.1.1.1", "dev1", limit=2) is True
assert lim.check("t1", "1.1.1.1", "dev1", limit=2) is True
assert lim.check("t2", "1.1.1.1", "dev1", limit=2) is True # 换租户不受影响
def test_ai_settings_override_and_fallback(monkeypatch):
import app.engine.config as cfg_mod
monkeypatch.setattr(cfg_mod, "settings",
SimpleNamespace(ZHIPU_API_KEY="ENV_KEY", ZHIPU_BASE_URL=None, AI_MODEL="glm-4-flash"))
fb = resolve_ai_settings(_cfg()) # 未覆盖 → 回退 env
assert fb.api_key == "ENV_KEY" and fb.model == "glm-4-flash"
ov = resolve_ai_settings(_cfg({"ai": {"api_key": "k2", "model": "glm-4-plus"}}))
assert ov.api_key == "k2" and ov.model == "glm-4-plus"
def test_contact_toggles_resolve():
flags = resolve_contact_flags(_cfg({"contact": {"cc_completion": False,
"email_light_ask": False}}))
assert flags.cc_completion is False and flags.email_light_ask is False
def test_name_to_region_extras_merged():
cfg = _cfg({"misc": {"name_to_region_extras": {"德意志": "DE"}}})
from app.engine.services.cc_completion import region_for_name
assert region_for_name("德意志", cfg) == "DE"
def test_chat_rest_seed_keys_from_config():
keys = allowed_seed_keys(_cfg({"engine": {"context_seed_keys":
{"vehicle_name": "app_vehicle_name", "vehicle_id": "app_vehicle_id"}}}))
assert keys == {"vehicle_name", "vehicle_id"}
```
- [ ] **Step 2: 跑测试确认失败**
@@ -610,14 +656,20 @@ def test_rate_limit_key_includes_tenant():
Run: `python -m pytest tests/test_tenant_config_wiring.py -v`
Expected: FAIL(resolve_* 函数不存在 / rate_limit 不收 tenant 参数)
- [ ] **Step 3: 实现接线**:email_service/lead_push_service 顶部各加 `resolve_email_settings(config)` / `resolve_push_settings(config)`(读 `config.data` 对应组;email.smtp 为 None 时回退平台默认 = 本阶段 ProcessSettings 的 env 值);服务调用点从读 settings 改为接收经 ConversationManager 传入的租户 config;rate_limit 桶 key 改 `(tenant_slug, ip, device)`、limit 读 `config.engine.rate_limit_per_hour`
- [ ] **Step 3: 实现接线**:
- email/push:`resolve_email_settings(config)` / `resolve_push_settings(config)`(读 config.data 对应组;email.smtp 为 None 回退 env settings)
- AI:`resolve_ai_settings(config)` 供 provider 工厂按租户实例化 glm(api_key/base_url/model 未覆盖回退 env settings)
- contact 开关:`resolve_contact_flags(config)` 接进 conversation 的 `_cc_pre_turn` 门控与邮箱轻补分支
- 映射表:cc_completion 查表函数加 extras 参数(NAME_TO_REGION ⊕ misc.name_to_region_extras)
- 种子键:chat_rest 白名单改读 `config.engine.context_seed_keys` 的键集合
- rate_limit:桶 key 改 `(tenant_slug, ip, device)`、limit 读 `config.engine.rate_limit_per_hour`
- [ ] **Step 4: 既有 email/push 测试回归**:monkeypatch 点从 settings 迁到 resolve_*_settings 返回值(或直接传 config 夹具),断言不变
- [ ] **Step 5: 全量回归 + 提交**
```bash
git add -A && git commit -m "feat: email/push/rate_limit 读租户配置段(服务层 settings 引用清零)"
git add -A && git commit -m "feat: 服务层配置接线——email/push/rate_limit/AI凭据/行为开关/映射表/种子键"
```
---
@@ -625,7 +677,7 @@ git add -A && git commit -m "feat: email/push/rate_limit 读租户配置段(
### Task 10: /api/public/brand + 前端租户化
**Files:**
- Create: `app/engine/routes/public.py`
- Create: `app/engine/routes/public.py`、`seed_profiles/jiancai/assets/kefu-small.png`(自 jc/static 拷)、`seed_profiles/auto/assets/kefu-small.png`(自 ai-kefu/static 拷——**两项目头像不同,各归各**)
- Modify: `static/widget.js`、`static/js/chat.js`、`static/chat.html`(从 jc 拷入后改)
- Test: `tests/test_brand_endpoint.py`、`tests/test_widget_namespacing.py`
@@ -641,6 +693,19 @@ def test_brand_whitelist_only(app_client):
assert set(r.json().keys()) == BRAND_EXPECTED # 白名单,绝不多吐
assert "api_key" not in r.text and "smtp" not in r.text.lower()
assert r.headers["access-control-allow-origin"] == "*"
def test_brand_site_overlay(app_client):
h = {"host": "chat2.agent.hnxinyouhe.com"}
r = app_client.get("/api/public/brand?site=es", headers=h)
assert r.json()["title"] == "HESUPIPE Soporte" # 站点覆盖生效
r2 = app_client.get("/api/public/brand", headers=h)
assert r2.json()["title"] == "HESUPIPE Support" # 不带 site = 租户默认门面
r3 = app_client.get("/api/public/brand?site=nope", headers=h)
assert r3.json()["title"] == "HESUPIPE Support" # 未知 site 回退
def test_avatar_served_per_tenant(app_client):
r = app_client.get("/api/public/avatar", headers={"host": "chat.agent.hnxinyouhe.com"})
assert r.status_code == 200 and r.headers["content-type"].startswith("image/")
```
```python
@@ -651,7 +716,11 @@ def test_widget_keys_namespaced_by_slug():
assert "widget_invite_dismissed_${" in src
```
- [ ] **Step 2: 实现 public.py**(显式字段序列化 + CORS 头)+ widget.js 改造:启动 fetch `/api/public/brand` → 存 `__brand`,所有默认值改读 brand;host id / storage key / localStorage 会话 key 全部拼 slug;fetch 失败退 data-*/中性默认。chat.html/chat.js 同法(服务端也可直接渲染 brand 进页面,二选一,倾向 fetch 统一)。
- [ ] **Step 2: 实现 public.py + 前端改造**
- `/api/public/brand?site=<slug>`:站点覆盖层 ⊕ 租户默认门面,显式字段白名单序列化 + CORS `*`;未带/未知 site 回退租户默认
- `/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(会话打站点标,后台可按站筛线索)
- chat.html/chat.js 同法(fetch 统一)
- [ ] **Step 3: 手动冒烟**:`uvicorn app.main:app --port 8018` 起服务,改本机 hosts 把两个测试域名指 127.0.0.1,浏览器开 `static/widget-test.html` 验证两租户品牌与同页共存。
@@ -54,14 +54,15 @@
┌─────────────────────────────────────────┐
│ all-kefu 平台(单进程 FastAPI) │
chat.agent.hnxinyouhe.com ──┐ │
chat2.agent.hnxinyouhe.com ─┼→ 按 Host 识别租户 → ├─ platform.db(租户/域名/账号/配置)
ai.hesupipe.com(未来) ──┘ (域名映射表) └─ tenants/auto.db、tenants/jc.db
chat2.agent.hnxinyouhe.com ─┼→ 按 Host 识别: ├─ platform.db(租户/站点/域名/账号/配置)
ai.hesupipe.com(未来) ──┘ 域名→站点→租户 └─ tenants/auto.db、tenants/jc.db
多站共用域名时:挂件带 data-site 参数选站点
admin.xxx.com ────────────→ 统一后台(超管+租户登录)│
└─────────────────────────────────────────┘
```
- **一个进程**:同时承载所有租户的聊天引擎 + 统一管理后台,一个 Docker 容器部署在现有阿里云 ECS(1Panel)上。
- **租户识别 = Host**:数据库存"域名 → 租户"映射;加租户 = 后台加记录 + DNS/反代指过来。
- **租户识别 = Host,站点在中间**:数据库存"域名 → 站点 → 租户"映射。**站点(site)= 一个租户下的一张门面**:一个租户(一个 AI 脑子 + 一套线索管道)可以挂多个官网,每个官网的 brand(颜色/标题/欢迎语/话题/头像/WhatsApp/邮箱/隐私链接)可各配各的。站点接入两种方式:绑自己的域名(Host 自动识别),或多站共用一个挂件域名 + 嵌入代码 `data-site="es"` 参数选择;**未指定或未知 site 回退租户默认门面**(向后兼容)。页面 data-* 参数仍可覆盖站点门面(优先级:data-* > 站点 > 租户默认)。加租户 = 后台加记录 + DNS/反代指过来。
- **数据分层**:platform.db = 租户注册表、域名映射、admin_users、配置版本快照、平台级默认(集团 AI key、默认 SMTP);tenants/<tenant_id>.db = 现有 5 张表(conversations/messages/customer_profiles/evaluations/lead_push_logs),一租户一文件,互不可见。
- **两级登录**:超管(tenant_id 为空)看所有租户数据、增删租户、改任何配置;租户管理员只看自己租户数据、改自己配置。数据级隔离由后端强制。
@@ -77,10 +78,10 @@
| 配置组 | 内容 | 对应现在散落的位置 |
|---|---|---|
| 基础 | 租户名、启用/停用、绑定域名列表 | compose/openresty 硬编码 |
| 品牌 | 展示名、主题色、头像、欢迎语、热门话题、输入框占位符、WhatsApp 号、联系邮箱、隐私政策 URL | chat.html / widget.js / chat.js 三处硬编码 |
| 基础 | 租户名、启用/停用、**站点列表**(每站点:短代号、名称、绑定域名、门面覆盖层) | compose/openresty 硬编码 |
| 品牌(租户默认门面,**站点可逐项覆盖**) | 展示名、主题色、头像、欢迎语、热门话题、输入框占位符、WhatsApp 号、联系邮箱、隐私政策 URL | chat.html / widget.js / chat.js 三处硬编码 |
| 提示词 4 件套 | system.md、company_facts.md(知识库)、extractor.md、evaluator.md(markdown) | app/prompts/ |
| 安全墙词表 | 价格墙正则组(多语言)、迂回话术池(EN/ZH/ES)、零号码替换句、触发词正则(边界/预算/索要)、防误杀白名单(建材 DN/PN/SDR vs 汽车 km/年款) | boundary_filter.py / conversation.py 内嵌 |
| 安全墙词表 | 价格墙正则组(多语言)、迂回话术池(EN/ZH/ES)、零号码替换句、触发词正则(边界/预算/索要)、**轮中提醒话术**(边界/索要/语言跟随/已留资/CC 指引/邮箱轻补六类) | boundary_filter.py / conversation.py 内嵌 |
| 联系方式策略 | 区号补全开关、模板句语言集、轻补邮箱开关 | conversation.py 内嵌参数 |
| 邮件 | SMTP(可选"用平台默认")、发件人显示名、通知邮箱列表 | .env + email_service.py 硬编码 |
| 推送 | 启用开关、URL、API Key、**external_key 前缀(全局唯一约束)**、超时/重试 | .env |
@@ -91,7 +92,8 @@
```sql
tenants(id, slug, name, status, created_at)
tenant_domains(domain UNIQUE, tenant_id)
sites(id, tenant_id, slug, name, status, brand_overlay JSON) -- 站点门面覆盖层(缺省项继承租户 brand)
site_domains(domain UNIQUE, site_id) -- 域名→站点(站点→租户随外键)
tenant_config_versions(tenant_id, version, config_json, updated_by, comment, created_at)
-- 当前生效版 = MAX(version);每次保存存整份快照(几十 KB,不做增量)
admin_users(id, tenant_id NULL=超管, username, password_hash, status)
@@ -120,12 +122,13 @@ platform_settings(key, value) -- 平台级默认:集团 AI 凭据、默认 S
- **话术单一事实源(R1)**:迂回话术池只存于墙配置;ai_service 组装 system prompt 时以模板变量(`$DEFLECTION_POOL` 等)注入提示词。改一处,提示词与墙同时变——消除"提示词教的说法"与"墙的执法标准"分叉。
- **配置演进(E4)**:Registry 加载 = 快照深合并出厂默认值,引擎升级新增字段不炸旧租户。
- **external_key 前缀全局唯一(E1)**:数据库约束 + 保存校验,防 CRM 线索空间串号。
- **tenant slug 创建后不可变**:客户端存储 key 全部以 slug 命名,改动 slug = 重置所有客户页面的记忆(邀请卡状态、会话令牌)。
- **tenant slug 创建后不可变**:客户端存储 key 全部以 slug 命名,改动 slug = 重置所有客户页面的记忆(邀请卡状态、会话令牌)。site slug 同理不可变。
- **站点只覆盖门面**:brand 跟站点走;AI 脑子(提示词/词表/行为开关/AI 凭据)与线索管道(邮件/推送)留租户级——一个租户 = 一个客户经理团队收所有站点的线索,脑子分叉就乱。需要"两套完全不同的 AI"时应建两个租户而非两个站点。会话记录带站点标(后台可按官网筛线索、看各站转化)。
- **出厂档案**:`seed_profiles/auto/`、`seed_profiles/jiancai/`(提示词 md + 词表 json + 品牌 json),由提取脚本从两个老项目生成;新租户创建时选出厂档案或空白。两个概念分清:**重置出厂** = 代码库当前模板(随平台升级演进);**租户创建** = 当时选定档案的快照(不随升级变化)。
### 5.7 内容与文件上限
- 头像等上传文件存**挂载卷** `data/uploads/`:服务端 uuid 重命名(防路径穿越)+ 类型校验 + 大小 ≤2MB,经接口动态读取——绝不写入镜像静态目录(容器重建即丢,同类教训:改 static 必须 build)。
- 头像等上传文件存**挂载卷** `data/uploads/`:服务端 uuid 重命名(防路径穿越)+ 类型校验 + 大小 ≤2MB,经接口动态读取——绝不写入镜像静态目录(容器重建即丢,同类教训:改 static 必须 build)。头像按 (租户[, 站点]) 维度解析:站点覆盖 > 租户默认 > 平台兜底图。
- 提示词/知识库保存时校验大小:知识库软上限 50KB,超限拒收并提示精炼——company_facts 每轮整段拼入 system prompt,过大会线性放大每轮 LLM 成本与时延,租户自己无感。
## 6. 引擎改造与代码合并
@@ -184,7 +187,7 @@ class TenantRegistry:
### 6.3 前端
1. widget.js:启动时 `fetch /api/public/brand`(Host 已识别租户,免鉴权返回品牌 JSON,**响应携带 tenant slug**)→ 与 data-* 合并(data-* 优先);失败时 data-*/中性默认兜底;no-cache。
1. widget.js:启动时 `fetch /api/public/brand`(Host 已识别租户,免鉴权返回品牌 JSON,**响应携带 tenant slug**;script 标签有 `data-site` 时带 `?site=` 选站点)→ 与 data-* 合并(data-* 优先);失败时 data-*/中性默认兜底;no-cache。
2. 客户端 key 全部租户化(`<tenant>` 取 brand 响应里的 tenant slug):`cc-widget-host-<tenant>`、`widget_invite_dismissed_<tenant>_<path>`、chat 页 localStorage 会话 key 同理——同页双挂件根治。
3. widget.js 与 chat.html 保持零构建原生 JS(嵌入官网资产必须轻量零依赖);主题派生单元机制不变,默认值来自品牌配置。
4. WS 通道补限流:现有限流只覆盖 REST 消息接口;平台化后单 IP 可对所有租户域名各开一圈 WS 耗尽单进程资源——加每 IP 并发 WS 连接上限(如 10,内存计数,超限拒绝)。
@@ -239,6 +242,7 @@ all-kefu/
| 租户管理 | 创建向导(名称→域名→选出厂档案 auto/jiancai/空白→生成租户管理员)、停用/启用、域名绑定 | ✗ |
| 会话与线索 | 顶部租户切换器,看所有租户会话/画像/推送日志 | 只看自己的 |
| 配置中心 | 编辑任何租户全部配置组 | 编辑自己全部配置组(全开) |
| 站点管理 | 租户下站点增删/停用、域名绑定、门面编辑(覆盖层+继承项提示+嵌入代码含 data-site) | 同左(限自己租户) |
| 版本历史 | 快照列表、两版对比、一键回滚(回滚生成新版本) | 同 |
| 平台配置 | 集团默认 AI 凭据、默认 SMTP、超管改密 | ✗ |
@@ -253,7 +257,7 @@ all-kefu/
后台 SPA 走 `/api/platform/*`(新增)与租户化 admin 数据端点;聊天引擎端点(`/ws/chat`、`/api/chat/*`、`/api/public/brand`)契约不变——widget 嵌入方完全无感。
`/api/public/brand` 用**显式字段白名单**序列化(只吐品牌字段,绝不序列化含密钥的整个配置 JSON);该端点被客户官网跨域 fetch,响应需带 `Access-Control-Allow-Origin: *`(与 widget.js 现行 CORS 策略一致)。
`/api/public/brand` 用**显式字段白名单**序列化(只吐品牌字段,绝不序列化含密钥的整个配置 JSON);支持 `?site=<slug>` 返回"站点覆盖层 ⊕ 租户默认门面"的合并结果,未带或未知 site 回退租户默认;该端点被客户官网跨域 fetch,响应需带 `Access-Control-Allow-Origin: *`(与 widget.js 现行 CORS 策略一致)。
## 8. 数据迁移