Joye Dev

Back

Team 席位、共享 credit pool 与 ledger 归属:从支付对象到数据库 owner

这条业务线解决的不是“在套餐列表里增加一个 Team”,而是把“谁付费、谁可以使用、哪一个 workspace 承担额度、一次扣费发生在哪里”拆成可落地的边界


source_automation: voyager-merged-pr run_date: 2026-08-09 anchor_pr_number: 5771 pr_number: 5771 pr_title: “feat(db): give a credit bucket a second possible owner” pr_url: https://github.com/adastralab-ai/voyager/pull/5771 author: “Shawn Hu / huxwfun” merged_at: “2026-08-09T07:20:03Z” modules:

  • backend/base/db/migration/000107_credit_buckets_workspace_owner.up.sql
  • backend/base/db/migration/000107_credit_buckets_workspace_owner.down.sql
  • backend/base/db/migration/000108_credit_buckets_workspace_scope_index.up.sql
  • backend/base/db/migration/000108_credit_buckets_workspace_scope_index.down.sql
  • backend/base/db/migration/000109_credit_transactions_workspace.up.sql
  • backend/base/db/migration/000109_credit_transactions_workspace.down.sql
  • backend/base/db/migration/000110_credit_transactions_pool_usage_index.up.sql
  • backend/base/db/migration/000110_credit_transactions_pool_usage_index.down.sql files_changed: 8 learning_tags:
  • billing
  • team-seats
  • credit-pool
  • credit-ledger
  • workspace-ownership
  • schema-migration
  • rollout-compatibility
  • postgres-indexing
  • cross-module-contract
  • anti-abuse business_line: “Team 席位、workspace credit pool 与 credits ledger 归属边界” related_prs: [5644, 5654, 5756, 5759] line_stage: “Stripe Team price/portal 隔离 (#5644) -> PlanTeam、workspace subscription/seat/config 词汇 (#5654) -> 按 resolved plan 判断付费与设备豁免 (#5759) -> truthful workspace billing API 与无订阅 guard (#5756) -> workspace-owned bucket 与 workspace-attributed ledger schema (#5771)” open_questions:
  • “#5771 只增加 schema,没有 runtime 创建 workspace bucket、按 seat 选择资金来源、写入 workspace_id 或执行 pool debit;下一步必须证明这些写入与扣费在同一业务事务内成立。”
  • “credit_buckets 与 credit_transactions 的新 FK/owner CHECK 使用 NOT VALID;新写入会受约束,但存量数据何时、由哪次迁移完成 VALIDATE CONSTRAINT 仍未在本线闭合。”
  • “保留旧的 credit_buckets_user_bucket_unique 是滚动发布兼容性要求;待旧 upsert 消失后是否清理、以及个人 bucket 与 workspace pool 的完整唯一性规则仍需确认。”
  • “ledger 只为 bucket_id、user_id、created_at 建索引,刻意没有 workspace_id 索引;若后续需要按 workspace 做审计或报表,查询契约和索引必须另行设计。”
  • “Stripe Team 资源需要 operator apply 和各环境 price mapping;workspace checkout、webhook 持久化、portal session 的显式 configuration 尚未由这些 PR 接通。”
  • “workspace pool 的每日上限、SeatMonthlyAllotment、个人 credits fallback,以及取消后期末只读的行为尚未有真实 debit/runtime 集成证明。”
  • “#5759 在 plan 未确认时对设备限制 fail-open;这是反滥用与付费用户可用性的取舍,需要监控 Redis/price-map 故障期间的实际放行量。” feishu_doc_url: null

业务线概览#

这条业务线解决的不是“在套餐列表里增加一个 Team”,而是把“谁付费、谁可以使用、哪一个 workspace 承担额度、一次扣费发生在哪里”拆成可落地的边界。

当前演进已经形成四个独立维度:

  • Stripe price 是钱和 SKU;SeatType 是 Basic/Pro 这种容量与策略类型。
  • workspace_subscriptions 是 workspace 的团队订阅;workspace_seats 是成员是否有权使用池子;workspace_config 保存跨订阅生命周期的每日上限。
  • credit_buckets 的 owner 从只能是 user 扩展为 user 或 workspace,但每行仍必须恰好有一个 owner。
  • credit_transactions.workspace_id 记录操作发生的空间,和“谁消费”“哪个 bucket 付款”分开,避免把使用场景误当成资金来源。

本次锚点 #5771 只改数据库 schema。它把未来的共享池和 workspace 归因预留出来,同时将旧版本仍在运行时依赖的 upsert 冲突目标保留下来,因此是这条业务线的持久化边界,而不是一个已经可购买或可扣费的 Team 功能。

今日锚点#

  • 标题:feat(db): give a credit bucket a second possible owner
  • 编号:#5771
  • 作者:Shawn Hu / huxwfun
  • merge 时间:2026-08-09 07:20:03 UTC(Australia/Melbourne 17:20:03)
  • merge commit:e80524dd86459a0c7a2f55dafc8c192aaff93c96
  • 选择理由:它是今天候选中最新的同线 PR,并第一次把团队席位的资金归属落到 credit bucket 与 ledger;前置的支付产品、计划模型和 API 契约已经能自然解释每个新列为什么存在。

演进时间线#

阶段PR改变的层代码证据与意义
支付资源#5644,2026-08-06Stripe Terraform增加 Team product、Basic/Pro 两个按席位月价和独立 customer portal;Team 不进入个人 subscription_update,席位数量留给 Voyager 自己的端点。
计划与存储词汇#5654,2026-08-09TypeSpec/Go model/DB schema/前端 plan narrowing引入 PlanTeamBillingSeatTypeworkspace_subscriptionsworkspace_seats 和寿命独立的 workspace_config;没有 checkout 或写表的 runtime,因此没有立即行为变化。
跨模块权益判断#5759,2026-08-07Go devices -> billing设备反滥用从“有无个人 subscription row”改为看 resolved plan;admin override 或未来 seat-funded account 不会被错误限制,无法确认 plan 时选择 fail-open。
API 契约与空状态#5756,2026-08-09TypeSpec/Go handler/service/credits interface固定 workspace billing 的读写形状;没有团队订阅的 workspace 返回稳定的 none/空数组,写操作返回明确 404,而不是静默成功或伪造 NotImplemented route。
资金 owner 与使用归因#5771,2026-08-09PostgreSQL migration/indexcredit_buckets.workspace_id 让 workspace 成为第二种 owner;credit_transactions.workspace_id 记录操作空间;partial unique index 和复合 ledger index 先于任何 pool 写入建立。

这条线目前仍是“schema 与 contract 先行”。#5771 的合入不会让用户立刻买到席位,也不会改变现有个人 credits 扣费路径;后续 runtime PR 才会把这些字段变成真实资金流。

当前架构与数据流#

  1. 支付入口:Terraform 创建 Team product,以及价格与个人 Basic/Pro 对齐的 team_seat_basic_monthly_2608team_seat_pro_monthly_2608。团队 customer 使用单独 portal,只允许账单、付款方式、发票和期末取消;席位数量不交给 Stripe portal 修改。
  2. 计划解析:Go 的 SeatTypeForPriceKey 先把 price key 归一为 SeatTypePlanForPriceKey 再把两种 seat price 归一为 PlanTeam。因此价格可以换代,席位策略仍按类型稳定;workspace_seats 不保存 price key。
  3. workspace 账单模型workspace_subscriptions 保存 workspace 级 Stripe customer、订阅状态、按 price key 的 stripe_items 和按 seat type 的 allotment override;workspace_seats 软删除成员席位;workspace_config 保存按 seat type 的每日上限,配置寿命不随订阅消失。
  4. 用户入口WorkspaceBillingService 暴露 /api/workspaces/{id}/billing 及 seats/seat-limit/portal 操作;读取要求 EnsureAdminOrOwner,付费与付费数量修改要求 owner。GET /api/credits/mepools 预留为账号可使用的所有 workspace pool,切换 workspace 不需要重新请求 credits。
  5. credits 持久化边界:未来一次 debit 至少需要同时回答“谁消费”(credit_transactions.user_id)、“谁付款”(credit_transactions.bucket_id 指向 user 或 workspace bucket)和“在哪里发生”(新 workspace_id)。#5771 只完成了数据库可表达性和查询索引,没有实现这次选择。
  6. 旁路权益:devices 通过 billing 的 ResolveUserPlan 判断是否付费,Team/seat-funded account 不再因为没有个人 subscription row 而被当作 free;credits/task 通过本地窄接口依赖 billing,避免 Team 字段扩展再次迫使测试 stub 实现整个 billing service。

关键代码#

1. Stripe 价格与 portal 先隔离个人套餐(#5644)#

backend/base/infra/stripe/modules/stripe/products.tf:208-260 定义 Team product 和两个按席位价格,金额分别为 $22$58,与对应个人档月价相同。backend/base/infra/stripe/modules/stripe/portal.tf:76-140 的团队配置没有 subscription_update,避免 workspace customer 被带到个人价格;席位数量只能走未来的业务 API。

resource "stripe_price" "team_seat_basic_monthly_2608" {
  product     = stripe_product.team.id
  unit_amount = 2200 # = basic_monthly_2606
}
plaintext

设计点是把“钱的对象”和“席位的控制面”拆开。Terraform apply 和各环境 price mapping 是独立运维动作,不能从 PR merge 推断线上已经有可用价格。

2. price key 归一到 seat type,再归一到 PlanTeam(#5654)#

backend/go/internal/billing/model.go:53-93 把 SKU 与业务类型分开:

func SeatTypeForPriceKey(priceKey string) (SeatType, bool) {
    switch api.PriceKey(priceKey) {
    case api.PriceKeyTeamSeatBasicMonthly2608:
        return SeatTypeBasic, true
    case api.PriceKeyTeamSeatProMonthly2608:
        return SeatTypePro, true
    }
    return "", false
}

func PlanForPriceKey(priceKey string) (Plan, bool) {
    if _, ok := SeatTypeForPriceKey(priceKey); ok {
        return PlanTeam, true
    }
    // personal price mappings follow below
}
plaintext

这让未来的年度价、改价或多币种价可以继续映射到同一个 SeatType,而不会把容量策略绑定到一个会变化的 Stripe price。

3. “没有购买”是永久正确的读结果(#5756)#

backend/go/internal/billing/workspace_billing.go:34-79 对不存在的 subscription 返回空 projection,而不是错误;backend/go/internal/billing/workspace_billing.go:82-146 让五个尚未实现的写操作共享同一个显式 guard:

if errors.Is(err, ErrWorkspaceSubscriptionNotFound) {
    return &WorkspaceBilling{}, nil
}
// writes use the same lookup, but return 404 when there is nothing to change
plaintext

API 层再把空 projection 序列化为 status: "none"、空的 seats/seatHolders。这避免 UI 把“尚未购买”误判成服务故障,也避免一个当前没有实现的写操作返回 200 造成“修改成功但实际没改”的错觉。

4. 权限层区分管理信息与付费动作(#5756)#

backend/go/apps/api/handler/workspace_billing.go:13-151 直接分成 workspaceAdminOrOwnerworkspaceOwnerOnly:读 seats panel 和每日上限属于管理面;购买、数量和 portal 属于 owner-only。这个分层与 TypeSpec idl/typespec/services/billing.tsp:360-472 的 403/404 契约一致,不把所有 workspace billing API 粗暴地绑定成同一个角色。

5. 计划不确定时,设备限制选择可用性优先(#5759)#

backend/go/internal/devices/service.go:268-284 不再检查个人 subscription row:

return resolution.Plan != billing.PlanFree || !resolution.PlanConfirmed,
    resolution.PlanConfirmed, nil
plaintext

这解释了为什么 PlanResolution 必须携带 PlanConfirmed:已解析为 Team 的账户当然放行;解析暂时不确定时也放行,但不会把这次不确定结果写成“已付费”的 durable audit resolution。

6. workspace owner 的数据库不变量与 ledger 维度(#5771)#

backend/base/db/migration/000107_credit_buckets_workspace_owner.up.sql:25-33 用一条 ALTER TABLE 同时加列、外键、owner check 并解除 user_id NOT NULL

ADD CONSTRAINT credit_buckets_one_owner
  CHECK ((user_id IS NULL) <> (workspace_id IS NULL)) NOT VALID,
ALTER COLUMN user_id DROP NOT NULL;
plaintext

随后 000108...up.sql:10-11 建立 CREATE UNIQUE INDEX CONCURRENTLY credit_buckets_workspace_scope ON credit_buckets(workspace_id, bucket_type) WHERE user_id IS NULL,保证每个 workspace 每种 bucket 只有一个 pool。000109...up.sql:21-24 为 ledger 加 workspace FK,000110...up.sql:10-11 只按真实读法建立 (bucket_id, user_id, created_at) 索引。

这里有两个很容易被忽略的细节:旧的 (user_id, bucket_type) unique 约束没有删除,因为旧版本的 raw upsert 用 ON CONFLICT (user_id, bucket_type);以及 ledger 没有 workspace_id 索引,因为当前 diff 没有按 workspace 查询者。两者都是发布顺序和查询契约的决定,不是遗漏。

工程取舍#

数据模型与边界#

  • price、type、owner、event scope 分开。 price key 表示 Stripe money;seat type 表示额度/策略;bucket owner 表示资金池;transaction workspace 表示发生地点。把四者压成一个 plan 字符串会使未来的改价、换 seat、按 workspace 计费都变成兼容性问题。
  • 订阅和配置分开。 workspace_config 不挂在 subscription 上,因为私人 workspace 没有订阅行,过期 workspace 仍应保留配置;seat_allotment_overrides 又与 Stripe mirror 分列,避免 webhook 整体覆盖运营数据。
  • Purchased 与 Assigned 分开。 池子容量由买了多少席位决定,成员能否使用由 active seat 决定;撤销用 soft delete 保留历史。

发布、兼容性与性能#

  • #5771 把 credit_buckets 的 owner 改动合在单条 ALTER TABLE,用 lock_timeout = '5s' 让热表不会无限排队;两个 CONCURRENTLY index 各自单独迁移,遵守 PostgreSQL 不能在事务中创建的限制。
  • 新 FK/check 使用 NOT VALID,避免对现有热表做阻塞验证;存量行由于新列为空且旧 user_id 非空,逻辑上已满足,后续可单独安排验证扫描。
  • 旧 conflict target 保留,是滚动发布期间“迁移先于新 binary”的兼容性处理。它比立刻清理旧 index 更保守,也把清理动作隔离到旧代码完全消失之后。
  • credit_transactions.workspace_id 没有预先建索引;当前需求是按 paying bucket、holder 和时间计算 pool usage,而不是按 workspace 查询全部操作。索引跟着读模型走,避免为尚不存在的报表提前支付写入成本。

契约与测试策略#

  • #5654 的 plan_test.go 覆盖两个 team price key 到 PlanTeam、Team 的 4K entitlement;plan_override_test.go 覆盖 API 层拒绝把用户 override 到没有 pool 的 Team。
  • #5756 把 credits/task 对 billing 的依赖收窄成本地 provider interface,删除测试 stub 对 billing 全部方法的无意义实现;它提高了新增 Team API 后测试边界的稳定性。
  • #5756 的 Go integration harness 继续使用真实容器数据库,但主要验证既有个人 credits/task 路径在新增 schema/接口后仍能运行;它不证明 workspace pool debit 已实现。
  • #5759 没有新增 devices 专用测试文件;其行为验证依赖现有 credits 集成路径和 PR 中列出的 build/vet。#5771 diff 只有 migration,不能把 migration 可应用等同于 ledger runtime 正确。
  • 当前最缺的是跨层契约测试:Stripe webhook → workspace subscription → seat assignment → bucket selection → debit/refund → /api/credits/me pools,以及取消/过期后的状态变化都没有由这些 PR 端到端证明。

和最近学习记录的关系#

  • #5455:insufficient credit telemetry 是最接近的 credits 观察面:它记录用户遇到额度不足的事实;本线进一步要求 ledger 记录“哪个 workspace 的 pool 付款”和“在哪里发生”,否则未来的 Team 用量与个人失败会混在一起。两者共享 credits 主题,但没有把 #5455 作为本线正式 related PR。
  • #5754:平台任务预算终止与自动续跑 关注 Agent runtime 的交付成本和 submission 状态,不共享本线的 billing/API/schema diff,因此只作为最近学习的对照,不强行拼入业务线。
  • #5683:Export Workflow 收口 同样没有共享用户流程或数据模型;本次正式 related PR 只保留能由 Stripe、billing、devices、workspace、credits 代码直接串起的四条前置/并行变更。

我会怎么吸收#

  1. 把“钱、策略、owner、发生地点”拆成不同字段。 先画清楚每个字段回答哪个业务问题,再决定表、API 和 index;不要用一个 plan 或一个 workspace id 承担所有含义。
  2. 迁移要设计旧 binary 与新 schema 的重叠窗口。 保留旧 ON CONFLICT 目标、把新约束和列改动放进短锁持有的语句,并把 CONCURRENTLY index 独立成单语句迁移。
  3. 先把空状态和拒绝边界固定,再实现 happy path。 none、空数组、无 subscription 的 404、未实现的显式 5xx 都比静默 nil 更容易让 UI 和下一阶段代码正确组合。
  4. 让 resolved-plan 的不确定性显式传播。 PlanConfirmed 使调用方能区分“可展示的 fallback”与“可以写入 durable state 的事实”,再按业务风险选择 fail-open 或阻断写入。
  5. 用真实读模型反推索引和测试。 当前索引服务 pool holder 的日用量,而不是泛化的 workspace 报表;测试也应优先覆盖 bucket selection、debit/refund 和滚动发布窗口,而不是只验证生成类型存在。

边界、风险与未解问题#

代码已经确认的边界#

  • #5771 的八个文件全是 migration up/down;没有 Go/TypeScript runtime 读写新列,现有个人 credits 路径不会因为本 PR 自动使用 workspace pool。
  • credit_buckets 新行必须是 user-owned 或 workspace-owned 二选一;workspace pool 的唯一性由 partial unique index 提供,个人旧 unique 约束仍保留。
  • credit_transactions.workspace_id 记录所有操作所在 workspace 的维度,包含个人 bucket 在 workspace 中付款的情况;它不等于 bucket owner。
  • #5756 的 workspace billing read 在没有订阅时是成功的空投影,五个写操作在没有订阅时统一返回 NotFound;有订阅时写操作目前仍返回明确的“not implemented yet”错误。
  • Team customer 的 Stripe portal 只有账单/支付/发票/取消能力;取消整条团队订阅会在期末让 workspace pool 消失并进入只读语义,但实际状态联动尚未出现在这些 PR 的 runtime 中。

仍需确认的风险#

  • runtime 缺口最大。 下一阶段需要证明 seat assignment 与 bucket owner 的选择、daily limit 的原子扣减、个人 credits fallback、refund,以及每行 transaction 的 workspace attribution 不会在并发下分叉。
  • 迁移验证与约束清理。 NOT VALID 缩短了发布阻塞,但必须有后续 validation;旧 unique index 未来若清理,也要先确认所有部署版本都不再发出旧 ON CONFLICT
  • Stripe 运维状态未由 merge 推断。 Terraform apply、price ID 写入环境映射、workspace webhook 持久化和 portal session 显式 configuration 都是外部动作或后续代码;当前报告不把它们当作已上线。
  • 未知 price key 会被读取层跳过并告警。 GetWorkspaceBilling 只统计能映射到 SeatType 的 item;新 price 没有同步映射时,UI 可能少报购买席位,必须有配置漂移告警或强失败策略。
  • fail-open 需要指标。 #5759 把未确认 plan 当作 paid 以保护真实付费用户,但如果 Redis 或 price map 长时间故障,设备反滥用会放宽;需要把 PlanConfirmed=false 的次数纳入观测。
  • 部署级覆盖不足。 目前没有真实 Stripe portal、webhook、workspace authz、Postgres concurrent migration、pool debit/refund 和浏览器 credits 切换的完整集成证明。

候选说明#

本次按 Australia/Melbourne 的 2026-08-09 合并窗口优先筛选。当天可自然归入同一业务线的未学习候选包括 #5654、#5756、#5771;#5771 是其中最新、且把前两者的 Team 语义推进到 credit ledger owner 的锚点。外部 study-log.jsonl 中没有 #5771,也没有本次正式 related PR 作为既有锚点。

四个 related PR 都通过代码证据串联:#5644 与 Stripe Team 资源/portal;#5654 与 workspace subscription/seat/config 和 PlanTeam;#5759 与 resolved plan 跨模块权益判断;#5756 与 workspace billing API、空状态和 credits pool response。它们在 30 天窗口内,且 merge commit 均已进入 Voyager origin/main;本次不需要扩大到 2026-08-08。

没有把标题相似的个人 billing、credit telemetry、Agent runtime 或前端 UI PR 拼入正式时间线。尤其 #5771 本身没有 runtime 行为,因此报告把“当前已实现”和“下一阶段必须实现”明确分开。

GitHub 文档#

  • 报告文件:outputs/voyager-daily-pr-study/2026-08-09-pr-5771-team-seat-credit-pool-ownership.md
  • 目标仓库:joyehuang/ai-agent-field-notes
  • Voyager 锚点:PR #5771
  • Voyager origin/main(阅读时):e80524dd86459a0c7a2f55dafc8c192aaff93c96
  • 本报告与索引将在本次目标仓库提交中一起持久化;实际目标仓库 commit 以本次 study-log.jsonlgithubCommit 为准。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back