关于 spree-multi-tenant
面向开发者的 Spree 6 多租户 SaaS 指南,使用需授权的 Enterprise Gems。内容涵盖数据隔离、请求中的租户解析、安装配置及租户感知代码。
主要功能
- 01行级租户数据隔离
- 02基于主机名解析租户
- 03店铺与客户的租户模型
- 04租户范围内的任务与代码
- 05Enterprise 安装说明
适用场景
- 01为独立商家构建 SaaS
- 02隔离不同租户的客户数据
- 03配置租户店铺及默认资源
- 04为每个租户运行定时任务
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-multi-tenant安装后在 Claude Code 中输入/spree-multi-tenant确认已加载。
详细介绍
多租户(SaaS)Spree
仅限企业版。 多租户功能通过获得许可的
spree_enterprise+spree_multi_tenantgems(私有 Keygen gem 源)提供。它不属于开源 Spree。在据此规划之前,请向 Spree 确认哪个 gem 版本支持你的 Spree 6 版本。
多租户、多店铺与市场平台
| 需求 | 使用 |
|---|---|
| 许多彼此独立的商家,数据完全隔离,单次部署(类似 Shopify 的 SaaS) | 多租户(本技能) |
| 一个商家运营多个共享商品目录/客户的店面 | 多个 Spree::Store(跨店共享可使用 spree_multi_store)——参见 spree-data-model |
| 许多卖家在同一个共享店面中发布商品 | 市场平台——参见 spree-marketplace |
隔离机制
Spree::Tenant是顶层记录。创建租户时会创建其店铺,并运行 Spree 的种子数据(国家、区域、税种类别……)。- 行级多租户:每个多租户表都有一个
tenant_id列。该 gem 基于acts_as_tenant构建。 - 该 gem 将
Spree.base_class设为SpreeMultiTenant::Base,因此所有继承自Spree.base_class的 Spree 模型都会自动启用多租户。SpreeMultiTenant::Base还会让spree_base_uniqueness_scope返回[:tenant_id],因此唯一性校验(slug、代码、SKU)按租户分别进行。 - 当前租户生效时,所有读取操作都会自动添加
WHERE tenant_id = ?,所有写入操作都会自动设置tenant_id。 - 当前租户会根据每个请求的主机名解析:租户子域名(
store1.example.com)或自定义域名 → 店铺 →store.tenant_id。在控制器中可通过current_tenant获取,在其他位置可通过SpreeMultiTenant.current_tenant获取。 - 一个租户可以拥有多个店铺。
Spree::Store.default返回第一个租户的默认店铺——面向租户的代码绝不能依赖它。 - 员工(管理员用户)是全局的,可受邀加入多个租户。客户按租户区分——同一邮箱可以在两个租户分别注册。
- 隔离同样适用于 Admin API、Store API 和控制面板。
安装(摘要)
-
在服务器环境(以及 CI、预发布、生产构建环境)中设置
KEYGEN_ACCOUNT_ID和KEYGEN_LICENSE_KEY。 -
如果使用 Docker CLI 流程,请运行
spree eject(必须将 gems 安装到你自己的镜像中)。 -
添加到
server/Gemfile:source "https://license:#{ENV['KEYGEN_LICENSE_KEY']}@rubygems.pkg.keygen.sh/#{ENV['KEYGEN_ACCOUNT_ID']}" do gem 'spree_enterprise' gem 'spree_multi_tenant' end -
仅限 Docker:在
server/Dockerfile中将 Keygen 值作为 BuildKit 密钥传递给每一条bundle install(RUN --mount=type=secret,id=keygen_license_key,env=KEYGEN_LICENSE_KEY ...),并在两个 compose 文件中声明这些密钥。绝不要使用ARG/ENV——这会把许可证写入镜像层。 -
运行
spree bundle install(或bundle install),然后:spree generate spree_enterprise:install && spree generate spree_multi_tenant:install # classic: bin/rails g spree_enterprise:install && bin/rails g spree_multi_tenant:install这会复制并运行迁移,在
config/routes.rb中为 Spree 引擎的挂载添加域名约束(并在应用子域名上挂载租户应用),在客户模型中包含SpreeMultiTenant::CustomerUserConcern,并将管理员用户的父类改为Spree::Base。
安装后配置
# server/config/initializers/spree.rb
Spree.root_domain = ENV.fetch('SPREE_ROOT_DOMAIN', 'lvh.me')
# server/.env
SPREE_ROOT_DOMAIN=lvh.me # local dev — NOT localhost (browsers reject localhost as a cookie domain)
COOKIE_TLD_LENGTH=2 # labels in the root domain: example.com=2, shop.example.co.uk=3
-
COOKIE_TLD_LENGTH会将会话 Cookie 限定到父域名,使员工在app.<root>和租户子域名之间切换时保持登录。配置错误时的表现:在子域名之间切换会退出登录,并出现ActionController::InvalidAuthenticityToken。 -
主机授权:允许根域名,否则请求会因“Blocked hosts”而失败:
# server/config/environments/development.rb (and production equivalent) if (root_domain = ENV['SPREE_ROOT_DOMAIN']).present? config.hosts << ".#{root_domain}" end -
客户模型:生成器添加
SpreeMultiTenant::CustomerUserConcern后,从客户模型中移除 Devise 的:validatable——它会强制邮箱全局唯一;该 concern 会按租户重新校验。不要添加其他 Devise 模块。管理员用户模型保留:validatable(员工邮箱是全局唯一的)。 -
config/initializers/spree_multi_tenant.rb:SpreeMultiTenant::Config[:app_subdomain] = 'app' # app.example.com — signup at /tenants/new SpreeMultiTenant::Config[:mail_from_name] = ENV.fetch('MAIL_FROM_NAME', 'Your SaaS') SpreeMultiTenant::Config[:mail_from_address] = ENV.fetch('MAIL_FROM_ADDRESS', "support@#{Spree.root_domain}")mail_from_*用于发送面向员工的邮件(邀请、导出)。客户邮件使用租户店铺的名称,并将店铺邮箱设为Reply-To。
编写面向租户的代码
新模型
租户模型继承自 SpreeMultiTenant::Base(如果使用 Spree.base_class,则会自动继承),并且需要 tenant_id 列。不要声明 belongs_to :tenant——该 gem 会处理。
spree generate model Spree::Brand tenant:references name:string --parent SpreeMultiTenant::Base
如果使用 spree generate api_resource / spree:model,请自行添加 tenant_id 列和索引,并在任何唯一数据库索引中包含 tenant_id([:tenant_id, :slug])。
强制设置 / 退出租户上下文
SpreeMultiTenant.with_tenant(tenant) do
# everything here is scoped to tenant
end
SpreeMultiTenant.without_tenant do
# cross-tenant: platform billing, global reports, maintenance
end
控制台 / rails runner / rake 任务启动时没有租户:
SpreeMultiTenant.current_tenant = Spree::Store.find_by(code: 'my_store').tenant
Spree::Current.store = Spree::Store.find_by(code: 'my_store') # Spree 6 code also expects a current store
为新商家的商店配置资源
在租户上下文中使用 Spree::Seeds::StoreResources.call(store: store) 为其填充数据(每个商店的种子数据——频道、角色、支付方式、API 密钥等——不会影响其他商店),然后使用 Spree::Stores::ProvisionDefaults.call(store:, country:) 配置适用于该国家/地区的默认项(市场、仓库、配送区域、自提)。可选演示数据:Spree::SampleData::LoadJob.perform_later(store.id)。参见 spree-data-model。
后台任务
在当前租户上下文中入队的任务会在该租户的上下文中运行。在租户上下文之外入队的任务(cron/周期性任务、without_tenant 代码块)必须显式遍历租户:
Spree::Tenant.find_each do |tenant|
SpreeMultiTenant.with_tenant(tenant) { MyApp::NightlySyncJob.perform_later }
end
注意事项
insert_all/upsert_all/ 原始 SQL 会绕过租户隔离——请自行在每一行设置tenant_id,否则会创建孤立的/跨租户的记录。- 在租户代码中使用
Spree::Store.default/Spree::Store.first会返回错误租户的商店——请使用current_store/Spree::Current.store。 - 唯一数据库索引中未包含
tenant_id,会导致第二个租户使用相同的 slug/SKU 时在数据库层面失败,即使验证通过也是如此。 - 本地开发时使用
localhost会导致 cookie 出问题;请使用lvh.me或localtest.me。 - 如果 Docker 构建步骤中(不仅是运行时)缺少 Keygen 凭证,
bundle install会返回 401 错误。 - 租户记录(包括 webhook 端点)只能在所属租户内访问;执行跨租户操作的订阅者或任务必须谨慎使用
without_tenant。 - 使用
insert_all写入数据或定义自有基类的第三方扩展可能需要tenant_id迁移和装饰器——请使用两个租户测试每个扩展。
延伸阅读
node_modules/@spree/docs/dist/developer/multi-tenant/quickstart.mdnode_modules/@spree/docs/dist/developer/multi-tenant/core-concepts.mdnode_modules/@spree/docs/dist/developer/multi-tenant/configuration.md- https://spreecommerce.org/pricing(Enterprise 许可证)
- 相关技能:
spree-data-model(商店、频道)、spree-marketplace、spree-deployment、spree-security