更新时间:2026-05-21
目标:用户生成书籍音频专辑后,其它用户通过付费购买进行收听
【创作者 A】 【消费者 B】
│ │
├─ 创建书籍 + AI 生成音频 ├─ 逛有声集市
├─ 发布到有声集市 ├─ 试听预览片段
├─ 设定价格(整张一口价) ├─ 付费购买专辑
├─ 查看收益、提现 ├─ 解锁全部内容,永久收听
├─ 我的已购
// 有声集市 - 专辑上架定价
model AlbumMarketListing {
id Int @id @default(autoincrement())
bookId Int @unique
creatorId Int
title String
description String? @db.Text
coverUrl String?
price Decimal @default(0.00) @db.Decimal(10, 2) // 售价(整张一口价)
isActive Boolean @default(false) // 是否上架
isRecommended Boolean @default(false) // 推荐位
totalSales Int @default(0) // 累计销量
totalRevenue Float @default(0.00) // 累计销售额
avgRating Float @default(0.00)
previewChapterCount Int @default(3) // 免费试听章节数
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
book Book @relation(fields: [bookId], references: [id])
@@index([isActive, totalSales])
@@index([creatorId])
}
// 内容购买记录
model ContentPurchase {
id Int @id @default(autoincrement())
buyerId Int
listingId Int
bookId Int
orderNo String @unique
amount Decimal @db.Decimal(10, 2) // 实付金额
status String @default("success") // 'success' | 'refunded'
paymentMethod String?
paymentId String?
platformFee Decimal @default(0) @db.Decimal(10, 2)
creatorShare Decimal @default(0) @db.Decimal(10, 2)
platformShare Decimal @default(0) @db.Decimal(10, 2)
createdAt DateTime @default(now())
listing AlbumMarketListing @relation(fields: [listingId], references: [id])
buyer User @relation("buyer", fields: [buyerId], references: [id])
@@unique([buyerId, bookId]) // 每人每本书只能购买一次
@@index([buyerId, createdAt])
@@index([listingId])
@@index([orderNo])
}
// 创作者收益记录
model CreatorEarning {
id Int @id @default(autoincrement())
creatorId Int
purchaseId Int
bookId Int
amount Decimal @db.Decimal(10, 2) // Float→Decimal
type String // 'sale' | 'refund'
status String @default("pending") // 'pending' | 'settled' | 'withdrawn'
settleDate DateTime?
withdrawDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation(fields: [creatorId], references: [id])
purchase ContentPurchase @relation(fields: [purchaseId], references: [id])
@@index([creatorId, status])
@@index([creatorId, createdAt])
}
// 创作者钱包
model CreatorWallet {
id Int @id @default(autoincrement())
userId Int @unique
balance Decimal @default(0.00) @db.Decimal(12, 2) // 可提现余额
totalEarned Decimal @default(0.00) @db.Decimal(12, 2)
totalWithdrawn Decimal @default(0.00) @db.Decimal(12, 2)
frozenBalance Decimal @default(0.00) @db.Decimal(12, 2) // T+7 冻结余额
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
// 提现记录
model WithdrawRecord {
id Int @id @default(autoincrement())
userId Int
amount Decimal @db.Decimal(10, 2) // 提现金额
method String @default("alipay")
account String
status String @default("pending")
remark String?
approvedAt DateTime?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId, status])
@@index([status, createdAt])
}
// User 新增
model User {
// ... 现有字段 ...
isCreator Boolean @default(false)
creatorBio String?
creatorListings AlbumMarketListing[]
purchases ContentPurchase[]
creatorEarnings CreatorEarning[]
creatorWallet CreatorWallet?
withdrawRecords WithdrawRecord[]
}
// Book 新增
model Book {
// ... 现有字段 ...
totalSales Int @default(0)
marketListing AlbumMarketListing?
}
/api/marketplace)| 方法 | 路由 | 功能 |
|---|---|---|
| GET | /api/marketplace/albums |
集市列表(分类/排序/分页) |
| GET | /api/marketplace/albums/:id |
专辑详情(试听控制+购买状态) |
| GET | /api/marketplace/albums/:id/try |
获取试听章节内容 |
| POST | /api/marketplace/albums/:id/purchase |
购买专辑 |
| GET | /api/marketplace/purchases |
我的已购列表 |
| GET | /api/marketplace/check-access/:bookId |
检查访问权限 |
/api/creator)| 方法 | 路由 | 功能 |
|---|---|---|
| POST | /api/creator/activate |
激活创作者身份 |
| GET | /api/creator/dashboard |
仪表盘汇总 |
| GET | /api/creator/listings |
我的上架作品 |
| POST | /api/creator/listings/:bookId |
创建/更新上架定价 |
| DELETE | /api/creator/listings/:listingId |
下架作品 |
| GET | /api/creator/earnings |
收益明细 |
| GET | /api/creator/wallet |
钱包信息 |
| POST | /api/creator/withdraw |
申请提现 |
// 核心逻辑:整张专辑一口价,购买后解锁全部章节
export async function requireContentAccess(ctx, next) {
const userId = ctx.state.user?.id;
const bookId = Number(ctx.params.bookId);
const chapterId = Number(ctx.params.chapterId);
// 1. 创作者本人 → 放行
const book = await prisma.book.findUnique({ where: { id: bookId } });
if (book?.userId === userId) return next();
// 2. 已购买整张专辑 → 放行
const purchase = await prisma.contentPurchase.findFirst({
where: { buyerId: userId, bookId, status: 'success' }
});
if (purchase) return next();
// 3. 试听章节 → 放行
const listing = await prisma.albumMarketListing.findUnique({
where: { bookId, isActive: true }
});
if (listing) {
const chapters = await prisma.bookChapter.findMany({
where: { bookId, level: 1 }, orderBy: { number: 'asc' }
});
const idx = chapters.findIndex(c => c.id === chapterId);
if (idx >= 0 && idx < listing.previewChapterCount) return next();
}
// 4. 拒绝
ctx.status = 402;
ctx.body = {
code: 402,
message: '此内容需要购买后才能收听',
needPurchase: true,
listingId: listing?.id
};
}
创作者自主定价,整张专辑一口价,简单清晰:
| 定价档位 | 建议价格 | 适用场景 |
|---|---|---|
| 体验价 | ¥0.99 ~ ¥4.99 | 短篇/新人引流 |
| 标准价 | ¥4.99 ~ ¥19.99 | 中长篇/常规内容 |
| 精品价 | ¥19.99 ~ ¥49.99 | 高质量/独家内容 |
| 旗舰价 | ¥49.99 ~ ¥99.99 | 超长篇/系列合集 |
消费者支付 ¥10
├─ 1% 支付手续费 → ¥0.10
├─ 20% 平台技术服务费 → ¥1.98
└─ 79% 创作者分成 → ¥7.92
| # | 页面路径 | 名称 | 功能 |
|---|---|---|---|
| 1 | pages/marketplace/index |
有声集市 | 浏览上架专辑,分类/排序/瀑布流 |
| 2 | pages/marketplace/detail |
专辑购买页 | 详情、试听、价格、购买 |
| 3 | pages/creator/dashboard |
创作者中心 | 数据仪表盘 |
| 4 | pages/creator/listings |
作品管理 | 上架/下架/定价 |
| 5 | pages/creator/earnings |
收益明细 | 流水查看 |
| 6 | pages/creator/wallet |
我的钱包 | 余额/提现 |
| 7 | pages/purchases/index |
我的已购 | 已购内容库 |
【消费者】
首页 → [有声集市] → 专辑详情 → 试听 → 购买 → 支付 → 解锁 → 播放
└→ [我的已购]
【创作者】
我的 → [创作者中心] → [作品管理] → 选择作品 → 设定价格 → 上架
→ [收益明细] → 查看流水
→ [我的钱包] → 提现
有声集市首页:分类标签栏(全部/有声书/知识/故事)+ 排序(销量/最新/价格)+ 双列瀑布流,每张卡片显示封面、标题、创作者、价格、评分。
专辑购买页:大封面 + 标题/创作者/评分/销量 + 章节列表(前3章可试听🎧,后面显示🔒)+ 底部固定购买按钮(显示价格)。
创作者中心:头像+名称 + 三指标卡片(累计销量/收益/余额)+ 收益趋势图 + 快捷入口。
| ID | 功能 | 优先级 |
|---|---|---|
| 1 | 数据库模型扩展 + Prisma 迁移 | 🔴 P0 |
| 2 | 有声集市列表 API + 前端页面 | 🔴 P0 |
| 3 | 专辑详情 API + 购买页 | 🔴 P0 |
| 4 | 购买流程 + 支付对接 | 🔴 P0 |
| 5 | 内容鉴权中间件 | 🔴 P0 |
| 6 | 我的已购页面 | 🟡 P1 |
| 7 | 创作者激活 | 🟡 P1 |
| 8 | 作品管理(上架/下架/定价) | 🟢 P2 |
| 9 | 创作者仪表盘 | 🟢 P2 |
| 10 | 收益明细 + 钱包 + 提现 | 🟢 P2 |
| 11 | 首页集市入口 + 推荐卡片 | 🟡 P1 |
预估总工时:约 36 小时(4.5 个工作日)
| # | 决策项 | 建议 | |---|--------|------| | 1 | 分账比例 | 平台 20% / 创作者 79% / 支付费 1% | | 2 | 结算周期 | T+7 | | 3 | 最低提现额 | ¥10 | | 4 | 试听章节数 | 前 3 章 | | 5 | 定价范围 | ¥0.99 ~ ¥99.99(整张一口价) | | 6 | 创作者门槛 | 完成至少 1 本书籍音频生成 | | 7 | 内容审核 | 初期不上审核,后续接入 |
以下为评审后发现的必须修复项和优化建议。
所有金额字段从 Float 改为 Decimal,与现有 Order.amount 保持一致,避免浮点精度问题。
// 修改前
albumPrice Float @default(0.00)
// 修改后
albumPrice Decimal @default(0.00) @db.Decimal(10, 2)
涉及字段:AlbumMarketListing.price / totalRevenue、ContentPurchase.amount / platformFee / creatorShare / platformShare、CreatorEarning.amount、CreatorWallet.balance / totalEarned / totalWithdrawn / frozenBalance、WithdrawRecord.amount
核心问题:当前 audioUrl 是静态路径(如 /public/videos/xxx.mp3),购买者拿到 URL 可直接分享。
方案:签名 URL + 音频代理端点
改造前:前端直接播放 /public/videos/chapter1.mp3
改造后:前端请求 /api/marketplace/audio/:chapterId?token=xxx
流程:
1. 前端请求音频播放地址
2. 后端验证用户权限(所有者/已购买/试听章节)
3. 生成带签名的临时 URL(有效期 2 小时)
4. 返回签名 URL,前端用签名 URL 播放
5. 过期后需重新请求
// 新增 API
GET /api/marketplace/audio/:chapterId
→ 鉴权 → 生成签名 URL → 302 重定向到签名 URL
// 签名 URL 格式
/protected/audio/chapter1.mp3?sign=abc123&expires=1716288000
// Nginx 层验证签名
location /protected/audio/ {
secure_link $arg_sign;
secure_link_md5 "$uri $arg_expires $secret";
if ($secure_link = "") { return 403; }
if ($secure_link = "0") { return 410; } # expired
alias /path/to/audio/;
}
// ContentPurchase 联合唯一约束
model ContentPurchase {
// ... 现有字段 ...
@@unique([buyerId, bookId]) // 每人每本书只能购买一次
}
同时购买操作需用数据库事务 + 乐观锁:
async function purchaseAlbum(buyerId: number, listingId: number) {
return await prisma.$transaction(async (tx) => {
// 1. 检查是否已购买(防重复)
const existing = await tx.contentPurchase.findFirst({
where: { buyerId, bookId: listing.bookId, status: 'success' }
});
if (existing) throw new Error('已购买该专辑');
// 2. 创建订单 + 扣款 + 分账(原子操作)
// ...
});
}
// 上架前必须校验
async function validateBeforeListing(bookId: number) {
const book = await prisma.book.findUnique({
where: { id: bookId },
include: { chapters: { where: { level: 1 } } }
});
const errors: string[] = [];
if (!book) errors.push('书籍不存在');
if (book.progress < 100) errors.push('书籍内容尚未生成完成');
if (book.chapters.length === 0) errors.push('书籍没有任何章节');
// 检查至少有 1 个章节已生成音频
const chaptersWithAudio = book.chapters.filter(c => c.audioUrl);
if (chaptersWithAudio.length === 0) errors.push('书籍没有任何音频内容');
// 检查是否已公开
const publicChapters = book.chapters.filter(c => c.isPublic);
if (publicChapters.length === 0) errors.push('请先将至少 1 个章节设为公开');
return { valid: errors.length === 0, errors };
}
model AlbumMarketListing {
// ... 现有字段 ...
category String @default("有声书") // '有声书' | '知识' | '故事' | '其他'
@@index([isActive, category])
}
// 新增:内容评分模型
model ContentRating {
id Int @id @default(autoincrement())
userId Int
listingId Int
rating Int // 1-5 星
review String? @db.Text // 评价文字
createdAt DateTime @default(now())
listing AlbumMarketListing @relation(fields: [listingId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([userId, listingId]) // 每人只能评一次
@@index([listingId])
}
新增 API:
POST /api/marketplace/albums/:id/rate → 提交评分(购买后才能评)
GET /api/marketplace/albums/:id/reviews → 获取评价列表
// server/src/modules/marketplace/settlement.job.ts
/**
* 每日凌晨执行:扫描购买后超过 7 天的 pending 收益,自动结算
*/
export async function settleEarnings() {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
// 找到所有待结算的收益
const pendingEarnings = await prisma.creatorEarning.findMany({
where: {
status: 'pending',
createdAt: { lte: sevenDaysAgo }
}
});
for (const earning of pendingEarnings) {
await prisma.$transaction(async (tx) => {
// 1. 更新收益状态为 settled
await tx.creatorEarning.update({
where: { id: earning.id },
data: {
status: 'settled',
settleDate: new Date()
}
});
// 2. 从冻结余额转入可提现余额
await tx.creatorWallet.update({
where: { userId: earning.creatorId },
data: {
frozenBalance: { decrement: earning.amount },
balance: { increment: earning.amount },
totalEarned: { increment: earning.amount }
}
});
});
}
console.log(`[Settlement] Settled ${pendingEarnings.length} earnings`);
}
// 注册为定时任务(node-cron)
// cron.schedule('0 2 * * *', settleEarnings); // 每天凌晨 2 点
只有整张专辑一种购买方式,鉴权逻辑清晰:
export async function requireContentAccess(ctx, next) {
const userId = ctx.state.user?.id;
const bookId = Number(ctx.params.bookId);
const chapterId = Number(ctx.params.chapterId);
// 1. 创作者本人 → 放行
const book = await prisma.book.findUnique({ where: { id: bookId } });
if (book?.userId === userId) return next();
// 2. 已购买 → 放行
const purchase = await prisma.contentPurchase.findFirst({
where: { buyerId: userId, bookId, status: 'success' }
});
if (purchase) return next();
// 3. 试听章节 → 放行
const listing = await prisma.albumMarketListing.findUnique({
where: { bookId, isActive: true }
});
if (listing) {
const chapters = await prisma.bookChapter.findMany({
where: { bookId, level: 1 }, orderBy: { number: 'asc' }
});
const idx = chapters.findIndex(c => c.id === chapterId);
if (idx >= 0 && idx < listing.previewChapterCount) return next();
}
// 4. 拒绝
ctx.status = 402;
ctx.body = {
code: 402,
message: '此内容需要购买后才能收听',
needPurchase: true,
listingId: listing?.id
};
}
策略:已购用户永久享受当前及新增内容
实现:
1. 创作者新增章节后,如专辑已上架,新章节自动加入
2. 已购买用户无需额外付费即可收听新章节
3. 价格调整不影响已购买用户
// 提现前验证
async function validateWithdrawal(userId: number, amount: number, account: string) {
// 1. 验证用户身份(已绑定手机号)
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.phone) throw new Error('请先绑定手机号');
// 2. 验证码校验(前端获取验证码后传入)
// request.body.verifyCode → 校验短信验证码
// 3. 金额校验
const wallet = await prisma.creatorWallet.findUnique({ where: { userId } });
if (amount > wallet.balance) throw new Error('余额不足');
if (amount < 10) throw new Error('最低提现金额 ¥10');
// 4. 账号校验(支付宝:手机号或邮箱格式;微信:openid)
if (method === 'alipay' && !/^(1\d{10}|[\w.]+@\w+\.\w+)$/.test(account)) {
throw new Error('支付宝账号格式不正确');
}
// 5. 频率限制(每天最多 1 次提现)
const todayWithdraw = await prisma.withdrawRecord.count({
where: { userId, createdAt: { gte: new Date(new Date().setHours(0,0,0,0)) } }
});
if (todayWithdraw >= 1) throw new Error('每天最多提现 1 次');
}
GET /api/marketplace/search?q=三体&category=有声书&sort=sales
// AlbumMarketListing 新增全文索引
model AlbumMarketListing {
// ... 现有字段 ...
@@index([isActive, category])
// MySQL 全文索引需要在 SQL 层添加
}
// 搜索逻辑(先用 LIKE,后期可接 Elasticsearch)
async function searchMarketplace(keyword: string, category?: string) {
return prisma.albumMarketListing.findMany({
where: {
isActive: true,
...(category ? { category } : {}),
OR: [
{ title: { contains: keyword } },
{ description: { contains: keyword } },
{ book: { title: { contains: keyword } } }
]
},
orderBy: { totalSales: 'desc' }
});
}
GET /api/marketplace/creators/:creatorId
→ 返回:创作者信息 + 作品列表 + 总销量 + 总评分
用途:消费者点击创作者头像,进入创作者主页查看所有作品。
POST /api/marketplace/albums/:id/share
→ 生成分享链接 + 海报图片
GET /api/marketplace/share/:shareCode
→ 解析分享链接,跳转到专辑详情页
追踪:分享来源统计(谁分享的 → 谁通过分享购买 → 分享者奖励)
策略:
· 购买后 24 小时内可申请退款
· 已收听超过 50% 内容不可退款
· 退款后立即撤销访问权限
· 退款金额从创作者冻结余额扣除
API:
POST /api/marketplace/purchases/:orderNo/refund
→ 验证条件 → 创建退款 → 撤销权限 → 通知创作者
model AlbumMarketListing {
id Int @id @default(autoincrement())
bookId Int @unique
creatorId Int
title String
description String? @db.Text
coverUrl String?
category String @default("有声书")
price Decimal @default(0.00) @db.Decimal(10, 2) // 售价(整张一口价)
isActive Boolean @default(false)
isRecommended Boolean @default(false)
totalSales Int @default(0)
totalRevenue Decimal @default(0.00) @db.Decimal(12, 2)
avgRating Float @default(0.00)
previewChapterCount Int @default(3)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
book Book @relation(fields: [bookId], references: [id])
ratings ContentRating[]
@@index([isActive, category])
@@index([isActive, totalSales])
@@index([creatorId])
}
model ContentPurchase {
id Int @id @default(autoincrement())
buyerId Int
listingId Int
bookId Int
orderNo String @unique
amount Decimal @db.Decimal(10, 2)
status String @default("success")
paymentMethod String?
paymentId String?
platformFee Decimal @default(0) @db.Decimal(10, 2)
creatorShare Decimal @default(0) @db.Decimal(10, 2)
platformShare Decimal @default(0) @db.Decimal(10, 2)
createdAt DateTime @default(now())
listing AlbumMarketListing @relation(fields: [listingId], references: [id])
buyer User @relation("buyer", fields: [buyerId], references: [id])
@@unique([buyerId, bookId])
@@index([buyerId, createdAt])
@@index([listingId])
@@index([orderNo])
}
model ContentRating {
id Int @id @default(autoincrement())
userId Int
listingId Int
rating Int // 1-5
review String? @db.Text
createdAt DateTime @default(now())
listing AlbumMarketListing @relation(fields: [listingId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([userId, listingId])
@@index([listingId])
}
model CreatorEarning {
id Int @id @default(autoincrement())
creatorId Int
purchaseId Int
bookId Int
amount Decimal @db.Decimal(10, 2) // Float→Decimal
type String
status String @default("pending")
settleDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation(fields: [creatorId], references: [id])
purchase ContentPurchase @relation(fields: [purchaseId], references: [id])
@@index([creatorId, status])
@@index([creatorId, createdAt])
}
model CreatorWallet {
id Int @id @default(autoincrement())
userId Int @unique
balance Decimal @default(0.00) @db.Decimal(12, 2) // Float→Decimal
totalEarned Decimal @default(0.00) @db.Decimal(12, 2)
totalWithdrawn Decimal @default(0.00) @db.Decimal(12, 2)
frozenBalance Decimal @default(0.00) @db.Decimal(12, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
model WithdrawRecord {
id Int @id @default(autoincrement())
userId Int
amount Decimal @db.Decimal(10, 2) // Float→Decimal
method String @default("alipay")
account String
status String @default("pending")
remark String?
approvedAt DateTime?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([userId, status])
@@index([status, createdAt])
}
| 方法 | 路由 | 功能 | 来源 |
|---|---|---|---|
| GET | /api/marketplace/search |
集市搜索 | 9.11 |
| GET | /api/marketplace/creators/:id |
创作者主页 | 9.12 |
| GET | /api/marketplace/audio/:chapterId |
音频鉴权代理(防盗链) | 9.2 |
| POST | /api/marketplace/albums/:id/rate |
提交评分 | 9.6 |
| GET | /api/marketplace/albums/:id/reviews |
评价列表 | 9.6 |
| POST | /api/marketplace/purchases/:orderNo/refund |
申请退款 | 9.14 |
| POST | /api/marketplace/albums/:id/share |
生成分享 | 9.13 |
| ID | 功能 | 优先级 | 备注 |
|---|---|---|---|
| 1 | 数据库模型扩展(含 Decimal 修正) | 🔴 P0 | 评审 9.1 |
| 2 | 音频防盗链 — 签名 URL + 代理端点 | 🔴 P0 | 评审 9.2 |
| 3 | 有声集市列表 API + 前端 | 🔴 P0 | 含 category 筛选 |
| 4 | 专辑详情 API + 购买页 | 🔴 P0 | |
| 5 | 购买流程(含防重复 + 事务安全) | 🔴 P0 | 评审 9.3 |
| 6 | 内容鉴权中间件 | 🔴 P0 | 评审 9.8 |
| 7 | 上架前置校验 | 🔴 P0 | 评审 9.4 |
| 8 | 我的已购页面 | 🟡 P1 | |
| 9 | 创作者激活 | 🟡 P1 | |
| 10 | 作品管理(上架/下架/定价) | 🟡 P1 | |
| 11 | T+7 结算定时任务 | 🟡 P1 | 评审 9.7 |
| 12 | 集市搜索 API | 🟡 P1 | 评审 9.11 |
| 13 | 评分系统 | 🟡 P1 | 评审 9.6 |
| 14 | 首页集市入口 | 🟡 P1 | |
| 15 | 创作者仪表盘 | 🟢 P2 | |
| 16 | 收益明细 + 钱包(含提现安全) | 🟢 P2 | 评审 9.10 |
| 17 | 创作者公开主页 | 🟢 P2 | 评审 9.12 |
| 18 | 分享传播 | 🟢 P2 | 评审 9.13 |
| 19 | 退款流程 | 🟢 P2 | 评审 9.14 |
修订后预估:约 48 小时(6 个工作日)