Files
ai-kefu-admin/docs/superpowers/plans/2026-10-13-phase2-platform-layer.md
T

293 lines
22 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.
# Phase 2:平台层(platform.db/认证/配置版本/试金石/统计)实施计划
> **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:** 在 Phase 1 的多租户引擎之上建平台层:platform.db(租户/站点/域名/配置版本/账号)、超管+租户管理员认证、配置保存-校验-版本-回滚-试金石 API、数据视图与统计 API;Registry 数据源抽象(文件源保测试、DB 源上生产)。
**Architecture:** 平台层拥有独立的 platform.db(单库,WAL);`TenantSource` 协议隔开"租户数据从哪来"——`FileTenantSource`(seed_profiles,测试与出厂档案)与 `DbTenantSource`(platform.db,生产);admin API 挂 `/api/platform/*`(Host 中间件豁免,认证鉴权守护);配置保存 = 整份快照新版本(校验管线前置)→ registry 热加载。
**Tech Stack:** 同 Phase 1(FastAPI/SQLAlchemy async/aiosqlite/pytest);认证零新依赖(hashlib.scrypt + 内存 token)。
**参照 spec:** §5(配置模型/存储/校验试金石)、§7.2-7.3(认证/页面后端)、§7.6(统计)。Phase 1 遗产:CLAUDE.md 清理单 12 项(本计划顺势处理 #1/#2/#3)、conftest 跨循环纪律、build_app/app_client/TenantTestClient 夹具。
**关键既有资产(不要重建):** `app/platform/registry.py`(TenantRegistry/resolve_site/reload/dispose_all)、`wall_schema.compile_wall`(校验已含键 allowlist/占位符白名单/flags)、`PromptProvider`、`config.py` 的 ProcessSettings(ADMIN_PASSWORD 保留即为本阶段超管引导)、`seed_profiles/`(出厂档案=创建租户的模板源)。
---
### Task 1: platform.db 模型与引导
**Files:**
- Create: `app/platform/db.py`、`tests/test_platform_db.py`
- Modify: `app/main.py`(lifespan 初始化 platform engine + bootstrap)
- [ ] **Step 1: 失败测试**
```python
# tests/test_platform_db.py 核心
async def test_bootstrap_creates_superuser_once(tmp_path):
engine = make_platform_engine(tmp_path / "platform.db")
await ensure_platform_schema(engine)
await bootstrap_superuser(engine, username="admin", password="s3cret!")
await bootstrap_superuser(engine, username="admin", password="other") # 幂等:不重复建/不改密
users = await list_admin_users(engine)
assert len(users) == 1 and users[0].username == "admin"
assert verify_password("s3cret!", users[0]) and not verify_password("other", users[0])
async def test_platform_db_wal(tmp_path):
engine = make_platform_engine(tmp_path / "platform.db")
await ensure_platform_schema(engine)
# PRAGMA journal_mode == wal(照抄 test_registry 的查法)
```
- [ ] **Step 2: 实现 `app/platform/db.py`**
```python
# 模型(PlatformBase 独立 MetaData,与租户库 Base 无关)
class Tenant(id, slug UNIQUE, name, status, created_at)
class Site(id, tenant_id FK, slug, name, status, brand_overlay JSON NULL) # UNIQUE(tenant_id, slug)
# brand_overlay = 站点门面覆盖层(spec §5.2;DbTenantSource 映射进站点 brand 合并链)
class SiteDomain(id, domain UNIQUE, site_id FK)
class TenantConfigVersion(id, tenant_id FK, version, config_json TEXT, updated_by, comment, created_at)
# 当前生效 = MAX(version);UNIQUE(tenant_id, version)
class AdminUser(id, tenant_id FK NULL=超管, username UNIQUE, password_hash, salt,
must_change_password BOOL, status, created_at)
class PlatformSetting(key PK, value)
def make_platform_engine(db_path) -> AsyncEngine: # WAL + pool 2,照抄 make_tenant_engine 纪律
async def ensure_platform_schema(engine): ...
async def bootstrap_superuser(engine, username, password): ... # 空表才建;must_change_password=True
# hash: hashlib.scrypt(password, salt=salt, n=2**14, r=8, p=1);verify 常量时间比较(hmac.compare_digest)
```
- [ ] **Step 3: main.py 装配**:lifespan 建 platform engine(`Path(__file__).parents[1]/"platform.db"`,.gitignore 已覆盖)挂 `app.state.platform_engine`;bootstrap 超管(env `ADMIN_USERNAME` 默认 admin / `ADMIN_PASSWORD` 默认 admin123,`must_change_password=True`);退出 dispose。测试缝:`app.state.platform_engine` 预置则沿用(照抄 registry 缝模式)。
- [ ] **Step 4: 全量回归 + 提交** `feat: platform.db 模型与超管引导(WAL/scrypt/幂等bootstrap)`
### Task 2: 认证(登录/令牌/限流/改密)
**Files:**
- Create: `app/platform/auth.py`、`app/platform/routes/__init__.py`、`app/platform/routes/auth_routes.py`、`tests/test_auth.py`
- Modify: `app/main.py`(挂 auth_routes——**本任务就挂**,jiancai Host 下 /api/platform/auth/login 可经中间件直达路由,TDD 循环才能闭环;Task 3 只管豁免与守护)
- [ ] **Step 1: 失败测试**(核心 8 项)
```python
def test_login_ok_returns_token(app_client): # admin + 正确密码 → {"token", "role":"super", "must_change_password":true}
def test_login_bad_password_401(app_client)
def test_login_lockout_after_5_fails(app_client): # 第 6 次 429/423,15 分钟窗(测试里可注入窗长)
def test_token_expiry(app_client, freezer_or_short_ttl) # 过期 token → 401
def test_protected_requires_auth(app_client): # 无 Authorization → 401
def test_change_password_flow(app_client): # 旧密改新密成功;旧密失效;must_change 清零
def test_tenant_admin_role_scoped(app_client): # 依赖工厂直测(require_super 拿租户管理员 UserContext → HTTPException 403);端点级覆盖在 Task 5
def test_logout_invalidates_token(app_client)
```
- [ ] **Step 2: 实现**
- `auth.py`:`hash_password/verify_password`(Task 1 已有则上移共用);`TokenStore`(内存 dict:token(secrets.token_urlsafe 32B)→{user_id, role, tenant_slug, expires_at};TTL 默认 8h;prune 过期;logout 删);`LoginLimiter`((username, ip) 键滑动窗 5 次/15 分钟,照抄 rate_limit 风格)
- `auth_routes.py`:`POST /api/platform/auth/login`(限流→验密→发 token);`POST /api/platform/auth/logout`;`POST /api/platform/auth/change-password`(验旧密,must_change 清零)
- 依赖项:`get_current_user(request) -> UserContext`(Authorization: Bearer 解析→TokenStore 查验→role/tenant);`require_super`/`require_tenant(slug)` 两个依赖工厂(403 语义)
- **must_change_password 强制语义**:UserContext 带该标志;除 `/auth/change-password` 外的所有 `/api/platform/*` 受保护端点在标志为真时返回 409(超管引导密码不得只当摆设)
- 密码永不明文进日志;token 走 Authorization 头(无 Cookie,免 CSRF——spec §7.2)
- 偏差注记:spec §7.2 写 bcrypt/argon2,本实现用 **hashlib.scrypt**(stdlib 零新依赖,抗 GPU 性能同级)——记录为有意取舍
- [ ] **Step 3: 全量回归 + 提交** `feat: 平台认证(scrypt/内存token 8h/登录限流/改密/角色依赖)`
### Task 3: 中间件豁免与 admin 挂载骨架
**Files:**
- Modify: `app/platform/host_middleware.py`(豁免 `/api/platform/`)
- Test: `tests/test_admin_mounting.py`
- [ ] **Step 1: 失败测试**
```python
def test_platform_api_bypasses_tenant_resolution(app_client):
# 未知 Host 下 POST /api/platform/auth/login 可达(不被中间件 404)
def test_platform_prefix_not_open(app_client):
# 无 token 打已存在的受保护 auth 端点(POST /api/platform/auth/logout)→ 401(豁免≠放行,认证守护)
def test_prefix_match_precise(app_client):
# /api/platform-evil/ 仍走租户解析(未知 Host → 404)——豁免必须 startswith("/api/platform/") 精确
```
- [ ] **Step 2: 实现**:中间件豁免规则 `path == "/health" or path.startswith("/api/platform/")`;顺序守护测试补一条(豁免后租户中间件仍最外层)。**全量回归确认既有 463 零破坏**。
- [ ] **Step 3: 提交** `feat: /api/platform 豁免租户解析(认证守护)+ admin 路由挂载`
### Task 4: TenantSource 抽象与 DB 源
**Files:**
- Create: `app/platform/tenant_source.py`、`tests/test_db_tenant_source.py`
- Modify: `app/platform/registry.py`(构造收 source;`load_from_dir` 变 File 便捷构造【既有调用零破坏】)、`app/engine/services/ai_service.py`(PromptProvider 加 `from_config(md: dict)` 类方法)
- [ ] **Step 1: 失败测试**
```python
async def test_db_source_loads_tenant(platform_db_with_tenant): # 夹具:建租户+站点+域名+版本1 config_json
src = DbTenantSource(platform_engine)
loaded = await src.load_all()
rt_cfg = loaded[0] # slug/status/sites(含域名)/snapshot(含 wall+prompts)
assert rt_cfg.snapshot["push"]["prefix"] == "ai-kefu-jc" # 快照组直读
assert "system" in rt_cfg.snapshot["prompts"]
async def test_db_source_no_version_raises(...) # 有租户无版本 → 显性错误
async def test_file_source_unchanged(...) # FileTenantSource 行为与旧 load_from_dir 一致(既有 test_registry 全绿即证)
async def test_registry_reload_via_db_source(...) # DB 改版本 → registry_reload → 新配置生效
```
- [ ] **Step 2: 实现**
- `tenant_source.py`:`LoadedTenant` dataclass(slug/name/status/sites raw/snapshot dict);`TenantSource` Protocol(`load_all() / reload(slug)`);`FileTenantSource(seed_dir)`(把 registry 现有 `_load_tenant` 逻辑搬入,**行为逐字等价**);`DbTenantSource(engine)`(JOIN tenants/sites/domains——**sites.brand_overlay 进站点覆盖层**——+ 当前版本 config_json;prompts 嵌在 config_json 的 `prompts` 键)
- **registry_reload 补 upsert 语义**:现行实现在 slug 未加载时抛 UnknownTenantError——改为"source 已知但未加载 → 载入并插入"(Task 5 创建租户后 reload 依赖此语义);补测试:新租户(未 resolve 过)创建后直接 reload 不炸且可 resolve
- registry:`__init__(source)`;`load_from_dir(seed_dir, data_dir)` = `TenantRegistry(FileTenantSource(seed_dir), data_dir)` 便捷函数(**既有 conftest/test 调用零改动**);reload 走 source
- `PromptProvider.from_config(md: dict)`(四件套字符串构造;`from_dir` 改为读文件后委托它);DbTenantSource 路径的 TenantRuntime.prompts 用 from_config(seed_dir 字段对 DB 源为 None——avatar 解析链 Task 7 更新)
- [ ] **Step 3: 全量回归(463+新增)+ 提交** `feat: TenantSource 抽象——File源(测试/档案)与DB源(生产),registry 数据源可切换`
### Task 5: 租户管理 API(超管)
**Files:**
- Create: `app/platform/routes/tenant_routes.py`、`tests/test_tenant_admin_api.py`
- [ ] **Step 1: 失败测试**(核心 6 项)
```python
def test_create_tenant_from_profile(super_client, file_source_app):
# POST /api/platform/tenants {name, slug:"demo", profile:"jiancai", admin_username, admin_password}
# → 201;platform.db 有 tenant+main 站点+版本1(config 内容=seed 档案);租户管理员可登录
def test_create_blank_tenant(...) # profile=blank → 出厂默认配置
def test_slug_immutable(...) # PATCH 改 slug → 400
def test_add_domain_and_resolve(...) # 加域名 demo.example.com → registry reload → resolve_site 命中(File/DB 源均可,测试用注入)
def test_disable_tenant_releases(...) # 停用 → resolve 抛 TenantDisabledError;engine 已 dispose
def test_tenant_admin_cannot_create_tenant(...) # 403
```
- [ ] **Step 2: 实现**(全部 require_super,除标注)
- `POST /api/platform/tenants`:profile ∈ {auto, jiancai, blank}——auto/jiancai 读 seed_profiles/<slug>/ 合并出首版快照(tenant.json 的 config + wall.json + prompts/*.md 嵌入);**blank = 其余组取 DEFAULT_CONFIG + wall/prompts 取 jiancai 出厂档案**(compile_wall 要求 wall 全字段在位、PromptProvider 要求四件套——DEFAULT_CONFIG["wall"]={} 会加载即炸;对齐既有先例 DEFAULT_WALL/DEFAULT_PROVIDER 均锚 jiancai 档案);slug 校验(^[a-z0-9-]+$、不可变、与现有租户不撞);同时建租户管理员账号(**must_change_password 置 True**——Task 2 的 409 语义对新租户管理员同样生效)
- `POST /api/platform/tenants/{slug}/sites`(站点 CRUD:建站/停用);`POST .../sites/{site}/domains`、`DELETE .../domains/{domain}`
- `PATCH /api/platform/tenants/{slug}`(name/status;status≠active 触发 registry 释放)
- 写操作后 `registry_reload(slug)`;域名表变更后重建域名索引(reload 内已做)
- GET 列表/详情(含版本号、域名、管理员账号概要)
- [ ] **Step 3: 提交** `feat: 租户管理API(创建向导消费出厂档案/域名/停用/slug不可变)`
### Task 6: 配置 API——校验管线/版本/回滚/试金石
**Files:**
- Create: `app/platform/config_validation.py`、`app/platform/routes/config_routes.py`、`tests/test_config_api.py`、`tests/test_touchstone.py`
- Modify: `app/engine/services/ai_service.py`(无改则不动)
- [ ] **Step 1: 失败测试**
```python
def test_put_group_bumps_version_and_hot_reloads(super_client, db_source_app):
# PUT /api/platform/tenants/{slug}/config/brand {"color":"#ff0000"} → 200 {version:2}
# → registry resolve → brand.color 已变;GET config 返回新值+version
def test_put_wall_bad_regex_400_with_field(super_client, ...):
# price_patterns 含 "([unclosed" → 400,错误信息含字段路径与 regex 字样;版本不涨
def test_prompts_size_cap(...) # company_facts > 50KB → 400
def test_prefix_uniqueness_across_tenants(...) # 把 auto 的 push.prefix 改成 ai-kefu-jc → 400(spec E1)
def test_rollback_restores(super_client, ...):
# 版本列表 → rollback v1 → 生效值回到 v1;产生新版本(历史不可变)
def test_touchstone_verdicts(super_client, ...):
# POST /api/platform/tenants/{slug}/wall/test {"sentences":["The total is €500...","预算5000美元","DN800 specs?"]}
# → 逐句 {action: blocked|pass|replaced, replacement?}——不落库不 bump 版本
def test_tenant_admin_scoped_config(...) # jiancai 管理员能改自己、403 改 auto(租户全开,spec §3)
```
- [ ] **Step 2: 实现校验管线 `config_validation.py`**
- `validate_group(group, payload, context) -> list[str]`(错误清单):类型检查(engine 组 int/bool;brand 字符串/字符串列表;contact 开关与 template_languages、misc 映射也纳入类型校验)、wall → `compile_wall`(复用全部既有校验)、prompts 大小上限(company_facts ≤50KB、其余 ≤100KB)、email notify_emails 格式、push/ai 数值范围、`context.other_prefixes` 查重
- `config_routes.py`:`GET /api/platform/tenants/{slug}/config`(当前 merged+version);`PUT .../config/{group}`(校验→整份快照写 MAX(version)+1【updated_by=当前用户, comment 可选】→ registry_reload);`POST .../config/{group}/reset`(**重置出厂**:拷出厂档案该组为新版本——auto/jiancai 档案取 seed_profiles;blank 租户的 wall/prompts 组取 jiancai 出厂档案,其余组取 DEFAULT_CONFIG;spec §5.4);`GET .../config/versions`(列表:版本/时间/作者/备注);`POST .../config/rollback` `{version}`(复制为新版本);`POST .../wall/test`(试金石:compile 请求体里的 wall【或当前+覆盖】→ 逐句调 boundary_filter 纯函数给判定)
- **平台设置 CRUD**(spec §7.3 平台配置页后端):`GET/PUT /api/platform/settings`(仅超管;PlatformSetting 表读写——集团默认 AI 凭据/默认 SMTP;**resolver 回退链接入 platform_settings 归 Phase 3**——本阶段先落存储与接口)
- 鉴权:超管任意租户;租户管理员限自己(require_tenant)
- [ ] **Step 3: 提交** `feat: 配置API(分组校验管线/整份版本/回滚/试金石端点/前缀唯一)`
### Task 7: 头像上传与解析链
**Files:**
- Create: `app/platform/routes/upload_routes.py`、`tests/test_avatar_upload.py`
- Modify: `app/engine/routes/public.py`(avatar 解析链:uploads → seed assets → static)
- [ ] **Step 1: 失败测试**
```python
def test_upload_and_serve(super_client, app_client):
# POST /api/platform/tenants/{slug}/brand/avatar (multipart png ≤2MB) → 200 {avatar:"<uuid>.png"}
# → GET /api/public/avatar(该租户 Host)返回新图字节;config 版本+1
def test_reject_bad_type_and_oversize(...) # .txt → 400;3MB → 413
def test_traversal_safe(...) # 文件名服务端 uuid 重命名;../ 不生效(解析链已有防线,测试钉住 uploads 段)
```
- [ ] **Step 2: 实现**:存 `data/uploads/<slug>/<uuid>.<ext>`(gitignore 已含 data/uploads/);类型白名单 png/jpg/jpeg/webp(魔数校验优先于扩展名);≤2MB;brand.avatar=文件名(经配置版本管线 Task 6——bump+reload);public.py avatar 解析链变:`data/uploads/<slug>/<name>` → `seed_profiles/<slug>/assets/<name>` → `static/kefu-small.png`
- [ ] **Step 3: 提交** `feat: 头像上传(uuid重命名/魔数校验/2MB)与三级解析链`
### Task 8: 数据视图 API(会话/消息/推送日志)
**Files:**
- Create: `app/platform/routes/data_routes.py`、`tests/test_data_views.py`
- [ ] **Step 1: 失败测试**
```python
def test_conversations_list_with_filters(super_client, seeded_app)
def test_conversation_detail_with_messages(super_client, ...)
def test_push_logs_payload_lazy(...) # 列表不含 request_payload 大字段
def test_tenant_scope_isolation(...) # jiancai 管理员 GET auto 的会话 → 403;自己的 → 200
```
- [ ] **Step 2: 实现**(查询逻辑从 jc 原 admin.py 移植查询语义——源文件在 `D:\refactor\ai-kefu-jc\app\api\routes\admin.py`,实施时可读;跑在 `runtime.session_factory()` 上;分页 page/page_size 上限;筛选:intent/site/日期区间;IP 归国沿用 ip_service)
- [ ] **Step 3: 提交** `feat: 数据视图API(会话/详情/推送日志/租户scope强制)`
### Task 9: 统计 API
**Files:**
- Create: `app/platform/routes/stats_routes.py`、`app/platform/stats.py`、`tests/test_stats.py`
- [ ] **Step 1: 失败测试**(小夹具造数→精确数断言)
```python
def test_cards_and_funnel(...): # 会话5→留资3→评估完成2→推送成功1
def test_daily_trend(...) # 两天的会话数按日分组
def test_distributions(...) # intent 三级占比 / source / site / country top
def test_push_health(...) # success/failed/skipped/duplicated 计数
def test_cross_tenant_super_view(...) # 超管无 tenant 参数=全部租户合并;有=单租户
```
- [ ] **Step 2: 实现**:`stats.py` 纯查询函数(输入 runtime/session,输出指标 dict);路由 `GET /api/platform/stats`(超管,可 ?tenant=)与 `GET /api/platform/tenants/{slug}/stats`(require_tenant);**逐库查询应用层合并,禁 ATTACH**(spec §7.6);日期边界用本地时区(容器 TZ=Asia/Shanghai 部署期生效,代码用 naive local 约定并注释)
- [ ] **Step 3: 提交** `feat: 统计API(卡片/漏斗/趋势/分布/推送健康,逐库聚合)`
### Task 10: 清理单顺势收拢(#1/#2/#3)
**Files:**
- Create: `app/platform/config_views.py`、`tests/test_config_views.py`
- Modify: 各 services 的 resolver import 改从 config_views(**re-export 保旧 import 路径不断**——或全量替换+旧名删;选全量替换,测试锚定)、`app/engine/services/conn_limiter.py` 与 `rate_limit.py` 的 0 语义注释与行为、`tests/conftest.py`(FakeAI 收敛)
- [ ] **Step 1: 失败测试**:config_views 单入口(一次 import 拿全 resolve_*);ws_conn_limit=0 与 rate_limit_per_hour=0 的语义统一测试(0=回退出厂默认,写进校验与文档)
- [ ] **Step 2: 实现**:`config_views.py` 聚合 resolve_email/push/ai/contact_flags/context_seed_map/name_to_region_extras(函数体从各服务搬入,服务层 import 之);0 语义统一(limiter 的 0 值回退默认);conftest 加 `FakeAI` 公共类(三份拷贝收敛,旧测试改 import)
- [ ] **Step 3: 全量回归 + 提交** `refactor: config_views 单入口 + 0语义统一 + FakeAI 收敛(清理单#1#2#3)`
### Task 11: 生产装配切换与 bootstrap 脚本
**Files:**
- Create: `scripts/bootstrap_platform.py`、`tests/test_bootstrap_script.py`
- Modify: `app/main.py`(TENANT_SOURCE 切换)
- [ ] **Step 1: 失败测试**:bootstrap 脚本幂等导入两租户(跑两遍第二遍零变化);db 源 boot 冒烟(bootstrap → DbTenantSource → registry resolve 两租户 → brand/墙/前缀就位)
- [ ] **Step 2: 实现**:脚本(init schema → bootstrap 超管 → 逐 seed 档案导入租户[已存在 slug 跳过]);main.py:env `TENANT_SOURCE`(db|file,**默认 db**;conftest 预置 file registry 的测试缝已有,不受影响——conftest 显式构造 File 源 app,不依赖 env 默认)
- [ ] **Step 3: 提交** `feat: 生产装配切换DbTenantSource + bootstrap脚本(超管/两租户幂等导入)`
### Task 12: Phase 2 收尾
- [ ] **Step 1: 全量回归(根+tests/ 双跑)**
- [ ] **Step 2: 零同步 IO 审计复查**(grep requests/urllib/time.sleep/open 于 app/——新增代码延续纪律;scrypt 在登录路径是 CPU 非阻塞 ~100ms 可接受,注释声明)
- [ ] **Step 3: 安全自查**:token 不进日志、密码不进日志、platform.db 进 gitignore 复核、登录限流在豁免路径上仍生效、上传目录不可执行
- [ ] **Step 4: CLAUDE.md/README 更新**(Phase 2 完成状态、Phase 3 清理单刷新、bootstrap 用法、admin API 速览、基线数字刷新 463→当前)
- [ ] **Step 5: 提交** `chore: Phase 2 收尾——平台层完成`
---
## 显式不做(Phase 2 范围外)
art-design-pro SPA 全部页面(Phase 3);嵌入代码复制块的前端展示(Phase 3,API 已含 brand);**resolver 回退链接入 platform_settings**(Phase 3 平台配置页联动;本阶段 PlatformSetting 表与 CRUD 已落);Dockerfile/部署/存量数据导入(Phase 4);/health 分级健康(Phase 4 批次,清理单在案);推送失败告警(二期)。
## 执行注意(继承 Phase 1 血泪)
conftest 跨循环三坑(async_db NullPool / TestClient portal / 禁 asyncio.run 直开租户 session_factory)继续遵守;所有测试路径 `Path(__file__)` 锚定;`PYTHONUTF8=1`;每任务提交带 Co-Authored-By。