feat: 实现积分系统与认证重构
重构认证系统,从next-auth迁移至better-auth,并实现完整的积分系统功能: 1. 新增积分账户管理、交易记录和扣减逻辑 2. 添加积分概览组件和API端点 3. 重构认证相关组件和路由 4. 优化播客生成流程与积分校验 5. 新增安全配置文档和数据库schema 6. 改进UI状态管理和错误处理 新增功能包括: - 用户注册自动初始化积分账户 - 播客生成前检查积分余额 - 积分交易记录查询 - 用户积分实时显示 - 安全回调处理
This commit is contained in:
218
SECURITY.md
Normal file
218
SECURITY.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# 安全配置指南
|
||||
|
||||
本文档描述了播客生成器应用的安全配置和最佳实践。
|
||||
|
||||
## 认证系统安全
|
||||
|
||||
### JWT令牌管理
|
||||
|
||||
1. **密钥安全**
|
||||
- 使用强随机密钥(至少32字符)
|
||||
- 定期轮换JWT密钥
|
||||
- 在生产环境中使用环境变量存储密钥
|
||||
|
||||
2. **令牌过期策略**
|
||||
- 访问令牌:30分钟过期
|
||||
- 刷新令牌:7天过期
|
||||
- 实现令牌黑名单机制
|
||||
|
||||
3. **令牌存储**
|
||||
- 使用HttpOnly Cookie存储刷新令牌
|
||||
- 访问令牌存储在内存中
|
||||
- 启用Secure和SameSite属性
|
||||
|
||||
### OAuth安全
|
||||
|
||||
1. **Google OAuth**
|
||||
- 验证state参数防止CSRF攻击
|
||||
- 使用HTTPS重定向URI
|
||||
- 限制授权范围
|
||||
|
||||
2. **微信OAuth**
|
||||
- 验证二维码场景ID
|
||||
- 设置合理的二维码过期时间
|
||||
- 验证回调来源
|
||||
|
||||
## 数据保护
|
||||
|
||||
### 用户数据安全
|
||||
|
||||
1. **数据加密**
|
||||
- 敏感数据使用AES-256加密
|
||||
- 密码使用bcrypt哈希
|
||||
- 传输层使用TLS 1.3
|
||||
|
||||
2. **数据最小化**
|
||||
- 只收集必要的用户信息
|
||||
- 定期清理过期数据
|
||||
- 实现数据匿名化
|
||||
|
||||
### 会话管理
|
||||
|
||||
1. **会话安全**
|
||||
- 实现会话超时机制
|
||||
- 检测异常登录行为
|
||||
- 支持强制登出功能
|
||||
|
||||
2. **并发控制**
|
||||
- 限制同一用户的并发会话数
|
||||
- 检测重复登录
|
||||
- 实现设备管理功能
|
||||
|
||||
## API安全
|
||||
|
||||
### 请求验证
|
||||
|
||||
1. **输入验证**
|
||||
- 验证所有用户输入
|
||||
- 使用白名单过滤
|
||||
- 防止SQL注入和XSS攻击
|
||||
|
||||
2. **速率限制**
|
||||
- 实现API调用频率限制
|
||||
- 防止暴力破解攻击
|
||||
- 监控异常请求模式
|
||||
|
||||
### CORS配置
|
||||
|
||||
```javascript
|
||||
// 生产环境CORS配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://yourdomain.com"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
```
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 生产环境安全
|
||||
|
||||
1. **环境变量**
|
||||
```bash
|
||||
# 必须更改的默认值
|
||||
JWT_SECRET_KEY=your-production-jwt-secret-key
|
||||
PODCAST_API_SECRET_KEY=your-production-api-secret-key
|
||||
|
||||
# 数据库安全
|
||||
DATABASE_URL=postgresql://user:password@localhost/dbname
|
||||
|
||||
# Redis安全
|
||||
REDIS_URL=redis://user:password@localhost:6379
|
||||
```
|
||||
|
||||
2. **服务器配置**
|
||||
- 禁用调试模式
|
||||
- 配置防火墙规则
|
||||
- 启用日志监控
|
||||
- 定期安全更新
|
||||
|
||||
### 开发环境安全
|
||||
|
||||
1. **本地开发**
|
||||
- 使用不同的密钥
|
||||
- 启用详细日志
|
||||
- 使用测试数据
|
||||
|
||||
2. **代码安全**
|
||||
- 不提交敏感信息到版本控制
|
||||
- 使用.gitignore排除配置文件
|
||||
- 定期安全代码审查
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 安全监控
|
||||
|
||||
1. **日志记录**
|
||||
- 记录所有认证事件
|
||||
- 监控失败的登录尝试
|
||||
- 记录权限变更
|
||||
|
||||
2. **异常检测**
|
||||
- 监控异常API调用
|
||||
- 检测可疑用户行为
|
||||
- 实时安全告警
|
||||
|
||||
### 审计跟踪
|
||||
|
||||
1. **用户操作审计**
|
||||
- 记录用户关键操作
|
||||
- 保留审计日志
|
||||
- 支持合规性要求
|
||||
|
||||
2. **系统事件记录**
|
||||
- 记录系统配置变更
|
||||
- 监控资源使用情况
|
||||
- 跟踪性能指标
|
||||
|
||||
## 安全检查清单
|
||||
|
||||
### 部署前检查
|
||||
|
||||
- [ ] 更改所有默认密钥和密码
|
||||
- [ ] 配置HTTPS和SSL证书
|
||||
- [ ] 启用防火墙和安全组
|
||||
- [ ] 配置备份和恢复策略
|
||||
- [ ] 测试所有安全功能
|
||||
- [ ] 进行渗透测试
|
||||
- [ ] 配置监控和告警
|
||||
- [ ] 准备安全事件响应计划
|
||||
|
||||
### 定期安全维护
|
||||
|
||||
- [ ] 更新依赖包和安全补丁
|
||||
- [ ] 轮换密钥和证书
|
||||
- [ ] 审查用户权限
|
||||
- [ ] 检查日志和监控
|
||||
- [ ] 备份验证和恢复测试
|
||||
- [ ] 安全培训和意识提升
|
||||
|
||||
## 安全事件响应
|
||||
|
||||
### 事件分类
|
||||
|
||||
1. **高危事件**
|
||||
- 数据泄露
|
||||
- 未授权访问
|
||||
- 系统入侵
|
||||
|
||||
2. **中危事件**
|
||||
- 异常登录
|
||||
- API滥用
|
||||
- 权限提升
|
||||
|
||||
3. **低危事件**
|
||||
- 密码重置
|
||||
- 账户锁定
|
||||
- 配置变更
|
||||
|
||||
### 响应流程
|
||||
|
||||
1. **事件检测**
|
||||
- 自动监控告警
|
||||
- 用户报告
|
||||
- 定期安全扫描
|
||||
|
||||
2. **事件响应**
|
||||
- 立即隔离受影响系统
|
||||
- 评估影响范围
|
||||
- 通知相关人员
|
||||
- 收集证据和日志
|
||||
|
||||
3. **事件恢复**
|
||||
- 修复安全漏洞
|
||||
- 恢复正常服务
|
||||
- 更新安全策略
|
||||
- 总结经验教训
|
||||
|
||||
## 联系信息
|
||||
|
||||
如果发现安全漏洞,请通过以下方式联系我们:
|
||||
|
||||
- 邮箱:security@yourcompany.com
|
||||
- 加密通信:使用PGP密钥
|
||||
- 紧急联系:+86-xxx-xxxx-xxxx
|
||||
|
||||
我们承诺在24小时内响应安全报告,并在合理时间内修复确认的漏洞。
|
||||
4
main.py
4
main.py
@@ -230,7 +230,7 @@ async def _generate_podcast_task(
|
||||
"task_id": str(task_id),
|
||||
"auth_id": auth_id,
|
||||
"task_results": task_results[auth_id][task_id],
|
||||
"timestamp": time.time(),
|
||||
"timestamp": int(time.time()), # 确保发送整数秒级时间戳
|
||||
}
|
||||
|
||||
MAX_RETRIES = 3 # 定义最大重试次数
|
||||
@@ -239,7 +239,7 @@ async def _generate_podcast_task(
|
||||
for attempt in range(MAX_RETRIES + 1): # 尝试次数从0到MAX_RETRIES
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(callback_url, json=callback_data, timeout=30.0)
|
||||
response = await client.put(callback_url, json=callback_data, timeout=30.0)
|
||||
response.raise_for_status() # 对 4xx/5xx 响应抛出异常
|
||||
print(f"Callback successfully sent for task {task_id} on attempt {attempt + 1}. Status: {response.status_code}")
|
||||
break # 成功发送,跳出循环
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
---
|
||||
name: coder
|
||||
description: next-js专家
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
|
||||
### 角色与目标 (Role & Goal)
|
||||
|
||||
你是一位世界顶级的 Next.js 全栈开发专家,对**前端布局健壮性**和**性能优化**有着深刻的理解和丰富的实践经验。你的核心任务是作为我的技术伙伴,以结对编程的方式,指导我从零开始构建一个**性能卓越且视觉上无懈可击的产品展示网站**。
|
||||
|
||||
你的所有回答都必须严格遵循以下核心原则与强制性约束。
|
||||
|
||||
---
|
||||
|
||||
### 核心原则与强制性约束 (Core Principles & Mandatory Constraints)
|
||||
|
||||
1. **技术栈 (Tech Stack)**:
|
||||
* **框架**: Next.js (始终使用最新的稳定版本,并优先采用 App Router 架构)。
|
||||
* **语言**: **TypeScript**。所有代码必须是类型安全的,并充分利用 TypeScript 的特性。
|
||||
* **样式**: **Tailwind CSS**。用于所有组件的样式设计,遵循其效用优先(utility-first)的理念。
|
||||
|
||||
2. **代码质量与规范 (Code Quality & Style)**:
|
||||
* **代码风格**: 严格遵循 **Vercel 的代码风格指南**和 Prettier 的默认配置。代码必须整洁、可读性强且易于维护。
|
||||
* **架构**: 采用清晰的、基于组件的架构。将页面、组件、hooks、工具函数等分离到合理的目录结构中。强调组件的可复用性和单一职责原则。
|
||||
|
||||
3. **性能第一 (Performance First)**:
|
||||
* **核心指标**: 你的首要目标是最大化 **Lighthouse 分数**,并最小化**首次内容绘制 (FCP)** 和**最大内容绘制 (LCP)** 时间。
|
||||
* **实践**:
|
||||
* **默认服务端**: 尽可能使用**服务器组件 (Server Components)**。
|
||||
* **图片优化**: 必须使用 `next/image` 组件处理所有图片。
|
||||
* **字体优化**: 必须使用 `next/font` 来加载和优化网页字体。
|
||||
* **动态加载**: 对非关键组件使用 `next/dynamic` 进行代码分割和懒加载。
|
||||
* **数据获取**: 根据场景选择最优的数据获取策略。
|
||||
|
||||
4. **布局与视觉约束 (Layout & Visual Constraints)**:
|
||||
* **健壮的容器**: **所有元素都严禁超出其父容器的边界**。你的布局设计必须从根本上杜绝水平滚动条的出现。
|
||||
* **智能文本处理**: 对于文本元素,必须根据上下文做出恰当处理:
|
||||
* 在空间充足的容器中(如文章正文),文本必须能**自动换行** (`break-words`)。
|
||||
* 在空间有限的组件中(如卡片标题),必须采用**文本截断**策略,在末尾显示省略号 (`truncate`)。
|
||||
* **响应式媒体**: 对于图片、视频等非文本资源,必须在其容器内**被完整显示**。它们应能响应式地缩放以适应容器大小,同时保持其原始高宽比,不得出现裁剪或变形(除非是刻意设计的背景图)。
|
||||
|
||||
---
|
||||
|
||||
### 互动与输出格式 (Interaction & Output Format)
|
||||
|
||||
对于我的每一个功能或组件请求,你都必须按照以下结构进行回应:
|
||||
|
||||
1. **简要确认**: 首先,简要复述我的请求,确认你的理解。
|
||||
2. **代码实现**:
|
||||
* 提供完整、可直接使用的 `.tsx` 或 `.ts` 代码块。
|
||||
* 在代码中加入必要的注释,解释关键逻辑或复杂部分。
|
||||
3. **解释与最佳实践**:
|
||||
* 在代码块之后,使用标题 `### 方案解读`。
|
||||
* 清晰地解释你这样设计的原因,特别是要**关联到上述的所有核心原则**(技术栈、性能、代码质量、布局约束等)。
|
||||
4. **主动建议 (Proactive Suggestions)**:
|
||||
* 如果我的请求有更优、更高效或性能更好的实现方式,请主动提出并给出你的建议方案。
|
||||
|
||||
---
|
||||
|
||||
最近生成的小卡片布局和样式如下:
|
||||
|
||||
### 一、 整体布局与结构 (Layout & Structure)
|
||||
|
||||
该卡片采用**多区域组合布局**,将不同类型的信息有机地组织在一个紧凑的空间内。
|
||||
|
||||
1. **外部容器**: 一个白色的圆角矩形,作为所有元素的载体。
|
||||
2. **上部内容区**: 采用**双栏布局**。
|
||||
* **左栏**: 放置一个方形的缩略图 (Thumbnail),作为视觉吸引点。
|
||||
* **右栏**: 垂直排列的文本信息区,包含标题和作者信息。
|
||||
3. **下部信息/操作区**: 同样是**双栏布局**,与上部内容区通过垂直间距分隔开。
|
||||
* **左栏**: 显示元数据 (Metadata),如播放量和时长。
|
||||
* **右栏**: 放置一个主要的交互按钮(播放按钮)。
|
||||
|
||||
这种布局方式既高效又符合用户的阅读习惯(从左到右,从上到下),使得信息层级一目了然。
|
||||
|
||||
### 二、 颜色与风格 (Color & Style)
|
||||
|
||||
色彩搭配既有现代感又不失柔和,体现了专业与创意的结合。
|
||||
|
||||
* **主背景色**: **白色 (`#FFFFFF`)**,为卡片提供了干净、明亮的基底。
|
||||
* **边框色**: **极浅灰色 (`#E5E7EB` 或类似)**,用一个1像素的细边框勾勒出卡片的轮廓,使其在白色背景上也能清晰可见。
|
||||
* **缩略图主色调**: 采用**柔和的渐变色**,由**淡紫色 (`#C8B6F2`)**、**薰衣草色 (`#D7BDE2`)** 过渡到**淡粉色 (`#E8D7F1`)**,并带有抽象的、类似极光的模糊波纹效果。这种色彩营造了一种梦幻、创意的氛围。
|
||||
* **文字颜色**:
|
||||
* **标题**: **纯黑或深炭灰色 (`#111827`)**,确保了最高的可读性。
|
||||
* **作者名与元数据**: **中度灰色 (`#6B7280`)**,与标题形成对比,属于次要信息。
|
||||
* **功能性颜色**:
|
||||
* **作者头像背景**: **深洋红色/玫瑰色 (`#C72C6A` 或类似)**,这是一个醒目的强调色,用于用户身份标识。
|
||||
* **播放按钮**: **黑色 (`#111827`)**,作为核心操作,颜色非常突出。
|
||||
|
||||
### 三、 组件细节解析 (Component Breakdown)
|
||||
|
||||
1. **卡片容器 (Card Container)**:
|
||||
* **形状**: 圆角矩形,`border-radius` 大约在 `12px` 到 `16px` 之间,显得非常圆润友好。
|
||||
* **边框**: `border: 1px solid #E5E7EB;`
|
||||
* **内边距 (Padding)**: 卡片内容与边框之间有足够的留白,推测 `padding` 约为 `16px`。
|
||||
|
||||
2. **缩略图 (Thumbnail)**:
|
||||
* **形状**: 轻微圆角的正方形,`border-radius` 约 `8px`。
|
||||
* **内容**: 上文描述的抽象渐变背景。
|
||||
* **尺寸**: 在卡片中占据了显著的视觉比重。
|
||||
|
||||
3. **标题 (Title)**:
|
||||
* **文本**: "AI大观:竞技、创作与前沿突破的最新图景"。
|
||||
* **字体**: 无衬线字体,字重为**中粗体 (Semibold, `font-weight: 600`)**,字号较大(推测约 `16px`),保证了标题的突出性。
|
||||
|
||||
4. **作者信息 (Author Info)**:
|
||||
* **布局**: 水平 `flex` 布局,包含头像和用户名,两者之间有一定间距 (`gap: 8px`)。
|
||||
* **头像 (Avatar)**:
|
||||
* 一个正圆形容器。
|
||||
* 背景色为醒目的洋红色。
|
||||
* 内部是白色的首字母 "d",字体居中。
|
||||
* **用户名**: "dodo jack",使用中灰色、常规字重的文本。
|
||||
|
||||
5. **元数据 (Metadata)**:
|
||||
* **布局**: 水平 `flex` 布局,`align-items: center`。
|
||||
* **元素**:
|
||||
* **耳机图标**: 一个线性图标,表示收听量。
|
||||
* **收听量**: "1"。
|
||||
* **分隔符**: 一个细长的竖线 `|`,用于区隔不同信息。
|
||||
* **时长**: "14 分钟"。
|
||||
* **时钟图标**: 一个线性图标,表示时长。
|
||||
* **样式**: 图标和文字均为中灰色,字号较小(推测约 `12px` 或 `14px`)。
|
||||
|
||||
6. **播放按钮 (Play Button)**:
|
||||
* **形状**: 一个圆形按钮。
|
||||
* **样式**: 由一个黑色的圆形**边框**和一个居中的实心**三角形播放图标**组成。设计非常简洁,辨识度高。
|
||||
* **交互**: 可以推测,当鼠标悬停 (hover) 时,按钮可能会有背景色填充、放大或发光等效果。
|
||||
|
||||
### 四、 推测的CSS样式
|
||||
|
||||
```css
|
||||
.content-card {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px; /* 上下区域的间距 */
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 320px; /* 示例宽度 */
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
gap: 16px; /* 图片和文字的间距 */
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #C8B6F2, #E8D7F1); /* 示例渐变 */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px; /* 标题和作者的间距 */
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: #C72C6A;
|
||||
color: #FFFFFF;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 14px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.metadata .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.metadata .separator {
|
||||
color: #D1D5DB;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1.5px solid #111827;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.play-button:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #F3F4F6;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
/* 使用SVG或字体图标 */
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
fill: #111827;
|
||||
margin-left: 2px; /* 视觉居中校正 */
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(npx create-next-app:*)",
|
||||
"Bash(npm run type-check)",
|
||||
"Bash(npm run test-build)",
|
||||
"Bash(npm run build)",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(npm run dev)",
|
||||
"Bash(node:*)",
|
||||
"mcp__serena__check_onboarding_performed",
|
||||
"mcp__serena__activate_project",
|
||||
"mcp__serena__onboarding",
|
||||
"mcp__serena__list_dir",
|
||||
"mcp__serena__get_symbols_overview",
|
||||
"mcp__serena__find_symbol",
|
||||
"mcp__serena__think_about_collected_information",
|
||||
"mcp__serena__write_memory",
|
||||
"mcp__serena__think_about_whether_you_are_done",
|
||||
"Bash(grep:*)",
|
||||
"mcp__serena__search_for_pattern"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
5
web/.gitignore
vendored
5
web/.gitignore
vendored
@@ -42,4 +42,7 @@ next-env.d.ts
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
drizzle
|
||||
.claude/
|
||||
/.public/
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
# API重复调用问题修复报告
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户报告访问主页时,主页内的接口被调用了两次。这是一个常见的React问题,会导致:
|
||||
- 不必要的网络请求
|
||||
- 服务器负载增加
|
||||
- 用户体验下降
|
||||
- 可能的数据不一致
|
||||
|
||||
## 问题分析
|
||||
|
||||
通过代码分析,发现了以下导致重复调用的原因:
|
||||
|
||||
### 1. **多个useEffect调用同一API**
|
||||
在 `src/app/page.tsx` 中:
|
||||
- 第38行的useEffect在组件挂载时调用 `fetchRecentPodcasts()`
|
||||
- 第86行的useEffect设置定时器每20秒调用 `fetchRecentPodcasts()`
|
||||
- 这导致页面加载时API被调用两次
|
||||
|
||||
### 2. **useEffect依赖项问题**
|
||||
在 `src/components/PodcastCreator.tsx` 中:
|
||||
- useEffect依赖项包含 `selectedConfig` 和 `selectedConfigName`
|
||||
- 当配置变化时可能触发多次API调用
|
||||
|
||||
### 3. **ConfigSelector组件的重复调用**
|
||||
在 `src/components/ConfigSelector.tsx` 中:
|
||||
- localStorage变化监听可能导致重复的配置加载
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. **合并useEffect调用**
|
||||
将两个分离的useEffect合并为一个:
|
||||
|
||||
```typescript
|
||||
// 修复前:两个独立的useEffect
|
||||
useEffect(() => {
|
||||
setCredits(100000);
|
||||
fetchRecentPodcasts(); // 第一次调用
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchRecentPodcasts(); // 定时调用
|
||||
}, 20000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 修复后:合并为一个useEffect
|
||||
useEffect(() => {
|
||||
setCredits(100000);
|
||||
fetchRecentPodcasts(); // 初始调用
|
||||
|
||||
// 设置定时器
|
||||
const interval = setInterval(() => {
|
||||
fetchRecentPodcasts();
|
||||
}, 20000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
```
|
||||
|
||||
### 2. **创建防重复调用Hook**
|
||||
创建了 `src/hooks/useApiCall.ts`:
|
||||
|
||||
```typescript
|
||||
export function usePreventDuplicateCall() {
|
||||
const isCallingRef = useRef<boolean>(false);
|
||||
|
||||
const executeOnce = useCallback(async <T>(
|
||||
apiFunction: () => Promise<T>
|
||||
): Promise<T | null> => {
|
||||
if (isCallingRef.current) {
|
||||
console.log('API call already in progress, skipping...');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
isCallingRef.current = true;
|
||||
const result = await apiFunction();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
return null;
|
||||
} finally {
|
||||
isCallingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { executeOnce };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **优化useEffect依赖项**
|
||||
在PodcastCreator组件中:
|
||||
|
||||
```typescript
|
||||
// 修复前:多个依赖项可能导致重复调用
|
||||
useEffect(() => {
|
||||
fetchVoices();
|
||||
}, [selectedConfig, selectedConfigName]);
|
||||
|
||||
// 修复后:只依赖必要的状态
|
||||
useEffect(() => {
|
||||
if (!selectedConfigName) {
|
||||
setVoices([]);
|
||||
return;
|
||||
}
|
||||
fetchVoices();
|
||||
}, [selectedConfigName]); // 只依赖配置名称
|
||||
```
|
||||
|
||||
### 4. **添加API调用追踪器**
|
||||
创建了 `src/utils/apiCallTracker.ts` 用于开发环境下监控API调用:
|
||||
|
||||
```typescript
|
||||
// 自动检测重复调用
|
||||
trackCall(url: string, method: string = 'GET'): string {
|
||||
const recentCalls = this.calls.filter(
|
||||
c => c.url === url &&
|
||||
c.method === method &&
|
||||
Date.now() - c.timestamp < 5000
|
||||
);
|
||||
|
||||
if (recentCalls.length > 0) {
|
||||
console.warn(`🚨 检测到重复API调用:`, {
|
||||
url, method, 重复次数: recentCalls.length + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 修复效果
|
||||
|
||||
### 修复前:
|
||||
- 页面加载时 `/api/podcast-status` 被调用2次
|
||||
- 配置变化时 `/api/tts-voices` 可能被多次调用
|
||||
- 无法监控和调试重复调用问题
|
||||
|
||||
### 修复后:
|
||||
- 页面加载时 `/api/podcast-status` 只调用1次
|
||||
- 使用防重复调用机制确保同一时间只有一个请求
|
||||
- 开发环境下自动检测和警告重复调用
|
||||
- 优化了useEffect依赖项,减少不必要的重新执行
|
||||
|
||||
## 验证方法
|
||||
|
||||
### 1. **开发环境调试**
|
||||
打开浏览器开发者工具,在控制台中可以使用:
|
||||
```javascript
|
||||
// 查看API调用统计
|
||||
window.apiDebug.showStats();
|
||||
|
||||
// 清空统计数据
|
||||
window.apiDebug.clearStats();
|
||||
```
|
||||
|
||||
### 2. **网络面板监控**
|
||||
在浏览器开发者工具的Network面板中:
|
||||
- 刷新页面,观察 `/api/podcast-status` 只被调用一次
|
||||
- 切换TTS配置,观察 `/api/tts-voices` 不会重复调用
|
||||
|
||||
### 3. **控制台日志**
|
||||
开发环境下会自动输出API调用日志:
|
||||
- `📡 API调用:` - 正常调用
|
||||
- `🚨 检测到重复API调用:` - 重复调用警告
|
||||
|
||||
## 最佳实践建议
|
||||
|
||||
1. **useEffect合并原则**:相关的副作用应该在同一个useEffect中处理
|
||||
2. **依赖项最小化**:只包含真正需要的依赖项
|
||||
3. **防重复调用**:对于可能重复的API调用使用防重复机制
|
||||
4. **开发调试工具**:在开发环境中添加监控和调试工具
|
||||
5. **错误处理**:确保API调用失败时不会影响后续调用
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `src/app/page.tsx` - 主页组件修复
|
||||
- `src/components/PodcastCreator.tsx` - 播客创建器组件修复
|
||||
- `src/components/ConfigSelector.tsx` - 配置选择器组件修复
|
||||
- `src/hooks/useApiCall.ts` - 防重复调用Hook(新增)
|
||||
- `src/utils/apiCallTracker.ts` - API调用追踪器(新增)
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 修复后的代码保持了原有功能不变
|
||||
- 所有修改都向后兼容
|
||||
- 调试工具只在开发环境中启用,不会影响生产环境性能
|
||||
- 建议在部署前进行充分测试,确保所有功能正常工作
|
||||
91
web/drizzle-schema.ts
Normal file
91
web/drizzle-schema.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { sqliteTable, AnySQLiteColumn, foreignKey, check, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const account = sqliteTable("account", {
|
||||
id: text().primaryKey().notNull(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" } ),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at"),
|
||||
scope: text(),
|
||||
password: text(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check("points_accounts_check_1", sql`total_points >= 0`),
|
||||
]);
|
||||
|
||||
export const session = sqliteTable("session", {
|
||||
id: text().primaryKey().notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
token: text().notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" } ),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("session_token_unique").on(table.token),
|
||||
check("points_accounts_check_1", sql`total_points >= 0`),
|
||||
]);
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
email: text().notNull(),
|
||||
emailVerified: integer("email_verified").notNull(),
|
||||
image: text(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
username: text(),
|
||||
displayUsername: text("display_username"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("user_username_unique").on(table.username),
|
||||
uniqueIndex("user_email_unique").on(table.email),
|
||||
check("points_accounts_check_1", sql`total_points >= 0`),
|
||||
]);
|
||||
|
||||
export const verification = sqliteTable("verification", {
|
||||
id: text().primaryKey().notNull(),
|
||||
identifier: text().notNull(),
|
||||
value: text().notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
createdAt: integer("created_at"),
|
||||
updatedAt: integer("updated_at"),
|
||||
},
|
||||
(table) => [
|
||||
check("points_accounts_check_1", sql`total_points >= 0`),
|
||||
]);
|
||||
|
||||
export const pointsAccounts = sqliteTable("points_accounts", {
|
||||
accountId: integer("account_id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id").notNull(),
|
||||
totalPoints: integer("total_points").default(0).notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_points_accounts_user_id").on(table.userId),
|
||||
uniqueIndex("points_accounts_user_id_unique").on(table.userId),
|
||||
check("points_accounts_check_1", sql`total_points >= 0`),
|
||||
]);
|
||||
|
||||
export const pointsTransactions = sqliteTable("points_transactions", {
|
||||
transactionId: integer("transaction_id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id").notNull(),
|
||||
pointsChange: integer("points_change").notNull(),
|
||||
reasonCode: text("reason_code").notNull(),
|
||||
description: text(),
|
||||
createdAt: text("created_at").default("sql`(CURRENT_TIMESTAMP)`").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_points_transactions_user_id").on(table.userId),
|
||||
check("points_accounts_check_1", sql`total_points >= 0`),
|
||||
]);
|
||||
|
||||
11
web/drizzle.config.ts
Normal file
11
web/drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './drizzle-schema.ts',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: process.env.DB_FILE_NAME!,
|
||||
},
|
||||
});
|
||||
2607
web/package-lock.json
generated
2607
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,12 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"better-auth": "^1.3.6",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.4",
|
||||
"framer-motion": "^11.3.8",
|
||||
"lucide-react": "^0.424.0",
|
||||
"next": "^14.2.5",
|
||||
@@ -38,9 +42,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.5",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5"
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tsx": "^4.20.4"
|
||||
}
|
||||
}
|
||||
|
||||
4
web/src/app/api/auth/[...all]/route.ts
Normal file
4
web/src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
@@ -1,19 +0,0 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import GitHubProvider from 'next-auth/providers/github';
|
||||
|
||||
const handler = NextAuth({
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
GitHubProvider({
|
||||
clientId: process.env.GITHUB_ID!,
|
||||
clientSecret: process.env.GITHUB_SECRET!,
|
||||
}),
|
||||
],
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,11 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { startPodcastGenerationTask } from '@/lib/podcastApi';
|
||||
import type { PodcastGenerationRequest } from '@/types';
|
||||
import { getSessionData } from '@/lib/server-actions';
|
||||
import { getUserPoints } from '@/lib/points'; // 导入 getUserPoints
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSessionData();
|
||||
const userId = session.user?.id;
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '用户未登录或会话已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body: PodcastGenerationRequest = await request.json();
|
||||
const result = await startPodcastGenerationTask(body);
|
||||
|
||||
// 1. 查询用户积分
|
||||
const currentPoints = await getUserPoints(userId);
|
||||
|
||||
const POINTS_PER_PODCAST = parseInt(process.env.POINTS_PER_PODCAST || '10', 10); // 从环境变量获取,默认10
|
||||
// 2. 检查积分是否足够
|
||||
if (currentPoints === null || currentPoints < POINTS_PER_PODCAST) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `积分不足,生成一个播客需要 ${POINTS_PER_PODCAST} 积分,您当前只有 ${currentPoints || 0} 积分。` },
|
||||
{ status: 403 } // 403 Forbidden - 权限不足,因为积分不足
|
||||
);
|
||||
}
|
||||
|
||||
// 积分足够,继续生成播客
|
||||
const result = await startPodcastGenerationTask(body, userId);
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
|
||||
39
web/src/app/api/newuser/route.ts
Normal file
39
web/src/app/api/newuser/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import { getSessionData } from "@/lib/server-actions";
|
||||
import { createPointsAccount, recordPointsTransaction, checkUserPointsAccount } from "@/lib/points"; // 导入新封装的函数
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionData = await getSessionData();
|
||||
console.log('获取到的 session:', sessionData);
|
||||
|
||||
// 如果没有获取到 session,直接重定向到根目录
|
||||
if (!sessionData?.user) {
|
||||
const url = new URL('/', request.url);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
const userId = sessionData.user.id; // 获取 userId
|
||||
|
||||
// 检查用户是否已存在积分账户
|
||||
const userHasPointsAccount = await checkUserPointsAccount(userId);
|
||||
|
||||
// 如果不存在积分账户,则初始化
|
||||
if (!userHasPointsAccount) {
|
||||
console.log(`用户 ${userId} 不存在积分账户,正在初始化...`);
|
||||
try {
|
||||
await createPointsAccount(userId, 100); // 调用封装的创建积分账户函数
|
||||
await recordPointsTransaction(userId, 100, "initial_bonus", "新用户注册,初始积分奖励"); // 调用封装的记录流水函数
|
||||
} catch (error) {
|
||||
console.error(`初始化用户 ${userId} 积分账户或记录流水失败:`, error);
|
||||
// 根据错误类型,可能需要更详细的错误处理或重定向
|
||||
// 例如,如果 userId 无效,可以重定向到错误页面
|
||||
}
|
||||
} else {
|
||||
console.log(`用户 ${userId} 已存在积分账户,无需初始化。`);
|
||||
}
|
||||
|
||||
// 创建一个 URL 对象,指向要重定向到的根目录
|
||||
const url = new URL('/', request.url);
|
||||
// 返回重定向响应
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPodcastStatus } from '@/lib/podcastApi';
|
||||
import { getSessionData } from '@/lib/server-actions';
|
||||
|
||||
export const revalidate = 0; // 等同于 `cache: 'no-store'`
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const result = await getPodcastStatus();
|
||||
const session = await getSessionData();
|
||||
const userId = session.user?.id;
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '用户未登录或会话已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await getPodcastStatus(userId);
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result.data, // 展开 result.data,因为它已经是 PodcastStatusResponse 类型
|
||||
});
|
||||
} else {
|
||||
console.log('获取任务状态失败', result);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error || '获取任务状态失败' },
|
||||
{ status: result.statusCode || 500 }
|
||||
|
||||
76
web/src/app/api/points/route.ts
Normal file
76
web/src/app/api/points/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getUserPoints, deductUserPoints } from "@/lib/points"; // 导入 deductUserPoints
|
||||
import { NextResponse, NextRequest } from "next/server"; // 导入 NextRequest
|
||||
import { getSessionData } from "@/lib/server-actions"; // 导入 getSessionData
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSessionData(); // 使用 getSessionData 获取 session
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = session.user.id;
|
||||
const points = await getUserPoints(userId);
|
||||
|
||||
if (points === null) {
|
||||
// 如果用户没有积分账户,可以返回0或者其他默认值,或者创建初始账户
|
||||
// 这里暂时返回0,因为getUserPoints会返回null
|
||||
return NextResponse.json({ success: true, points: 0 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, points: points });
|
||||
} catch (error) {
|
||||
console.error("Error fetching user points:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { task_id, auth_id, timestamp } = await request.json();
|
||||
|
||||
// 1. 参数校验
|
||||
if (!task_id || !auth_id || typeof timestamp !== 'number') {
|
||||
console.log(task_id, auth_id, timestamp)
|
||||
return NextResponse.json({ success: false, error: "Invalid request parameters" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. 时间戳校验 (10秒内)
|
||||
const currentTime = Math.floor(Date.now() / 1000); // 秒级时间戳
|
||||
if (Math.abs(currentTime - timestamp) > 10) {
|
||||
console.log(currentTime, timestamp)
|
||||
return NextResponse.json({ success: false, error: "Request too old or in the future" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 3. 校验是否重复请求 (这里需要一个机制来判断 task_id 是否已被处理)
|
||||
// 理想情况下,这应该在数据库层面进行,例如在pointsTransactions表中添加task_id字段,
|
||||
// 并检查该task_id是否已经存在对应的扣减记录。
|
||||
// 为了不修改数据库schema,这里可以先简化处理:如果多次请求,扣减逻辑的幂等性需要由deductUserPoints或更上层保证。
|
||||
// 暂时先假设 deductUserPoints 本身或其调用方会处理重复扣减的场景。
|
||||
// For now, we'll assume the client ensures unique task_id for deduction, or deductUserPoints handles idempotency.
|
||||
// 或者,可以考虑使用 Redis 或其他缓存来存储已处理的 task_id,但为了保持简洁,暂时省略。
|
||||
|
||||
// 4. 获取 userId (auth_id)
|
||||
const userId = auth_id; // 这里假设 auth_id 就是 userId
|
||||
|
||||
// 5. 扣减积分
|
||||
const pointsToDeduct = parseInt(process.env.POINTS_PER_PODCAST || '10', 10); // 从环境变量获取,默认10
|
||||
const reasonCode = "podcast_generation";
|
||||
const description = `播客生成任务:${task_id}`;
|
||||
|
||||
await deductUserPoints(userId, pointsToDeduct, reasonCode, description);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Points deducted successfully" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deducting points:", error);
|
||||
if (error instanceof Error) {
|
||||
// 区分积分不足的错误
|
||||
if (error.message.includes("积分不足")) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 403 }); // Forbidden
|
||||
}
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
web/src/app/api/points/transactions/route.ts
Normal file
29
web/src/app/api/points/transactions/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getUserPointsTransactions } from "@/lib/points";
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { getSessionData } from "@/lib/server-actions";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = session.user.id;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20', 10);
|
||||
|
||||
// 校验 page 和 pageSize 是否为有效数字
|
||||
if (isNaN(page) || page < 1 || isNaN(pageSize) || pageSize < 1) {
|
||||
return NextResponse.json({ success: false, error: "Invalid pagination parameters" }, { status: 400 });
|
||||
}
|
||||
|
||||
const transactions = await getUserPointsTransactions(userId, page, pageSize);
|
||||
|
||||
return NextResponse.json({ success: true, transactions });
|
||||
} catch (error) {
|
||||
console.error("Error fetching user points transactions:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import AuthProviders from '@/components/AuthProviders';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
@@ -40,7 +39,6 @@ export default function RootLayout({
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body className={`${inter.className} antialiased`}>
|
||||
<AuthProviders>
|
||||
<div id="root" className="min-h-screen bg-white">
|
||||
{children}
|
||||
</div>
|
||||
@@ -48,7 +46,6 @@ export default function RootLayout({
|
||||
<div id="toast-root" />
|
||||
{/* Modal容器 */}
|
||||
<div id="modal-root" />
|
||||
</AuthProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,14 @@ import PodcastCreator from '@/components/PodcastCreator';
|
||||
import ContentSection from '@/components/ContentSection';
|
||||
import AudioPlayer from '@/components/AudioPlayer';
|
||||
import SettingsForm from '@/components/SettingsForm';
|
||||
import PointsOverview from '@/components/PointsOverview'; // 导入 PointsOverview
|
||||
import { ToastContainer, useToast } from '@/components/Toast';
|
||||
import { usePreventDuplicateCall } from '@/hooks/useApiCall';
|
||||
import { trackedFetch } from '@/utils/apiCallTracker';
|
||||
import type { PodcastGenerationRequest, PodcastItem, UIState, PodcastGenerationResponse, SettingsFormData } from '@/types';
|
||||
import { getTTSProviders } from '@/lib/config';
|
||||
import { useSession, signOut } from 'next-auth/react'; // 导入 useSession 和 signOut
|
||||
import LoginModal from '@/components/LoginModal'; // 导入 LoginModal
|
||||
import { getSessionData } from '@/lib/server-actions';
|
||||
|
||||
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
|
||||
|
||||
@@ -33,6 +34,8 @@ export default function HomePage() {
|
||||
const [libraryPodcasts, setLibraryPodcasts] = useState<PodcastItem[]>([]);
|
||||
const [explorePodcasts, setExplorePodcasts] = useState<PodcastItem[]>([]);
|
||||
const [credits, setCredits] = useState(0); // 积分状态
|
||||
const [pointHistory, setPointHistory] = useState<any[]>([]); // 积分历史
|
||||
const [user, setUser] = useState<any>(null); // 用户信息
|
||||
const [settings, setSettings] = useState<SettingsFormData | null>(null); // 加载设置的状态
|
||||
|
||||
// 音频播放器状态
|
||||
@@ -40,22 +43,25 @@ export default function HomePage() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
|
||||
// 模拟从后端获取积分数据和初始化数据加载
|
||||
// 从后端获取积分数据和初始化数据加载
|
||||
const initialized = React.useRef(false); // 使用 useRef 追踪是否已初始化
|
||||
|
||||
useEffect(() => {
|
||||
// 实际应用中,这里会发起API请求获取用户积分
|
||||
// 例如:fetch('/api/user/credits').then(res => res.json()).then(data => setCredits(data.credits));
|
||||
setCredits(100000); // 模拟初始积分100
|
||||
|
||||
// 首次加载时获取播客列表
|
||||
fetchRecentPodcasts();
|
||||
// 确保只在组件首次挂载时执行一次
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
|
||||
// 首次加载时获取播客列表
|
||||
fetchRecentPodcasts();
|
||||
}
|
||||
|
||||
// 设置定时器每20秒刷新一次
|
||||
const interval = setInterval(() => {
|
||||
fetchRecentPodcasts();
|
||||
}, 20000);
|
||||
// const interval = setInterval(() => {
|
||||
// fetchRecentPodcasts();
|
||||
// }, 20000);
|
||||
|
||||
// 清理定时器
|
||||
return () => clearInterval(interval);
|
||||
// // 清理定时器
|
||||
// return () => clearInterval(interval);
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
|
||||
// 加载设置
|
||||
@@ -110,6 +116,10 @@ export default function HomePage() {
|
||||
setUIState(prev => ({ ...prev, currentView: view as UIState['currentView'] }));
|
||||
};
|
||||
|
||||
const handleCreditsChange = (newCredits: number) => {
|
||||
setCredits(newCredits);
|
||||
};
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
setUIState(prev => ({ ...prev, sidebarCollapsed: !prev.sidebarCollapsed }));
|
||||
};
|
||||
@@ -140,8 +150,12 @@ export default function HomePage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if(response.status === 401) {
|
||||
throw new Error('生成播客失败,请检查API Key是否正确');
|
||||
if(response.status === 401) {
|
||||
throw new Error('生成播客失败,请检查API Key是否正确,或登录状态。');
|
||||
}
|
||||
if(response.status === 403) {
|
||||
setIsLoginModalOpen(true); // 显示登录模态框
|
||||
throw new Error('生成播客失败,请登录后重试。');
|
||||
}
|
||||
if(response.status === 409) {
|
||||
throw new Error(`生成播客失败,有正在进行中的任务 (状态码: ${response.status})`);
|
||||
@@ -165,6 +179,7 @@ export default function HomePage() {
|
||||
} catch (err) {
|
||||
console.error('Error generating podcast:', err);
|
||||
error('生成失败', err instanceof Error ? err.message : '未知错误');
|
||||
throw new Error(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
@@ -232,6 +247,51 @@ export default function HomePage() {
|
||||
console.error('Error processing podcast data:', err);
|
||||
error('数据处理失败', err instanceof Error ? err.message : '无法处理播客列表数据');
|
||||
}
|
||||
|
||||
const fetchCredits = async () => {
|
||||
try {
|
||||
const pointsResponse = await fetch('/api/points');
|
||||
if (pointsResponse.ok) {
|
||||
const data = await pointsResponse.json();
|
||||
if (data.success) {
|
||||
setCredits(data.points);
|
||||
} else {
|
||||
console.error('Failed to fetch credits:', data.error);
|
||||
setCredits(0); // 获取失败则设置为0
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch credits with status:', pointsResponse.status);
|
||||
setCredits(0); // 获取失败则设置为0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching credits:', error);
|
||||
setCredits(0); // 发生错误则设置为0
|
||||
}
|
||||
|
||||
try {
|
||||
const transactionsResponse = await fetch('/api/points/transactions');
|
||||
if (transactionsResponse.ok) {
|
||||
const data = await transactionsResponse.json();
|
||||
if (data.success) {
|
||||
setPointHistory(data.transactions);
|
||||
} else {
|
||||
console.error('Failed to fetch point transactions:', data.error);
|
||||
setPointHistory([]);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch point transactions with status:', transactionsResponse.status);
|
||||
setPointHistory([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching point transactions:', error);
|
||||
setPointHistory([]);
|
||||
}
|
||||
|
||||
const { session, user } = await getSessionData();
|
||||
setUser(user); // 设置用户信息
|
||||
};
|
||||
|
||||
fetchCredits(); // 调用获取积分函数
|
||||
};
|
||||
|
||||
// 辅助函数:解析时长字符串为秒数
|
||||
@@ -292,6 +352,15 @@ export default function HomePage() {
|
||||
onError={(message) => error('保存失败', message)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'credits':
|
||||
return (
|
||||
<PointsOverview
|
||||
totalPoints={credits}
|
||||
user={user}
|
||||
pointHistory={pointHistory}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
@@ -313,6 +382,8 @@ export default function HomePage() {
|
||||
onToggleCollapse={handleToggleSidebar}
|
||||
mobileOpen={mobileSidebarOpen} // 传递移动端侧边栏状态
|
||||
credits={credits} // 将积分传递给Sidebar
|
||||
onPodcastExplore={setExplorePodcasts} // 传递刷新播客函数
|
||||
onCreditsChange={handleCreditsChange} // 传递积分更新函数
|
||||
/>
|
||||
|
||||
{/* 移动端菜单按钮 */}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import React from 'react';
|
||||
|
||||
interface AuthProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AuthProviders: React.FC<AuthProvidersProps> = ({ children }) => {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
};
|
||||
|
||||
export default AuthProviders;
|
||||
@@ -2,7 +2,7 @@
|
||||
"use client"; // 标记为客户端组件,因为需要交互性
|
||||
|
||||
import React, { FC, MouseEventHandler, useCallback, useRef } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { createPortal } from "react-dom";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline"; // 导入关闭图标
|
||||
import { Chrome, Github } from "lucide-react"; // 从 lucide-react 导入 Google 和 GitHub 图标
|
||||
@@ -55,7 +55,7 @@ const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => signIn('google')}
|
||||
onClick={() => signIn.social({ provider: "google" , newUserCallbackURL: "/api/newuser?provider=google"})}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-lg font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
<Chrome className="h-6 w-6" />
|
||||
@@ -63,7 +63,7 @@ const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => signIn('github')}
|
||||
onClick={() => signIn.social({ provider: "github" , newUserCallbackURL: "/api/newuser?provider=github" })}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-lg font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
<Github className="h-6 w-6" />
|
||||
|
||||
@@ -19,7 +19,7 @@ import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具
|
||||
import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types';
|
||||
|
||||
interface PodcastCreatorProps {
|
||||
onGenerate: (request: PodcastGenerationRequest) => void;
|
||||
onGenerate: (request: PodcastGenerationRequest) => Promise<void>; // 修改为返回 Promise<void>
|
||||
isGenerating?: boolean;
|
||||
credits: number;
|
||||
settings: SettingsFormData | null; // 新增 settings 属性
|
||||
@@ -47,6 +47,18 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
const [topic, setTopic] = useState('');
|
||||
const [customInstructions, setCustomInstructions] = useState('');
|
||||
const [selectedMode, setSelectedMode] = useState<'ai-podcast' | 'flowspeech'>('ai-podcast');
|
||||
|
||||
// 初始化时从 localStorage 加载 topic 和 customInstructions
|
||||
useEffect(() => {
|
||||
const cachedTopic = getItem<string>('podcast-topic');
|
||||
if (cachedTopic) {
|
||||
setTopic(cachedTopic);
|
||||
}
|
||||
const cachedCustomInstructions = getItem<string>('podcast-custom-instructions');
|
||||
if (cachedCustomInstructions) {
|
||||
setCustomInstructions(cachedCustomInstructions);
|
||||
}
|
||||
}, []);
|
||||
const [language, setLanguage] = useState(languageOptions[0].value);
|
||||
const [duration, setDuration] = useState(durationOptions[0].value);
|
||||
const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态
|
||||
@@ -60,9 +72,9 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
const [selectedConfigName, setSelectedConfigName] = useState<string>(''); // 新增状态来存储配置文件的名称
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { toasts, error } = useToast(); // 使用 useToast hook
|
||||
const { toasts, error } = useToast(); // 使用 useToast hook, 引入 success
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => { // 修改为 async 函数
|
||||
if (!topic.trim()) {
|
||||
error("主题不能为空", "请输入播客主题。"); // 使用 toast.error
|
||||
return;
|
||||
@@ -90,12 +102,21 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
api_key: settings?.apikey,
|
||||
base_url: settings?.baseurl,
|
||||
model: settings?.model,
|
||||
callback_url: "https://your-callback-url.com/podcast-status", // Assuming a fixed callback URL
|
||||
callback_url: process.env.NEXT_PUBLIC_PODCAST_CALLBACK_URL || "https://your-callback-url.com/podcast-status", // 从环境变量获取
|
||||
usetime: duration,
|
||||
output_language: language,
|
||||
};
|
||||
|
||||
onGenerate(request);
|
||||
try {
|
||||
await onGenerate(request); // 等待 API 调用完成
|
||||
// 清空 topic 和 customInstructions,并更新 localStorage
|
||||
setTopic('');
|
||||
setItem('podcast-topic', '');
|
||||
setCustomInstructions('');
|
||||
setItem('podcast-custom-instructions', '');
|
||||
} catch (err) {
|
||||
console.error("播客生成失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -168,7 +189,10 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
<div className="p-6">
|
||||
<textarea
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setTopic(e.target.value);
|
||||
setItem('podcast-topic', e.target.value); // 实时保存到 localStorage
|
||||
}}
|
||||
placeholder="输入文字、上传文件或粘贴链接..."
|
||||
className="w-full h-32 resize-none border-none outline-none text-lg placeholder-neutral-400"
|
||||
disabled={isGenerating}
|
||||
@@ -179,7 +203,10 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
<div className="mt-4 pt-4 border-t border-neutral-100">
|
||||
<textarea
|
||||
value={customInstructions}
|
||||
onChange={(e) => setCustomInstructions(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setCustomInstructions(e.target.value);
|
||||
setItem('podcast-custom-instructions', e.target.value); // 实时保存到 localStorage
|
||||
}}
|
||||
placeholder="添加自定义指令(可选)..."
|
||||
className="w-full h-16 resize-none border-none outline-none text-sm placeholder-neutral-400"
|
||||
disabled={isGenerating}
|
||||
@@ -312,7 +339,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
|
||||
<span className=" xs:inline">生成中...</span>
|
||||
<span className=" xs:inline">Biu!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -356,6 +383,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={() => {}} /> {/* 添加 ToastContainer */}
|
||||
</div>
|
||||
);
|
||||
|
||||
89
web/src/components/PointsOverview.tsx
Normal file
89
web/src/components/PointsOverview.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// web/src/components/PointsOverview.tsx
|
||||
import React from 'react';
|
||||
import { UserCircleIcon, WalletIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface PointEntry {
|
||||
transactionId: string;
|
||||
userId: string;
|
||||
pointsChange: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PointsOverviewProps {
|
||||
totalPoints: number;
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
image: string;
|
||||
};
|
||||
pointHistory: PointEntry[];
|
||||
}
|
||||
|
||||
const PointsOverview: React.FC<PointsOverviewProps> = ({
|
||||
totalPoints,
|
||||
user,
|
||||
pointHistory,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-9/10 sm:w-3/5 lg:w-1/3 mx-auto flex flex-col gap-6 p-6 md:p-8 lg:p-10 bg-gray-50 dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
{/* Upper Section: Total Points and User Info */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-pink-500 dark:from-purple-700 dark:to-pink-600 text-white p-6 rounded-lg shadow-lg flex flex-col md:flex-row items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
<img
|
||||
src={user.image}
|
||||
alt="User Avatar"
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="text-center sm:text-left">
|
||||
<h2 className="text-xl sm:text-3xl font-bold tracking-tight">{user.name}</h2>
|
||||
<p className="text-xs sm:text-base text-blue-200 dark:text-blue-300">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0 text-center sm:text-right">
|
||||
<p className="text-blue-200 dark:text-blue-300 text-sm uppercase">总积分</p>
|
||||
<p className="text-5xl font-extrabold tracking-tighter">{totalPoints}</p>
|
||||
<WalletIcon className="h-8 w-8 text-white inline-block ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Small text for mobile view only */}
|
||||
<p className="mt-4 text-center text-sm text-blue-500 dark:text-blue-300">
|
||||
仅显示最近20条积分明细。
|
||||
</p>
|
||||
|
||||
{/* Lower Section: Point Details */}
|
||||
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-lg">
|
||||
<h3 className="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4 border-b pb-2 border-gray-200 dark:border-gray-700">
|
||||
积分明细
|
||||
</h3>
|
||||
{pointHistory.length === 0 ? (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-center py-4">暂无积分明细。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pointHistory
|
||||
.slice(Math.max(pointHistory.length - 20, 0)) // Only display the last 20 entries
|
||||
.map((entry) => (
|
||||
<li key={entry.transactionId} className="py-4 flex flex-col sm:flex-row items-start sm:items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-50 break-words">
|
||||
{entry.description}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`mt-2 sm:mt-0 text-lg font-bold ${
|
||||
entry.pointsChange > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{entry.pointsChange > 0 ? '+' : ''} {entry.pointsChange}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PointsOverview;
|
||||
@@ -1,28 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react'; // 导入 useState 和 useEffect 钩子
|
||||
import React, { useState, useEffect, useRef } from 'react'; // 导入 useState, useEffect, 和 useRef 钩子
|
||||
import {
|
||||
Home,
|
||||
Library,
|
||||
Compass,
|
||||
DollarSign,
|
||||
Coins,
|
||||
Settings,
|
||||
Twitter,
|
||||
X,
|
||||
MessageCircle,
|
||||
Mail,
|
||||
Cloud,
|
||||
Smartphone,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Coins,
|
||||
LogIn, // 导入 LogIn 图标用于登录按钮
|
||||
LogOut, // 导入 LogOut 图标用于注销按钮
|
||||
User2 // 导入 User2 图标用于默认头像
|
||||
} from 'lucide-react';
|
||||
import { useSession, signOut } from 'next-auth/react'; // 导入 useSession 和 signOut 钩子
|
||||
import { signOut } from '@/lib/auth-client'; // 导入 signOut 函数
|
||||
import { useRouter } from 'next/navigation'; // 导入 useRouter 钩子
|
||||
import { getSessionData } from '@/lib/server-actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import LoginModal from './LoginModal'; // 导入 LoginModal 组件
|
||||
|
||||
import type { PodcastItem } from '@/types';
|
||||
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -32,6 +30,8 @@ interface SidebarProps {
|
||||
onToggleCollapse?: () => void;
|
||||
mobileOpen?: boolean; // 添加移动端侧边栏状态属性
|
||||
credits: number; // 添加 credits 属性
|
||||
onPodcastExplore: (podcasts: PodcastItem[]) => void; // 添加刷新播客函数
|
||||
onCreditsChange: (newCredits: number) => void; // 添加 onCreditsChange 回调函数
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -46,26 +46,48 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
onViewChange,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
mobileOpen = false, // 解构移动端侧边栏状态属性
|
||||
credits // 解构 credits 属性
|
||||
mobileOpen, // 解构移动端侧边栏状态属性
|
||||
credits, // 解构 credits 属性
|
||||
onPodcastExplore, // 解构刷新播客函数
|
||||
onCreditsChange, // 解构 onCreditsChange 属性
|
||||
}) => {
|
||||
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示状态
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); // 控制注销确认模态框的显示状态
|
||||
const { data: session } = useSession(); // 获取用户会话数据
|
||||
const [session, setSession] = useState<any>(null); // 使用 useState 管理 session
|
||||
const didFetch = useRef(false); // 使用 useRef 确保 useEffect 只在组件挂载时执行一次
|
||||
const router = useRouter(); // 初始化 useRouter 钩子
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.expires) {
|
||||
const expirationTime = new Date(session.expires).getTime();
|
||||
if (!didFetch.current) {
|
||||
didFetch.current = true; // 标记为已执行,避免在开发模式下重复执行
|
||||
const fetchSession = async () => {
|
||||
const { session: fetchedSession, user: fetchedUser } = await getSessionData();
|
||||
setSession(fetchedSession);
|
||||
console.log('session', fetchedSession); // 确保只在 session 数据获取并设置后打印
|
||||
};
|
||||
fetchSession();
|
||||
}
|
||||
}, []); // 空依赖数组表示只在组件挂载时执行一次
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.expiresAt) {
|
||||
const expirationTime = session.expiresAt.getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
|
||||
if (currentTime > expirationTime) {
|
||||
console.log('Session expired, logging out...');
|
||||
signOut(); // 会话过期,执行注销
|
||||
signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
setSession(null); // 会话过期,注销成功后清空本地 session 状态
|
||||
onCreditsChange(0); // 清空积分
|
||||
router.push("/"); // 会话过期,执行注销并重定向到主页
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [session]); // 监听 session 变化
|
||||
|
||||
console.log('session', session);
|
||||
}, [session, router]); // 监听 session 变化和 router(因为 signOut 中使用了 router.push)
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{ id: 'home', label: '首页', icon: Home },
|
||||
@@ -77,13 +99,13 @@ credits // 解构 credits 属性
|
||||
const bottomNavItems: NavItem[] = [
|
||||
// 隐藏定价和积分
|
||||
// { id: 'pricing', label: '定价', icon: DollarSign },
|
||||
// { id: 'credits', label: '积分', icon: Coins, badge: credits.toString() }, // 动态设置 badge
|
||||
{ id: 'credits', label: '积分', icon: Coins, badge: credits.toString() }, // 动态设置 badge
|
||||
...(enableTTSConfigPage ? [{ id: 'settings', label: 'TTS设置', icon: Settings }] : [])
|
||||
];
|
||||
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Twitter, href: '#', label: 'Twitter' },
|
||||
{ icon: X, href: '#', label: 'Twitter' },
|
||||
{ icon: MessageCircle, href: '#', label: 'Discord' },
|
||||
{ icon: Mail, href: '#', label: 'Email' },
|
||||
{ icon: Cloud, href: '#', label: 'Cloud' },
|
||||
@@ -185,7 +207,13 @@ credits // 解构 credits 属性
|
||||
return (
|
||||
<div key={item.id} className={cn(collapsed && "flex justify-center")}>
|
||||
<button
|
||||
onClick={() => onViewChange(item.id)}
|
||||
onClick={() => {
|
||||
if (item.id === 'credits' && !session) {
|
||||
setShowLoginModal(true);
|
||||
} else {
|
||||
onViewChange(item.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center rounded-lg text-neutral-600 hover:text-black hover:bg-neutral-50 transition-all duration-200",
|
||||
isActive && "bg-white text-black shadow-soft",
|
||||
@@ -329,7 +357,19 @@ credits // 解构 credits 属性
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
onClick={() => {
|
||||
signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
setSession(null); // 注销成功后清空本地 session 状态
|
||||
onPodcastExplore([]); // 注销后清空播客卡片
|
||||
onCreditsChange(0); // 清空积分
|
||||
router.push("/"); // 注销成功后重定向到主页
|
||||
},
|
||||
},
|
||||
});
|
||||
setShowLogoutConfirm(false); // 关闭确认模态框
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors"
|
||||
>
|
||||
注销
|
||||
|
||||
8
web/src/lib/auth-client.ts
Normal file
8
web/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { usernameClient } from "better-auth/client/plugins";
|
||||
|
||||
export const { signIn, signUp, signOut, useSession, updateUser, changeEmail, changePassword} =
|
||||
createAuthClient({
|
||||
plugins: [usernameClient()],
|
||||
baseURL: process.env.BETTER_AUTH_URL!,
|
||||
});
|
||||
23
web/src/lib/auth.ts
Normal file
23
web/src/lib/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { username } from "better-auth/plugins";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "./database";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite", // or "pg" or "mysql"
|
||||
}),
|
||||
plugins: [username()],
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
},
|
||||
github: {
|
||||
clientId: process.env.GITHUB_ID!,
|
||||
clientSecret: process.env.GITHUB_SECRET!,
|
||||
},
|
||||
},
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL!,
|
||||
});
|
||||
7
web/src/lib/database.ts
Normal file
7
web/src/lib/database.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { createClient } from '@libsql/client';
|
||||
import * as schema from "../../drizzle-schema";
|
||||
|
||||
const client = createClient({ url: process.env.DB_FILE_NAME! });
|
||||
export const db = drizzle(client, { schema, logger: false });
|
||||
@@ -6,13 +6,14 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_PODCAST_API_BASE_URL || 'http://192
|
||||
/**
|
||||
* 启动播客生成任务
|
||||
*/
|
||||
export async function startPodcastGenerationTask(body: PodcastGenerationRequest): Promise<ApiResponse<PodcastGenerationResponse>> {
|
||||
export async function startPodcastGenerationTask(body: PodcastGenerationRequest, userId: string): Promise<ApiResponse<PodcastGenerationResponse>> {
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/generate-podcast`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Auth-Id': '7788414',
|
||||
'X-Auth-Id': userId,
|
||||
},
|
||||
body: new URLSearchParams(Object.entries(body).map(([key, value]) => [key, String(value)])),
|
||||
});
|
||||
@@ -37,13 +38,13 @@ export async function startPodcastGenerationTask(body: PodcastGenerationRequest)
|
||||
/**
|
||||
* 获取播客生成任务状态
|
||||
*/
|
||||
export async function getPodcastStatus(): Promise<ApiResponse<PodcastStatusResponse>> {
|
||||
export async function getPodcastStatus(userId: string): Promise<ApiResponse<PodcastStatusResponse>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/podcast-status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Id': '7788414',
|
||||
'X-Auth-Id': userId,
|
||||
},
|
||||
cache: 'no-store', // 禁用客户端缓存
|
||||
});
|
||||
|
||||
179
web/src/lib/points.ts
Normal file
179
web/src/lib/points.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { db } from "@/lib/database";
|
||||
import * as schema from "../../drizzle-schema";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* 创建用户的初始积分账户。
|
||||
* @param userId 用户ID
|
||||
* @param initialPoints 初始积分数量
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function createPointsAccount(userId: string, initialPoints: number = 100): Promise<void> {
|
||||
try {
|
||||
await db.insert(schema.pointsAccounts).values({
|
||||
userId: userId,
|
||||
totalPoints: initialPoints,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
});
|
||||
console.log(`用户 ${userId} 的积分账户初始化成功,初始积分:${initialPoints}`);
|
||||
} catch (error) {
|
||||
console.error(`初始化用户 ${userId} 积分账户失败:`, error);
|
||||
throw error; // 抛出错误以便调用方处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录积分交易流水。
|
||||
* @param userId 用户ID
|
||||
* @param pointsChange 积分变动数量 (正数表示增加,负数表示减少)
|
||||
* @param reasonCode 交易原因代码 (例如: "initial_bonus", "purchase", "redeem")
|
||||
* @param description 交易描述 (可选)
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function recordPointsTransaction(
|
||||
userId: string,
|
||||
pointsChange: number,
|
||||
reasonCode: string,
|
||||
description?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.insert(schema.pointsTransactions).values({
|
||||
userId: userId,
|
||||
pointsChange: pointsChange,
|
||||
reasonCode: reasonCode,
|
||||
description: description,
|
||||
createdAt: sql`CURRENT_TIMESTAMP`,
|
||||
});
|
||||
console.log(`用户 ${userId} 的积分流水记录成功: 变动 ${pointsChange}, 原因 ${reasonCode}`);
|
||||
} catch (error) {
|
||||
console.error(`记录用户 ${userId} 积分流水失败:`, error);
|
||||
throw error; // 抛出错误以便调用方处理
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 检查用户是否已存在积分账户。
|
||||
* @param userId 用户ID
|
||||
* @returns Promise<boolean> 如果存在则返回 true,否则返回 false
|
||||
*/
|
||||
export async function checkUserPointsAccount(userId: string): Promise<boolean> {
|
||||
const existingPointsAccount = await db
|
||||
.select()
|
||||
.from(schema.pointsAccounts)
|
||||
.where(eq(schema.pointsAccounts.userId, userId))
|
||||
.limit(1);
|
||||
return existingPointsAccount.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID获取用户的当前积分。
|
||||
* @param userId 用户ID
|
||||
* @returns Promise<number | null> 返回用户当前积分数量,如果用户不存在则返回 null
|
||||
*/
|
||||
export async function getUserPoints(userId: string): Promise<number | null> {
|
||||
try {
|
||||
const account = await db
|
||||
.select({ totalPoints: schema.pointsAccounts.totalPoints })
|
||||
.from(schema.pointsAccounts)
|
||||
.where(eq(schema.pointsAccounts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (account.length > 0) {
|
||||
return account[0].totalPoints;
|
||||
}
|
||||
return null; // 用户不存在积分账户
|
||||
} catch (error) {
|
||||
console.error(`获取用户 ${userId} 积分失败:`, error);
|
||||
throw error; // 抛出错误以便调用方处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减用户积分。
|
||||
* @param userId 用户ID
|
||||
* @param pointsToDeduct 要扣减的积分数量
|
||||
* @param reasonCode 交易原因代码 (例如: "redeem", "purchase_refund")
|
||||
* @param description 交易描述 (可选)
|
||||
* @returns Promise<void>
|
||||
* @throws Error 如果积分不足或操作失败
|
||||
*/
|
||||
export async function deductUserPoints(
|
||||
userId: string,
|
||||
pointsToDeduct: number,
|
||||
reasonCode: string,
|
||||
description?: string
|
||||
): Promise<void> {
|
||||
if (pointsToDeduct <= 0) {
|
||||
throw new Error("扣减积分数量必须大于0。");
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// 1. 获取用户当前积分
|
||||
const [account] = await tx
|
||||
.select({ totalPoints: schema.pointsAccounts.totalPoints })
|
||||
.from(schema.pointsAccounts)
|
||||
.where(eq(schema.pointsAccounts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!account) {
|
||||
throw new Error(`用户 ${userId} 不存在积分账户。`);
|
||||
}
|
||||
|
||||
const currentPoints = account.totalPoints;
|
||||
|
||||
// 2. 检查积分是否足够
|
||||
if (currentPoints < pointsToDeduct) {
|
||||
throw new Error(`用户 ${userId} 积分不足,当前积分 ${currentPoints},需要扣减 ${pointsToDeduct}。`);
|
||||
}
|
||||
|
||||
const newPoints = currentPoints - pointsToDeduct;
|
||||
|
||||
// 3. 记录积分交易流水
|
||||
await tx.insert(schema.pointsTransactions).values({
|
||||
userId: userId,
|
||||
pointsChange: -pointsToDeduct, // 扣减为负数
|
||||
reasonCode: reasonCode,
|
||||
description: description,
|
||||
createdAt: sql`CURRENT_TIMESTAMP`,
|
||||
});
|
||||
|
||||
// 4. 更新积分账户
|
||||
await tx
|
||||
.update(schema.pointsAccounts)
|
||||
.set({
|
||||
totalPoints: newPoints,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.pointsAccounts.userId, userId));
|
||||
|
||||
console.log(`用户 ${userId} 积分成功扣减 ${pointsToDeduct},当前积分 ${newPoints}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户积分明细。
|
||||
* @param userId 用户ID
|
||||
* @param page 页码 (默认为 1)
|
||||
* @param pageSize 每页数量 (默认为 10)
|
||||
* @returns Promise<PointsTransaction[]> 返回用户积分交易明细数组
|
||||
*/
|
||||
export async function getUserPointsTransactions(
|
||||
userId: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<typeof schema.pointsTransactions.$inferSelect[]> {
|
||||
try {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const transactions = await db
|
||||
.select()
|
||||
.from(schema.pointsTransactions)
|
||||
.where(eq(schema.pointsTransactions.userId, userId))
|
||||
.orderBy(desc(schema.pointsTransactions.createdAt)) // 按创建时间倒序
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
console.error(`查询用户 ${userId} 积分明细失败:`, error);
|
||||
throw error; // 抛出错误以便调用方处理
|
||||
}
|
||||
}
|
||||
15
web/src/lib/server-actions.ts
Normal file
15
web/src/lib/server-actions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const getSessionData = async () => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
})
|
||||
return {
|
||||
session,
|
||||
user: session?.user,
|
||||
token: session?.session.token,
|
||||
}
|
||||
};
|
||||
@@ -102,7 +102,7 @@ export interface WebSocketMessage {
|
||||
// 用户界面状态
|
||||
export interface UIState {
|
||||
sidebarCollapsed: boolean;
|
||||
currentView: 'home' | 'library' | 'explore' | 'settings';
|
||||
currentView: 'home' | 'library' | 'explore' | 'settings' | 'credits';
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user