mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
226 lines
15 KiB
Markdown
226 lines
15 KiB
Markdown
# 工作区类型模型:`personal` 是否还有存在的必要
|
|||
|
|
|
||
|
|
状态:**协作面准入已修复并合并(vela #916);「是否废弃 `personal` 类型」待产品决策**
|
||
|
|
2026-07-21,来自 `release/ws-team-beta` 的一次 dogfood 排查 + 对 vela `origin/feat/workspace-team` 的代码核对。
|
||
|
|
|
||
|
|
本文分三档:**已核实**(读了代码或线上探测,附出处)、**待决策**(没人拍过板)、**未核实**(没查到底的,别当结论用)。
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 一句话结论
|
||
|
|
|
||
|
|
B 内部对个人工作区的态度是矛盾的:**产品面允许它邀人和共享(实测邀请成功),鉴权层却拒绝它的一切协作调用。** 这一条无论如何都该对齐。
|
||
|
|
|
||
|
|
至于是否废弃 `personal` 类型:全仓核对后**不建议**。它承载着一个真实且难以替代的职责——**团队工作区因欠费被锁时,个人工作区是保证用户仍有落脚点的逃生舱**(`workspaces/core.ts:1198`)。
|
||
|
|
|
||
|
|
因此建议改为:**保留 `personal` 类型,但把「资源面 / 协作面准入」从类型判断中摘出去** —— 已由 vela #916 实施并合并。
|
||
|
|
|
||
|
|
**当前最需要拍板的一条**:个人工作区能邀人、能协作,却不能开团队订阅,那么邀进来的成员**席位从哪来**?详见「遗留矛盾」。
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 背景与边界
|
||
|
|
|
||
|
|
这个提案来自一次双客户端 dogfood:协作面的 presence 调用返回 `403 missing_principal`。
|
||
|
|
|
||
|
|
排查结论是**客户端**的问题——我们把一行「团队共享」记在了个人工作区上,又拿这个工作区 id 去请求协作接口。**B 拒绝它是正确的**,我们这侧已经修完(写入校验 + 读取校验 + 启动对账)。
|
||
|
|
|
||
|
|
所以本文**不是报障,也不需要 B 侧做任何紧急修复**。它是在排查过程中读 B 的代码时发现的一个结构性问题,提出来供讨论。
|
||
|
|
|
||
|
|
## 已核实
|
||
|
|
|
||
|
|
### 1. 只有两种类型,没有隐藏的第三种
|
||
|
|
|
||
|
|
`db/schema/public.hcl:541` —— `CHECK (type = ANY (ARRAY['personal','team']))`。也没有 `is_default` 字段。
|
||
|
|
|
||
|
|
### 2. 个人工作区在**注册时**创建,升级订阅**不创建**任何工作区
|
||
|
|
|
||
|
|
`ensurePersonalWorkspace(appUserId)`(`services/api/src/workspaces/postgres.ts:236`)幂等地创建:工作区行、owner 成员行、以及一条 `active_workspace_selections`。
|
||
|
|
|
||
|
|
`services/api/src/billing/**` 下**没有**任何地方调用 `createWorkspace` 或 `ensurePersonalWorkspace`。升级到团队版的用户必须手动建团队工作区,否则他没有任何可协作的空间。
|
||
|
|
|
||
|
|
### 3. 结构上,个人工作区 = 只有一个成员的团队工作区
|
||
|
|
|
||
|
|
两者都是 `workspaces` 行 + `workspace_members` 行(`upsertPersonalOwnerMembership`),同两张表、同样的成员模型。**唯一区别是 `type` 这一列本身**,以及基于它分支的代码。
|
||
|
|
|
||
|
|
### 4. B 内部对个人工作区的态度自相矛盾
|
||
|
|
|
||
|
|
- **产品面**:vela web 上,个人工作区有邀请和共享按钮,**实测邀请成功**(2026-07-21,产品负责人手动验证)。
|
||
|
|
- **鉴权层**:`services/api/src/resources/auth.ts` 约 :205 ——
|
||
|
|
`if (context.workspaceType !== "team") return 403 missing_principal`,
|
||
|
|
理由是 B 没有独立 team id,团队工作区的 id 就是团队身份。
|
||
|
|
|
||
|
|
**同一个空间,能把人邀进来,进来后却不能协作。** 这不是取舍,是两层没对齐。
|
||
|
|
|
||
|
|
### 5. `type` 的完整波及面:全仓 18 处判断(已逐条核对)
|
||
|
|
|
||
|
|
全仓非测试代码中基于工作区类型的判断共 18 处,分布如下:
|
||
|
|
|
||
|
|
| 分类 | 处数 | 出处 | 说明 |
|
||
|
|
| --- | --- | --- | --- |
|
||
|
|
| **成员额度体系** | 6 | `core.ts:2195/2260/2318/2382/2707/3429` | 额度可见性、发放、成员充值、运行时结算 —— 全部 team-only |
|
||
|
|
| **成员目录** | 1 | `core.ts:1471` | "member directory requires a Team Workspace" |
|
||
|
|
| **协作/资源面准入** | 1 | `resources/auth.ts:205` | 就是本文要摘掉的那一处 |
|
||
|
|
| **欠费锁定逃生** | 1 | `core.ts:1198` | 见下面第 6 节,**这是核心职责** |
|
||
|
|
| **生命周期推导** | 2 | `core.ts:3643/3658` | 个人工作区不按订阅状态推导生命周期 |
|
||
|
|
| **自动充值付款方** | 3 | `core.ts:3303/2957/2543` | 在个人工作区里与通用路径结果相同(见下) |
|
||
|
|
| **保护规则** | 2 | `core.ts:1327/1408` | 不可删、owner 不可移除 |
|
||
|
|
| **计费兜底默认** | 1 | `core.ts:3602` | 仅在**无计费适配器**时生效,给出 `seatLimit: 1` |
|
||
|
|
| **前端展示** | 2 | `apps/web/.../workspace-runtime-context.tsx:217`、`routes/billing-records.tsx:83` | UI 分支 |
|
||
|
|
|
||
|
|
关于自动充值那三处:通用路径找的是 `role==='owner' && status==='active'` 的成员(`:3318`),而个人工作区里那就是用户自己,所以 `personal_owner`(`payer: null`)和 `workspace_sponsored`(payer = owner)**指向同一个人**;禁止赞助和排除结算两条,则因为个人工作区只有一个成员而**自动成立**。这三处是可化约的。
|
||
|
|
|
||
|
|
但**成员额度体系那 6 处不可化约** —— 统一类型后,整套成员额度逻辑会突然适用于每一个个人工作区,这是实质的行为变更。
|
||
|
|
|
||
|
|
### 6. 个人工作区是欠费锁定时的逃生舱(核心职责)
|
||
|
|
|
||
|
|
`workspaces/core.ts:1198`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
if (!input.workspaceId && lifecycleState === "locked" && workspace.type !== "personal") {
|
||
|
|
const personal = await this.repository.ensurePersonalWorkspace(input.appUserId);
|
||
|
|
await this.repository.selectActiveWorkspace?.({ appUserId, workspaceId: personal.workspace.id });
|
||
|
|
return this.getCurrentWorkspaceContext({ … });
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
团队工作区因欠费进入 `locked` 后,B 会**自动把用户切到个人工作区**。配合 `:3658`(个人工作区不按订阅推导生命周期),个人工作区**在设计上就是那个永远不会被账单锁死的空间**。
|
||
|
|
|
||
|
|
这是废弃该类型最大的障碍:统一类型后,所有工作区都可能被锁,用户会失去保底落脚点。
|
||
|
|
|
||
|
|
### 7. 保护规则用类型表达,但语义上不是类型
|
||
|
|
|
||
|
|
- `core.ts:1327` 个人工作区不可删(`personal_workspace_protected`)
|
||
|
|
- `core.ts:1408` 个人工作区的 owner 成员不可移除
|
||
|
|
|
||
|
|
这两条真正想表达的是「用户的默认/第一个工作区不能被删掉,否则他会无处可去」。这该由一个显式标记承载,而不是搭在类型上。
|
||
|
|
|
||
|
|
### 8. 两侧都默认落在个人工作区
|
||
|
|
|
||
|
|
- B:`ensurePersonalWorkspace` 用 `on conflict do nothing` 把个人工作区写进 `active_workspace_selections`,所以新账号的第一个当前工作区必然是它。
|
||
|
|
- 客户端:`apps/daemon/src/collab/vela-workspace-context.ts:262` 的兜底顺序里,`workspaceType === 'personal'` 排在 `candidates[0]` 之前。
|
||
|
|
|
||
|
|
于是刚升级团队版的用户打开客户端,落点恰好是**按当前规则唯一不能做团队协作**的空间。
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 提案(已实施):保留类型,把协作准入从类型里摘出去
|
||
|
|
|
||
|
|
> vela #916 已合并(2026-07-21)。
|
||
|
|
|
||
|
|
**改一处:`services/api/src/resources/auth.ts:205`。**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
if (context.workspaceType !== "team") return 403 missing_principal;
|
||
|
|
```
|
||
|
|
|
||
|
|
这一处判断的理由是「B 没有独立 team id,团队工作区的 id 就是团队身份」。但个人工作区**同样有 id、有 `workspace_members` 行**(已核实 3),资源中心本来就按 workspace id 寻址 —— 它在结构上完全能承载资源面,是被类型判断挡在门外的。
|
||
|
|
|
||
|
|
而产品面已经允许往个人工作区邀人(已核实 4)。**邀得进来却不能协作,是这两层没对齐的直接后果。**
|
||
|
|
|
||
|
|
### 为什么不建议一并废弃 `personal` 类型
|
||
|
|
|
||
|
|
初版提案是废弃类型。全仓核对后放弃这个方向,原因:
|
||
|
|
|
||
|
|
1. **逃生舱职责无替代**(已核实 6)。统一类型后所有工作区都可能因欠费锁定,用户失去保底落脚点。
|
||
|
|
2. **成员额度体系 6 处 team-only 判断不可化约**(已核实 5)。统一类型会让整套额度逻辑突然适用于每个个人工作区,是实质行为变更,风险远大于收益。
|
||
|
|
|
||
|
|
保护规则(已核实 7)和默认落点(已核实 8)仍然值得单独讨论,但它们不构成废弃类型的理由。
|
||
|
|
|
||
|
|
### 改动范围
|
||
|
|
|
||
|
|
- **B**:1 处判断。个人工作区的资源面作用域仍是它自己的 workspace id,成员就是 owner 一人。
|
||
|
|
- **客户端**:可删掉为规避该 403 而加的守卫(`apps/daemon/src/collab/team-share-scope.ts`),以及两处「优先挑 personal」的兜底。
|
||
|
|
|
||
|
|
### 需要 B 确认的风险
|
||
|
|
|
||
|
|
个人工作区获得资源面后,会不会有别的 team-only 逻辑被间接触发?尤其是**成员额度体系那 6 处** —— 它们判断的是工作区类型而非资源面准入,理论上不受影响,但需要 B 侧确认没有隐式耦合。
|
||
|
|
|
||
|
|
## 完整推理:`personal` 类型能不能干掉
|
||
|
|
|
||
|
|
这一节记录得出「不建议废弃」的完整过程,包括中途被推翻的判断,以便后来者不必重走。
|
||
|
|
|
||
|
|
### 第一轮:看起来可以干掉
|
||
|
|
|
||
|
|
初始观察是三条:结构上个人工作区和团队工作区用同两张表(已核实 3);产品面已经允许它邀人(已核实 4);资源面纯按 workspace id 寻址。由此得出「`type` 只是个多余的分类」,建议废弃。
|
||
|
|
|
||
|
|
**这一轮的错误在于抽样。** 当时只读了 `resources/auth.ts` 和 `billing/` 下少数几处,就断言 `type` 不管席位和套餐。
|
||
|
|
|
||
|
|
### 第二轮:全仓核对推翻了它
|
||
|
|
|
||
|
|
对全仓 18 处判断逐条核对后(已核实 5),两条硬约束浮现:
|
||
|
|
|
||
|
|
1. **`workspaces/core.ts:1198` 的锁定逃生舱**(已核实 6)。团队工作区因欠费进入 `locked` 时,B 自动把用户切回个人工作区;配合 `:3658`(个人工作区不按订阅推导生命周期),个人工作区在设计上就是**那个永远不会被账单锁死的空间**。统一类型后所有工作区都可能被锁,用户失去保底落脚点。**这是最难替代的一条**,因为它要的不是「一个工作区」,而是「一个结构上不受计费状态影响的工作区」。
|
||
|
|
2. **成员额度体系 6 处 team-only 判断不可化约**(`core.ts:2195/2260/2318/2382/2707/3429`)。统一类型会让整套额度逻辑突然适用于每一个个人工作区,是实质行为变更。
|
||
|
|
|
||
|
|
相比之下,自动充值那三处(`:3303/2957/2543`)**是可以化约的** —— 通用路径找的是 active owner,在个人工作区里就是用户自己,结果相同;禁止赞助和排除结算则因为只有一个成员而自动成立。保护规则(已核实 7)也只需换一个显式标记。
|
||
|
|
|
||
|
|
**所以答案不是「type 没用」,而是「type 承载了两类不同的职责,其中一类不该由它承载」:**
|
||
|
|
|
||
|
|
| 职责 | 该不该由 `type` 判 | 现状 |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| 计费:订阅、席位、成员额度 | ✅ 该 | 保留 |
|
||
|
|
| 生命周期是否受账单影响 | ✅ 该(逃生舱依赖它) | 保留 |
|
||
|
|
| **资源面 / 协作面准入** | ❌ 不该 —— 这是寻址问题 | **已由 vela #916 移除** |
|
||
|
|
|
||
|
|
### 第三轮:B 线同事独立印证了同一条边界
|
||
|
|
|
||
|
|
vela #915(`fix(api): guard Team billing workspace type`,2026-07-21 合并)在**计费侧加强**了 `workspaceType === "team"` 判断,覆盖 7 个订阅相关操作:开团队订阅、换套餐、取消订阅、订阅信息读取、成员充值包。
|
||
|
|
|
||
|
|
这与本文 #916 **方向相反但结论一致**:一个加强计费侧的类型判断,一个移除寻址侧的类型判断。两者共同指向同一条边界 —— **`type` 管计费,不管寻址**。
|
||
|
|
|
||
|
|
### 遗留矛盾:能邀人,但没有席位来源
|
||
|
|
|
||
|
|
#915 只挡计费,**没有挡邀请**(邀请走 `workspaces/` 路径)。所以当前状态是:
|
||
|
|
|
||
|
|
- 个人工作区**可以邀人**(实测成功)
|
||
|
|
- 个人工作区**可以协作**(#916 之后)
|
||
|
|
- 个人工作区**不能开团队订阅**(#915 之后)
|
||
|
|
|
||
|
|
那么邀进来的成员,**席位和额度从哪里来?** #915 的正文提到,个人工作区开团队订阅后成员充值会失败 `workspace_forbidden`,所以他堵住了入口 —— 但没有回答邀请该不该同时堵。
|
||
|
|
|
||
|
|
**这是目前最需要产品拍板的一条**:个人工作区到底该不该能邀人?
|
||
|
|
|
||
|
|
- 若**该**:需要为它定义席位与额度模型,#915 挡住的那部分要重新设计。
|
||
|
|
- 若**不该**:vela web 上那两个按钮是 bug,应当移除;届时 #916 虽仍成立(个人工作区用自己的资源面做单人协作,如版本历史、评论),但价值会小很多。
|
||
|
|
|
||
|
|
## 另一件相关但独立的事:草稿没有工作区归属
|
||
|
|
|
||
|
|
`projects` 表**没有 workspace 列**(`apps/daemon/src/db.ts`)。工作区归属只存在于 `workspace_projects`,而且只有共享过的项目才有行。`buildDraftsList` 是「所有本地项目减去已共享的」,没有工作区过滤。
|
||
|
|
|
||
|
|
后果:
|
||
|
|
|
||
|
|
- 从工作区 A 切到 B,看到的是**同一批草稿**;
|
||
|
|
- 退出登录看起来隐藏了一切,但那只是因为 `EntryShell` 在没有工作区上下文时把用户**路由离开**了那些页面 —— 数据仍在,任何绕过该路由的路径都会漏出来;
|
||
|
|
- 一个项目在被共享之前不属于任何工作区,所以共享动作只能从环境状态**猜**一个工作区。
|
||
|
|
|
||
|
|
产品已确认目标模型:**草稿和共享的方案都和 workspace 绑定**(2026-07-21)。代码对草稿没有实现。
|
||
|
|
|
||
|
|
**这一条不依赖上面的类型提案**,可以独立推进:给项目本身加工作区归属,共享就只是翻转 `visibility`,不再需要推断。
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 待决策
|
||
|
|
|
||
|
|
1. **个人工作区该不该能邀人?** 这是当前最关键的一条,决定了席位/额度模型是否需要为它定义,也决定了 vela web 上那两个按钮的去留。见上面「遗留矛盾」。
|
||
|
|
2. **生命周期推导改判「有无订阅」后**,已有的付费团队工作区行为是否完全不变?这是唯一需要计费同学确认的技术点。
|
||
|
|
3. **默认工作区的保护标记叫什么、加在哪张表**。
|
||
|
|
4. **存量 flip 的时机和责任人**,以及回滚方案。
|
||
|
|
5. **升级团队版后要不要自动建一个工作区**?统一类型后这个问题会自然消失(默认那个就能用),所以可能不需要单独做。
|
||
|
|
6. **草稿绑定工作区的迁移目标**:统一类型后,默认工作区是唯一无歧义的目标。
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 核实记录
|
||
|
|
|
||
|
|
初稿列为「未核实」的三项,已全部核实:
|
||
|
|
|
||
|
|
1. **`type` 判断的完整波及面** —— 全仓非测试代码 18 处,已逐条读并分类(已核实 5)。核实**推翻了初稿的判断**:初稿称 `type` 不管席位和套餐,实际上成员额度体系有 6 处 team-only 判断,`:3602` 也在无计费适配器时给出 `seatLimit: 1`。
|
||
|
|
2. **`active_workspace_selections` 的消费方** —— 仅 `services/api/src/workspaces/postgres.ts`(外加迁移、种子数据、测试)。`apps/web/`、`apps/admin/`、`services/link/` **均无直接引用**。
|
||
|
|
3. **生命周期推导路径** —— 追完 `resolveWorkspaceLifecycleFromEntitlement` 及其调用点,发现了 `:1198` 的锁定逃生逻辑(已核实 6),这正是本文改变提案方向的原因。
|
||
|
|
|
||
|
|
仍未核实:
|
||
|
|
|
||
|
|
- 个人工作区获得资源面后,成员额度体系是否存在隐式耦合(需 B 侧确认,见上)。
|
||
|
|
- admin 后台是否按工作区类型做展示或筛选 —— 检索未发现 `apps/admin/` 下有类型判断,但未逐页核对 UI。
|