mcpField
S
spree-marketplace

spree-marketplace

github.com/spree开发10102 次安装4 周前更新
代码仓库

关于 spree-marketplace

spree-marketplace 是介绍 Spree 6 卖家入驻、商品审核、订单拆分、佣金和付款的技能。它面向将 Spree 商店配置或扩展为市场平台的团队,涵盖核心数据模型、工作流和商店级设置。

主要功能

  1. 01卖家入驻及状态工作流
  2. 02可配置的卖家要求清单
  3. 03卖家商品提交与审核
  4. 04多卖家订单组及付款拆分
  5. 05佣金记录与卖家付款账本

适用场景

  1. 01配置市场平台的卖家入驻流程
  2. 02构建运营方商品审核队列
  3. 03处理跨多个卖家的结账
  4. 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::Sellersel_店铺内的供应商(单店铺资源,支持软删除)。通过角色拥有商品、库存地点、配送方式、包装类型、政策及自己的团队
Spree::Product#seller_id—nil = Marketplace 自有库存。LineItem#seller_id 会在选择行项目时从变体/商品快照而来,并在下单时冻结
Spree::ProductSubmissionprodsub_审核记录:pending / approved / rejected / withdrawn;记录会累积,最新记录为当前有效记录
Spree::SellerRequirement(STI)selreq_一条检查清单项,是已注册类型的一个实例
Spree::SellerRequirementSubmissionselsub_卖家提交的内容:pending / accepted / rejected / waived
Spree::OrderGroupogrp_一笔结账涉及多个卖家时生成的记录;包含客户、地址、付款和汇总总额
Spree::PaymentSplitpaysp_卖家订单在订单组单笔付款中的份额
Spree::CommissionRate / CommissionRulecrate_ / comrule_配置:收取什么费用,以及何时收取
Spree::CommissionLinecline_单笔费用的冻结记录(按商品收取;配送也收取佣金时,还会按每次履约收取)
Spree::SellerTransfervtr_账本条目,kind 为 earning 或 refund_reversal
Spree::SellerPayoutvpo_转账的结算批次,每种货币一个
卖家状态

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_providernil → Spree::PayoutProvider::System必须是 Spree.payout_providers 中的类名,否则写入会被拒绝。若商店使用的服务提供方已从注册表中移除,则回退到 System
preferred_default_payouts_schedule_intervalmonthlydaily / weekly / biweekly / monthly / manual。卖家的单独设置优先
preferred_default_minimum_payout_amount0低于此金额的余额会结转
preferred_auto_approve_sellersfalse卖家完成清单后立即接纳,无需人工审核
preferred_auto_approve_seller_productsfalse提交流程直接进入审批(仍会写入提交记录,并标记为自动批准)
preferred_send_seller_transactional_emailstrue如果卖家沟通由你自行处理,可将此项关闭
preferred_default_commission_tax_rate0一个小数(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_stripe gem)是 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_data rake 任务仅供开发和测试使用。

延伸阅读

  • node_modules/@spree/docs/dist/developer/core-concepts/sellers.md、…/commissions.md
  • node_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.md
  • node_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

评论

支持 Markdown