Files
ZY-Agent/docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md
T
1445043649 6090b9e5b1 docs(impl): PR8 implementation note + STATUS update (Stage 0 收尾)
Adds `pr8-headless-api-schema.md`:
- Scope summary (3 schema-only tables for Stage 1 headless API base)
- Per-task commit table (T8.1-T8.6)
- Acceptance: 9 new tests + 3 tables auto-created + FK behaviour + partial index DDL pinned
- Architecture decisions (key_prefix global UNIQUE rationale, String scopes not PG text[], external_users workspace_id redundancy, no Repository class until Stage 1, T8.6 reverse invariant)
- File structure index (new vs modified)
- Live smoke commands (RDS \dt + \d+ api_keys for partial index)

Updates STATUS.md:
- One-line status: Stage 0 工程层面收尾 (PR1-PR8 全合)
- 8-PR status table: PR8 row marked merged with commit range and impl note link
- Test baseline: PR8 末 3250 passed + 31 skipped + 18 flake; +163 new tests over Stage 0
- Skipped/deferred 行 加 PR8 RDS live smoke 项
- Next-step suggestion 翻新:6 个 live verification 用户跟进项 + Stage 0 退出 Go/No-Go 工程门已满足 + Stage 1 可启动方向
- PR8 经验回顾段:Inline + 严格 TDD,3 张表互相独立,T8.6 反向 invariant 锁定历史坑

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:18:18 +08:00

97 lines
7.7 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.
# PR8 · `service_accounts` + `api_keys` + `external_users` schema only
> 实现笔记。对应 [docs/superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md](../../superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md) PR8T8.1-T8.6)。
>
> 状态:**已落地**5 个 commits 提交到 `docs/multi-tenant-redesign`PR8 起始 `1fb07e48..` 结束 `f803f393`)。
## 范围
为 Stage 1 headless API 鉴权层准备底座:3 张新表 + ORM。仿 PR3 模式——纯 schema、不接路由、不写仓储、不暴露 API。
1. **`service_accounts`** — 非人身份,属于唯一 workspace。3 状态字段:`role`Stage 0 仅 `member`/ `identity_mode``collapsed` / `external_passthrough` / `both`,决定是否记录终端用户身份)/ `status``active` / `suspended` / `deleted`)。`workspace_id` FK CASCADE`created_by` FK 用户 **RESTRICT**(防止误删带 SA 的 user)。
2. **`api_keys`** — service_account 的凭证。`key_prefix` String(16) **全局 unique**(撤销后亦不复用,避免审计混淆),`key_hash` String(128) 存 sha-256 hexplaintext 仅创建时返)。`scopes` String(1024) 逗号分隔(不用 PG `text[]` 以保 SQLite dev 双驱动兼容;Stage 2 切纯 PG 可平滑迁)。`revoked_at`/`expires_at`/`last_used_at`/`rate_limit_rpm` 全 nullable。**双驱动部分索引** `idx_api_keys_active`key_prefixWHERE revoked_at IS NULL——同时声明 `sqlite_where` + `postgresql_where`,鉴权热路径 prefix lookup 加速。`service_account_id` FK CASCADE。
3. **`external_users`** — passthrough 模式下的终端身份。每次调用带 `X-External-User-Id` header 时 upsert 一行(Stage 1 起)。**复合 UNIQUE** `(service_account_id, external_id)`——同 external_id 可在不同 SA 下复用,但单 SA 下唯一。`workspace_id` 冗余存储(可经 SA 间接得到,但直接存以加速 workspace-scoped 跨 SA 聚合)。`metadata_json` JSON nullable=False default {} 存 plan tier / region / 自定义 tag。两个 FK 均 CASCADE。
4. **ORM 注册**`deerflow/persistence/models/__init__.py` 加 3 行 import 让 `Base.metadata.create_all()``init_engine` 启动时自动建 3 张表。`test_pr8_metadata_registration.py` 反向验证:拉一个 fresh SQLite 引擎 inspect 表名集合,断言 3 张表都在。
**不在范围**Stage 1):
- API key 鉴权中间件 / token 生成 / hash 验证
- `@require_permission` scope 升级(接 `service_account`/`api_key` 主体)
- Pattern A/B endpoint 设计
- `external_users` upsert 逻辑
- 鉴权层的 rate limiting / scopes 校验
## Tasks 完成清单
| Task | Commit | 关键改动 |
|---|---|---|
| **T8.1** | `1fb07e48` | `service_account/{__init__, model}.py` + insert smoke + 注册进 persistence.models |
| **T8.2 + T8.3** | `bb728978` | `test_cascade_on_workspace_delete` + `test_restrict_on_created_by_user_delete` |
| **T8.4** | `52e9999a` | `api_key/{__init__, model}.py` + 3 测试(column UNIQUE + 双驱动 partial index DDL + CASCADE |
| **T8.5** | `6f806ff4` | `external_user/{__init__, model}.py` + 2 测试(复合 UNIQUE + CASCADE |
| **T8.6** | `f803f393` | `test_pr8_metadata_registration.py` 反向验证 `Base.metadata.create_all()` 真的建 3 张表 |
## 验收
- [x] **3 + 3 + 2 + 1 = 9 个新单测全过**T8.1/T8.2/T8.3 三个 service_accountT8.4 三个 api_keyT8.5 两个 external_userT8.6 一个 metadata registration
- [x] **3 张表 `Base.metadata.create_all()` 自动建**T8.6 inspect 表名集合断言 `{service_accounts, api_keys, external_users}.issubset(tables)`
- [x] **FK 行为按 plan 设计**CASCADE on workspace/SA delete、RESTRICT on creator user delete、SQLite + PG 均生效(SQLite 通过 engine.py connect-listener 的 `PRAGMA foreign_keys=ON`
- [x] **partial index 双驱动 DDL** 通过 `dialect_options` 检查锁定(不只看 SQLAlchemy emit,下次有人删 `postgresql_where` 测试会红)
## 文件结构
**新增**
- `backend/packages/harness/deerflow/persistence/service_account/{__init__.py, model.py}`
- `backend/packages/harness/deerflow/persistence/api_key/{__init__.py, model.py}`
- `backend/packages/harness/deerflow/persistence/external_user/{__init__.py, model.py}`
- `backend/tests/test_service_account_schema.py`3 cases
- `backend/tests/test_api_key_schema.py`3 cases
- `backend/tests/test_external_user_schema.py`2 cases
- `backend/tests/test_pr8_metadata_registration.py`1 case
- `docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md` — 本文件
**修改**
- `backend/packages/harness/deerflow/persistence/models/__init__.py` — 加 3 行 import + `__all__` 注册
## 关键设计决策
1. **`key_prefix` 全局 UNIQUE,而非"活跃 UNIQUE"**column-level `unique=True` 覆盖整个 key 生命周期。理由:撤销 + 复用同前缀会让审计日志里 "prefix X did Y" 的语义模糊;prefix 16 字符的命名空间足够大(≈10^25)从不复用没有成本。`idx_api_keys_active` 走部分非唯一索引——纯粹是热路径优化,撤销 key 不进活跃索引以减小热索引大小。
2. **`scopes``String(1024)` 而非 PG `text[]`**Stage 0 仍要 SQLite 跑得动(dev / unit test 兜底)。逗号分隔字符串两端通用;Stage 2 切纯 PG 后再迁 `text[]` + GIN 索引代价低。LOCK 由 plan 记下。
3. **`external_users.workspace_id` 冗余存储**:技术上可从 `service_account_id` JOIN 出来,但 Stage 1 几个高频查询(workspace 级配额聚合 / admin UI 列出 workspace 所有 external user)每次走 JOIN 会随 SA 数量增长变慢。冗余一列、CASCADE 同 SA 一致,是值得的存储成本。
4. **不写 Repository 类**Stage 0 PR3 / PR5-6 的 Repository 是给 Gateway 当前在用的表准备的。PR8 三张表 Stage 0 内**没人读写**——直到 Stage 1 headless API 才用得上。写空 Repository 现在不知道接口形态,等 Stage 1 真用时连同 token 生成 / 哈希校验一起设计更合理。Plan 也明确"仅暴露 ORM"。
5. **T8.6 反向 metadata registration 测试**Stage 0 已经踩过坑——PR1-PR6 多次出现"模型类写了但 `persistence/models/__init__.py` 漏 import → `create_all()` 不建表 → 上线后 SELECT 时炸 'no such table'"。T8.6 把这条 invariant 锁定。
6. **`identity_mode` 三态保持字符串而非 enum**:和 `role` / `status` 同款思路——String(16) 比 enum 更易加值(Stage 2 可能加 `cli_only` 等新态),不动 schema。
## Live smoke 命令(用户跟进)
PR8 纯 schema 改造,无路由 / 中间件 / 文件系统副作用。
**单机自检**
```bash
make stop && make dev # 起服务,让 lifespan 跑 init_engine
# 看 Gateway 启动日志无报错;create_all 默认 silent,无需额外断言
```
**RDS 实跑表存在**(需要密码):
```bash
psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users"
# 期望:3 行
psql "$DATABASE_URL" -c "\d+ api_keys"
# 期望看到 idx_api_keys_active (key_prefix) WHERE revoked_at IS NULL
```
**双驱动 partial index 在 PG 真生效**(可选):
```bash
# 用 testcontainers 跑 @pytest.mark.postgres 系列;当前 PR8 没写 PG 专属测试,
# 但 idx_api_keys_active 的 DDL 已在 dialect_options 里覆盖,PG schema dump
# 应见 "WHERE revoked_at IS NULL"
PYTHONPATH=. uv run pytest -m postgres -v
```
## Stage 0 退出门
PR8 是 Stage 0 工程层面最后一个 PR。剩余 Stage 0 退出条件见 [STATUS.md](./STATUS.md)"用户必须跟进的事"
- [ ] RDS 上 `service_accounts` / `api_keys` / `external_users` 三张表 `\dt`
- [ ] `make migrate-paths --dry-run` 在 fresh DB 上输出空
- [ ] testcontainers ephemeral PG smoke 跑过一次
- [ ] 生产稳定运行 ≥ 2 周(业务条件)