import Router from '@koa/router'; import { Context } from 'koa'; import * as PlayerService from './player.service'; import { BadRequestError } from '../../middleware/errorHandler'; import { optionalAuth } from '../../middleware/auth'; import { prisma } from '../../models'; // 测试用户ID(开发环境使用) const TEST_USER_ID = '1'; const router = new Router(); // 获取播放进度列表 router.get('/progress', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const { audioId } = ctx.query as { audioId?: string }; const records = await PlayerService.getPlayProgress( userId, audioId ? parseInt(audioId) : undefined ); ctx.body = { code: 0, message: 'success', data: records, }; }); // 保存播放进度 router.post('/progress', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const body = ctx.request.body as { audioId: number; progress: number; duration: number; }; const { audioId, progress, duration } = body; if (!audioId || typeof progress !== 'number' || typeof duration !== 'number') { throw new BadRequestError('参数错误'); } const record = await PlayerService.savePlayProgress(userId, audioId, progress, duration); ctx.body = { code: 0, message: 'success', data: record, }; }); // 更新播放进度 (在 DELETE 路由之前定义) router.put('/progress/:audioId', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const audioId = parseInt(ctx.params.audioId as string); const body = ctx.request.body as { progress: number; duration?: number }; const { progress, duration } = body; if (typeof progress !== 'number') { throw new BadRequestError('进度参数错误'); } const record = await PlayerService.updatePlayProgress(userId, audioId, progress, duration); ctx.body = { code: 0, message: 'success', data: record, }; }); // 删除播放记录 router.delete('/progress/:audioId', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const audioId = parseInt(ctx.params.audioId as string); await PlayerService.deletePlayRecord(userId, audioId); ctx.body = { code: 0, message: '删除成功', }; }); // 批量删除播放记录 router.delete('/progress/batch', optionalAuth, async (ctx: Context) => { const userId = ctx.state.user?.userId || TEST_USER_ID; const { audioIds } = ctx.request.body as { audioIds: number[] }; if (!audioIds || !Array.isArray(audioIds)) { throw new BadRequestError('参数错误'); } for (const audioId of audioIds) { await PlayerService.deletePlayRecord(userId, audioId); } ctx.body = { code: 0, message: '批量删除成功', }; }); // ============ OPT-03: 首页最近收听 ============ // 获取用户最近播放记录 router.get('/recent', optionalAuth, async (ctx: Context) => { const userId = ctx.state.user?.userId; // 未登录用户返回空列表 if (!userId) { ctx.body = { code: 0, message: 'success', data: { list: [] }, }; return; } const records = await PlayerService.getRecentPlayRecords(userId, 10); ctx.body = { code: 0, message: 'success', data: { list: records }, }; }); // ============ 临时 API:为播放器页面适配书籍章节音频 ============ // 获取播放列表(兼容前端 /api/player/playlist) router.get('/playlist', optionalAuth, async (ctx: Context) => { const { page = '1', pageSize = '100' } = ctx.query as { page?: string; pageSize?: string }; // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; // 获取所有有音频的章节:公开的 + 当前用户自己的 const chapters = await prisma.bookChapter.findMany({ where: { audioUrl: { not: null, }, AND: [ { audioUrl: { not: '' } }, { OR: [ { isPublic: true }, { book: { userId: parseInt(userId) } }, { book: { userId: null } }, ] } ] }, include: { book: true }, orderBy: [ { bookId: 'asc' }, { number: 'asc' }, ], skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), }); const total = await prisma.bookChapter.count({ where: { audioUrl: { not: null, }, AND: [ { audioUrl: { not: '' } }, { OR: [ { isPublic: true }, { book: { userId: parseInt(userId) } }, ] } ] }, }); const list = await Promise.all(chapters.map(async (chapter) => { const urlParts = (chapter.audioUrl || '').match(/\/uploads\/([^\/]+)\//); const audioId = urlParts ? urlParts[1] : null; let finalAudioUrl = chapter.audioUrl || ''; if (chapter.level === 1) { const mergedUrl = await PlayerService.getChapterAudioUrl(chapter.id); if (mergedUrl) { finalAudioUrl = mergedUrl; } } return { id: chapter.id, _id: chapter.id, audioId, title: chapter.title, summary: chapter.summary || '', text: chapter.content || '', audioUrl: finalAudioUrl, audioDuration: chapter.audioDuration || 0, wordCount: chapter.wordCount || 0, albumId: chapter.bookId, albumName: chapter.book?.title || '默认专辑', isFavorite: false, isPublic: chapter.isPublic, isOwner: chapter.book?.userId === parseInt(userId), level: chapter.level, lrcLyrics: chapter.lrcLyrics || '', }; })); ctx.body = { code: 0, message: 'success', data: { list, total, page: parseInt(page), pageSize: parseInt(pageSize) }, }; }); // 获取播放列表(适配旧播放器)- 必须放在 /:id 之前 // 过滤规则:公开的音频 + 当前用户自己的音频 router.get('/audio/list', optionalAuth, async (ctx: Context) => { const { page = '1', pageSize = '100' } = ctx.query as { page?: string; pageSize?: string }; // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; // 获取所有有音频的章节:公开的 + 当前用户自己的 const chapters = await prisma.bookChapter.findMany({ where: { audioUrl: { not: null, }, AND: [ { audioUrl: { not: '' } }, { OR: [ { isPublic: true }, // 公开的音频 { book: { userId: parseInt(userId) } }, // 当前用户自己的 { book: { userId: null } }, // 匿名用户创建的音频(无归属用户) ] } ] }, include: { book: true }, orderBy: [ { bookId: 'asc' }, { number: 'asc' }, ], skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), }); const total = await prisma.bookChapter.count({ where: { audioUrl: { not: null, }, AND: [ { audioUrl: { not: '' } }, { OR: [ { isPublic: true }, { book: { userId: parseInt(userId) } }, ] } ] }, }); const list = await Promise.all(chapters.map(async (chapter) => { // 从 audioUrl 中提取 audioId (UUID) // 格式: /uploads/{audioId}/output.mp3 const urlParts = (chapter.audioUrl || '').match(/\/uploads\/([^\/]+)\//); const audioId = urlParts ? urlParts[1] : null; // 如果是章(level=1),自动合并小节音频 let finalAudioUrl = chapter.audioUrl || ''; if (chapter.level === 1) { const mergedUrl = await PlayerService.getChapterAudioUrl(chapter.id); if (mergedUrl) { finalAudioUrl = mergedUrl; } } return { id: chapter.id, _id: chapter.id, audioId, title: chapter.title, summary: chapter.summary || '', text: chapter.content || '', audioUrl: finalAudioUrl, audioDuration: chapter.audioDuration || 0, wordCount: chapter.wordCount || 0, albumId: chapter.bookId, albumName: chapter.book?.title || '默认专辑', isFavorite: false, isPublic: chapter.isPublic, isOwner: chapter.book?.userId === parseInt(userId), level: chapter.level, lrcLyrics: chapter.lrcLyrics || '', // OPT-17: LRC 歌词时间轴 }; })); ctx.body = { code: 0, message: 'success', data: { list, total, page: parseInt(page), pageSize: parseInt(pageSize) }, }; }); // 获取章节音频详情(适配旧播放器) router.get('/audio/:id', optionalAuth, async (ctx: Context) => { const chapterId = parseInt(ctx.params.id as string); if (isNaN(chapterId)) { ctx.status = 400; ctx.body = { code: 1, message: '无效的音频ID' }; return; } // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterId }, include: { book: true }, }); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '音频不存在' }; return; } // 如果音频未公开且不是所有者(且不是null userId的记录),拒绝访问 const isNullOwner = chapter.book?.userId === null; if (!chapter.isPublic && chapter.book?.userId !== parseInt(userId) && !isNullOwner) { ctx.status = 403; ctx.body = { code: 1, message: '该音频未公开,无法访问' }; return; } // 如果章节是章(level=1),自动合并小节音频 let finalAudioUrl = chapter.audioUrl || ''; if (chapter.level === 1) { const mergedUrl = await PlayerService.getChapterAudioUrl(chapterId); if (mergedUrl) { finalAudioUrl = mergedUrl; } } // 适配旧的 AudioItem 格式 const audioItem = { id: chapter.id, _id: chapter.id, title: chapter.title, summary: chapter.summary || '', text: chapter.content || '', audioUrl: finalAudioUrl, audioDuration: chapter.audioDuration || 0, wordCount: chapter.wordCount || 0, albumId: chapter.bookId, albumName: chapter.book?.title || '默认专辑', isFavorite: false, isPublic: chapter.isPublic, isOwner: chapter.book?.userId === parseInt(userId), level: chapter.level, lrcLyrics: chapter.lrcLyrics || '', // OPT-17: LRC 歌词时间轴 }; ctx.body = { code: 0, message: 'success', data: audioItem, }; }); // 更新章节公开状态 router.put('/audio/:id/public', optionalAuth, async (ctx: Context) => { const chapterId = parseInt(ctx.params.id as string); if (isNaN(chapterId)) { ctx.status = 400; ctx.body = { code: 1, message: '无效的音频ID' }; return; } // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const { isPublic } = ctx.request.body as { isPublic: boolean }; // 获取章节信息,验证所有权 const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterId }, include: { book: true }, }); if (!chapter) { ctx.status = 404; ctx.body = { code: 1, message: '章节不存在' }; return; } // 验证是否是所有者 if (chapter.book?.userId !== parseInt(userId)) { ctx.status = 403; ctx.body = { code: 1, message: '无权限操作' }; return; } // 更新公开状态 const updated = await prisma.bookChapter.update({ where: { id: chapterId }, data: { isPublic }, }); ctx.body = { code: 0, message: isPublic ? '已公开到首页' : '已取消公开', data: { isPublic: updated.isPublic }, }; }); export default router;