产品商店功能接口文档

概述

产品商店功能允许艺术家销售数字产品(如设计文件、素材包等),支持多规格、文件版本管理、库存控制等完整的电商功能。

核心设计理念

预创建 + 一次性提交

本系统采用混合方案,平衡用户体验和数据一致性:

  1. 文件预创建:文件上传后立即保存(option_id=null),后续关联到规格
  2. 一次性提交:产品、规格、文件关联在一个请求中完成,保证数据一致性
  3. 通过 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

认证要求: 需要登录且为艺术家身份

功能说明: 统一的产品保存接口,支持创建和编辑,一次性处理产品、规格、文件关联

请求参数:

参数名类型必填说明
idinteger产品ID,可为null或不传表示新增,有值表示编辑
nameobject多语言产品名称
detailobject多语言产品详情
category_idsarray产品分类ID数组(product_categories表)
category_ids.*integer产品分类ID
statusstring产品状态 (draft:草稿, active:上架, inactive:下架)
tagsarray关联标签ID数组
optionsarray规格数组
options[].idinteger规格ID,有则编辑,无则新增
options[].nameobject多语言规格名称
options[].priceinteger价格(以分为单位,与 service.price 一致)
options[].currency_idinteger货币ID
options[].cover_idinteger/null规格封面图ID(upload_images表),传 null 可移除封面;必须是当前用户上传的图片
options[].stock_typestring库存类型 (unlimited:无限, fixed:固定)
options[].stock_quantityinteger/null条件必填库存数量;stock_type=fixed 时必填;stock_type=unlimited 时为 null(表示无限库存)
options[].sortinteger排序顺序
options[].statusstring状态 (active:启用, inactive:禁用)
options[].filesarray文件数组
options[].files[].idinteger文件ID,有则编辑,无则新增
options[].files[].upload_file_idinteger上传文件ID
options[].delete_file_idsarray要删除的文件ID数组
delete_option_idsarray要删除的规格ID数组
showcase_idsarray要关联的手动创建的展示图ID数组

校验与注意事项:

  • 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_typestock_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

请求参数:

参数名类型必填说明
pageinteger页码,默认1
sizeinteger每页数量,默认15,最大50

示例响应:

{
  "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

请求参数:

参数名类型必填说明
idinteger产品ID

5. 创建 Product Showcase

接口地址: /api/artist_center/product_showcases/create

请求方法: POST

功能说明: 预创建产品展示图,支持多种类型(预创建模式,不需要 product_id)

请求参数:

参数名类型必填说明
typestring展示类型:artwork, upload_image, youtube_link, bilibili_link, upload_video
artwork_idsarray条件必填type为artwork时必填,作品ID数组
upload_image_idinteger条件必填type为upload_image时必填
video_urlstring条件必填type为youtube_link或bilibili_link时必填
vidstring条件必填type为youtube_link或bilibili_link时必填
video_preview_imagestring视频预览图
upload_video_idinteger条件必填type为upload_video时必填

示例请求:

{
  "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

请求参数:

参数名类型必填说明
idinteger展示图ID

7. 更新产品翻译

接口地址: /api/artist_center/products/update_translation

请求方法: POST

认证要求: 需要登录且为艺术家身份

功能说明: 独立更新产品的多语言 name 和 detail 字段,用于翻译弹窗场景

请求参数:

参数名类型必填说明
idinteger产品ID
nameobject条件必填多语言产品名称,与 detail 至少提供一个
detailobject条件必填多语言产品详情,与 name 至少提供一个

示例请求:

{
  "id": 1,
  "name": {
    "_lang": "en",
    "en": "Digital Art Pack",
    "zh": "数字艺术包",
    "ja": "デジタルアートパック"
  },
  "detail": {
    "_lang": "en",
    "en": "High quality digital art resources",
    "zh": "高质量数字艺术资源",
    "ja": "高品質デジタルアートリソース"
  }
}

示例响应:

{
  "ok": true
}

错误响应:

// 产品不存在或无权限
{
  "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 字段,用于翻译弹窗场景

请求参数:

参数名类型必填说明
idinteger规格ID
nameobject多语言规格名称

示例请求:

{
  "id": 10,
  "name": {
    "_lang": "en",
    "en": "Standard Edition",
    "zh": "标准版",
    "ja": "スタンダード版"
  }
}

示例响应:

{
  "ok": true
}

错误响应:

// 规格不存在或无权限
{
  "message": "Option not found"
}

9. 画师销售记录(许可证列表)

接口地址: /api/artist_center/product_licenses/list

请求方法: POST

认证要求: 需要登录且为艺术家身份

功能说明: 查询画师卖出的产品许可证列表(销售记录)

请求参数:

参数名类型必填说明
product_idinteger产品ID筛选
statusarray订单状态筛选(OrderStatus枚举值数组)
pageinteger页码,默认1
sizeinteger每页数量,默认15,最大100

示例请求:

{
  "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

请求参数:

参数名类型必填说明
pageinteger页码,默认1
sizeinteger每页数量,默认15,最大50
category_idsarray分类ID数组筛选
category_ids.*integer分类ID
artist_cnamestring艺术家 cname 筛选,若传入数字将按 ID 过滤
price_currency_idinteger价格筛选的货币ID(currencies表),与 price_from/price_to 配合使用
price_frominteger最低价格筛选(指定货币的分),会自动转换为 USD 后与 base_price 比较
price_tointeger最高价格筛选(指定货币的分),会自动转换为 USD 后与 base_price 比较
stock_typestring库存类型筛选 (unlimited, fixed)
order_bystring排序方式:latest(最新,默认)、sales(销量)

示例响应:

{
  "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

请求参数:

参数名类型必填说明
idinteger产品ID

3. 预计算支付金额

接口地址: /api/user/pay/product/pre_calc

请求方法: POST

认证要求: 需要登录

功能说明: 预计算支付金额,支持礼品卡钱包抵扣

请求参数:

参数名类型必填说明
product_option_idinteger产品规格ID
pay_channelstring支付渠道 (stripe, alipay)
walletsarray要使用的钱包ID数组
wallets.*integer钱包ID

示例响应:

{
  "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

认证要求: 需要登录

功能说明: 创建产品许可证和支付会话,锁定库存和钱包

请求参数:

参数名类型必填说明
product_option_idinteger产品规格ID
pay_channelstring支付渠道 (stripe, alipay)
walletsarray要使用的钱包ID数组
wallets.*integer钱包ID

示例响应(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

认证要求: 需要登录

功能说明: 确认零金额支付(全额礼品卡抵扣),触发结算

请求参数:

参数名类型必填说明
license_idinteger许可证ID

示例响应:

{
  "data": {
    "success": true
  }
}

错误响应:

  • 订单金额不为零: 400 "Order amount is not zero"
  • 订单状态不正确: 400 "Order status is not paying"

6. 许可证列表

接口地址: /api/product_licenses/list

请求方法: POST

认证要求: 需要登录

请求参数:

参数名类型必填说明
pageinteger页码,默认1
sizeinteger每页数量,默认15,最大100
statusarray状态筛选数组,可选值: pending, paying, paid, cancelled, refunded

示例请求:

{
  "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

认证要求: 需要登录

请求参数:

参数名类型必填说明
license_idinteger许可证ID

示例响应:

{
  "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 元订单(钱包全额抵扣)

请求参数:

参数名类型必填说明
license_idinteger许可证ID

示例响应(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_channelinternal 时,直接调用 /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

认证要求: 需要登录

功能说明: 取消待支付许可证,自动回滚库存和退还礼品卡

请求参数:

参数名类型必填说明
license_idinteger许可证ID

示例响应:

{
  "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: 只显示"购买时存在且现在被删除"的文件(不包括购买前就已删除的文件)

请求参数:

参数名类型必填说明
license_idinteger许可证ID

响应说明:

  • 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

认证要求: 需要登录

功能说明: 获取指定文件的版本历史记录

请求参数:

参数名类型必填说明
license_idinteger许可证ID
option_file_idinteger文件ID

响应说明:

  • 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

认证要求: 需要登录

功能说明: 生成文件临时下载链接

请求参数:

参数名类型必填说明
license_idinteger许可证ID
file_history_idinteger文件历史记录ID(指定版本)

响应说明:

  • 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: 文件版本在购买时间之前

前端使用流程:

  1. 文件列表页面

    • 调用 /api/product_licenses/files/list 获取文件列表
    • 显示 Downloads 和 Archived 两个区域
    • 每个文件显示"下载"和"版本历史"按钮
  2. 版本历史弹窗

    • 点击"版本历史"按钮
    • 调用 /api/product_licenses/files/histories 获取版本列表
    • 显示版本号、上传时间、文件大小
  3. 文件下载

    • 点击"下载"按钮(最新版本或历史版本)
    • 调用 /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)

说明
draft草稿
active上架
inactive下架

规格状态枚举 (ProductOptionStatus)

说明
active启用
inactive禁用

库存类型枚举 (StockType)

说明
unlimited无限库存
fixed固定库存

文件状态枚举 (FileStatus)

说明
active活跃
deleted已删除

文件操作枚举 (FileAction)

说明
created创建
updated更新
deleted删除

许可证状态枚举 (LicenseStatus)

说明
pending待支付
paying支付中
paid已支付
cancelled已取消
refunded已退款

核心业务流程

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. 文件版本历史业务逻辑

核心理念:用户购买产品选项后获得持续更新权限,可以访问该选项的所有后续更新。

业务规则

  1. Downloads(可下载文件)

    • 显示产品选项中所有当前 ACTIVE 的文件
    • 包括购买后新增的文件(体现持续更新权限)
    • 用户可以访问这些文件的所有可用版本
  2. Archived(已归档文件)

    • 只显示"购买时存在 + 现在被删除"的文件
    • 不显示购买前就已删除的文件
    • 通过 ProductLicenseFileVersion 快照表判断文件是否在购买时存在
  3. 版本访问范围

    • 购买时存在的文件:可访问 >= 购买版本的所有版本
    • 购买后新增的文件:可访问从 v1 开始的所有版本

时间线示例

符号约定

  • F = 文件(file)
  • v1/v2 = 版本号
  • D = Downloads(当前可下载)
  • A = Archived(已归档)
时间点卖家操作产品选项状态License1 看到License2 看到License3 看到
T1创建产品
F1(v1), F2(v1)
F1(v1) ✅
F2(v1) ✅
---
T2用户购买 → License1F1(v1) ✅
F2(v1) ✅
D: F1(v1), F2(v1)
A:
--
T3删除 F1
更新 F2→v2
新增 F3(v1)
F1(v1) ❌
F2(v2) ✅
F3(v1) ✅
D: F2(v1,v2), F3(v1)
A: F1(v1)
--
T4用户购买 → License2F1(v1) ❌
F2(v2) ✅
F3(v1) ✅
D: F2(v1,v2), F3(v1)
A: F1(v1)
D: F2(v2), F3(v1)
A:
-
T5删除 F2
更新 F3→v2
新增 F4(v1)
F1(v1) ❌
F2(v2) ❌
F3(v2) ✅
F4(v1) ✅
D: F3(v1,v2), F4(v1)
A: F1(v1), F2(v1,v2)
D: F3(v1,v2), F4(v1)
A: F2(v2)
-
T6用户购买 → License3F1(v1) ❌
F2(v2) ❌
F3(v2) ✅
F4(v1) ✅
D: F3(v1,v2), F4(v1)
A: F1(v1), F2(v1,v2)
D: F3(v1,v2), F4(v1)
A: F2(v2)
D: F3(v2), F4(v1)
A:

关键场景说明

场景 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(从购买版本开始)

前端实现建议

  1. 文件列表页面

    • 使用两个标签页或区域显示 Downloads 和 Archived
    • Downloads 区域显示"最新"标记
    • Archived 区域显示"已归档"标记和删除时间
  2. 版本历史弹窗

    • 显示版本号、更新时间、文件大小
    • 标记购买时的版本(如果是购买时存在的文件)
    • 最新版本置顶显示
  3. 下载按钮

    • 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

最佳实践

前端开发建议

产品管理

  1. 初始化:页面加载时先调用 /meta 接口获取产品分类
  2. 状态管理:使用 Vuex/Pinia 管理产品编辑状态
  3. 文件上传:显示上传进度,支持取消
  4. 数据校验:提交前验证必填字段(包括分类选择)
  5. 错误处理:友好的错误提示,支持批量错误显示
  6. 库存展示:渲染售罄标记时优先使用响应中的 option.is_sold_out 字段,以保证与后端计算一致
  7. 防重复提交:保存时禁用按钮

支付流程

  1. 预计算:选择规格后先调用 pre_calc 显示最终价格
  2. 礼品卡选择:允许用户选择要使用的礼品卡钱包
  3. 零金额处理:当 create_checkout_session 返回 pay_channel='internal' 时,自动调用 confirm_zero
  4. 支付跳转
    • Stripe:使用 checkout_session_id 跳转到 Stripe Checkout
    • Alipay:使用 pay_url 跳转到支付宝支付页面
  5. 支付状态轮询:支付页面返回后轮询许可证状态,检测支付是否成功
  6. 继续支付:对于 paying 状态的许可证,提供"继续支付"按钮
  7. 取消订单:对于 pendingpaying 状态的许可证,提供"取消"按钮
  8. 文件下载:只有 paid 状态的许可证才能下载文件

后端开发建议

  1. 事务使用:所有写操作使用事务
  2. 权限验证:验证用户是否有权操作(产品和规格都要校验)
  3. 批量校验:使用 whereIn 批量校验 IDs,避免循环查询
  4. 数据验证:严格的参数验证,支持 nullable 字段
  5. 日志记录:记录关键操作日志
  6. 性能优化:使用 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_calccreate_checkout_sessionconfirm_zero
    • 新增许可证管理接口(5个):listinfocontinue_paymentcancelfiles
    • 支持 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_idprice_fromprice_tostock_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_optionartist.rating_scoreartist.avatar
    • 买家端产品列表(/api/content/products/list)新增 lowest_price_optionartist.rating_score
    • 用户许可证列表(/api/product_licenses/list)新增 artist.rating_scoreproduct.sales_countproductOption.sales_count
    • 用户收藏产品列表(/api/bookmark/saved_products)新增 lowest_price_optionartist.rating_scoreartist.avatar
    • 文件列表接口(/api/product_licenses/files/list)新增 latest_history 字段,支持直接下载文件