Spree 媒体库(Media Library):统一管理门店图片与视频的复用、检索与删除机制
【免费下载链接】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
Spree 6.x 引入了"媒体库(Media Library)":门店中所有的图片和视频不再分散在每个商品、分类与集合的图片字段里,而是统一收纳在后台 Products → Media 页面下,支持按文件名搜索、按类型和"是否被使用"筛选,并且可以在"决定放哪"之前先上传文件。从库中挑选文件是复用(reuse)而非复制(copy)——同一张照片挂在三个商品上,存储中仍然只有一个文件。本文基于.changeset/media-library.md的变更说明,结合@spree/admin-sdk的media资源与 Spree API 的服务端实现,完整讲解媒体库的工作模型、SDK 接口与删除保护机制。
读完本文,你将能够:理解媒体库"文件-行(row)-挂载点(placement)"三层数据模型;调用@spree/admin-sdk的media资源完成上传、检索、复用与带确认的删除;读懂服务端MediaLibraryController中 422 拒绝删除、detach=true一键解绑、distinct_by_file列表去重等关键实现。
媒体库解决什么问题
在媒体库之前,媒体文件是"挂在谁身上就属于谁":商品图片归属于商品,分类图片归属于分类。同一个文件被放到三个商品上就是三个独立副本,无法检索"这个文件在哪些地方用到了",删除一个商品上的图片也不会让你知道另外两个地方还引用着它。
媒体库把模型反转过来:文件是一等公民,挂载只是引用。变更文档中的核心陈述是:
"Picking a file from the library reuses it rather than copying it, so the same photo on three products is one file in storage."(从库中挑选文件是复用它而不是复制它,因此同一张照片出现在三个商品上时,存储中只有一个文件。)
这带来三个直接能力:
- 集中浏览与检索:在 Products → Media 页面浏览全店文件,按文件名搜索,按类型(图片/视频)或"是否被使用"过滤;
- 先上传后安置:
media.create可以创建一条还没有归属任何商品的媒体行,之后再通过source_media_id把它"放"到具体位置; - 删除前可视化影响范围:每个文件都可以先查询它在哪里被使用(usage),删除仍在使用的文件时,商家确认后系统会一次性把它从所有挂载点移除。
数据模型:文件、行与挂载点
要理解媒体库的 API 设计,需要先理解其数据模型。Spree 的媒体记录Spree::Media本质上是一个多态挂载行:它可以挂在商品(product_id)、变体(variant_ids)上,也可以挂在分类、集合等拥有图片字段的对象上。生成的 Media 类型定义 展示了 admin 接口返回的完整字段:
interface Media { id: string; product_id: string | null; // 挂载的商品(可为空 = 未安置) variant_ids: Array<string>; // 挂载的变体 position: number; // 在画廊中的排序 alt: string | null; // 替代文本 media_type: 'image' | 'video' | 'external_video'; focal_point_x: number | null; // 焦点坐标(用于裁切/缩略图) focal_point_y: number | null; external_video_url: string | null; video_provider: string | null; video_url: string | null; poster_url: string | null; original_url: string | null; // 原始尺寸及各档缩略图 URL mini_url: string | null; small_url: string | null; medium_url: string | null; large_url: string | null; xlarge_url: string | null; og_image_url: string | null; // ---- 媒体库新增的文件级字段 ---- attached: boolean; // 是否已被使用(过滤条件) filename: string | null; // 文件名(搜索依据) content_type: string | null; // MIME 类型 byte_size: number | null; // 字节数 embed_url: string | null; // 富文本编辑器嵌入用 URL signed_id: string | null; // 已上传 blob 的签名引用 viewable_id: string | null; // 当前挂载目标的 id download_url: string | null; metadata: Record<string, unknown>; viewable_type: string | null; // 当前挂载目标的类型 }其中attached、filename、content_type、byte_size、embed_url、signed_id正是本次变更在 admin media payload 上新增的字段。它们共同支撑了媒体库的两个核心交互:按文件名/类型/使用状态过滤列表,以及富文本编辑器通过embed_url在商品描述中内嵌图片(这是富文本编辑器第一次支持在描述里嵌入图片)。
"一个文件,多行记录"是理解复用的关键:从源码结构看(MediaLibraryController 注释),当同一张图被放到三个商品上时,数据库里是三行共享同一个存储 blob 的Spree::Media记录——"库显示的是文件(files),usage告诉你每个文件出现在哪里(where each one appears)"。
服务端实现:MediaLibraryController
媒体库的服务端入口是 Spree::Api::V3::Admin::MediaLibraryController,它继承商品作用域的MediaController但丢弃了父级约束(set_parent直接返回nil):
# 库行"出生时未安置"。viewable 保持为 nil, # 直到有人把文件复制到某个商品上。 def build_resource current_store.media.build(media_attributes) end这一设计实现了"先上传、后安置":库中行没有viewable(没有归属目标),而把文件放上架(putting a file ON a product)是嵌套控制器的工作——向该商品的 media 端点 POST 时带上这行的 id 作为source_media_id。
列表去重:distinct_by_file
复用导致同一 blob 对应多行记录,因此列表接口需要按文件去重。控制器的scope方法区分两种请求:
def scope media = current_store.media .accessible_by(current_ability, ability_action_for_request) .order(created_at: :desc) listing? ? media.distinct_by_file : media end- index(列表)应用
distinct_by_filescope(定义在 Spree::Media 模型),每个文件只显示一行; - 成员操作(show/update/destroy)不过滤,因为被分组隐藏的行仍然是客户端可能持有 id 的真实记录,若收窄查询会导致 show 或 destroy 返回 404。
同时,库端点是唯一会应用 admin 基类accessible_by的媒体端点(父控制器的 scope 读取@product,而库请求没有 product),角色层面的记录级权限规则仍然生效;租户隔离则来自行上的store_id而非"两跳之外的商品"。
删除保护:422 + usage + detach
媒体库的destroy是整个功能中防护最严格的路径:
def destroy references = Spree::Media::Usage.call(media: @resource).value return super if references.empty? unless detach_requested? return render_error( code: ERROR_CODES[:resource_invalid], message: Spree.t( 'api.errors.media_in_use', places: references.filter_map(&:name).uniq.first(5).to_sentence ), status: :unprocessable_content, details: { usage: references.map { |reference| reference_payload(reference) } } ) end result = Spree::Media::Destroy.call(media: @resource) return head :no_content if result.success? # ... end行为链条与变更文档完全对应:"Every file shows where it is used before it is deleted, and deleting one that is still in use removes it from those places once the merchant confirms."
- 文件未被使用:直接走普通删除流程(
super); - 文件仍在使用且未传
detach=true:返回422,错误信息列出前 5 个使用位置(places),details.usage返回完整的引用列表,每项包含kind、name、owner_type、owner_id、field——这正是 Dashboard 在删除前弹出的"该文件正在这些地方使用"确认框的数据来源; - 传了
detach=true(Dashboard 在商家确认后发送):调用 Spree::Media::Destroy 服务 所在目录下的销毁流程,一次性把该文件从所有挂载点移除——商品画廊、分类与集合图片字段都在一次操作中处理。
源码注释还明确区分了两种"删除"语义:从库中删除 = 删除文件(受上述保护);而从某个商品画廊移除媒体是嵌套端点的destroy,无此防护——因为那只是移除一个挂载点(placement),不是删除文件。
另外注意create_from_url在库端点被显式禁用并返回 422:URL 导入任务需要一个viewable作为目标,而商家应该在"正在填写的商品"里做 URL 导入,库本身只接收文件上传。
usage 端点与权限映射
usage动作调用Spree::Media::Usage服务返回引用列表,语义上等价于"读",但 CanCanCan 的:read别名只覆盖 index 和 show,因此控制器做了两处显式声明:
def read_actions super + %w[usage] # 让 usage 纳入 API key scope 的"读"判定 end def authorize_resource!(resource = @resource, action = action_name.to_sym) authorize!(action == :usage ? :show : action, resource || Spree::Media) end从源码结构看,这两处注释表明usage被映射为:show来授权——否则仅有只读权限的店员(staffer)会被拒绝访问使用位置,而这恰恰是删除确认流程的前置读取。
@spree/admin-sdk:media 资源
SDK 侧的实现在 admin-client.ts 的 media 命名空间,提供变更文档所列的六个操作list/get/create/update/delete/usage:
// packages/admin-sdk/src/admin-client.ts(节选) readonly media = { // GET /media —— 支持 ListParams 的分页/过滤 list: (params?: ListParams & Record<string, unknown>, options?: RequestOptions) => this.request<PaginatedResponse<Media>>('GET', '/media', { ... }), // GET /media/:id get: (id: string, options?: RequestOptions) => this.request<Media>('GET', `/media/${id}`, options), // POST /media —— 上传一个文件,之后再决定它放在哪 create: (params: MediaLibraryCreateParams, options?: RequestOptions) => this.request<Media>('POST', '/media', { ...options, body: params }), // PATCH /media/:id update: (id: string, params: MediaUpdateParams, options?: RequestOptions) => this.request<Media>('PATCH', `/media/${id}`, { ...options, body: params }), // DELETE /media/:id —— 仍在使用时返回 422 并附 usage, // 除非传 detach,一次性从所有使用位置移除 delete: (id: string, params?: { detach?: boolean }, options?: RequestOptions) => this.request<void>('DELETE', `/media/${id}`, { ...options, params: params?.detach ? { detach: 'true' } : undefined, }), // GET /media/:id/usage —— 删除前先看它被用在哪里 usage: (id: string, options?: RequestOptions) => this.request<{ data: MediaUsageReference[] }>('GET', `/media/${id}/usage`, options), };典型调用序列(删除一个仍在使用的文件):
// 1. 查询使用位置,展示给商家确认 const { data: usage } = await adminClient.media.usage(mediaId); // 2. 确认后带 detach 删除(文件与所有挂载点一并移除) await adminClient.media.delete(mediaId, { detach: true });SDK 注释同样强调了这个安全边界:"A file still in use is refused (422 with its usage) unlessdetachis set"——不带detach的 API 客户端无法意外地从目录底下抽走一个文件。
复用:source_media_id 的工作原理
source_media_id是连接"库"与"具体位置"的桥梁,其行为在两个端点略有不同(见 MediaLibraryController 顶部注释):
- 商品嵌套端点(
POST /products/:product_id/media):source_media_id表示"把库中这个文件放到该商品上"。产品媒体创建参数中它被显式列入可写属性(products_controller.rb),并在 products 嵌套属性 workflow 中由place_from_library处理:它通过product.store&.media&.find_by_param(source_media_id)解析库行——注意必须同门店查找,跨门店的source_media_id不会被采纳; - 库端点(
POST /media):source_media_id表示"把这个文件再复制进库里一次",产生一条与原行共享 blob 的未安置行。
商品创建/更新时同样支持在media数组中直接携带source_media_id(例如{ media: [{ source_media_id: 'xxx', alt: 'Front view' }] }),状态流转测试 覆盖了这些场景,包括source_media_id与其他上传参数(signed_id)同传时的取舍、以及外部门店文件 id 被拒绝的行为。media_controller_spec 则验证了商品级复用与跨门店拒绝。
这一机制还约束了参数解析:在商品媒体创建分支中,携带source_media_id的请求不允许attachment、url、signed_id与其并存覆盖(media_controller.rb 的permitted_params.except(...)逻辑),保证"复用"与"上传"两条分支互斥、语义清晰。
Dashboard 入口与使用场景
变更文档描述了媒体库在 Dashboard 中的四个入口,均位于 packages/dashboard 与 packages/dashboard-core 中:
- 商品画廊的 "Add from library":添加图片时可直接从库中挑选,走上述
source_media_id复用路径。库页面本身的路由见 products/media.tsx; - 分类、集合、卖家(seller)图片字段的 "Choose from library":这些字段同样支持从库挑选。值得注意的是,分类与集合图片现在也出现在媒体库中——即在这些位置上传的文件可以在别处复用,反向亦然;
- 富文本编辑器的图片内嵌:商品描述首次支持嵌入图片,通过 payload 中的
embed_url字段完成; - 删除确认流:点击删除前调用
media.usage,把kind/name/field渲染成"该文件正在此处使用"的确认列表,确认后以detach: true发起删除。
适用前提与边界
- 本文描述的
media资源、source_media_id与 payload 字段对应@spree/admin-sdk与@spree/dashboard的 minor 版本变更(见 变更集),需要运行包含该特性的 Spree 6.x 服务端; - 媒体库端点不支持 URL 导入(
create_from_url返回 422),按 URL 导入文件应在具体商品页面进行; - 删除语义需精确区分:库
delete删除文件本身(受 422/detach 保护);商品嵌套destroy只移除挂载点;源码注释还提示,商品描述中嵌入的文件在detach删除后会留下一个不再可解析的 URL——描述内嵌与挂载点列表不在同一个自动清理范围内,自动化脚本处理时应自行评估; - 权限上,
usage被显式归入"读"类动作,需要对应 API key scope 与 CanCanCanshow权限,仅写权限的 key 无法查询使用位置。
小结
Spree 媒体库把"文件"从各实体的附属属性提升为门店级的一等资源:库中按文件去重展示、source_media_id实现零拷贝复用、usage端点加 422/detach 机制把"删除仍在使用的文件"从一次危险操作变成"查询-确认-批量解绑"的可审计流程。对 API 集成方而言,核心心智模型只有三句话——上传进库不带归属,放上架用source_media_id,删除前先查usage。相关的规划背景可参考 6.0-media-library 计划文档,端到端行为则由 media_spec 与 media_controller_spec 覆盖验证。
【免费下载链接】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),仅供参考