关于 spree-marketplace
spree-marketplace 是介绍 Spree 6 卖家入驻、商品审核、订单拆分、佣金和付款的技能。它面向将 Spree 商店配置或扩展为市场平台的团队,涵盖核心数据模型、工作流和商店级设置。
主要功能
- 01卖家入驻及状态工作流
- 02可配置的卖家要求清单
- 03卖家商品提交与审核
- 04多卖家订单组及付款拆分
- 05佣金记录与卖家付款账本
适用场景
- 01配置市场平台的卖家入驻流程
- 02构建运营方商品审核队列
- 03处理跨多个卖家的结账
- 04检查佣金与付款操作
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-marketplace安装后在 Claude Code 中输入/spree-marketplace确认已加载。
附带文件references/payout-provider.mdreferences/seller-api.md
详细介绍
Spree Marketplace(卖家、佣金、付款)
此技能中的所有功能都包含在开源版 Spree 6 中:卖家、入驻流程、商品审核、订单拆分、佣金、付款账本、Stripe Connect 和卖家面板。没有“Marketplace 模式”开关:店铺拥有卖家后,就成为 Marketplace。大多数 Marketplace 都是通过配置而非代码实现的;只有在需要新增要求类型、佣金规则、付款提供商或定制卖家应用时才编写代码。
仅限 Spree Enterprise:退款追索和跨结算净额处理、与提供商对账单进行对账、KYC 运营、卖家税务申报(DAC7),以及将卖家标记为 platform 以代扣代缴税款(Marketplace Facilitator)。不要承诺开源版支持这些功能。
模型
| 模型 | 前缀 | 说明 |
|---|---|---|
Spree::Seller | sel_ | 店铺内的供应商(单店铺资源,支持软删除)。通过角色拥有商品、库存地点、配送方式、包装类型、政策及自己的团队 |
Spree::Product#seller_id | — | nil = Marketplace 自有库存。LineItem#seller_id 会在选择行项目时从变体/商品快照而来,并在下单时冻结 |
Spree::ProductSubmission | prodsub_ | 审核记录:pending / approved / rejected / withdrawn;记录会累积,最新记录为当前有效记录 |
Spree::SellerRequirement(STI) | selreq_ | 一条检查清单项,是已注册类型的一个实例 |
Spree::SellerRequirementSubmission | selsub_ | 卖家提交的内容:pending / accepted / rejected / waived |
Spree::OrderGroup | ogrp_ | 一笔结账涉及多个卖家时生成的记录;包含客户、地址、付款和汇总总额 |
Spree::PaymentSplit | paysp_ | 卖家订单在订单组单笔付款中的份额 |
Spree::CommissionRate / CommissionRule | crate_ / comrule_ | 配置:收取什么费用,以及何时收取 |
Spree::CommissionLine | cline_ | 单笔费用的冻结记录(按商品收取;配送也收取佣金时,还会按每次履约收取) |
Spree::SellerTransfer | vtr_ | 账本条目,kind 为 earning 或 refund_reversal |
Spree::SellerPayout | vpo_ | 转账的结算批次,每种货币一个 |
卖家状态
has_status :pending, :invited, :canceled, :onboarding, :ready_for_review, :approved, :rejected, :suspended(默认 pending)。没有状态机。每次状态变更都是一个工作流,Admin API 会将每个工作流公开为独立的成员操作:
pending ─invite→ invited ─(invitation accepted)→ onboarding ─submit_for_review→ ready_for_review ─approve→ approved
ready_for_review ─reopen_onboarding→ onboarding onboarding|ready_for_review ─reject→ rejected
approved ─suspend→ suspended ─approve→ approved rejected ─approve→ approved
工作流位于 Spree::Sellers::{Create, Invite, StartOnboarding, SubmitForReview, Approve, Reject, Suspend, ReopenOnboarding} 中。每个工作流都声明 hooks :validate, :after_<verb>,因此可通过 Spree.hooks.register('sellers.approve.validate', ->(wf) { wf.reject!('…') if … }) 进行扩展(参见 spree-workflows)。**绝不要编写 seller.update(status: 'approved')。**这会跳过检查、邮件和钩子。只有处于假期模式之外的 approved 卖家才能销售(Spree::Seller.sellable);卖家可通过 holiday_mode_until 自行暂停营业。Sellers::Create 还会为卖家配置库存地点。
事件:seller.invited、seller.onboarding_started、seller.submitted_for_review、seller.approved(负载包含 requirements_overridden 和 unmet_requirements)、seller.rejected、seller.suspended、seller.onboarding_reopened、seller_requirement_submission.{accepted,rejected,waived}、seller_payout.completed、order_group.completed,以及生命周期事件 seller.created/updated/deleted 和 product_submission.*。
商店偏好设置(设置 → Marketplace)
所有这些设置都可以通过 PATCH /api/v3/admin/store(adminClient.store.update({...}))写入:
| 偏好设置 | 默认值 | 说明 |
|---|---|---|
preferred_payout_provider | nil → Spree::PayoutProvider::System | 必须是 Spree.payout_providers 中的类名,否则写入会被拒绝。若商店使用的服务提供方已从注册表中移除,则回退到 System |
preferred_default_payouts_schedule_interval | monthly | daily / weekly / biweekly / monthly / manual。卖家的单独设置优先 |
preferred_default_minimum_payout_amount | 0 | 低于此金额的余额会结转 |
preferred_auto_approve_sellers | false | 卖家完成清单后立即接纳,无需人工审核 |
preferred_auto_approve_seller_products | false | 提交流程直接进入审批(仍会写入提交记录,并标记为自动批准) |
preferred_send_seller_transactional_emails | true | 如果卖家沟通由你自行处理,可将此项关闭 |
preferred_default_commission_tax_rate | 0 | 一个小数(0.23 = 23%)。仪表板会显示为百分比 |
入驻要求
清单按商店配置,在读取时计算。它不会存储在卖家身上,因此新增要求会立即适用于所有人。它只会在两个时点强制执行:SubmitForReview(422 processing_error,指出阻止提交的问题)和 Approve(除非设置 override_requirements: true,否则会被拒绝;且覆盖操作会记录在事件中)。此后仅作参考。
13 种核心类型(Spree.seller_requirements)分为三组:
- 计算型,Spree 会读取卖家数据:
accept_terms、complete_profile、billing_address、returns_address、delivery_method、package_type、minimum_products、payout_account、required_custom_fields、policy。 - 声明型,由卖家提交:
attestation。 - 验证型,由卖家提交,操作员接受或拒绝:
operator_review、document。
新商店的默认清单包含前 7 项。attestation、operator_review、document 和 policy 可以多次出现,并且需要 name。其余类型在每个商店中只能出现一次。type 只能写入一次。
始终根据 GET /api/v3/admin/seller_requirements/types(adminClient.sellerRequirements.types())构建清单界面,该接口会返回每种类型的 preference_schema。切勿将类型列表写死,因为这样自定义类型就能自动显示。
自定义类型是 STI 子类,注册方式如下:
# app/models/my_app/seller_requirements/insurance_on_file.rb
module MyApp::SellerRequirements
class InsuranceOnFile < Spree::SellerRequirement
preference :minimum_cover, :integer, default: 1_000_000
def met_by_seller?(seller) # override this, never #satisfied? (waivers live there)
seller.get_custom_field('insurance.cover')&.value.to_i >= preferred_minimum_cover
end
def applicable?(seller) = true # false hides the row entirely
end
end
# config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.seller_requirements << MyApp::SellerRequirements::InsuranceOnFile
end
需要接收提交内容的类型会重写 self.accepts_submissions?、self.reviewed_by_operator?、self.requires_file? 和 self.accepted_content_types 类方法。上传的文档会根据其字节内容(而非文件名)进行检查,并以私有方式存储。
产品审核
Marketplace 会添加 proposed 和 rejected 产品状态。卖家提交产品,由操作员作出决定。卖家端:sellerClient.products.submit(id) / bulkSubmit;卖家始终可以将自己的商品信息设为 draft 或 archive。操作员端:adminClient.products.approve(id) / reject(id, { reason })。审核队列为 products.list({ status_eq: 'proposed' })。在拒绝调用中填写拒绝原因,该原因会存储在提交记录中。切勿将其放在产品 metadata 中,因为卖家可以覆盖该字段。请参阅 spree-catalog。
拆分结账:订单组
当购物车包含 N 个卖家时,完成结账会创建一个 OrderGroup,其中每个卖家对应一个 Order(另加运营方自营商品对应的订单)。付款只有一笔,并按比例分配为多个 PaymentSplit。单个卖家的结账会生成普通订单,不带订单组。拆分发生在 Spree::Orders::Complete(订单侧工作流)中,购物车结账和管理员创建的草稿订单都会经过此流程。因此:
POST /api/v3/store/carts/:id/complete可以返回OrderGroup(ogrp_…,包含orders[]),而不是Order。@spree/sdk将carts.complete的类型定义为Promise<Order | OrderGroup>;可使用其isOrderGroup(result)守卫进行类型收窄(根据orders[]判断)。确认页面和订单历史记录应将整个订单组显示为一次购买。PATCH /api/v3/admin/orders/:id/complete也可以返回订单组。 在@spree/admin-sdk中,使用isOrderGroup(result):const orders = isOrderGroup(r) ? r.orders : [r]。- 订单组总额是子订单金额之和,而不是均分。配送费和订单级费用会按商品价值比例分摊。
- 结账数据会被复制,而不是共享:每个子订单(以及订单组)都包含结账级字段——客户、电子邮件、公司、市场、渠道、
customer_note、po_number——并各自深度复制购物车的metadata(可由客户写入,因此绝不能将其用于存放审批或欺诈标记)。编辑一个同级子订单的metadata不会影响其他子订单。 - 如果要通过自定义代码完成订单,请调用
Spree.order_complete_workflow或Spree.carts_complete_workflow,绝不要直接写入状态。卖家归属、佣金和订单组都依赖这些工作流。 - 一次购买,一封确认邮件。 每个子订单都会静默创建(其
order.placed负载中的notify_customer: false);客户和店主的电子邮件会在**order_group.completed**时由订单组发送一次(负载包含购买记录实际的notify_customer),通过Spree::OrderGroupMailer/Spree::OrderGroupEmailSubscriber发送,并由order_group.confirmation_delivered/store_owner_notification_delivered记录。从管理后台对任一子订单重新发送确认邮件(POST /api/v3/admin/orders/:id/resend_confirmation→order.resend_confirmation_email)时,系统会转发到order_group.resend_confirmation_email,因此重新发送的是订单组的邮件。如果你的店面或 ERP 会发送自己的确认邮件,也要订阅order_group.completed和order.placed,并同时遵循两者的notify_customer(在 Ruby 中,order.grouped?可用于判断该订单是否为子订单),否则拆分结账将不会发送确认邮件,或会为每个卖家各发送一封。非拆分结账不受影响。 - 订单 ≠ 包裹。 从同一仓库发货的多个卖家订单共用一笔配送费(由这些订单分摊)和一个包裹。要了解“将收到什么”,请使用
order_group.fulfillment_groups(Spree::FulfillmentGroup:delivery_method、cost、line_items、sellers、orders),而不是子订单数量。 - 仅供管理员读取:
adminClient.orderGroups.list()/get('ogrp_…', { expand: ['orders'] })。
佣金
Spree 在 order.placed 上同步收取佣金(Spree::OrderCommissionSubscriber → Spree.commissions_commission_order_service,具有幂等性)。佣金按 line_item.seller_id 分组计算。没有卖家的明细行不收取佣金。核心语义:
- 按列表顺序尝试费率,匹配到的第一个费率生效。 不进行特定性评分。没有规则的费率会匹配所有情况,因此兜底费率应放在列表末尾(例如
position: 99)。新建费率会放在列表顶部。 - 在一个费率中,每条规则都必须匹配。同一条规则中的 ID 表示可选项。规则类型(
Spree.commission_rules)包括product_rule、category_rule(也匹配后代分类)、seller_rule(空规则不匹配任何内容)和item_total_rule(下限包含,上限不包含)。每种类型的规则在每个费率中只能有一条。更新时,rules: [...]会替换整个规则集。通过GET /admin/commission_rates/rule_types查看规则类型。 - 没有匹配的费率就不收取佣金。 Spree 不会自动创建默认费率。
- 默认按折扣后净额计算(
tax_inclusive: false)。include_shipping会对配送费也收取佣金,但fixed费率不能对配送费收取佣金。 - 金额按币种指定,绝不进行换算。
amounts: { USD: '5.00' }设置固定费用,按每件商品收取;如果amounts中没有某种币种,则继续尝试下一个费率。bounds: { EUR: { min_amount, max_amount } }设置百分比费率的下限和上限。对于没有设置边界的币种,百分比费率仍会不设上限地应用。 - 佣金税依据卖家的司法管辖地确定,按以下顺序解析:费率的
commission_tax_rate,然后由税务引擎使用卖家的账单地址确定,最后使用商店的preferred_default_commission_tax_rate。taxability_reason为standard_rated/zero_rated/reverse_charge。 - 明细行是冻结的快照,不提供写入途径。 编辑或软删除费率都不会改写历史记录。
- 佣金不是
Spree::Fee。客户看不到佣金,佣金也不计入订单总额。
可替换点是 Spree::Dependencies 中的 commissions_resolve_rate_service、commissions_resolve_tax_rate_service、commissions_calculate_line_service 和 commissions_commission_order_service 这些键。自定义规则需继承 Spree::CommissionRule、实现 applicable?(context)(上下文会提供 seller、order、line_item、fulfillment、currency、product、subject),并在 Rails.application.config.after_initialize do … end 中追加到 Spree.commission_rules(与上文 requirement kinds 的模式相同)。
打款:账本
order.fulfilled → SellerTransfer(kind: earning) = seller's sale − commission, sale currency
refund.created → SellerTransfer(kind: refund_reversal) (never edits the earning)
sweep (schedule) → SellerPayout per seller per currency, lists the transfers it settles
complete → operator (System provider) or provider webhook → seller_payout.completed
- 卖家在履约时,而非付款时获得收入。数字订单会立即履约,因此收入也会立即产生。
- 转账和打款共用状态:
pending→processing→completed、failed和unresolved。只有已完成的打款才会扣减余额。 创建时不会完成任何操作。unresolved表示结果未知,因此转账会继续保持已认领状态,以避免同一笔钱被支付两次。由操作员(或 webhook)解决。 - 余额按币种计算得出(
adminClient.sellers.balances(id))。手动结算请使用adminClient.sellers.settle(id)(POST /admin/sellers/:id/payouts)。即使部分失败,该操作也会返回 201,失败项列在meta.failures下;没有应付金额时则返回 422。对于 System 提供方,可使用adminClient.sellerPayouts.complete('vpo_…', { reference })将打款标记为已支付。 - 宿主应用必须安排两个定期任务。 如果没有安排,卖家将一直收不到款项,且不会有任何提示。请检查
server/config/recurring.yml(Solid Queue)或使用你自己的调度器:
production:
sweep_due_seller_payouts:
class: Spree::SellerPayouts::SweepDueJob
schedule: at 3am every day
execute_pending_seller_transfers:
class: Spree::SellerTransfers::ExecutePendingDueJob
schedule: every hour at minute 27
在没有卖家的商店中,这两个任务都会快速空操作。每个卖家的间隔逻辑在任务内部运行,因此不要尝试在 cron 中表达。
打款提供方。 提供方在 Spree.payout_providers 中注册,并按商店选择:
Spree::PayoutProvider::System是默认提供方。它负责记账,不会转移资金。SpreeStripe::PayoutProvider(spree_stripegem)是 Stripe Connect。卖家通过 Express 完成入驻。每笔收入都会变成一笔转账,并将source_transaction设置为客户的扣款。打款按照 Spree 的计划运行(关联账户使用手动计划),payout.paid/payout.failed会发送到POST /api/v3/webhooks/payouts/:payment_method_id。在本地开发时,使用带有STRIPE_CONNECT_SIGNING_SECRET的stripe listen --forward-connect-to …。退款只会写入一条冲销记录,因为 Stripe 转账冲销功能属于 Enterprise。- 自定义提供方需继承
Spree::PayoutProvider::Base。有关契约、错误语义、幂等性和 webhook 模式,请参阅 references/payout-provider.md。
当提供方的 requires_payout_account? 返回 true 时,请添加 payout_account 要求。否则,卖家可以获批并开始销售,但其收入会一直处于待处理状态。
卖家 API 和卖家面板
/api/v3/seller/* 是面向已登录卖家团队的独立接口。@spree/seller-sdk 对其进行了封装。以下是需要了解的要点:
- 认证仅使用 JWT,受众为
seller_api(管理员和店铺令牌会返回 401)。刻意不设密钥。每次调用都需要X-Spree-Seller-Id(GET/PATCH /seller/me除外,该接口用于已登录人员自己的账户)。刷新令牌是作用域限定为/api/v3/seller/auth的 HttpOnly Cookie。没有经营任何卖家的店铺员工登录时会返回 401。 - 403 表示卖家标头缺失或未知,或缺少权限键。404 表示该记录对当前卖家不存在;其他卖家的记录也会返回此状态,因此无法枚举记录。没有任何端点会在路径中接收卖家 ID。
- 卖家可以提交产品,且无法设置
active。卖家无法将订单标记为已送达。账本端点(balances、transfers、payouts)为只读。 - 卖家员工的权限来自卖家拥有的角色,使用与后台相同的扁平权限键,但权限范围更窄(参见
spree-auth-permissions)。
import { createSellerClient } from '@spree/seller-sdk'
const seller = createSellerClient({ baseUrl: 'https://marketplace.example.com' })
const { token, sellers } = await seller.auth.login({ email, password })
seller.setToken(token)
seller.setSeller(sellers[0].id)
await seller.orders.fulfillments.fulfill(orderId, fulfillmentId, { tracking: '1Z…' })
完整的命名空间表、运营人员 Admin 端点和路由请参见 references/seller-api.md。
卖家面板。 @spree/seller-dashboard 是一个 React SPA,与管理面板共用 @spree/dashboard-core 和 -ui。create-spree-app 会自动生成该面板。在现有项目中,运行 spree add seller-dashboard,即可创建 apps/seller-dashboard/。开发端口为 5174(管理面板使用 5173),生产环境下通过 /sellers 提供服务。需要注意:
- 仅在
.env.local中设置VITE_API_PROXY_TARGET。开发环境中不要设置VITE_SPREE_API_URL,因为这会绕过代理并导致SameSite=Lax刷新 Cookie 失效。 - 在
src/plugins.ts中通过从@spree/seller-dashboard导入的defineDashboardPlugin自定义(参见spree-dashboard-plugins)。仅有两个插槽:seller.team.actions和seller.team.after。其他功能请添加路由。seller.form_*插槽属于管理面板。 - 内置语言包括英语、阿拉伯语、德语、法语、波兰语和简体中文(
src/locales/{en,ar,de,fr,pl,zh-CN}.json)。每位卖家用户都可在账户对话框中选择语言;该设置会作为selected_locale保存到账户中(PATCH /seller/me),并在其使用任何浏览器登录时应用。切换语言会重新加载页面。要添加语言,只需将另一个src/locales/<code>.json放入面板中——系统会从打包文件中发现它,而不是通过 API 发现。没有品牌配置:可通过 Tailwind 调整样式,并覆盖翻译键来自定义文案。 - 账户对话框通过
PATCH /seller/me编辑已登录人员的信息(姓名、照片、语言);卖家企业(公开资料)则通过PATCH /seller/profile编辑。自定义 UI 中请保留这种区分。
如需在开发环境中使用示例数据,请运行 spree rake spree:sellers:sample_data(原生方式:bin/rails spree:sellers:sample_data)。该命令会以 [email protected] / spree123 登录,并拒绝在开发和测试环境之外运行。
注意事项
- 店铺前台和后台必须处理完成后返回的
OrderGroup结果。这是最常见的 marketplace 错误。 - 缺少定期任务:清理任务和待处理转账重试任务必须配置在
recurring.yml或调度器中。如果缺少这些任务,系统不会发出任何警告。 - 兜底佣金率的优先级高于具体佣金率:排在它后面的所有佣金率都无法生效,新佣金率会添加到它前面。
- 不指定卖家的
seller_rule不会匹配任何对象;如果固定费率没有某种货币对应的金额,则该费率会对该货币跳过。 - 在开放市场中启用自动批准会移除唯一的人工审核关卡。
- 不要直接设置 Seller、Product、SellerPayout 或 Order 的状态。请使用工作流和端点。
- **支付提供方超时时抛出
GatewayError**会导致重新发送款项。请改为抛出Spree::Core::AmbiguousGatewayError。 - **确保支付 Webhook 处理程序具有幂等性。**通过
(provider, reference)匹配付款,绝不能按金额或时间新旧匹配。 - 卖家范围的自定义端点必须将每次查找限定在当前卖家范围内;对于其他卖家的记录,应返回 404,而不是 403。
spree:sellers:sample_datarake 任务仅供开发和测试使用。
延伸阅读
node_modules/@spree/docs/dist/developer/core-concepts/sellers.md、…/commissions.mdnode_modules/@spree/docs/dist/developer/how-to/build-a-marketplace.md:端到端设置指南node_modules/@spree/docs/dist/developer/providers/payouts.md:编写支付提供方node_modules/@spree/docs/dist/integrations/payments/stripe-connect.mdnode_modules/@spree/docs/dist/api-reference/seller-api/{introduction,authentication,errors}.md- https://spreecommerce.org/docs/developer/core-concepts/sellers
- 相关技能:
spree-workflows(钩子)、spree-checkout(购物车完成)、spree-fulfillment(卖家配送方式、包裹类型)、spree-payments(Stripe)、spree-dashboard-plugins、spree-events-webhooks