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 范围说明)曾两次声明卖家不能退款。本规划判定这两处对"正在开放的模块"而言都是错误的,并在此点上取代该计划。
依据有三层:
- 遗留 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商业逻辑:卖家是其子订单的 merchant of record(消费者税与运费收入都归属卖家),"谁收了钱,谁就该退款"。若把每个卖家退款都路由给运营者,运营者就变成了替别人做客服的工单队列——这正是 marketplace 要规避的成本。
约束来自算术,而非权限:三个边界在代码中已经存在(详见下文)。
三条既有算术边界
Returns::Refund的退款上限:return_record.refundable_total(refund_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_available。Spree::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::Refund、Claims::Resolve、Exchanges::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 订单上的退款/索赔/换货都是坏的——因此它作为独立提交先于任何卖家功能落地。
撤销路径与取消退款
已履约订单上的退款已通过订阅者写入负的SellerTransfer(kind: 'refund_reversal',以reversible_amount为界),所以卖家自行退款会正确减少自己的 payout。
卖家取消端点最初未带refund_payments,理由是"退钱是运营者的决定"——但这与本决策自相矛盾:无法履约而撤单的卖家正是收钱的一方。端点现在接受refund_payments,卖家面板提供与运营者相同的开关。边界无需新增:Orders::Cancel#amount_paid只汇总该订单在分组订单上的payment_splits,settle_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.rb、exchange_serializer.rb、claim_serializer.rb、order_serializer.rb均直接或经 V3 基础声明,卖家分支不携带 email、payments、cost price 等字段。
关键决策四:原因词汇在卖家分支只读
创建退货或索赔需要原因,而词汇表(Spree::ReturnReason、Spree::ClaimReason)是运营者定义的Spree::NamedType。卖家获得GET /seller/return_reasons与GET /seller/claim_reasons,仅 index、仅 active 行——与已有的product_types、delivery_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::ReturnReason与reasons_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 :orders与current_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_locations、replacement_variant_for限定current_seller.variants、退货入库也回到卖家自己的货架。
工作流本身原样共享——Spree.return_*_workflow、Spree.claim_*_workflow、Spree.exchange_*_workflow、Spree.fulfillment_*_workflow——所以 hooks、邮件、税额抵免与账本冲销对触发者是谁一视同仁。
关键决策六:不新增权限键
Spree::Return、Spree::Exchange、Spree::Claim已是orderscatalog 资源的 subject,而orders已声明audiences: %i[seller],故read_orders/write_orders门控这一切,种子默认卖家角色(持有write_orders)无需改动。Spree::Refund属于独立的refunds资源,不可授予卖家——但卖家是通过自己退货上的Returns::Refund退款,而非退款端点,因此该处也无键变更。把refunds加入卖家 audience 会开放/admin形状的退款创建,明确不属本计划。
关键决策七:卖家用户已是正确的主体
Return#created_by、Exchange#created_by、Claim#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 仅tracking与tracking_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),以及顶层returnReasons、claimReasons、orderCancellationReasons、trackingCarriers。SDK 注释明确了几处边界语义:orders.cancel无refund_amount(撤单整单退全额,部分退款是退货);fulfillments.fulfill的items收窄即拆分;fulfillments.update可移动库存位置并重选费率;returns.refund由退货与分组支付的份额双重封顶;claims.resolve支持 refund / replacement / 二者兼有。
序列化器设计
全部为新增,均从V3::BaseSerializer声明且不展开客户:Seller::ReturnSerializer、Seller::ReturnLineItemSerializer、Seller::ExchangeSerializer、Seller::ExchangeLineItemSerializer、Seller::ClaimSerializer、Seller::ClaimLineItemSerializer、Seller::ReasonSerializer(一个序列化器服务两个词汇表——原因是id/name/active)。
Seller::FulfillmentSerializer无需新增字段:已携带tracking、tracking_carrier、tracking_url、tracking_status、fulfilled_at、delivery_method_name、stock_location_name,而当前 UI 全部忽略。
Seller::OrderSerializer增加摘要卡片所需、目前缺失的金额字段(shipping_total、promo_total、additional_tax_total、included_tax_total及各自的display_变体),外加canceled_at。不增加email、customer、payments、fees或discounts。从当前仓库实现可见该面已进一步成熟:序列化器还通过many :payment_splits(expand: '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 的订单表也没有批量操作。
迁移路径
- 分组订单退款(Decision 1a),非增量:
Spree::RefundsOrderPayments+PaymentSplit#refundable_amount,被Returns::Refund、Claims::Resolve、Exchanges::Fulfill采用,三个服务均补充分组场景 spec。最先独立发布——它同时修正运营者退款,且卖家端点离开它无法工作。 - 后端,仅增量:路由、六个控制器(
orders/returns、orders/exchanges、orders/claims、return_reasons、claim_reasons、tracking_carriers)、七个序列化器、履约控制器的四个新 member actions。每控制器 spec:happy path、跨卖家 404、退款资金上限。 - SDK:
seller-sdk增加orders.returns、orders.exchanges、orders.claims、returnReasons、claimReasons、trackingCarriers与新履约动词;typelizer:generate重新生成类型。 - 面板:按上文顺序移植组件;履约卡片优先——那是卖家每天都要碰的。
- 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 添加
refunds、payments或commissions。卖家经自己的退货退款,这是唯一路径。 - 承运商凭据为 store 级期间,卖家分支不购买面单。
- 市场计划的"卖家无退款"表述已被本计划 Decision 1 取代;应更新它们而非重新推导旧结论。
遗留的开放问题
- 卖家是否应看到客户姓名?本计划认为应当(地址上无论如何都带姓名,装箱单也要印),但更严格的市场可能只想给首字母。延后决定:它是序列化器字段,改起来无需重构。
- 换货履约起运地:
Exchanges::Fulfill构建替换履约;从卖家哪个库存位置出货目前取订单默认值。值得在卖家自提能力落地时重新审视。 - 卖家的跨订单售后列表:运营者有只读
/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),仅供参考