产品商店功能接口文档
概述
产品商店功能允许艺术家销售数字产品(如设计文件、素材包等),支持多规格、文件版本管理、库存控制等完整的电商功能。
核心设计理念
预创建 + 一次性提交
本系统采用混合方案,平衡用户体验和数据一致性:
- 文件预创建:文件上传后立即保存(
option_id=null),后续关联到规格
- 一次性提交:产品、规格、文件关联在一个请求中完成,保证数据一致性
- 通过 ID 判断:有
id 字段表示编辑,无 id 表示新增
前端开发流程
创建产品流程
1. 获取元数据
- 调用 /meta 接口
- 获取产品分类树(product_categories)
2. 用户填写产品基本信息(名称、详情、分类等)
3. 用户添加规格(前端暂存在内存中)
- 填写规格名称、价格、库存类型等
4. 用户为规格上传文件
- 上传到文件服务器(获取 upload_file_id)
- 前端将 upload_file_id 暂存到对应规格
5. 用户点击保存
- 调用 /save 接口(id 为 null 或不传)
- 提交完整数据结构(产品+规格+文件ID)
6. 后端处理
- 创建产品
- 创建规格
- 将文件关联到规格
- 返回完整产品数据
编辑产品流程
1. 获取元数据
- 调用 /meta 接口
- 获取产品分类树(product_categories)
2. 加载产品数据
- 调用 /info 接口
- 获取产品、规格、文件完整数据
3. 用户修改
- 修改产品信息
- 编辑已有规格(有 id)
- 添加新规格(无 id 或 id 为 null)
- 上传新文件(上传到文件服务器,获得 upload_file_id)
- 移除规格封面(将 cover_id 设为 null)
- 标记要删除的规格(记录 delete_option_ids)
- 标记要删除的文件(记录 delete_file_ids)
4. 用户点击保存
- 调用 /save 接口(传入产品 id)
- 提交完整数据结构
5. 后端处理
- 批量校验产品 ID 和规格 IDs
- 更新产品
- 更新/新增规格
- 删除标记的规格
- 关联新文件
- 删除标记的文件
文件处理
重要:文件直接在 save 接口中处理,无需预创建
// 1. 用户选择文件
const file = event.target.files[0];
// 2. 上传到文件服务器(获取 upload_file_id)
const uploadFileResponse = await uploadToFileServer(file);
// 3. 直接暂存到规格的 files 数组中
currentOption.files.push({
upload_file_id: uploadFileResponse.id
});
// 4. 编辑已有文件(更新版本)
const existingFile = currentOption.files[0];
existingFile.upload_file_id = new_upload_file_id; // 更换文件
// 5. 最终提交时,在 /save 接口中传入完整的 files 数组
// 系统会自动判断:
// - 有 id 且 upload_file_id 变化 → 更新版本
// - 有 id 且 upload_file_id 未变化 → 无需操作
// - 无 id → 直接创建新文件
产品展示图(Showcase)处理
重要:产品展示图支持多种类型,需要手动创建
// 1. 上传规格封面图(可选)
const coverResponse = await uploadImage(coverFile, 'product_option_cover');
const cover_id = coverResponse.id;
// 2. 创建 showcase(可选,预创建模式)
const showcaseResponse = await fetch('/api/artist_center/product_showcases/create', {
method: 'POST',
body: JSON.stringify({
type: 'upload_image', // 或 artwork, youtube_link, bilibili_link, upload_video
upload_image_id: image_id
})
});
// 3. 获得 showcase_id,暂存
const showcase_id = showcaseResponse.data[0].id;
// 4. 最终提交时,showcase_ids 会关联到产品
// 在 /save 接口中传入 showcase_ids: [showcase_id]
// 注意:
// - showcase 采用预创建模式,创建时不需要 product_id
// - showcase 只能手动创建,不会自动创建
// - 支持的类型:artwork, upload_image, youtube_link, bilibili_link, upload_video
卖家端接口
1. 统一保存接口(推荐)
接口地址: /api/artist_center/products/save
请求方法: POST
认证要求: 需要登录且为艺术家身份
功能说明: 统一的产品保存接口,支持创建和编辑,一次性处理产品、规格、文件关联
请求参数:
校验与注意事项:
cover_id 会校验归属当前用户(upload_images.user_id),不满足返回 422 错误
- 删除封面需显式传
cover_id: null;不传该字段表示保持原封面不变
- 上传封面图时建议使用 scene 为
product_option_cover,后端会自动设置该场景标识
- 每个规格必须至少包含一个文件:
options[].files 数组不能为空,否则返回错误码 70002
示例请求(创建):
{
"name": {
"_lang": "en",
"en": "Digital Art Pack",
"zh": "数字艺术包"
},
"detail": {
"_lang": "en",
"en": "High quality digital art resources",
"zh": "高质量数字艺术资源"
},
"category_ids": [1, 2],
"status": "draft",
"options": [
{
"name": {
"_lang": "en",
"en": "Standard Edition",
"zh": "标准版"
},
"price": 2999,
"currency_id": 1,
"cover_id": 201,
"stock_type": "fixed",
"stock_quantity": 100,
"files": [
{
"upload_file_id": 456
}
]
},
{
"name": {
"_lang": "en",
"en": "Premium Edition",
"zh": "高级版"
},
"price": 4999,
"currency_id": 1,
"cover_id": 202,
"stock_type": "unlimited",
"files": [
{
"upload_file_id": 789
}
]
}
]
}
错误响应:
// 产品ID不存在
{
"message": "The given data was invalid.",
"errors": {
"id": ["The selected id is invalid."]
}
}
// 规格ID不存在或不属于当前艺术家(支持批量校验,可能返回多个错误)
{
"message": "The given data was invalid.",
"errors": {
"options.0.id": ["The selected id is invalid."],
"options.2.id": ["The selected id is invalid."]
}
}
// 规格缺少文件(错误码 70002)
{
"error_code": 70002,
"message": "Option at index 0 must have at least one file"
}
示例请求(编辑):
{
"id": 1,
"name": {
"_lang": "en",
"en": "Digital Art Pack Updated",
"zh": "数字艺术包(更新)"
},
"status": "active",
"options": [
{
"id": 10,
"price": 3999,
"cover_id": null,
"stock_quantity": 80,
"files": [
{
"id": 101,
"upload_file_id": 456
},
{
"id": 102,
"upload_file_id": 999
},
{
"upload_file_id": 888
}
],
"delete_file_ids": [107]
},
{
"name": {
"_lang": "en",
"en": "New Edition",
"zh": "新版本"
},
"price": 5999,
"currency_id": 1,
"cover_id": 204,
"stock_type": "fixed",
"stock_quantity": 50,
"files": [
{
"upload_file_id": 777
}
]
}
],
"delete_option_ids": [11]
}
说明: 示例中第一个规格的 cover_id: null 表示移除该规格的封面图
示例响应:
{
"data": {
"id": 1,
"user_id": 1,
"artist_id": 1,
"name": {
"en": "Digital Art Pack",
"zh": "数字艺术包"
},
"detail": {
"en": "High quality digital art resources",
"zh": "高质量数字艺术资源"
},
"status": "active",
"created_at": "2025-12-03 10:00:00",
"categories": [
{"id": 1, "name": {...}},
{"id": 2, "name": {...}}
],
"options": [
{
"id": 10,
"name": {...},
"price": 2999,
"stock_type": "fixed",
"stock_quantity": 100,
"is_sold_out": false,
"status": "active",
"files": [
{
"id": 101,
"name": "artwork.psd",
"upload_file_id": 456,
"file_size": 10485760,
"status": "active"
}
]
}
],
"showcases": []
}
}
响应字段说明:
stock_quantity 可能为 null,表示该规格为无限库存
is_sold_out (boolean) 由后端自动计算,前端判断售罄状态时可直接使用该字段,无需再比较 stock_type 和 stock_quantity
2. 获取元数据
接口地址: /api/artist_center/products/meta
请求方法: GET
功能说明: 获取创建和编辑产品所需的元数据信息
示例响应:
{
"data": {
"categories": [
{
"id": 1,
"parent_id": null,
"name": {
"en": "Digital Art",
"zh": "数字艺术"
},
"sort": 1,
"children": [
{
"id": 2,
"parent_id": 1,
"name": {
"en": "Illustrations",
"zh": "插画"
},
"sort": 1,
"children": []
}
]
}
]
}
}
3. 产品列表
接口地址: /api/artist_center/products/list
请求方法: POST
请求参数:
示例响应:
{
"data": [
{
"id": 1,
"name": {...},
"status": "active",
"sales_count": 10,
"created_at": "2025-12-03 10:00:00",
"categories": [...],
"lowest_price_option": {
"id": 10,
"price": 2999,
"stock_type": "fixed",
"sales_count": 5,
"currency": {
"id": 1,
"code": "USD",
"symbol": "$"
}
},
"artist": {
"id": 1,
"name": "Artist Name",
"cname": "artist_cname",
"avatar_id": 100,
"rating_score": 4.8,
"avatar": {
"id": 100,
"url_sm": "https://...",
"url_md": "https://...",
"url_lg": "https://..."
}
}
}
],
"total": 50
}
字段说明:
lowest_price_option: 所有规格中价格最低的规格信息(缓存计算),包含价格、库存类型、销量和货币信息
artist.rating_score: 画师评分(1-5分)
sales_count: 产品总销量
4. 产品详情
接口地址: /api/artist_center/products/info
请求方法: POST
请求参数:
5. 创建 Product Showcase
接口地址: /api/artist_center/product_showcases/create
请求方法: POST
功能说明: 预创建产品展示图,支持多种类型(预创建模式,不需要 product_id)
请求参数:
示例请求:
{
"type": "upload_image",
"upload_image_id": 201
}
示例响应:
{
"data": [
{
"id": 1,
"type": "upload_image",
"uploadImage": {
"id": 201,
"url_sm": "https://...",
"url_md": "https://...",
"url_lg": "https://..."
}
}
]
}
6. 删除 Product Showcase
接口地址: /api/artist_center/product_showcases/delete
请求方法: POST
请求参数:
7. 更新产品翻译
接口地址: /api/artist_center/products/update_translation
请求方法: POST
认证要求: 需要登录且为艺术家身份
功能说明: 独立更新产品的多语言 name 和 detail 字段,用于翻译弹窗场景
请求参数:
示例请求:
{
"id": 1,
"name": {
"_lang": "en",
"en": "Digital Art Pack",
"zh": "数字艺术包",
"ja": "デジタルアートパック"
},
"detail": {
"_lang": "en",
"en": "High quality digital art resources",
"zh": "高质量数字艺术资源",
"ja": "高品質デジタルアートリソース"
}
}
示例响应:
错误响应:
// 产品不存在或无权限
{
"message": "Product not found"
}
// 未提供任何翻译字段
{
"message": "The given data was invalid.",
"errors": {
"name": ["The name field is required when detail is not present."]
}
}
8. 更新产品规格翻译
接口地址: /api/artist_center/product_options/update_translation
请求方法: POST
认证要求: 需要登录且为艺术家身份
功能说明: 独立更新产品规格的多语言 name 字段,用于翻译弹窗场景
请求参数:
示例请求:
{
"id": 10,
"name": {
"_lang": "en",
"en": "Standard Edition",
"zh": "标准版",
"ja": "スタンダード版"
}
}
示例响应:
错误响应:
// 规格不存在或无权限
{
"message": "Option not found"
}
9. 画师销售记录(许可证列表)
接口地址: /api/artist_center/product_licenses/list
请求方法: POST
认证要求: 需要登录且为艺术家身份
功能说明: 查询画师卖出的产品许可证列表(销售记录)
请求参数:
示例请求:
{
"product_id": 10,
"status": ["paid", "paying"],
"page": 1,
"size": 15
}
示例响应:
{
"total": 100,
"data": [
{
"id": 123,
"status": "paid",
"created_at": "2025-12-12T10:30:00.000000Z",
"order": {
"id": 456,
"out_trade_no": "ORDER_20251212103000123456",
"status": "paid",
"init_amount": 5000,
"init_currency_id": 1,
"amount": 4500,
"currency_id": 1,
"currency": {
"id": 1,
"code": "USD",
"name": "美元"
},
"initCurrency": {
"id": 1,
"code": "USD",
"name": "美元"
}
},
"product": {
"id": 10,
"name": "Character Design Pack"
},
"product_option": {
"id": 20,
"name": "Standard License",
"price": 5000,
"currency_id": 1,
"currency": {
"id": 1,
"code": "USD",
"name": "美元"
}
},
"user": {
"id": 789,
"name": "John Doe",
"avatar_id": 100,
"avatar": {
"id": 100,
"url_sm": "https://...",
"url_md": "https://...",
"url_lg": "https://..."
}
}
}
]
}
字段说明:
init_amount: 订单原价(折扣前,单位:分)
amount: 实付金额(折扣后,单位:分)
status: 许可证状态(LicenseStatus枚举)
order.status: 订单状态(OrderStatus枚举)
错误响应:
// 产品不存在
{
"message": "The given data was invalid.",
"errors": {
"product_id": ["The selected product id is invalid."]
}
}
买家端接口
1. 产品列表(公开)
接口地址: /api/content/products/list
请求方法: POST
请求参数:
示例响应:
{
"data": [
{
"id": 1,
"name": {...},
"detail": {...},
"status": "active",
"base_price": 2999,
"stock_type": "fixed",
"sales_count": 10,
"artist": {
"id": 1,
"name": "Artist Name",
"cname": "artist_cname",
"avatar_id": 100,
"rating_score": 4.8,
"avatar": {
"id": 100,
"url_sm": "https://...",
"url_md": "https://...",
"url_lg": "https://..."
}
},
"categories": [...],
"cover": {
"id": 201,
"url_sm": "https://...",
"url_md": "https://...",
"url_lg": "https://..."
},
"lowest_price_option": {
"id": 10,
"price": 2999,
"stock_type": "fixed",
"sales_count": 5,
"currency": {
"id": 1,
"code": "USD",
"symbol": "$"
}
}
}
],
"total": 50
}
字段说明:
base_price: 所有 options 中的最低价(转换为 USD 分),用于价格筛选和排序
stock_type: 产品库存类型。如果所有 options 的 stock_type 都是 fixed 则为 fixed,否则为 unlimited
sales_count: 产品销量(已支付的许可证数量)
lowest_price_option: 所有规格中价格最低的规格信息(缓存计算),包含价格、库存类型、销量和货币信息
artist.rating_score: 画师评分(1-5分)
cover 字段为产品封面图,自动计算并缓存
- 优先级:option cover(第一个有封面的规格) > showcase(第一个展示图)
- 如果都没有则为
null
2. 产品详情(公开)
接口地址: /api/content/products/info
请求方法: POST
请求参数:
3. 预计算支付金额
接口地址: /api/user/pay/product/pre_calc
请求方法: POST
认证要求: 需要登录
功能说明: 预计算支付金额,支持礼品卡钱包抵扣
请求参数:
示例响应:
{
"data": {
"before_amount": {
"amount": 2999,
"currency": {...}
},
"after_amount": {
"amount": 1999,
"currency": {...}
},
"process": [
{
"type": "init_from_product_option",
"before_amount": {...},
"after_amount": {...}
},
{
"type": "wallet_balance_deduction",
"wallet_id": 1,
"wallet_minus": {
"amount": 1000,
"currency": {...}
},
"before_amount": {...},
"after_amount": {...}
}
]
}
}
错误响应:
// 产品规格已售罄(错误码 70001)
{
"error_code": 70001,
"message": "Product option is sold out"
}
4. 创建支付会话
接口地址: /api/user/pay/product/create_checkout_session
请求方法: POST
认证要求: 需要登录
功能说明: 创建产品许可证和支付会话,锁定库存和钱包
请求参数:
示例响应(Stripe):
{
"data": {
"pay_channel": "stripe",
"pay_data": {
"checkout_session_id": "cs_test_xxx",
"client_secret": "cs_test_xxx_secret_xxx"
},
"status": "paying",
"amount": 1999,
"currency": {...}
}
}
示例响应(Alipay):
{
"data": {
"pay_channel": "alipay",
"pay_data": {
"pay_url": "https://openapi.alipay.com/...",
"out_trade_no": "ORDER_xxx"
},
"status": "paying",
"amount": 1999,
"currency": {...}
}
}
示例响应(零金额):
{
"data": {
"pay_channel": "internal",
"pay_data": {
"out_trade_no": "ORDER_xxx",
"license_id": 1
},
"status": "paying",
"amount": 0,
"currency": {...}
}
}
错误响应:
// 产品已售罄(错误码 70001)
{
"error_code": 70001,
"message": "Product is sold out"
}
// 艺术家 Stripe 账户未配置
{
"message": "Artist stripe account not found"
}
5. 确认零金额支付
接口地址: /api/user/pay/product/confirm_zero
请求方法: POST
认证要求: 需要登录
功能说明: 确认零金额支付(全额礼品卡抵扣),触发结算
请求参数:
示例响应:
{
"data": {
"success": true
}
}
错误响应:
- 订单金额不为零: 400 "Order amount is not zero"
- 订单状态不正确: 400 "Order status is not paying"
6. 许可证列表
接口地址: /api/product_licenses/list
请求方法: POST
认证要求: 需要登录
请求参数:
示例请求:
{
"status": ["paid", "pending"],
"page": 1,
"size": 15
}
示例响应:
{
"data": [
{
"id": 1,
"user_id": 1,
"artist_id": 1,
"product_id": 1,
"product_option_id": 1,
"price_snapshot": 2999,
"status": "paid",
"created_at": "2025-12-03 10:00:00",
"product": {
"id": 1,
"name": {...},
"sales_count": 10
},
"productOption": {
"id": 1,
"name": {...},
"price": 2999,
"sales_count": 5,
"currency": {
"id": 1,
"code": "USD",
"symbol": "$"
}
},
"artist": {
"id": 1,
"name": "Artist Name",
"cname": "artist_cname",
"avatar_id": 100,
"rating_score": 4.8,
"avatar": {
"id": 100,
"url_sm": "https://...",
"url_md": "https://...",
"url_lg": "https://..."
}
},
"order": {...}
}
],
"total": 50
}
字段说明:
product.sales_count: 产品总销量
productOption.sales_count: 该规格的销量
artist.rating_score: 画师评分(1-5分)
7. 许可证详情
接口地址: /api/product_licenses/info
请求方法: POST
认证要求: 需要登录
请求参数:
示例响应:
{
"data": {
"id": 1,
"user_id": 1,
"artist_id": 1,
"product_id": 1,
"product_option_id": 1,
"price_snapshot": 2999,
"status": "paid",
"created_at": "2025-12-03 10:00:00",
"product": {...},
"productOption": {
"id": 1,
"name": {...},
"price": 2999,
"files": [...]
},
"artist": {...},
"order": {
"id": 100,
"amount": 2999,
"status": "paid",
"stripeCheckoutSessions": [...],
"alipayCheckoutSessions": [...]
}
}
}
8. 继续支付
接口地址: /api/product_licenses/continue_payment
请求方法: POST
认证要求: 需要登录
功能说明: 继续未完成的支付,自动检测会话过期并重新创建。支持三种支付渠道:
stripe: Stripe 支付
alipay: 支付宝支付
internal: 0 元订单(钱包全额抵扣)
请求参数:
示例响应(0 元订单):
{
"data": {
"pay_channel": "internal",
"pay_data": {
"out_trade_no": "ORDER_xxx",
"license_id": 123
},
"status": "paying",
"amount": 0,
"currency": {
"id": 1,
"code": "USD",
"name": "美元"
}
}
}
前端处理: 当 pay_channel 为 internal 时,直接调用 /api/product_licenses/confirm_zero 接口完成支付。
示例响应(Stripe):
{
"data": {
"pay_channel": "stripe",
"pay_data": {
"checkout_session_id": "cs_test_xxx",
"client_secret": "cs_test_xxx_secret_xxx"
},
"status": "paying"
}
}
示例响应(Alipay):
{
"data": {
"pay_channel": "alipay",
"pay_data": {
"pay_url": "https://openapi.alipay.com/...",
"out_trade_no": "ORDER_xxx"
},
"status": "paying"
}
}
错误响应:
- 许可证状态不允许支付: 400 "License status does not allow payment"
- 订单未找到: 400 "Order not found or not in paying status"
9. 取消许可证
接口地址: /api/product_licenses/cancel
请求方法: POST
认证要求: 需要登录
功能说明: 取消待支付许可证,自动回滚库存和退还礼品卡
请求参数:
示例响应:
{
"data": {
"success": true
}
}
错误响应:
- 许可证无法取消: 400 "License cannot be cancelled"
- 订单已支付: 400 "Order is already paid, cannot cancel"
- 支付处理中: 400 "Payment is processing or completed, cannot cancel"
10. 文件下载功能
产品许可证文件下载功能提供三个接口,支持文件列表查看、版本历史追踪和文件下载。
10.1 获取文件列表
接口地址: /api/product_licenses/files/list
请求方法: POST
认证要求: 需要登录
功能说明: 获取已支付许可证的可下载文件列表,按状态分组显示
业务逻辑:
- 用户购买产品选项后获得持续更新权限,可以访问购买后新增的所有文件
downloads: 显示产品选项中所有当前可用的文件(包括购买后新增的文件)
archived: 只显示"购买时存在且现在被删除"的文件(不包括购买前就已删除的文件)
请求参数:
响应说明:
downloads: 产品选项中所有 status='active' 的文件(包括购买后新增的文件)
archived: 购买时存在(有快照记录)且现在被删除的文件
latest_history: 每个文件的最新版本历史记录,前端可直接使用 latest_history.id 调用 download 接口下载
示例响应:
{
"data": {
"downloads": [
{
"id": 1,
"name": "artwork.psd",
"file_size": 10485760,
"current_version": 3,
"status": "active",
"created_at": "2025-12-03 10:00:00",
"updated_at": "2025-12-05 14:30:00",
"uploadFile": {
"id": 456,
"url": "https://...",
"name": "artwork.psd"
},
"latest_history": {
"id": 15,
"version": 3,
"action": "updated",
"file_size": 10485760,
"upload_file_id": 456,
"created_at": "2025-12-05 14:30:00",
"uploadFile": {
"id": 456,
"url": "https://...",
"name": "artwork.psd"
}
}
}
],
"archived": [
{
"id": 2,
"name": "old_design.ai",
"file_size": 5242880,
"current_version": 1,
"status": "deleted",
"created_at": "2025-12-03 10:00:00",
"deleted_at": "2025-12-06 09:00:00",
"uploadFile": {
"id": 457,
"url": "https://...",
"name": "old_design.ai"
}
}
]
}
}
错误响应:
- 403: 许可证不属于当前用户
- 400: 许可证未支付
10.2 获取文件版本历史
接口地址: /api/product_licenses/files/histories
请求方法: POST
认证要求: 需要登录
功能说明: 获取指定文件的版本历史记录
请求参数:
响应说明:
file: 文件基本信息
histories: 版本历史列表,按版本号倒序排列(最新版本在前)
- 版本访问规则:
- 购买时存在的文件:返回购买时的版本及之后发布的所有版本
- 例如:卖家发布 v1 → v2 → 用户购买 v2 → 卖家发布 v3,则用户可访问 v2 和 v3
- 购买后新增的文件:返回从 v1 开始的所有版本
- 例如:用户购买 → 卖家新增文件 v1 → 更新到 v2,则用户可访问 v1 和 v2
示例响应:
{
"data": {
"file": {
"id": 1,
"name": "artwork.psd",
"current_version": 3,
"status": "active"
},
"histories": [
{
"id": 3,
"action": "updated",
"upload_file_id": 789,
"file_size": 11534336,
"created_at": "2025-12-05 14:30:00",
"uploadFile": {
"id": 789,
"name": "artwork_v3.psd",
"url": "https://..."
}
},
{
"id": 2,
"action": "updated",
"upload_file_id": 567,
"file_size": 10485760,
"created_at": "2025-12-04 11:20:00",
"uploadFile": {
"id": 567,
"name": "artwork_v2.psd",
"url": "https://..."
}
}
]
}
}
错误响应:
- 403: 许可证不属于当前用户
- 404: 文件不存在或不属于该许可证
- 400: 许可证未支付
10.3 下载文件
接口地址: /api/product_licenses/files/download
请求方法: POST
认证要求: 需要登录
功能说明: 生成文件临时下载链接
请求参数:
响应说明:
temporary_url: S3 临时下载链接,有效期 1 小时
expires_at: 链接过期时间
file_name: 文件名
file_size: 文件大小(字节)
示例响应:
{
"data": {
"temporary_url": "https://s3.amazonaws.com/bucket/file.psd?X-Amz-Algorithm=...",
"expires_at": "2025-12-11 12:40:00",
"file_name": "artwork_v3.psd",
"file_size": 11534336
}
}
错误响应:
- 403: 许可证不属于当前用户
- 404: 文件历史记录不存在
- 400: 许可证未支付
- 400: 文件版本在购买时间之前
前端使用流程:
-
文件列表页面:
- 调用
/api/product_licenses/files/list 获取文件列表
- 显示 Downloads 和 Archived 两个区域
- 每个文件显示"下载"和"版本历史"按钮
-
版本历史弹窗:
- 点击"版本历史"按钮
- 调用
/api/product_licenses/files/histories 获取版本列表
- 显示版本号、上传时间、文件大小
-
文件下载:
- 点击"下载"按钮(最新版本或历史版本)
- 调用
/api/product_licenses/files/download 获取临时链接
- 使用临时链接触发浏览器下载
- 链接有效期1小时,过期后需重新获取
数据模型
产品分类系统 (ProductCategory)
产品使用独立的分类系统 product_categories,与服务分类 categories 完全分离。
特点:
- 树状结构:支持父子级分类
- 多语言:分类名称支持多语言
- 独立管理:不与其他业务模块共享分类
数据库表:product_categories
字段:
id: 分类ID
parent_id: 父分类ID(null 表示顶级分类)
name: JSON 格式的多语言名称
sort: 排序顺序
关联关系:
- 通过
product_category_pivot 中间表与 products 多对多关联
产品状态枚举 (ProductStatus)
规格状态枚举 (ProductOptionStatus)
库存类型枚举 (StockType)
文件状态枚举 (FileStatus)
文件操作枚举 (FileAction)
许可证状态枚举 (LicenseStatus)
核心业务流程
1. 完整的产品发布流程
卖家操作:
1. 获取元数据(调用 /meta 接口,获取产品分类)
2. 填写产品基本信息(选择分类)
3. 添加多个规格
4. 为每个规格上传文件(上传到文件服务器,获取 upload_file_id)
5. 点击保存(调用 /save 接口,id 为 null 或不传)
系统处理:
1. 批量校验规格 IDs(如果有)
2. 创建产品记录
3. 创建规格记录
4. 直接创建文件记录
5. 返回完整数据
数据流:
Product (id=1)
├─ ProductOption (id=10, product_id=1)
│ ├─ ProductOptionFile (id=101, option_id=10) ← 创建
│ └─ ProductOptionFile (id=102, option_id=10) ← 创建
└─ ProductOption (id=11, product_id=1)
└─ ProductOptionFile (id=103, option_id=11) ← 创建
2. 编辑产品流程
卖家操作:
1. 获取元数据(调用 /meta 接口,获取产品分类)
2. 加载产品数据(/info)
3. 修改产品信息(包括分类)
4. 编辑规格(有 id)
5. 添加新规格(无 id 或 id 为 null)
6. 上传新文件(上传到文件服务器,获取 upload_file_id)
7. 删除文件(标记 delete_file_ids)
8. 删除规格(标记 delete_option_ids)
9. 保存(/save,传入产品 id)
系统处理:
1. 批量校验产品 ID 和规格 IDs
2. 更新产品
3. 更新已有规格
4. 创建新规格
5. 删除标记的规格(检查是否有已支付订单)
6. 创建/更新文件
7. 删除标记的文件(软删除)
3. 购买和下载流程
买家操作:
1. 浏览产品列表(/api/content/products/list)
2. 查看产品详情(/api/content/products/info)
3. 选择规格
4. 预计算支付金额(/api/user/pay/product/pre_calc)
5. 创建支付会话(/api/user/pay/product/create_checkout_session)
6. 完成支付(Stripe/Alipay)
7. 查看许可证列表(/api/product_licenses/list)
8. 查看可下载文件列表(/api/product_licenses/files/list)
9. 查看文件版本历史(/api/product_licenses/files/histories)
10. 下载文件(/api/product_licenses/files/download)
系统处理:
1. 预计算:检查库存、计算礼品卡抵扣
2. 创建会话:
- 锁定库存(行锁 + 原子递减)
- 创建许可证记录(status=pending)
- 锁定钱包(行锁)
- 创建订单(status=paying)
- 创建支付会话(Stripe/Alipay)
3. 支付回调:
- 更新订单状态(status=paid)
- 更新许可证状态(status=paid)
- 确认钱包扣减
- 结算给卖家
4. 文件下载:返回规格关联的所有文件
特殊情况:
- 零金额支付:调用 /confirm_zero 直接完成支付
- 继续支付:调用 /continue_payment 重新获取支付链接
- 取消订单:调用 /cancel 回滚库存和钱包
4. 文件版本历史业务逻辑
核心理念:用户购买产品选项后获得持续更新权限,可以访问该选项的所有后续更新。
业务规则
-
Downloads(可下载文件):
- 显示产品选项中所有当前 ACTIVE 的文件
- 包括购买后新增的文件(体现持续更新权限)
- 用户可以访问这些文件的所有可用版本
-
Archived(已归档文件):
- 只显示"购买时存在 + 现在被删除"的文件
- 不显示购买前就已删除的文件
- 通过 ProductLicenseFileVersion 快照表判断文件是否在购买时存在
-
版本访问范围:
- 购买时存在的文件:可访问 >= 购买版本的所有版本
- 购买后新增的文件:可访问从 v1 开始的所有版本
时间线示例
符号约定:
- F = 文件(file)
- v1/v2 = 版本号
- D = Downloads(当前可下载)
- A = Archived(已归档)
关键场景说明
场景 1:购买后新增文件
- T2 时 License1 购买,当时只有 F1 和 F2
- T3 时卖家新增 F3
- License1 可以看到 F3(体现持续更新权限)
- License1 可以访问 F3 的所有版本(v1, v2...)
场景 2:文件删除的可见性
- T3 时卖家删除 F1
- License1 在 Archived 中看到 F1(购买时存在)
- License2 在 T4 购买,不会在 Archived 中看到 F1(购买前已删除)
场景 3:版本访问范围
- License1 在 T2 购买时 F2 是 v1
- T3 时 F2 更新到 v2
- License1 可以访问 F2 的 v1 和 v2(从购买版本开始)
- License2 在 T4 购买时 F2 是 v2
- License2 只能访问 F2 的 v2(从购买版本开始)
前端实现建议
-
文件列表页面:
- 使用两个标签页或区域显示 Downloads 和 Archived
- Downloads 区域显示"最新"标记
- Archived 区域显示"已归档"标记和删除时间
-
版本历史弹窗:
- 显示版本号、更新时间、文件大小
- 标记购买时的版本(如果是购买时存在的文件)
- 最新版本置顶显示
-
下载按钮:
- Downloads 区域:默认下载最新版本
- 版本历史:每个版本都有独立下载按钮
- Archived 区域:下载最后一个可用版本
技术实现细节
1. 预创建机制
为什么需要预创建?
- 文件上传是耗时操作,不能等到最后提交
- 用户可能上传多个文件,需要立即反馈
- 前端需要展示已上传的文件列表
如何实现?
// 上传时 option_id 为 null
$file = new ProductOptionFile;
$file->product_id = $data['product_id'];
$file->option_id = null; // 关键
$file->save();
// 保存时关联到规格
ProductOptionFile::whereIn('id', $file_ids)
->where('product_id', $product->id)
->where('option_id', null)
->update(['option_id' => $option->id]);
2. 事务保证
所有写操作都在事务中执行:
DB::beginTransaction();
try {
// 创建产品
// 创建规格
// 关联文件
// 删除规格
// 删除文件
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
3. 库存控制
使用数据库行锁防止超卖:
$affected = DB::table('product_options')
->where('id', $option->id)
->where('stock_quantity', '>=', $quantity)
->decrement('stock_quantity', $quantity);
return $affected > 0;
4. 并发控制
使用行锁和原子操作防止并发问题:
// 库存锁定
$updated = ProductOption::where('id', $option->id)
->where('stock_quantity', '>=', 1)
->lockForUpdate()
->decrement('stock_quantity', 1);
// 钱包锁定
$wallets = Wallet::whereIn('id', $walletIds)
->lockForUpdate()
->get();
// 订单锁定(取消时)
$order = Order::where('id', $orderId)
->lockForUpdate()
->first();
5. 支付会话管理
Stripe 会话:
- 过期时间:30分钟
- 继续支付:可重新创建新会话
- 状态检查:防止支付中取消
Alipay 会话:
- 过期时间:10分钟
- 继续支付:检查过期后重新生成 pay_url
- 金额转换:分→元(÷100)
6. 零金额支付
当礼品卡完全抵扣时:
if ($amount === 0) {
// 返回特殊响应,前端调用 confirm_zero
return [
'pay_channel' => 'internal',
'pay_data' => [
'license_id' => $license->id,
'out_trade_no' => $order->out_trade_no
]
];
}
常见问题
Q1: 为什么不能直接在创建规格时上传文件?
A: 因为前端是在同一个页面完成所有操作,规格可能还没保存到数据库,没有 option_id。采用预创建机制,先上传文件获得 file_id,最后一次性关联。
Q2: 产品分类为什么独立于服务分类?
A: 产品和服务是不同的业务模块,分类需求不同。独立的 product_categories 表可以:
- 独立管理产品分类体系
- 避免与服务分类混淆
- 支持不同的分类结构和层级
Q3: 如果用户上传文件后没有保存产品怎么办?
A: 这些文件会保留在数据库中(option_id=null),可以通过定时任务清理超过一定时间未关联的文件。
Q4: 编辑时如何判断是新增还是更新?
A: 通过 id 字段判断:
id 为 null 或不传 = 创建新记录
id 有值 = 更新已有记录(会提前校验是否存在)
Q5: 支付会话过期后怎么办?
A: 调用 /api/product_licenses/continue_payment 接口,系统会自动检测过期并重新创建支付会话。Stripe 会话有效期 30 分钟,Alipay 会话有效期 10 分钟。
Q6: 如何防止支付后立即取消?
A: 取消接口会检查支付会话状态,如果支付会话状态为 PROCESSING 或 COMPLETED,会拒绝取消操作。同时使用行锁防止并发修改。
Q7: 礼品卡完全抵扣时如何处理?
A: 当计算后金额为 0 时,create_checkout_session 会返回特殊响应(pay_channel='internal'),前端需要调用 /confirm_zero 接口完成支付,无需跳转到支付网关。
Q8: 删除规格时文件会被删除吗?
A: 文件会被软删除(status=deleted),已购买用户仍可下载。
Q9: 批量校验有什么好处?
A: 批量校验规格 IDs 使用 whereIn 查询,相比循环查询:
- 性能更好:N 次查询优化为 1 次查询
- 减少数据库连接开销
- 可以一次性返回所有无效 ID 的错误信息
Q10: 如何保证库存不会超卖?
A: 使用数据库行锁和原子操作:
UPDATE product_options
SET stock_quantity = stock_quantity - 1
WHERE id = ? AND stock_quantity >= 1
最佳实践
前端开发建议
产品管理:
- 初始化:页面加载时先调用 /meta 接口获取产品分类
- 状态管理:使用 Vuex/Pinia 管理产品编辑状态
- 文件上传:显示上传进度,支持取消
- 数据校验:提交前验证必填字段(包括分类选择)
- 错误处理:友好的错误提示,支持批量错误显示
- 库存展示:渲染售罄标记时优先使用响应中的
option.is_sold_out 字段,以保证与后端计算一致
- 防重复提交:保存时禁用按钮
支付流程:
- 预计算:选择规格后先调用
pre_calc 显示最终价格
- 礼品卡选择:允许用户选择要使用的礼品卡钱包
- 零金额处理:当
create_checkout_session 返回 pay_channel='internal' 时,自动调用 confirm_zero
- 支付跳转:
- Stripe:使用
checkout_session_id 跳转到 Stripe Checkout
- Alipay:使用
pay_url 跳转到支付宝支付页面
- 支付状态轮询:支付页面返回后轮询许可证状态,检测支付是否成功
- 继续支付:对于
paying 状态的许可证,提供"继续支付"按钮
- 取消订单:对于
pending 和 paying 状态的许可证,提供"取消"按钮
- 文件下载:只有
paid 状态的许可证才能下载文件
后端开发建议
- 事务使用:所有写操作使用事务
- 权限验证:验证用户是否有权操作(产品和规格都要校验)
- 批量校验:使用 whereIn 批量校验 IDs,避免循环查询
- 数据验证:严格的参数验证,支持 nullable 字段
- 日志记录:记录关键操作日志
- 性能优化:使用 eager loading 避免 N+1
版本历史
- v1.0 (2025-12-03): 初始版本,支持基础产品管理
- v1.1 (2025-12-03): 添加统一保存接口
- v1.2 (2025-12-04):
- 独立产品分类系统(product_categories)
- 添加 meta 接口获取元数据
- 优化 save 接口支持 id 为 null
- 批量校验产品和规格 IDs
- 完善错误响应格式
- 简化文件处理:移除预创建接口,文件直接在 save 接口中创建
- 文件元数据(name、file_size)从 upload_file 关联获取
- v1.3 (2025-12-11):
- 重大更新:完整的产品购买支付系统
- 新增支付接口(3个):
pre_calc、create_checkout_session、confirm_zero
- 新增许可证管理接口(5个):
list、info、continue_payment、cancel、files
- 支持 Stripe 和 Alipay 双支付渠道
- 支持礼品卡钱包抵扣
- 完整的并发控制(库存锁、钱包锁、订单锁)
- 防止支付后取消攻击
- 支付会话过期自动重建
- 零金额支付特殊处理
- 数据模型:ProductLicense 替代 ProductPurchase
- 状态枚举:LicenseStatus(新增 paying 状态)
- 许可证列表响应新增
productOption.currency 货币信息
- 许可证列表响应的
artist 字段新增 avatar 头像信息
- v1.4 (2025-12-12):
- Bug 修复:文件版本历史查询逻辑优化
- 修复版本历史查询排除购买时版本的问题
- 现在正确返回购买时的版本及之后的所有版本
- v1.5 (2025-12-12):
- 功能增强:
continue_payment 接口支持 0 元订单
- 钱包全额抵扣订单返回
pay_channel: 'internal'
- 前端收到
internal 渠道后调用 confirm_zero 完成支付
- v1.6 (2025-12-12):
- Bug 修复:文件列表和版本历史逻辑重构
- 修复
archived 显示所有已删除文件的问题,现在只显示"购买时存在且现在被删除"的文件
- 修复购买后新增文件无法查看历史的问题,现在购买后新增的文件可以查看从 v1 开始的所有版本
- 明确业务逻辑:用户购买产品选项后获得持续更新权限,可以访问购买后新增的所有文件
- v1.7 (2025-12-26):
- 功能增强:产品列表接口新增价格和库存类型筛选
- 新增
base_price 字段:所有 options 中的最低价(转换为 USD 分)
- 新增
stock_type 字段:如果所有 options 都是 fixed 则为 fixed,否则为 unlimited
- 新增筛选参数:
price_currency_id、price_from、price_to、stock_type
- 价格筛选支持多货币:用户传入指定货币的价格,系统自动转换为 USD 后与 base_price 比较
- ProductOption 创建/更新/删除时自动更新 Product 的 base_price 和 stock_type
- 新增
sales_count 字段和 order_by 排序参数(latest/sales)
- 支付成功时自动更新产品销量
- v1.8 (2025-12-31):
- 功能增强:产品列表接口新增
lowest_price_option 和画师评分字段
- 新增
lowest_price_option 字段:所有规格中价格最低的规格信息(缓存计算),包含 id、price、stock_type、sales_count、currency
- 缓存机制:ProductOption 创建/更新/删除时自动清除缓存
- 卖家端产品列表(
/api/artist_center/products/list)新增 lowest_price_option、artist.rating_score、artist.avatar
- 买家端产品列表(
/api/content/products/list)新增 lowest_price_option、artist.rating_score
- 用户许可证列表(
/api/product_licenses/list)新增 artist.rating_score、product.sales_count、productOption.sales_count
- 用户收藏产品列表(
/api/bookmark/saved_products)新增 lowest_price_option、artist.rating_score、artist.avatar
- 文件列表接口(
/api/product_licenses/files/list)新增 latest_history 字段,支持直接下载文件