Spree 6.0 B2B 前台采购:公司自助管理、公司地址簿与结账如何落地
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
本篇基于 Spree 6.0 的开发计划文档 6.0-b2b-storefront-purchasing.md 展开:它回答一个具体问题——当买家不是“一个人”而是“一家公司”时,Spree 如何在前台(storefront)补齐公司自助管理界面,并让结账流程真正理解“为公司采购”这一上下文。读完你可以掌握:customer_id与company_id双轴模型的设计动机、公司地址簿“读继承、写归属”的实现(selectable_address谓词、HasAddressBook三态默认地址标志)、以及 Store API 已落地的公司自助与邀请接受端点,全部附仓库内可核实的源码路径。
背景:底座已就绪,缺的是前台与结账感知
前置计划 6.0-b2b-companies-and-catalogs.md 已交付了公司树(company法律实体节点与division组织节点,深度上限 5)、成员关系(membership)、邀请(invitation)、多态地址属主(owner_type/owner_id),以及 Store API 的自助管理面。
本计划要补的是三块缺失(见计划文档 Summary 一节):
- 前台没有 UI——成员管理、邀请、地址簿、公司订单树视图,这些 API 都有,但标准账户区里没有对应页面;
- 结账不认识公司——买家只能看到自己的个人地址簿;
- 邀请邮件里的接受链接是 404——
Spree::CompanyMailer指向<storefront_url>/account/company-invitation?token=…,但那个页面此前并不存在。
计划文档明确声明:不改变购物车与订单的所有权模型——一笔 B2B 采购始终有两个买方主体(下单的人 + 采购所属的组织),这与 Shopify 的 purchasing-entity 形态、Medusa 与 BigCommerce 的 B2B 构建方向一致。因此本计划是“界面层”工作,不是“schema”工作。需要说明:storefront 位于独立仓库(spree/storefront的6-0-dev分支),本仓库承载的是后端实现、Store API 端点与这一份“两仓库共同事实记录”的计划文档。
关键设计决策(计划文档的约束性结论)
计划文档把五条决策标记为 “do not deviate without discussion”,它们构成了后续所有实现的依据:
决策一:Cart/Order 不加多态属主列
地址模型能用owner_type/owner_id多态属主,是因为一个地址恰好只有一个属主。而 B2B 采购有两个职责不同的买方主体:
- 人(person):负责认证、“我的订单”、事务邮件,是企业审批与额度管控的作用对象;
- 组织(organization):负责税务锚点、目录与价格解析、公司订单视图。
把两者坍缩进一个多态列,就无法表达“Alice 替 Acme 买了这个”这一事实。Spree 的既有模型正是双轴:customer_id+company_id,配合 standing 校验与完成时冻结,模型无需改动。
决策二:OSS 是机制,买家门户是 Enterprise
开源前台把公司自助管理织入标准账户区,成员、邀请、地址、公司订单列表,样式只依赖前台自身的设计系统,不允许长出任何角色感知——每个成员能看到所有公司页面,授权由服务端 standing 检查强制;Enterprise 版买家门户(审批收件箱、支出看板、发票、角色)通过 access-policy 子类收窄权限,而不是靠 UI 开关。
决策三:结账引用公司地址簿,完成时仍然拷贝
- 前台在选中已存地址时提交
shipping_address_id; - 购物车的属主守卫写入口径从“只接受客户自有行”放宽为**“也接受本采购所属公司及其祖先节点持有的地址”**——与
Company#default_billing_address走的 self-and-ancestors 链同一条链,于是分部(division)预填时继承到的地址簿,正是其买家可选的地址簿; - standing 已在更上游强制(购物车不能命名一个其客户没有 standing 的公司);
- 完成时仍把地址拷贝到订单上,地址簿行永远不会被一次销售“冻结”;
- 公司地址永远不会进入客户的地址簿,反之亦然——守卫对跨越这条线的 id 的拒绝方式,与拒绝其他客户的 id 完全一致。
决策四:多成员关系时公司上下文必须显式
单一 standing 的买家静默解析(sole_standing_company,已交付);多个成员关系的买家在结账时显式选择节点,写入购物车的company_id(已交付,standing 校验)。前台永远不在成员关系之间做猜测。清除公司选择即可个人采购,目录、定价与税务锚点随之一起清除。
决策五:邀请接受页是本计划的组成部分,不可选
该未认证路由页面要处理 token 流服务的两类人群:以被邀请邮箱注册的新用户(账户用该邮箱创建),以及登录后接受邀请的既有客户——登录账户邮箱与被邀请邮箱不符时拒绝,行为与 API 一致。成功落地到公司页面。
Store API 已落地的端点面
以下端点在 spree/api/config/routes.rb 中可以直接核到(store 作用域):
| 端点 | 作用 | 前台对应页面 |
|---|---|---|
GET /store/account/companies | 当前买家的成员关系列表(含祖先路径) | 账户导航仅在返回成员关系时出现公司入口 |
GET /store/companies/:id | 节点详情(名称 + 祖先路径) | /account/companies/[id] |
PATCH /store/companies/:id | 重命名(API 允许;updateCompany已接线但 UI 暂不放出编辑控件,见“开放问题”) | 节点页名称只读 |
GET/POST/DELETE /store/companies/:id/members | 按邮箱添加成员:服务端将其转为 membership 或 invitation | 成员列表 + 按邮箱添加 |
GET/DELETE /store/companies/:id/invitations | 待处理邀请与撤销 | 节点页待处理邀请区 |
…/companies/:id/addresses(子资源) | 地址簿 list/create/edit/delete/set-default,全部已交付 | 节点页地址簿,复用前台既有 address-card 约定 |
GET /store/companies/:id/orders | 该节点子树的已完成订单 | /account/companies/[id]/orders,复用既有订单列表组件 |
GET /store/company_invitations/:token | 未认证 token 查询,返回公司与店铺名称 | 邀请接受页的“谁邀请你加入什么” |
POST /store/company_invitations/:token/accept | 接受邀请(注册或登录两种路径) | 接受后跳转公司页 |
实现集中在 companies_controller.rb 与 company_invitations_controller.rb 等控制器;路由文件中对应注释直接标注了本计划文档路径。注意account/companies#index单独挂出,供账户导航做“是否显示公司入口”的存在性判断——成员关系恰好一个时,/account/companies直接重定向到该节点,不浪费一次点击。
后端增量:放宽一个守卫,外加被低估的假设修正
计划文档诚实地记录:原计划是“widen one guard”,这部分成立——无迁移、无序列化器改动(购物车与订单本来就会上报 company,公司地址本来就有label与默认标志)。但实现暴露出“按客户形态写的假设”蔓延得比预期远,增量如下,均可在本仓库源码中逐条核对。
selectable_address:属主守卫的唯一判定点
Spree::Purchase::Addresses的ship_address_id=/bill_address_id=共用一个私有谓词(spree/core/app/models/concerns/spree/purchase/addresses.rb#L192-L202):
def selectable_address(id) address = ::Spree::Address.find_by(id: id) return nil if address.nil? return address if customer_id.present? && address.customer_owned? && address.owner_id == customer_id company = resolved_company return nil if company.nil? || address.owner_type != 'Spree::Company' company.self_and_ancestors.any? { |node| node.id == address.owner_id } ? address : nil end规则解读:
- 第一本合格的书:买家自己的地址簿(
customer_owned?且owner_id等于本单客户); - 第二本合格的书:本采购所属公司及其祖先节点持有的地址——即“分部可以选它已继承的总部地址”;
- standing 不在此重复检查:购物车模型层已有
customer_has_standing_over_company校验(spree/core/app/models/concerns/spree/purchase/company.rb#L77-L82),能走到这里就意味着买家有权代表该节点行动; - 被拒 id 的写入者行为保持不变:静默置 nil(
self['ship_address_id'] = selectable_address(id)&.id),而不是抛错。
Company#address_book:继承的阅读清单
spree/core/app/models/spree/company.rb#L246-L248 定义了那条“本节点可发货到的地址”阅读链:
def address_book Spree::Address.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id)) end关键语义是读继承、写归属:分部节点可以“读”总部的条目,但不拥有它们。Store API 的授权纯粹依据scope返回内容,因此分部成员不能借由这条阅读链触达父节点的条目去做写操作。与Company#addresses(仅本节点自有的行)形成对照——后者才是写目标。默认地址预填走同一条链:default_billing_address从本节点开始逐级向上找第一个非空默认(company.rb#L263-L269),且defaults_are_own_addresses校验保证默认指针只能指向本节点自有的地址。
Spree::HasAddressBook:让“默认插槽”成为属主的声明
新增 concern(spree/core/app/models/concerns/spree/has_address_book.rb)解决一个结构性问题:客户与公司节点的默认插槽列名不同——客户叫bill_address_id/ship_address_id,公司节点叫default_bill_address_id/default_ship_address_id。于是属主一次性声明自己的列名,所有调用方问属主而不是按类名分支:
# Spree::Company 中的声明([company.rb#L41](https://link.gitcode.com/i/b86c8c8724070bf5e3188639c9426e50)) has_address_book bill: :default_bill_address_id, ship: :default_ship_address_idAddresses::Create/Update服务由此对任意属主通用。计划文档特别记录了修复前的真实缺陷:修复前,把公司条目过一遍Update会返回成功但静默丢弃默认标志,因为所有本该设置标志的分支都在找“地址背后的客户”——这个 bug 正是按客户形态写的假设渗入服务层的证据。
三态默认标志是该 concern 的核心不变式(assign_default_address,has_address_book.rb#L40-L60):
| 标志值 | 语义 |
|---|---|
true | 提升该地址到对应插槽 |
false | 仅当当前持有插槽的正是该地址时才让出插槽——别的地条目的默认不是本调用方的事 |
nil(静默) | 不动插槽 |
实现上,让出插槽用条件UPDATE而非读后写(release_default_columns,has_address_book.rb#L80-L87):
self.class.where(id: id, column => address_id). update_all(column => nil, updated_at: Time.current)WHERE子句即检查——两次请求之间落地的提升不会在释放操作中被回滚。这个竞态场景(“不撤销在读取之后落地的提升”)在 has_address_book_spec.rb 中有专门用例证明,且对客户与公司节点两种属主都各证一遍,覆盖三态规则全部分支。
resolved_company:显式选择优先、单 standing 静默解析、完成即冻结
公司解析的完整优先级在 spree/core/app/models/concerns/spree/purchase/company.rb#L44-L52:
- 已下单且完成的订单只回答“下单时盖章的值”,绝不重新解析——否则买家几个月后加入某公司,会让一笔本应收税的历史订单回头拿到免税;
- 购物车显式
company_id优先(前台选择器写入的就是它); - 兜底
sole_standing_company:Spree::Company.sole_standing_for(company.rb#L125-L144)在店铺范围内统计该客户的成员关系,恰好一条才返回节点,多条返回 nil——拒绝猜测,因为猜测等于把一笔采购开给另一家企业。注释还解释了为何按“成员关系节点”而非按 standing 子树扩张计数:对“一个父节点 + 三个分部的成员”而言,standing 覆盖四个节点但成员关系唯一,仍无歧义。
配套两条模型层校验保证公司上下文不可被注入:company_belongs_to_store(公司按店铺作用域,跨店铺节点不能给本店铺销售挂上他人的税务身份)与customer_has_standing_over_company(仅购物车路径;已下单订单的公司来自完成时拷贝,员工改单走管理端凭据)。
测试与验证面
计划文档 “Specs” 一段列出的验证场景对应本仓库内的用例文件:
- 公司书 id 在有 standing 的购物车上被接受;在无公司、跨无 standing 公司、面向后代节点、以及其他客户的行时被拒绝——后者覆盖
selectable_address的全部拒绝分支; - 三态标志规则(含竞态)在 spree/core/spec/models/concerns/spree/has_address_book_spec.rb 中对两种属主分别证明;
- Store API 层的控制器/集成用例在 spree/api/spec 下,如 companies/addresses_controller_spec.rb 与 store/companies_spec.rb(后者含
company-invitations/accept的 SDK 示例集成断言)。
迁移路径、约束与范围外
迁移路径:无需迁移——无 schema 变更、无重命名。前台工作是独立仓库里的增量页面,后端增量是放宽一个接收守卫。
对当前工作的约束(计划文档 “Constraints on Current Work”):
- 前台公司 UI 只允许调用已交付的 Store API 自助端点,不允许新增私有端点(否则须先更新本计划);
- 前台任何代码不得按成员的 “role” 分支——OSS 没有角色,只按 standing(服务端已做)设门;
- 购物车/订单代码必须继续把
customer_id与company_id当作两个独立轴;除已交付的sole_standing_company兜底外,任何代码不得从一个推断另一个。
明确不在本计划内:角色、审批、支出限额、代下单、发票(Enterprise 买家门户);公司为目标的游客结账(公司采购必须由已登录成员发起);账期/净付与报价(Enterprise 侧后续计划);前台的(wholesale)演示路由组保持其“客户组门控目录演示”的本色,与公司流组合但不共享代码路径。
开放问题:成员能否在前台重命名自己的公司节点
Store API 允许重命名,updateCompany也已接线进前台数据层,但节点页把名称渲染为只读——这是刻意留白而非遗忘。OSS 没有公司角色,放出编辑意味着任何成员都能重命名其他所有成员在其名下采购的组织,爆炸半径大于该界面的其余部分(添加成员是增量的,重命名不是)。商户可以从 dashboard 重命名;Enterprise 侧由角色机制决定门户中谁可操作。
小结与深入阅读路径
本计划的价值在于把“公司作为买方主体”这件事做成了界面层增量:模型双轴(customer_id+company_id)、standing 校验、地址簿继承链都已在核心交付,前台只是给它们装上了账户区页面、公司选择器与邀请接受页。核心源码脉络建议按以下顺序读:
- 计划文档:docs/plans/6.0-b2b-storefront-purchasing.md(底座:docs/plans/6.0-b2b-companies-and-catalogs.md);
- 公司模型与树:spree/core/app/models/spree/company.rb;
- 采购侧公司解析与 standing 校验:spree/core/app/models/concerns/spree/purchase/company.rb;
- 地址守卫与三态默认标志:spree/core/app/models/concerns/spree/purchase/addresses.rb、spree/core/app/models/concerns/spree/has_address_book.rb;
- Store API 路由与控制器:spree/api/config/routes.rb、spree/api/app/controllers/spree/api/v3/store;
- 配套文档:B2B 商业模型 docs/use-case/b2b/b2b-commerce-model.mdx 与买家能力 docs/use-case/b2b/b2b-buyer-capabilities.mdx。
适用前提说明:本文所述端点与行为以当前仓库的 6.0 开发线代码为准;计划文档标注该工作处于 “Implemented, in review”(2026-08-27),后台 PR 与前台 PR 在标注时点仍为开放状态,storefront 侧页面属于独立仓库(spree/storefront),本文不对其内部文件路径作引用。
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考