news 2026/9/15 1:14:08

Spree 6.0 卖家订单管理:为 Marketplace 卖家构建自助履约与售后 API 体系

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spree 6.0 卖家订单管理:为 Marketplace 卖家构建自助履约与售后 API 体系

Spree 6.0 卖家订单管理:为 Marketplace 卖家构建自助履约与售后 API 体系

【免费下载链接】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

导读

本文基于 docs/plans/6.0-seller-order-management.md 规划文档,系统讲解 Spree 6.0 如何为 marketplace 卖家重建完整的订单管理界面与/api/v3/seller分支上的售后 API:履约(fulfillments)、退货(returns)、换货(exchanges)、索赔(claims)与退款能力。读完本文,你将掌握卖家分支的路由设计、七个关键决策背后的安全与边界原理(尤其是"卖家可以退自己订单"的算术约束)、序列化层的隐私隔离策略,以及后端 → SDK → 面板的迁移路径,并能对照当前仓库源码逐条验证。

背景:卖家是订单的法定商户(merchant of record)

在 6.0 的多卖家市场架构中,一次跨卖家购物车在结账时被拆分成 N 个子订单(6.0-multi-vendor-marketplace.md),每个卖家各自成为其子订单的 merchant of record:他们负责打包、发货、收回退货并把客户的钱退还。此前卖家面板的订单界面是一个手工搭建的最小实现——一个扁平的商品列表、一个物流单号输入框和一个取消按钮;而运营者(operator)后台已经拥有完整的订单界面:带承运商感知追踪的履约卡片、部分履约与拆分、以及退货/换货/索赔卡片。

本规划的核心主张是:这不应是权限差异,而是产品缺口。运营者的后台是参考实现,卖家面板应复刻其结构与词汇表,仅裁剪掉运营者专属的部分。该计划被明确定位为"对既有成熟界面的移植",而非新设计,从而降低风险、保证两套界面的行为一致。

注:规划文档标注为 Draft,但当前仓库中该设计的大部分已落地——spree/api/app/controllers/spree/api/v3/seller/orders/下的履约/退货/换货/索赔控制器、spree/api/app/serializers/spree/api/v3/seller/下的序列化器、以及 packages/seller-sdk/src/seller-client.ts 中的卖家 SDK 方法均已存在,可作为逐条验证的依据。

关键决策一:卖家可以退款自己的订单(并修订市场计划)

原市场计划(6.0-multi-vendor-marketplace.md 的 Decision 10 与 Phase 4 范围说明)曾两次声明卖家不能退款。本规划判定这两处对"正在开放的模块"而言都是错误的,并在此点上取代该计划。

依据有三层:

  1. 遗留 Enterprise 行为:旧的多卖家模块通过SpreeMultiVendor::PermissionSets::VendorUser#apply_returns_permissions已授予卖家作用于自己订单的完整退款面:
can :create, Spree::Refund can :manage, Spree::Refund, payment: { order: { vendor_id: @vendor_ids } } can :create, Spree::Reimbursement can :manage, Spree::Reimbursement, order: { vendor_id: @vendor_ids } can :manage, Spree::ReturnAuthorization, order: { vendor_id: @vendor_ids } can :manage, Spree::CustomerReturn, stock_location: { vendor_id: @vendor_ids } can :manage, Spree::ReturnItem, inventory_unit: { order: { vendor_id: @vendor_ids } } can :read, Spree::RefundReason can :read, Spree::ReimbursementType
  1. 商业逻辑:卖家是其子订单的 merchant of record(消费者税与运费收入都归属卖家),"谁收了钱,谁就该退款"。若把每个卖家退款都路由给运营者,运营者就变成了替别人做客服的工单队列——这正是 marketplace 要规避的成本。

  2. 约束来自算术,而非权限:三个边界在代码中已经存在(详见下文)。

三条既有算术边界

  • Returns::Refund的退款上限return_record.refundable_totalrefund_total - refunded_total)拒绝超出金额;退货只属于一个卖家的订单,因此卖家只能索取自己订单退回商品的价值。
  • Spree::Refund#order_is_covered_by_payment:验证退款指向"哪个子订单"。在分组支付(grouped payment)上order_id必填,且必须由该支付的一个 split 命名。当前实现见 spree/core/app/models/spree/refund.rb:对分组支付检查payment.payment_splits.exists?(order_id: order_id),对普通支付检查payment.order_id == order_id,杜绝"给无关订单退款、该记录份额的 split 永远找不到"的错账。
  • Spree::Refunds::Create#refundable_share:把金额封顶在该子订单已捕获份额内,从退款行(refund rows)而非 split 上的订阅者写入数值重新计数——两个同时落地的退款不会各自看到一个"尚未退款"的份额——并在支付的行锁下复检。当前实现见 spree/core/app/workflows/spree/refunds/create.rb:分组支付下取split.refundable_amount,并在payment.with_lock内重新加载 splits、复检ensure_share_still_availableSpree::PaymentSplit#refundable_amount(spree/core/app/models/spree/payment_split.rb)从Spree::Refund.where(payment_id:..., order_id:...).sum(:amount)反推已退款额,正是为了防止"订阅者延迟写回列"造成的并发竞态。它是代码库中唯一创建Spree::Refund的路径,因此无法被绕过。

遗留版本无需这些约束,因为每个子订单都有自己真实的Payment行,credit_allowed天然按卖家隔离;而 6.0 中支付挂在OrderGroup上,由 split 把金额归属到子订单——refundable_share正是这一定位落点。

前置条件(Decision 1a):Spree::RefundsOrderPayments

真正的问题从来不是边界,而是根本够不到支付Returns::RefundClaims::ResolveExchanges::Fulfill三者此前都读取order.payments.completed——这在分组子订单上是空集合,导致三者恰好在卖家最可能退款的订单上报:no_refundable_payments,且三者的 spec 均未覆盖分组场景。

修复方案是共享的Spree::RefundsOrderPayments模块,实现在 spree/core/app/workflows/spree/refunds/order_payments.rb:

  • 非分组订单:按创建时间遍历order.payments.completed,每笔取credit_allowed
  • 分组订单:读取order.payment_splits.includes(:payment),按captured_amount - 已退款行求和计算每笔份额,跳过捕获进行中(capture_in_flight?)的份额——与Orders::Cancel的处理一致,并上报GatewayError供运营者手动结算。
  • 每笔退款仍全部经Spree::Refunds::Create(行锁余额检查、网关贷记、失败行补偿、refund hooks 与payment.refunded事件),模块自身不触碰网关。

这同时也是对运营者的正确性修复——此前所有 marketplace 订单上的退款/索赔/换货都是坏的——因此它作为独立提交先于任何卖家功能落地。

撤销路径与取消退款

已履约订单上的退款已通过订阅者写入负的SellerTransferkind: 'refund_reversal',以reversible_amount为界),所以卖家自行退款会正确减少自己的 payout。

卖家取消端点最初未带refund_payments,理由是"退钱是运营者的决定"——但这与本决策自相矛盾:无法履约而撤单的卖家正是收钱的一方。端点现在接受refund_payments,卖家面板提供与运营者相同的开关。边界无需新增:Orders::Cancel#amount_paid只汇总该订单在分组订单上的payment_splitssettle_grouped_payments逐份额退款、以net_captured_amount封顶并跳过捕获中的份额。refund_amount不进入卖家端点:撤单整单只退该订单已付金额,部分金额属于"退货",走退货端点及其自有上限。

关键决策二:卖家面板复刻 dashboard 结构,减去运营者专属卡片

订单页采用与 dashboard 相同的双列ResourceLayout,卡片词汇与顺序一致。卖家所见是子集,逐卡片按"卖家是否拥有该问题"决定:

卡片卖家原因
履约(追踪、部分履约、拆分、取消/恢复)卖家打包并发货
退货 / 换货 / 索赔卖家收回商品并负责善后
订单摘要(合计)已序列化,但今日从未渲染
商品、两个地址、客户备注merchant of record
客户(仅姓名)是,无邮箱见决策三
支付、税行、折扣、费用市场的资金面
采购订单、内部备注、标签、市场卡片、数字链接、自定义字段、元数据运营者记账

面板继续组合@spree/dashboard-ui@spree/dashboard-core@spree/dashboard导入——运营者应用不是库,其组件绑定adminClient。即使组件除 SDK 外完全相同,也在sellerClient()之上重新实现而非跨两者参数化,因为两个 client 返回不同类型,共享泛型组件将不得不同时泛化两者。

关键决策三:客户邮箱保持隐藏,任何新端点不得泄露

Seller::OrderSerializer(spree/api/app/serializers/spree/api/v3/seller/order_serializer.rb)刻意扣留买家邮箱:这是唯一能让 marketplace 客户被"带走"的联系方式,而打包、发货、开票只需要收货地址上的电话。序列化器类注释明确记录了该理由。Spree::Exports::Orders::SELLER_OMITTED_HEADERS(spree/core/app/models/spree/exports/orders.rb)出于同样原因从卖家 CSV 中剔除邮箱。

该规则必须覆盖三个新嵌套资源。退货/换货/索赔都会间接触达order.customer,因此它们的卖家序列化器一律从V3::BaseSerializer声明,绝不子类化 admin 版本,且没有一个展开 customer。从当前仓库的序列化器目录可见这一纪律的执行:return_serializer.rbexchange_serializer.rbclaim_serializer.rborder_serializer.rb均直接或经 V3 基础声明,卖家分支不携带 email、payments、cost price 等字段。

关键决策四:原因词汇在卖家分支只读

创建退货或索赔需要原因,而词汇表(Spree::ReturnReasonSpree::ClaimReason)是运营者定义的Spree::NamedType。卖家获得GET /seller/return_reasonsGET /seller/claim_reasons,仅 index、仅 active 行——与已有的product_typesdelivery_profiles一致。无 CRUD:卖家自造私有原因会割裂运营者的报表维度。

仓库实现印证了该设计:ReasonsController(spree/api/app/controllers/spree/api/v3/seller/reasons_controller.rb)将read_actions固定为%w[index],scope 为current_store.public_send(reasons_association).active(store 级、剔除已退役原因);ReturnReasonsController仅声明model_class = Spree::ReturnReasonreasons_association = :return_reasons两个配置点。ReasonSerializer统一输出id/name/active,一个序列化器服务两个词汇表。

关键决策五:控制器 scope-first 编写,绝不子类化 admin

市场计划的 Decision 10 在此有约束力:"不要把 admin 控制器子类化进 seller 命名空间——继承的current_store.*查找正是漏洞所在;共享工作流与 permitted-param 列表,绝不共享控制器。"每个新控制器都根植于current_seller_orders,并经由订单触达记录——外来 id 按构造即 404,而非按规则 403

当前实现完全遵循:Orders::BaseController(spree/api/app/controllers/spree/api/v3/seller/orders/base_controller.rb)明确注释"Deliberately not a subclass of the admin base",通过scoped_resource :orderscurrent_seller_orders.find_by_prefix_id!(params[:order_id])解析父订单,并据read_actions派生读写授权(读只需:show,写需:update)。履约/退货/换货/索赔控制器通过include Spree::Api::V3::Orders::FulfillmentActions / ReturnActions / ClaimActions共享运营者工作流,但各自用卖家自有资源限定范围:stock_location_for_split限定current_seller.stock_locationsreplacement_variant_for限定current_seller.variants、退货入库也回到卖家自己的货架。

工作流本身原样共享——Spree.return_*_workflowSpree.claim_*_workflowSpree.exchange_*_workflowSpree.fulfillment_*_workflow——所以 hooks、邮件、税额抵免与账本冲销对触发者是谁一视同仁。

关键决策六:不新增权限键

Spree::ReturnSpree::ExchangeSpree::Claim已是orderscatalog 资源的 subject,而orders已声明audiences: %i[seller],故read_orders/write_orders门控这一切,种子默认卖家角色(持有write_orders)无需改动。Spree::Refund属于独立的refunds资源,可授予卖家——但卖家是通过自己退货上的Returns::Refund退款,而非退款端点,因此该处也无键变更。把refunds加入卖家 audience 会开放/admin形状的退款创建,明确不属本计划。

关键决策七:卖家用户已是正确的主体

Return#created_byExchange#created_byClaim#created_by类型为Spree.admin_user_class,而卖家的团队成员本身是AdminUser(市场计划 Decision 10——一个类、多顶帽子),因此归因无需模型变更:卖家提交的退货记录该卖家的用户。

API 设计:/api/v3/seller新增接口

嵌套在卖家自己的订单下,镜像 admin 路由但剔除资金面:

resources :orders, only: [:index, :show] do member { patch :cancel } resources :fulfillments, only: [:index, :show], controller: 'orders/fulfillments' do member do patch :fulfill patch :update # tracking number + carrier patch :cancel patch :resume patch :split patch :mark_delivered end end resources :returns, only: [:index, :show, :create], controller: 'orders/returns' do member { patch :approve; patch :receive; patch :refund; patch :cancel } end resources :exchanges, only: [:index, :show, :create], controller: 'orders/exchanges' do member { patch :approve; patch :receive; patch :fulfill; patch :cancel } end resources :claims, only: [:index, :show, :create], controller: 'orders/claims' do member { patch :approve; patch :resolve; patch :deny; patch :cancel } end end resources :return_reasons, only: [:index] resources :claim_reasons, only: [:index] resources :tracking_carriers, only: [:index]

刻意缺席:履约 member 列表中没有purchase_label——承运商账户位于 store 级Spree::Integration上,卖家方法是内部费率/手动履约(市场计划 Decision 13),因此 6.0 中卖家不购买面单。卖家分支上的update收窄为仅追踪:admin 端点还会在仓库间移动包裹并重选配送费率,而卖家的"起运地移动"是另一个问题,不在本次裁剪内;permitted params 仅trackingtracking_carrier

对照当前仓库 packages/seller-sdk/src/seller-client.ts 的orders资源,该面已基本兑现:fulfillments(list/get/fulfill/update/cancel/split + deliveries/labels 子资源)、returns(list/get/create/approve/receive/refund/cancel)、exchanges(list/get/create/approve/receive/fulfill/cancel)、claims(list/get/create/approve/resolve/deny/cancel),以及顶层returnReasonsclaimReasonsorderCancellationReasonstrackingCarriers。SDK 注释明确了几处边界语义:orders.cancelrefund_amount(撤单整单退全额,部分退款是退货);fulfillments.fulfillitems收窄即拆分;fulfillments.update可移动库存位置并重选费率;returns.refund由退货与分组支付的份额双重封顶;claims.resolve支持 refund / replacement / 二者兼有。

序列化器设计

全部为新增,均从V3::BaseSerializer声明且不展开客户:Seller::ReturnSerializerSeller::ReturnLineItemSerializerSeller::ExchangeSerializerSeller::ExchangeLineItemSerializerSeller::ClaimSerializerSeller::ClaimLineItemSerializerSeller::ReasonSerializer(一个序列化器服务两个词汇表——原因是id/name/active)。

Seller::FulfillmentSerializer无需新增字段:已携带trackingtracking_carriertracking_urltracking_statusfulfilled_atdelivery_method_namestock_location_name,而当前 UI 全部忽略。

Seller::OrderSerializer增加摘要卡片所需、目前缺失的金额字段(shipping_totalpromo_totaladditional_tax_totalincluded_tax_total及各自的display_变体),外加canceled_at增加emailcustomerpaymentsfeesdiscounts。从当前仓库实现可见该面已进一步成熟:序列化器还通过many :payment_splitsexpand: 'payment_splits'时返回本订单在篮子支付中的份额)与佣金金额族(commission_amount_total/commission_tax_total/commission_total)扩展了摘要能力,并内置internal_note/cancel_reason_name等运营词汇的只读呈现。

面板结构

pages/order.tsx → composition only, like the dashboard's route components/orders/order-header.tsx → PageHeader + cancel in destructiveItems components/orders/fulfillments-card.tsx components/orders/fulfillment-fulfill-form.tsx components/orders/fulfillment-tracking-dialog.tsx components/orders/fulfillment-split-dialog.tsx components/orders/fulfillment-item-list.tsx components/orders/order-items-card.tsx components/orders/order-summary-card.tsx components/orders/order-customer-card.tsx → name + addresses, no email components/orders/returns-card.tsx components/orders/post-sale-cards.tsx → exchanges + claims components/orders/post-sale-create-dialogs.tsx → create return/exchange/claim, resolve claim hooks/use-order.ts, use-fulfillments.ts, use-returns.ts, use-post-sale.ts, use-reasons.ts

卖家面板目前没有hooks/目录——每个页面内联useQuery。本次引入一个,对齐 dashboard 约定,因为四个资源上的十一个 mutation 内联进卡片正是当前页面变得不可维护的原因。

订单列表大体已正确(已有导出按钮、快速筛选与状态词汇)。两处补充:新增status快速筛选以便找到已取消订单;行选择刻意不新增——dashboard 的订单表也没有批量操作。

迁移路径

  1. 分组订单退款(Decision 1a),非增量Spree::RefundsOrderPayments+PaymentSplit#refundable_amount,被Returns::RefundClaims::ResolveExchanges::Fulfill采用,三个服务均补充分组场景 spec。最先独立发布——它同时修正运营者退款,且卖家端点离开它无法工作。
  2. 后端,仅增量:路由、六个控制器(orders/returnsorders/exchangesorders/claimsreturn_reasonsclaim_reasonstracking_carriers)、七个序列化器、履约控制器的四个新 member actions。每控制器 spec:happy path、跨卖家 404、退款资金上限。
  3. SDKseller-sdk增加orders.returnsorders.exchangesorders.claimsreturnReasonsclaimReasonstrackingCarriers与新履约动词;typelizer:generate重新生成类型。
  4. 面板:按上文顺序移植组件;履约卡片优先——那是卖家每天都要碰的。
  5. OpenAPI + i18n:新端点的集成 spec、swaggerize、全部六个 locale 文件的新键。

无数据迁移:此处一切均为增量,没有既有列、路由或序列化器字段改变含义。

对当前工作的约束清单

  • 绝不把 admin 控制器子类化进 seller 命名空间。根植于current_seller_orders,嵌套记录经订单触达。
  • 卖家序列化器永不是 admin 或 store 序列化器的子类。从V3::BaseSerializer声明;子类会继承卖家分支不得携带的字段(email、payments、cost price)。
  • 任何新卖家端点不得暴露买家邮箱,直接或经展开皆不可。若新面需要联系客户,使用收货地址上的电话。
  • 资金只能经共享工作流移动,绝不手写退款。Refunds::Create独占每子订单上限、行锁与网关贷记;以其他方式写Spree::Refund的调用点是跨卖家资金 bug。
  • 分组支付上payment.credit_allowed是全组范围的——它跨所有子订单汇总退款,永远不是单卖家上限。该子订单份额是PaymentSplit#refundable_amount
  • 在可能见到分组订单的路径上绝不读order.payments——那里为空。使用order.payment_splits(或确实需要支付列表时的Order#settlement_payments);新售后资金路径使用Spree::RefundsOrderPayments
  • 不要向权限目录的 seller audience 添加refundspaymentscommissions。卖家经自己的退货退款,这是唯一路径。
  • 承运商凭据为 store 级期间,卖家分支不购买面单
  • 市场计划的"卖家无退款"表述已被本计划 Decision 1 取代;应更新它们而非重新推导旧结论。

遗留的开放问题

  1. 卖家是否应看到客户姓名?本计划认为应当(地址上无论如何都带姓名,装箱单也要印),但更严格的市场可能只想给首字母。延后决定:它是序列化器字段,改起来无需重构。
  2. 换货履约起运地Exchanges::Fulfill构建替换履约;从卖家哪个库存位置出货目前取订单默认值。值得在卖家自提能力落地时重新审视。
  3. 卖家的跨订单售后列表:运营者有只读/returns/exchanges/claims页面;卖家或许也想要"当前未决事项"视图。本轮略去,因为按订单卡片先回答每日问题。

延伸阅读(仓库内)

  • docs/plans/6.0-multi-vendor-marketplace.md — Decision 10(卖家分支)、Decision 13(卖家配送)、Phase 3/3a(拆分 + 账本);其中的"无退款"表述已被本计划 Decision 1 修订。
  • docs/plans/6.0-seller-ledger-ui.md — 本计划未纳入的资金面:为订单页增加支付卡片(本订单在篮子支付中的份额,仅金额)与收益卡片,故上文卡片表中"Payments — 否"指的是分组Payment本体,而非子订单份额。
  • docs/plans/6.0-returns-exchanges-claims.md — 三个模型及其十五个工作流。
  • docs/plans/6.0-fulfillment-and-delivery.md — 履约状态模型、部分履约、追踪轴。
  • spree/core/app/workflows/spree/refunds/order_payments.rb 与 spree/core/app/workflows/spree/refunds/create.rb — 分组退款的实际实现。
  • spree/api/app/controllers/spree/api/v3/seller/orders/ 与 packages/seller-sdk/src/seller-client.ts — 卖家分支 API 与 SDK 的落地形态。

【免费下载链接】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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 1:13:53

2026大模型实测对比:Fable、Astra、GLM Flash与Luna选型指南

2026年9月,我照例把手头在跑的模型全部拉出来做了一轮横向能力测试,包括Fable 5.1、GPT-6 Astra、GLM Flash和Luna。这四款基本代表了当前大模型领域的四种典型路线:综合旗舰、编码特化、轻量快响应、均衡性价比。很多朋友在选型时最容易犯的…

作者头像 李华
网站建设 2026/9/15 1:12:27

GPT2微调实战:从零构建春节对联自动生成系统

简介:基于GPT2的春节对联自动生成系统,聚焦中文对联创作,面向NLP开发者、深度学习者及传统文化传播者。系统借助transformers库与深度学习技术,在自定义春节期间对联数据集上反复训练,使模型掌握对仗工整、平仄相谐的生…

作者头像 李华
网站建设 2026/9/15 1:11:21

GD32F303独立开发指南:时钟/外设/Flash全栈避坑实践

简介:本资源是面向嵌入式初学者与GD32F303单片机开发者的完整软硬件入门套件,覆盖芯片选型、外设驱动开发与工程实践全链路。压缩包含652个文件,总计24.52MB,其中C源码(251个)与头文件(284个&am…

作者头像 李华
网站建设 2026/9/15 1:11:14

Kvasir-SEG+YOLOv8单类别息肉检测实战指南

简介:本资源是面向医学图像AI初学者与计算机视觉实践者的YOLO格式息肉检测专用数据集,基于Kvasir-SEG公开数据构建,专为单类别(息肉)目标检测任务优化,可直接用于模型训练、验证与可视化调试。压缩包共2000…

作者头像 李华
网站建设 2026/9/15 1:09:59

公积金缴纳比例对实际收入的影响分析

1. 公积金缴纳比例差异解析 最近帮朋友分析offer时发现一个有趣现象:两家公司提供的月薪都是2万,但公积金缴纳比例一家是12%,另一家只有5%。粗看似乎差别不大,但实际计算后才发现,这个差异对实际收入的影响远超想象。 …

作者头像 李华