feat: 实现积分系统与认证重构

重构认证系统,从next-auth迁移至better-auth,并实现完整的积分系统功能:
1. 新增积分账户管理、交易记录和扣减逻辑
2. 添加积分概览组件和API端点
3. 重构认证相关组件和路由
4. 优化播客生成流程与积分校验
5. 新增安全配置文档和数据库schema
6. 改进UI状态管理和错误处理

新增功能包括:
- 用户注册自动初始化积分账户
- 播客生成前检查积分余额
- 积分交易记录查询
- 用户积分实时显示
- 安全回调处理
This commit is contained in:
hex2077
2025-08-18 00:21:02 +08:00
parent b63fcb3f6d
commit e479ffb789
31 changed files with 3634 additions and 559 deletions

218
SECURITY.md Normal file
View 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小时内响应安全报告并在合理时间内修复确认的漏洞。

View File

@@ -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 # 成功发送,跳出循环

View File

@@ -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; /* 视觉居中校正 */
}

View File

@@ -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
View File

@@ -42,4 +42,7 @@ next-env.d.ts
*.swo
# OS
Thumbs.db
Thumbs.db
drizzle
.claude/
/.public/

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);

View File

@@ -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 };

View File

@@ -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({

View 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);
}

View File

@@ -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 }

View 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 });
}
}

View 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 });
}
}

View File

@@ -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>
);

View File

@@ -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} // 传递积分更新函数
/>
{/* 移动端菜单按钮 */}

View File

@@ -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;

View File

@@ -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" />

View File

@@ -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>
);

View 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;

View File

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

View 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
View 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
View 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 });

View File

@@ -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
View 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; // 抛出错误以便调用方处理
}
}

View 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,
}
};

View File

@@ -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';
}