240c6bd0e2
命名统一为 .zh-CN.md 后缀(与既有 17 个文件 + README 一致):
- 03-impl/{pr1-8,STATUS}.md → *.zh-CN.md
- Stage 1 spec 去日期前缀、加 .zh-CN,对齐 01-redesign 语义命名
README.zh-CN.md 修复 4 处不统一:
- 顶部加进度指引(现状只信 STATUS,本文是设计/路线导航)
- §0 文档总图补 03-impl 层 + Stage 1 spec + 命名约定注
- §1 表加 Stage 1 spec 行;新增 §1.1 执行记录层(STATUS + 8 impl note 索引)
- §7 阅读路径首次进项目/Stage 1 均加 STATUS + spec 入口
同步更新所有交叉链接(STATUS/pr/spec 自引用、database-schema-as-built、
根 README_zh.md、Stage 0 master plan);全树相对链接校验可达。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.7 KiB
7.7 KiB
PR8 · service_accounts + api_keys + external_users schema only
实现笔记。对应 docs/superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md PR8(T8.1-T8.6)。
状态:已落地,5 个 commits 提交到
docs/multi-tenant-redesign(PR8 起始1fb07e48..结束f803f393)。
范围
为 Stage 1 headless API 鉴权层准备底座:3 张新表 + ORM。仿 PR3 模式——纯 schema、不接路由、不写仓储、不暴露 API。
service_accounts— 非人身份,属于唯一 workspace。3 状态字段:role(Stage 0 仅member)/identity_mode(collapsed/external_passthrough/both,决定是否记录终端用户身份)/status(active/suspended/deleted)。workspace_idFK CASCADE,created_byFK 用户 RESTRICT(防止误删带 SA 的 user)。api_keys— service_account 的凭证。key_prefixString(16) 全局 unique(撤销后亦不复用,避免审计混淆),key_hashString(128) 存 sha-256 hex(plaintext 仅创建时返)。scopesString(1024) 逗号分隔(不用 PGtext[]以保 SQLite dev 双驱动兼容;Stage 2 切纯 PG 可平滑迁)。revoked_at/expires_at/last_used_at/rate_limit_rpm全 nullable。双驱动部分索引idx_api_keys_active(key_prefix)WHERE revoked_at IS NULL——同时声明sqlite_where+postgresql_where,鉴权热路径 prefix lookup 加速。service_account_idFK CASCADE。external_users— passthrough 模式下的终端身份。每次调用带X-External-User-Idheader 时 upsert 一行(Stage 1 起)。复合 UNIQUE(service_account_id, external_id)——同 external_id 可在不同 SA 下复用,但单 SA 下唯一。workspace_id冗余存储(可经 SA 间接得到,但直接存以加速 workspace-scoped 跨 SA 聚合)。metadata_jsonJSON nullable=False default {} 存 plan tier / region / 自定义 tag。两个 FK 均 CASCADE。- 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_permissionscope 升级(接service_account/api_key主体)- Pattern A/B endpoint 设计
external_usersupsert 逻辑- 鉴权层的 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 张表 |
验收
- 3 + 3 + 2 + 1 = 9 个新单测全过(T8.1/T8.2/T8.3 三个 service_account;T8.4 三个 api_key;T8.5 两个 external_user;T8.6 一个 metadata registration)
- 3 张表
Base.metadata.create_all()自动建:T8.6 inspect 表名集合断言{service_accounts, api_keys, external_users}.issubset(tables) - FK 行为按 plan 设计:CASCADE on workspace/SA delete、RESTRICT on creator user delete、SQLite + PG 均生效(SQLite 通过 engine.py connect-listener 的
PRAGMA foreign_keys=ON) - 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.zh-CN.md— 本文件
修改:
backend/packages/harness/deerflow/persistence/models/__init__.py— 加 3 行 import +__all__注册
关键设计决策
key_prefix全局 UNIQUE,而非"活跃 UNIQUE":column-levelunique=True覆盖整个 key 生命周期。理由:撤销 + 复用同前缀会让审计日志里 "prefix X did Y" 的语义模糊;prefix 16 字符的命名空间足够大(≈10^25)从不复用没有成本。idx_api_keys_active走部分非唯一索引——纯粹是热路径优化,撤销 key 不进活跃索引以减小热索引大小。scopes用String(1024)而非 PGtext[]:Stage 0 仍要 SQLite 跑得动(dev / unit test 兜底)。逗号分隔字符串两端通用;Stage 2 切纯 PG 后再迁text[]+ GIN 索引代价低。LOCK 由 plan 记下。external_users.workspace_id冗余存储:技术上可从service_account_idJOIN 出来,但 Stage 1 几个高频查询(workspace 级配额聚合 / admin UI 列出 workspace 所有 external user)每次走 JOIN 会随 SA 数量增长变慢。冗余一列、CASCADE 同 SA 一致,是值得的存储成本。- 不写 Repository 类:Stage 0 PR3 / PR5-6 的 Repository 是给 Gateway 当前在用的表准备的。PR8 三张表 Stage 0 内没人读写——直到 Stage 1 headless API 才用得上。写空 Repository 现在不知道接口形态,等 Stage 1 真用时连同 token 生成 / 哈希校验一起设计更合理。Plan 也明确"仅暴露 ORM"。
- T8.6 反向 metadata registration 测试:Stage 0 已经踩过坑——PR1-PR6 多次出现"模型类写了但
persistence/models/__init__.py漏 import →create_all()不建表 → 上线后 SELECT 时炸 'no such table'"。T8.6 把这条 invariant 锁定。 identity_mode三态保持字符串而非 enum:和role/status同款思路——String(16) 比 enum 更易加值(Stage 2 可能加cli_only等新态),不动 schema。
Live smoke 命令(用户跟进)
PR8 纯 schema 改造,无路由 / 中间件 / 文件系统副作用。
单机自检:
make stop && make dev # 起服务,让 lifespan 跑 init_engine
# 看 Gateway 启动日志无报错;create_all 默认 silent,无需额外断言
RDS 实跑表存在(需要密码):
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 真生效(可选):
# 用 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.zh-CN.md"用户必须跟进的事":
- RDS 上
service_accounts/api_keys/external_users三张表\dt见 make migrate-paths --dry-run在 fresh DB 上输出空- testcontainers ephemeral PG smoke 跑过一次
- 生产稳定运行 ≥ 2 周(业务条件)