Compare commits

...

7 Commits

Author SHA1 Message Date
Kevin Wong
c6c4b2313f 更新 2026-01-26 16:38:30 +08:00
Kevin Wong
f99bd336c9 更新 2026-01-26 12:18:54 +08:00
Kevin Wong
c918dc6faf 更新 2026-01-23 18:09:12 +08:00
Kevin Wong
3a3df41904 优化界面 2026-01-23 10:38:03 +08:00
Kevin Wong
561d74e16d 更新 2026-01-23 10:07:35 +08:00
Kevin Wong
cfe21d8337 更新 2026-01-23 09:42:10 +08:00
Kevin Wong
3a76f9d0cf 更新 2026-01-22 17:15:42 +08:00
45 changed files with 4099 additions and 551 deletions

View File

@@ -27,12 +27,18 @@ node --version
# 检查 FFmpeg
ffmpeg -version
# 检查 pm2 (用于服务管理)
pm2 --version
```
如果缺少 FFmpeg:
如果缺少依赖:
```bash
sudo apt update
sudo apt install ffmpeg
# 安装 pm2
npm install -g pm2
```
---
@@ -48,7 +54,30 @@ cd /home/rongye/ProgramFiles/ViGent2
---
## 步骤 3: 安装后端依赖
## 步骤 3: 部署 AI 模型 (LatentSync 1.6)
> ⚠️ **重要**LatentSync 需要独立的 Conda 环境和 **~18GB VRAM**。请**不要**直接安装在后端环境中。
请参考详细的独立部署指南:
**[LatentSync 部署指南](../models/LatentSync/DEPLOY.md)**
该指南包含以下关键步骤,请务必严格按照文档操作:
1. 创建独立的 `latentsync` Conda 环境
2. 安装 PyTorch 2.5.1 和相关依赖
3. 下载模型权重 (HuggingFace CLI)
4. 复制核心推理代码
5. 验证推理脚本
**验证 LatentSync 部署**:
```bash
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
conda activate latentsync
python -m scripts.server # 测试能否启动Ctrl+C 退出
```
---
## 步骤 4: 安装后端依赖
```bash
cd /home/rongye/ProgramFiles/ViGent2/backend
@@ -69,47 +98,25 @@ playwright install chromium
---
## 步骤 4: 部署 AI 模型 (LatentSync 1.6)
## 步骤 5: 部署用户认证系统 (Supabase + Auth)
> ⚠️ **重要**LatentSync 需要独立的 Conda 环境和 **~18GB VRAM**。请**不要**直接安装在后端环境中。
> 🔐 **包含**: 登录/注册、Supabase 数据库配置、JWT 认证、管理员后台
请参考详细的独立部署指南:
**[LatentSync 部署指南](../models/LatentSync/DEPLOY.md)**
该指南包含以下关键步骤,请务必严格按照文档操作:
1. 创建独立的 `latentsync` Conda 环境
2. 安装 PyTorch 2.5.1 和相关依赖
3. 下载模型权重 (HuggingFace CLI)
4. 复制核心推理代码
5. 验证推理脚本
确保 LatentSync 部署成功后,再继续后续步骤。
请参考独立的认证系统部署指南:
**[用户认证系统部署指南](AUTH_DEPLOY.md)**
---
## 步骤 5: 启动 LatentSync 常驻加速服务 (可选)
为了消除每次生成视频时的 30-40秒 模型加载时间,建议启动常驻服务:
```bash
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
# 后台启动服务 (自动读取 backend/.env 中的 GPU 配置)
nohup python -m scripts.server > server.log 2>&1 &
```
---
## 步骤 7: 配置环境变量
## 步骤 6: 配置环境变量
```bash
cd /home/rongye/ProgramFiles/ViGent2/backend
# 复制配置模板 (默认配置已经就绪)
# 复制配置模板
cp .env.example .env
```
> 💡 **说明**`.env.example` 已包含正确的 LatentSync 默认配置,直接复制即可使用。
> 💡 **说明**`.env.example` 已包含正确的默认配置,直接复制即可使用。
> 如需自定义,可编辑 `.env` 修改以下参数:
| 配置项 | 默认值 | 说明 |
@@ -122,20 +129,25 @@ cp .env.example .env
---
## 步骤 8: 安装前端依赖
## 步骤 7: 安装前端依赖
```bash
cd /home/rongye/ProgramFiles/ViGent2/frontend
# 安装依赖
npm install
# 生产环境构建 (可选)
npm run build
```
---
## 步骤 9: 测试运行
## 步骤 8: 测试运行
### 启动后端
> 💡 先手动启动测试,确认一切正常后再配置 pm2 常驻服务。
### 启动后端 (终端 1)
```bash
cd /home/rongye/ProgramFiles/ViGent2/backend
@@ -143,16 +155,22 @@ source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8006
```
### 启动前端 (新开终端)
### 启动前端 (终端 2)
```bash
cd /home/rongye/ProgramFiles/ViGent2/frontend
npm run dev -- -H 0.0.0.0 --port 3002
```
---
### 启动 LatentSync (终端 3, 可选加速)
## 步骤 10: 验证
```bash
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
conda activate latentsync
python -m scripts.server
```
### 验证
1. 访问 http://服务器IP:3002 查看前端
2. 访问 http://服务器IP:8006/docs 查看 API 文档
@@ -160,53 +178,118 @@ npm run dev -- -H 0.0.0.0 --port 3002
---
## 使用 systemd 管理服务 (可选)
## 步骤 9: 使用 pm2 管理常驻服务
### 后端服务
> 推荐使用 pm2 管理所有服务,支持自动重启和日志管理。
创建 `/etc/systemd/system/vigent2-backend.service`:
```ini
[Unit]
Description=ViGent2 Backend API
After=network.target
### 1. 启动后端服务 (FastAPI)
[Service]
Type=simple
User=rongye
WorkingDirectory=/home/rongye/ProgramFiles/ViGent2/backend
Environment="PATH=/home/rongye/ProgramFiles/ViGent2/backend/venv/bin"
ExecStart=/home/rongye/ProgramFiles/ViGent2/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
Restart=always
建议使用 Shell 脚本启动以避免环境问题。
[Install]
WantedBy=multi-user.target
1. 创建启动脚本 `run_backend.sh`:
```bash
cat > run_backend.sh << 'EOF'
#!/bin/bash
cd /home/rongye/ProgramFiles/ViGent2/backend
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
EOF
chmod +x run_backend.sh
```
### 前端服务
创建 `/etc/systemd/system/vigent2-frontend.service`:
```ini
[Unit]
Description=ViGent2 Frontend
After=network.target
[Service]
Type=simple
User=rongye
WorkingDirectory=/home/rongye/ProgramFiles/ViGent2/frontend
ExecStart=/usr/bin/npm run start
Restart=always
[Install]
WantedBy=multi-user.target
2. 使用 pm2 启动:
```bash
pm2 start ./run_backend.sh --name vigent2-backend
```
### 启用服务
### 2. 启动前端服务 (Next.js)
⚠️ **注意**:生产模式启动前必须先进行构建。
```bash
sudo systemctl daemon-reload
sudo systemctl enable vigent2-backend vigent2-frontend
sudo systemctl start vigent2-backend vigent2-frontend
cd /home/rongye/ProgramFiles/ViGent2/frontend
# 1. 构建项目 (如果之前没跑过或代码有更新)
npm run build
# 2. 启动服务
pm2 start npm --name vigent2-frontend -- run start -- -p 3002
```
### 3. 启动 LatentSync 模型服务
1. 创建启动脚本 `run_latentsync.sh` (使用你的 conda python 路径):
```bash
cat > run_latentsync.sh << 'EOF'
#!/bin/bash
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
# 替换为你的实际 Python 路径
/home/rongye/ProgramFiles/miniconda3/envs/latentsync/bin/python -m scripts.server
EOF
chmod +x run_latentsync.sh
```
2. 使用 pm2 启动:
```bash
pm2 start ./run_latentsync.sh --name vigent2-latentsync
```
### 4. 保存当前列表 (开机自启)
```bash
pm2 save
pm2 startup
```
### pm2 常用命令
```bash
pm2 status # 查看所有服务状态
pm2 logs # 查看所有日志
pm2 logs vigent2-backend # 查看后端日志
pm2 restart all # 重启所有服务
pm2 stop vigent2-latentsync # 停止 LatentSync 服务
pm2 delete all # 删除所有服务
```
---
## 步骤 10: 配置 Nginx HTTPS (可选 - 公网访问)
如果您需要通过公网域名 HTTPS 访问 (如 `https://vigent.hbyrkj.top`),请参考以下 Nginx 配置。
**前置条件**
1. 已申请 SSL 证书 (如 Let's Encrypt)。
2. 使用 FRP 或其他方式将本地 3002 端口映射到服务器。
**配置示例** (`/etc/nginx/conf.d/vigent.conf`):
```nginx
server {
listen 80;
server_name your.domain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name your.domain.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3002; # 转发给 Next.js 前端
# 必须配置 WebSocket 支持,否则热更和即时通信失效
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
---
@@ -227,14 +310,58 @@ python3 -c "import torch; print(torch.cuda.is_available())"
# 查看端口占用
sudo lsof -i :8006
sudo lsof -i :3002
sudo lsof -i :8007
```
### 查看日志
```bash
# 后端日志
journalctl -u vigent2-backend -f
# 前端日志
journalctl -u vigent2-frontend -f
# pm2 日志
pm2 logs vigent2-backend
pm2 logs vigent2-frontend
pm2 logs vigent2-latentsync
```
### SSH 连接卡顿 / 系统响应慢
**原因**LatentSync 模型服务启动时会占用大量 I/O 和 CPU 资源,或者模型加载到 GPU 时导致瞬时负载过高。
**解决**
1. 检查系统负载:`top``htop`
2. 如果不需要实时生成视频,可以暂时停止 LatentSync 服务:
```bash
pm2 stop vigent2-latentsync
```
3. 确保服务器有足够的 RAM 和 Swap 空间。
4. **代码级优化**:已在 `scripts/server.py` 和 `scripts/inference.py` 中强制限制 `OMP_NUM_THREADS=8`,防止 PyTorch 占用所有 CPU 核心导致系统假死。
---
## 依赖清单
### 后端关键依赖
| 依赖 | 用途 |
|------|------|
| `fastapi` | Web API 框架 |
| `uvicorn` | ASGI 服务器 |
| `edge-tts` | 微软 TTS 配音 |
| `playwright` | 社交媒体自动发布 |
| `biliup` | B站视频上传 |
| `loguru` | 日志管理 |
### 前端关键依赖
| 依赖 | 用途 |
|------|------|
| `next` | React 框架 |
| `swr` | 数据请求与缓存 |
| `tailwindcss` | CSS 样式 |
### LatentSync 关键依赖
| 依赖 | 用途 |
|------|------|
| `torch` 2.5.1 | PyTorch GPU 推理 |
| `diffusers` | Latent Diffusion 模型 |
| `accelerate` | 模型加速 |

122
Docs/DevLogs/Day10.md Normal file
View File

@@ -0,0 +1,122 @@
---
## 🔧 隧道访问与视频播放修复 (11:00)
### 问题描述
在通过 FRP 隧道 (如 `http://8.148.x.x:3002`) 访问时发现:
1. **视频无法播放**:后端返回 404 (Not Found)。
2. **发布页账号列表为空**:后端返回 500 (Internal Server Error)。
### 解决方案
#### 1. 视频播放修复
- **后端 (`main.py`)**:这是根源问题。后端缺少 `uploads` 目录的挂载,导致静态资源无法访问。
```python
app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads")
```
- **前端 (`next.config.ts`)**:添加反向代理规则,将 `/outputs` 和 `/uploads` 转发到后端端口 8006。
```typescript
{
source: '/uploads/:path*',
destination: 'http://localhost:8006/uploads/:path*',
},
{
source: '/outputs/:path*',
destination: 'http://localhost:8006/outputs/:path*',
}
```
#### 2. 账号列表 500 错误修复
- **根源**`backend/app/core/paths.py` 中的白名单缺少 `weixin` 和 `kuaishou`。
- **现象**:当 `PublishService` 遍历所有平台时,遇到未在白名单的平台直接抛出 `ValueError`,导致整个接口崩溃。
- **修复**:更新白名单。
```python
VALID_PLATFORMS: Set[str] = {"bilibili", "douyin", "xiaohongshu", "weixin", "kuaishou"}
```
### 结果
- ✅ 视频预览和历史视频均可正常播放。
- ✅ 发布页账号列表恢复显示。
---
## 🚀 Nginx HTTPS 部署 (11:30)
### 需求
用户在阿里云服务器上配置了 SSL 证书,需要通过 HTTPS 访问应用。
### 解决方案
提供了 Nginx 配置文件 `nginx_vigent.conf`,配置了:
1. **HTTP -> HTTPS 重定向**。
2. **SSL 证书路径** (`/etc/letsencrypt/live/vigent.hbyrkj.top/...`)。
3. **反向代理** 到本地 FRP 端口 (3002)。
4. **WebSocket 支持** (用于 Next.js 热更和通信)。
### 结果
- ✅ 用户可通过 `https://vigent.hbyrkj.top` 安全访问。
- ✅ 代码自适应:前端 `API_BASE` 为空字符串,自动适配 HTTPS 协议,无需修改代码。
---
## 🎨 UI 细节优化 (11:45)
### 修改
- 修改 `frontend/src/app/layout.tsx` 中的 Metadata。
- 标题从 `Create Next App` 改为 `ViGent`。
### 结果
- ✅ 浏览器标签页名称已更新。
---
## 🚪 用户登录退出功能 (12:00)
### 需求
用户反馈没有退出的入口。
### 解决方案
- **UI 修改**:在首页和发布管理页面的顶部导航栏添加红色的“退出”按钮 (位于最右侧)。
- **逻辑实现**
```javascript
onClick={async () => {
if (confirm('确定要退出登录吗?')) {
await fetch(`${API_BASE}/api/auth/logout`, { method: 'POST' });
window.location.href = '/login';
}
}}
```
- **部署**:已同步代码并重建前端。
---
## 🚢 Supabase 服务部署 (16:10)
### 需求
由于需要多用户隔离和更完善的权限管理,决定从纯本地文件存储迁移到 Supabase BaaS 架构。
### 实施步骤
1. **Docker 部署 (Ubuntu)**
- 使用官方 `docker-compose.yml`。
- **端口冲突解决**
- `Moodist` 占用 4000 -> 迁移 Analytics 到 **4004**。
- `code-server` 占用 8443 -> 迁移 Kong HTTPS 到 **8444**。
- 自定义端口Studio (**3003**), API (**8008**)。
2. **安全加固 (Aliyun Nginx)**
- **双域名策略**
- `supabase.hbyrkj.top` -> Studio (3003)
- `api.hbyrkj.top` -> API (8008)
- **SSL**:配置 Let's Encrypt 证书。
- **访问控制**:为 Studio 域名添加 `auth_basic` (htpasswd),防止未授权访问管理后台。
- **WebSocket**Nginx 配置 `Upgrade` 头支持 Realtime 功能。
3. **数据库初始化**
- 使用 `backend/database/schema.sql` 初始化了 `users`, `social_accounts` 等表结构。
### 下一步计划 (Storage Migration)
目前文件仍存储在本地磁盘,无法通过 RLS 进行隔离。
**计划改造 LatentSync 流程**
1. 后端集成 Supabase Storage SDK。
2. 实现 `Download (Storage) -> Local Process (LatentSync) -> Upload (Storage)` 闭环。
3. 前端改为请求 Signed URL 进行播放。

113
Docs/DevLogs/Day8.md Normal file
View File

@@ -0,0 +1,113 @@
# Day 8: 用户体验优化
**日期**: 2026-01-22
**目标**: 文件名保留 + 视频持久化 + 界面优化
---
## 📋 任务概览
| 任务 | 状态 |
|------|------|
| 文件名保留 | ✅ 完成 |
| 视频持久化 | ✅ 完成 |
| 历史视频列表 | ✅ 完成 |
| 删除功能 | ✅ 完成 |
---
## 🎉 实施成果
### 后端改动
**修改文件**:
- `backend/app/api/materials.py`
-`sanitize_filename()` 文件名安全化
- ✅ 时间戳前缀避免冲突 (`{timestamp}_{原始文件名}`)
-`list_materials` 显示原始文件名
-`DELETE /api/materials/{id}` 删除素材
- `backend/app/api/videos.py`
-`GET /api/videos/generated` 历史视频列表
-`DELETE /api/videos/generated/{id}` 删除视频
### 前端改动
**修改文件**:
- `frontend/src/app/page.tsx`
-`GeneratedVideo` 类型定义
-`generatedVideos` 状态管理
-`fetchGeneratedVideos()` 获取历史
-`deleteMaterial()` / `deleteVideo()` 删除功能
- ✅ 素材卡片添加删除按钮 (hover 显示)
- ✅ 历史视频列表组件 (右侧预览区下方)
- ✅ 生成完成后自动刷新历史列表
---
## 🔧 API 变更
### 新增端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/videos/generated` | 获取生成视频列表 |
| DELETE | `/api/videos/generated/{id}` | 删除生成视频 |
| DELETE | `/api/materials/{id}` | 删除素材 |
### 文件命名规则
```
原始: 测试视频.mp4
保存: 1737518400_测试视频.mp4
显示: 测试视频.mp4 (前端自动去除时间戳前缀)
```
---
## ✅ 完成总结
1. **文件名保留** - 上传保留原始名称,时间戳前缀避免冲突
2. **视频持久化** - 从文件系统读取,刷新不丢失
3. **历史列表** - 右侧显示历史视频,点击切换播放
4. **删除功能** - 素材和视频均支持删除
---
## 📊 测试清单
- [x] 上传视频后检查素材列表显示原始文件名
- [x] 刷新页面后检查历史视频列表持久化
- [x] 测试删除素材功能
- [x] 测试删除生成视频功能
- [x] 测试历史视频列表点击切换播放
---
## 🔧 发布功能修复 (Day 8 下半场)
> 以下修复在用户体验优化后进行
### 问题
1. **抖音 QR 登录假成功** - 前端检测到旧 Cookie 文件就显示"登录成功",实际可能已过期
2. **抖音上传循环卡死** - 发布后检测逻辑不完善,`while True` 无超时
3. **前端轮询不规范** - 使用 `setInterval` 手动轮询,不符合 React 最佳实践
### 修复
**后端**:
- `publish_service.py` - 添加 `logout()` 方法、修复 `get_login_session_status()` 优先检查活跃会话
- `api/publish.py` - 新增 `POST /api/publish/logout/{platform}` 端点
- `douyin_uploader.py` - 添加 `import time`,修复发布按钮点击竞态条件
**前端**:
- `publish/page.tsx` - 使用 `useSWR` 替代 `setInterval` 轮询登录状态
- `package.json` - 添加 `swr` 依赖
### 新增 API
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/publish/logout/{platform}` | 注销平台登录 |

320
Docs/DevLogs/Day9.md Normal file
View File

@@ -0,0 +1,320 @@
# Day 9: 发布模块代码优化
**日期**: 2026-01-23
**目标**: 代码质量优化 + 发布功能验证
---
## 📋 任务概览
| 任务 | 状态 |
|------|------|
| B站/抖音发布验证 | ✅ 完成 |
| 资源清理保障 (try-finally) | ✅ 完成 |
| 超时保护 (消除无限循环) | ✅ 完成 |
| 小红书 headless 模式修复 | ✅ 完成 |
| API 输入验证 | ✅ 完成 |
| 类型提示完善 | ✅ 完成 |
| 服务层代码优化 | ✅ 完成 |
| 扫码登录等待界面 | ✅ 完成 |
| 抖音登录策略优化 | ✅ 完成 |
| 发布成功审核提示 | ✅ 完成 |
| 用户认证系统规划 | ✅ 计划完成 |
---
## 🎉 发布验证结果
### 登录功能
-**B站登录成功** - 策略3(Text)匹配Cookie已保存
-**抖音登录成功** - 策略3(Text)匹配Cookie已保存
### 发布功能
-**抖音发布成功** - 自动关闭弹窗、跳转管理页面
-**B站发布成功** - API返回 `bvid: BV14izPBQEbd`
---
## 🔧 代码优化
### 1. 资源清理保障
**问题**Playwright 浏览器在异常路径可能未关闭
**修复**`try-finally` 模式确保资源释放
```python
browser = None
context = None
try:
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(...)
# ... 业务逻辑 ...
finally:
if context:
try: await context.close()
except Exception: pass
if browser:
try: await browser.close()
except Exception: pass
```
### 2. 超时保护
**问题**`while True` 循环可能导致任务卡死
**修复**:添加类级别超时常量
```python
class DouyinUploader(BaseUploader):
UPLOAD_TIMEOUT = 300 # 视频上传超时
PUBLISH_TIMEOUT = 180 # 发布检测超时
PAGE_REDIRECT_TIMEOUT = 60 # 页面跳转超时
```
### 3. B站 bvid 提取修复
**问题**API 返回的 bvid 在 `data` 字段内
**修复**:同时检查多个位置
```python
bvid = ret.get('data', {}).get('bvid') or ret.get('bvid', '')
aid = ret.get('data', {}).get('aid') or ret.get('aid', '')
```
### 4. API 输入验证
**修复**:所有端点添加平台验证
```python
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
```
---
## 🎨 用户体验优化
### 1. 扫码登录等待界面
**问题**:点击登录后,二维码获取需要几秒,用户无反馈
**优化**
- 点击登录后立即显示加载弹窗
- 加载动画 (旋转圈 + "正在获取二维码...")
- 二维码获取成功后自动切换显示
### 2. 抖音登录策略优化
**问题**:抖音登录需要约 23 秒获取二维码 (策略1/2超时)
**原因分析**
| 策略 | 抖音耗时 | B站耗时 | 结果 |
|------|----------|---------|------|
| Role | 10s 超时 | N/A | ❌ |
| CSS | 8s 超时 | 8s 超时 | ❌ |
| Text | ~1s | ~1s | ✅ |
**优化**
```python
# 抖音/B站Text 策略优先
if self.platform in ("douyin", "bilibili"):
qr_element = await self._try_text_strategy(page) # 优先
if not qr_element:
await page.wait_for_selector(..., timeout=3000) # CSS 备用
else:
# 其他平台保持 CSS 优先
```
**效果**
- 抖音登录二维码获取:~23s → ~5s
- B站登录二维码获取~13s → ~5s
### 3. 发布成功审核提示
**问题**:发布成功后,用户不知道需要审核
**优化**
- 后端消息改为 "发布成功,待审核"
- 前端增加提示 "⏳ 审核一般需要几分钟,请耐心等待"
- 发布结果 10 秒后自动消失
---
## 📁 修改文件列表
### 后端
| 文件 | 修改内容 |
|------|----------|
| `app/api/publish.py` | 输入验证、平台常量、文档改进 |
| `app/services/publish_service.py` | 类型提示、平台 enabled 标记 |
| `app/services/qr_login_service.py` | **策略顺序优化**、超时缩短 |
| `app/services/uploader/base_uploader.py` | 类型提示 |
| `app/services/uploader/bilibili_uploader.py` | **发布消息改为"待审核"** |
| `app/services/uploader/douyin_uploader.py` | **发布消息改为"待审核"** |
| `app/services/uploader/xiaohongshu_uploader.py` | **发布消息改为"待审核"** |
### 前端
| 文件 | 修改内容 |
|------|----------|
| `src/app/publish/page.tsx` | **加载动画、审核提示、结果自动消失** |
---
## ✅ 完成总结
1. **发布功能验证通过** - B站/抖音登录和发布均正常
2. **代码健壮性提升** - 资源清理、超时保护、异常处理
3. **代码可维护性** - 完整类型提示、常量化配置
4. **服务器兼容性** - 小红书 headless 模式修复
5. **用户体验优化** - 加载状态、策略顺序、审核提示
---
## 🔐 用户认证系统规划
> 规划完成,待下一阶段实施
### 技术方案
| 项目 | 方案 |
|------|------|
| 认证框架 | FastAPI + JWT (HttpOnly Cookie) |
| 数据库 | Supabase (PostgreSQL + RLS) |
| 管理员 | .env 预设 + startup 自动初始化 |
| 授权期限 | expires_at 字段,可设定有效期 |
| 单设备登录 | 后踢前模式 + Session Token 强校验 |
| 账号隔离 | 规范化 Cookie 路径 `user_data/{user_id}/` |
### 安全增强
1. **HttpOnly Cookie** - 防 XSS 窃取 Token
2. **Session Token 校验** - JWT 包含 session_token每次请求验证
3. **Startup 初始化管理员** - 服务启动自动创建
4. **RLS 最后防线** - Supabase 行级安全策略
5. **Cookie 路径规范化** - UUID 格式验证 + 白名单平台校验
### 数据库表
```sql
-- users (用户)
-- user_sessions (单设备登录)
-- social_accounts (社交账号绑定)
```
> 详细设计见 [implementation_plan.md](file:///C:/Users/danny/.gemini/antigravity/brain/06e7632c-12c6-4e80-b321-e1e642144560/implementation_plan.md)
### 后端实现进度
**状态**:✅ 核心模块完成
| 文件 | 说明 | 状态 |
|------|------|------|
| `requirements.txt` | 添加 supabase, python-jose, passlib | ✅ |
| `app/core/config.py` | 添加 Supabase/JWT/管理员配置 | ✅ |
| `app/core/supabase.py` | Supabase 客户端单例 | ✅ |
| `app/core/security.py` | JWT + 密码 + HttpOnly Cookie | ✅ |
| `app/core/paths.py` | Cookie 路径规范化 | ✅ |
| `app/core/deps.py` | 依赖注入 (当前用户/管理员) | ✅ |
| `app/api/auth.py` | 注册/登录/登出 API | ✅ |
| `app/api/admin.py` | 用户管理 API | ✅ |
| `app/main.py` | startup 初始化管理员 | ✅ |
| `database/schema.sql` | Supabase 数据库表 + RLS | ✅ |
### 前端实现进度
**状态**:✅ 核心页面完成
| 文件 | 说明 | 状态 |
|------|------|------|
| `src/lib/auth.ts` | 认证工具函数 | ✅ |
| `src/app/login/page.tsx` | 登录页 | ✅ |
| `src/app/register/page.tsx` | 注册页 | ✅ |
| `src/app/admin/page.tsx` | 管理后台 | ✅ |
| `src/middleware.ts` | 路由保护 | ✅ |
### 账号隔离集成
**状态**:✅ 完成
| 文件 | 修改内容 | 状态 |
|------|----------|------|
| `app/services/publish_service.py` | 重写支持 user_id 隔离 Cookie | ✅ |
| `app/api/publish.py` | 添加认证依赖,传递 user_id | ✅ |
**Cookie 存储路径**:
- 已登录用户: `user_data/{user_id}/cookies/{platform}_cookies.json`
- 未登录用户: `app/cookies/{platform}_cookies.json` (兼容旧版)
---
## 🔐 用户认证系统实现 (2026-01-23)
### 问题描述
为了支持多用户管理和资源隔离,需要实现一套完整的用户认证系统,取代以前的单用户模式。要求:
- 使用 Supabase 作为数据库
- 支持注册、登录、登出
- 管理员审核机制 (is_active)
- 单设备登录限制
- HttpOnly Cookie 存储 Token
### 解决方案
#### 1. 数据库设计 (Supabase)
创建了三张核心表:
- `users`: 存储邮箱、密码哈希、角色、激活状态
- `user_sessions`: 存储 Session Token实现单设备登录 (后踢前)
- `social_accounts`: 社交账号绑定信息 (B站/抖音Cookie)
#### 2. 后端实现 (FastAPI)
- **依赖注入** (`deps.py`): `get_current_user` 自动验证 Token 和 Session
- **安全模块** (`security.py`): JWT 生成与验证,密码 bcrypt 哈希
- **路由模块** (`auth.py`):
- `/register`: 注册后默认为 `pending` 状态
- `/login`: 验证通过后生成 JWT 并写入 HttpOnly Cookie
- `/me`: 获取当前用户信息
#### 3. 部署方案
- 采用 Supabase 云端免费版
- 为了防止 7 天不活跃暂停,配置了 GitHub Actions / Crontab 自动保活
- 创建了独立的部署文档 `Docs/AUTH_DEPLOY.md`
### 结果
- ✅ 成功实现了完整的 JWT 认证流程
- ✅ 管理员可以控制用户激活状态
- ✅ 实现了安全的无感 Token 刷新 (Session Token)
- ✅ 敏感配置 (Supabase Key) 通过环境变量管理
---
## 🔗 相关文档
- [用户认证系统实现计划](file:///C:/Users/danny/.gemini/antigravity/brain/06e7632c-12c6-4e80-b321-e1e642144560/implementation_plan.md)
- [代码审核报告](file:///C:/Users/danny/.gemini/antigravity/brain/a28bb1a6-2929-4c55-b837-c989943844e1/walkthrough.md)
- [部署手册](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
---
## 🛠️ 部署调试记录 (2026-01-23)
### 1. 服务启动方式修正
- **问题**: pm2 直接启动 python/uvicorn 会导致 `SyntaxError` (Node.js 尝试解释 Python)
- **解决**: 改用 `.sh` 脚本封装启动命令
### 2. 依赖缺失与兼容性
- **问题 1**: `ImportError: email-validator is not installed` (Pydantic 依赖)
- **修复**: 添加 `email-validator>=2.1.0`
- **问题 2**: `AttributeError: module 'bcrypt' has no attribute '__about__'` (Passlib 兼容性)
- **修复**: 锁定 `bcrypt==4.0.1`
### 3. 前端生产环境构建
- **问题**: `Error: Could not find a production build`
- **解决**: 启动前必须执行 `npm run build`
### 4. 性能调优
- **现象**: SSH 远程连接出现显著卡顿
- **排查**: `vigent2-latentsync` 启动时模型加载占用大量系统资源
- **优化**: 生产环境建议按需开启 LatentSync 服务,或确保服务器 IO/带宽充足。停止该服务后 SSH 恢复流畅。

View File

@@ -16,6 +16,21 @@
---
## 🧾 全局文档更新清单 (Checklist)
> **每次提交重要变更时,请核对以下文件是否需要同步:**
| 优先级 | 文件路径 | 检查重点 |
| :---: | :--- | :--- |
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `frontend/README.md` | **(前端文档)** 新页面路由、组件用法、UI变更 |
---
## 🔍 修改原内容的判断标准
### 场景 1错误修正 → **替换/删除**
@@ -120,7 +135,7 @@
---
## <EFBFBD> 工具使用规范
## 工具使用规范
> **核心原则**:使用正确的工具,避免字符编码问题
@@ -185,7 +200,7 @@ replace_file_content(
---
## <EFBFBD>📁 文件结构
## 📁 文件结构
```
ViGent/Docs/
@@ -198,28 +213,13 @@ ViGent/Docs/
---
## 🧾 全局文档更新清单 (Checklist)
> **每次提交重要变更时,请核对以下文件是否需要同步:**
| 优先级 | 文件路径 | 检查重点 |
| :---: | :--- | :--- |
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
| 🧊 **Low** | `frontend/README.md` | **(前端文档)** 新页面路由、组件用法、UI变更 |
---
## 📅 DayN.md 更新规则(日常更新)
### 新建判断 (对话开始前)
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
2. **检查日期**:查看最新 `DayN.md`
- **今天** → 追加到现有文件
- **之前** → 创建 `Day{N+1}.md`
- **今天 (与当前日期相同)** → 🚨 **绝对禁止创建新文件**,必须**追加**到现有 `DayN.md` 末尾!即使是完全不同的功能模块。
- **之前 (昨天或更早)** → 创建 `Day{N+1}.md`
### 追加格式
```markdown
@@ -301,4 +301,4 @@ ViGent/Docs/
---
**最后更新**2026-01-21
**最后更新**2026-01-23

29
Docs/Logs.md Normal file
View File

@@ -0,0 +1,29 @@
rongye@r730-ubuntu:~/ProgramFiles/Supabase$ docker compose up -d
[+] up 136/136
✔ Image timberio/vector:0.28.1-alpine Pulled 63.3ss
✔ Image supabase/storage-api:v1.33.0 Pulled 78.6ss
✔ Image darthsim/imgproxy:v3.30.1 Pulled 151.9s
✔ Image supabase/postgres-meta:v0.95.1 Pulled 87.5ss
✔ Image supabase/logflare:1.27.0 Pulled 229.2s
✔ Image supabase/postgres:15.8.1.085 Pulled 268.3s
✔ Image supabase/supavisor:2.7.4 Pulled 101.6s
✔ Image supabase/realtime:v2.68.0 Pulled 56.5ss
✔ Image postgrest/postgrest:v14.1 Pulled 201.8s
✔ Image supabase/edge-runtime:v1.69.28 Pulled 254.0s
✔ Network supabase_default Created 0.1s
✔ Volume supabase_db-config Created 0.1s
✔ Container supabase-vector Healthy 16.9s
✔ Container supabase-imgproxy Created 7.4s
✔ Container supabase-db Healthy 20.6s
✔ Container supabase-analytics Created 0.4s
✔ Container supabase-edge-functions Created 1.8s
✔ Container supabase-auth Created 1.7s
✔ Container supabase-studio Created 2.0s
✔ Container realtime-dev.supabase-realtime Created 1.7s
✔ Container supabase-pooler Created 1.8s
✔ Container supabase-kong Created 1.7s
✔ Container supabase-meta Created 2.0s
✔ Container supabase-rest Created 0.9s
✔ Container supabase-storage Created 1.4s
Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint supabase-analytics (2fd60a510a1f16bf29f8f5140f14ef457a284c5b65a2567b7be250a4f9708f34): failed to bind host port 0.0.0.0:4000/tcp: address already in use
[ble: exit 1]

210
Docs/SUPABASE_DEPLOY.md Normal file
View File

@@ -0,0 +1,210 @@
# Supabase 全栈部署指南 (Infrastructure + Auth)
本文档涵盖了 Supabase 基础设施的 Docker 部署、密钥配置、Nginx 安全加固以及用户认证系统的数据库初始化。
---
## 第一部分:基础设施部署 (Infrastructure)
### 1. 准备 Docker 环境 (Ubuntu)
Supabase 严重依赖官方目录结构(挂载配置文件),**必须包含完整的 `docker` 目录**。
```bash
# 1. 创建目录
mkdir -p /home/rongye/ProgramFiles/Supabase
cd /home/rongye/ProgramFiles/Supabase
# 2. 获取官方配置
# 克隆仓库并提取 docker 目录
git clone --depth 1 https://github.com/supabase/supabase.git temp_repo
mv temp_repo/docker/* .
rm -rf temp_repo
# 3. 复制环境变量模板
cp .env.example .env
```
### 2. 生成安全密钥
**警告**:官方模板使用的是公开的弱密钥。生产环境必须重新生成。
使用项目提供的脚本自动生成全套强密钥:
```bash
# 在 ViGent2 项目目录下
cd /home/rongye/ProgramFiles/ViGent2/backend
python generate_keys.py
```
将脚本生成的输出(包括 `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY` 等)复制并**覆盖** `/home/rongye/ProgramFiles/Supabase/.env` 中的对应内容。
### 3. 配置端口与冲突解决
编辑 Supabase 的 `.env` 文件修改以下端口以避免与现有服务Code-Server, Moodist冲突
```ini
# --- Port Configuration ---
# 避免与 Code-Server (8443) 冲突
KONG_HTTPS_PORT=8444
# 自定义 API 端口 (默认 8000)
KONG_HTTP_PORT=8008
# 自定义管理后台端口 (默认 3000)
STUDIO_PORT=3003
# 外部访问 URL (重要:填入你的公网 API 域名/IP)
# 如果配置了 Nginx 反代: https://api.hbyrkj.top
# 如果直连: http://8.148.25.142:8008
API_EXTERNAL_URL=https://api.hbyrkj.top
```
### 4. 启动服务
```bash
docker compose up -d
```
---
## 第二部分:安全访问配置 (Nginx)
建议在阿里云公网网关上配置 Nginx 反向代理,通过 Frp 隧道连接内网服务。
### 1. 域名规划
- **管理后台**: `https://supabase.hbyrkj.top` -> 内网 3003
- **API 接口**: `https://api.hbyrkj.top` -> 内网 8008
### 2. Nginx 配置示例
```nginx
# Studio (需要密码保护)
server {
server_name supabase.hbyrkj.top;
# SSL 配置略...
location / {
# Basic Auth 保护后台
auth_basic "Restricted Studio";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:3003;
# WebSocket 支持 (Realtime 必须)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# API (公开访问)
server {
server_name api.hbyrkj.top;
# SSL 配置略...
location / {
proxy_pass http://127.0.0.1:8008;
# 允许 WebSocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
---
## 第三部分:数据库与认证配置 (Database & Auth)
### 1. 初始化表结构 (Schema)
访问管理后台 (Studio) 的 **SQL Editor**,执行以下 SQL 来初始化 ViGent2 所需的表结构:
```sql
-- 1. 用户表 (扩展 auth.users 或独立存储)
-- 注意:这里使用独立表设计,与 FastAPI 逻辑解耦
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
username TEXT,
role TEXT DEFAULT 'pending' CHECK (role IN ('pending', 'user', 'admin')),
is_active BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 2. 会话表 (单设备登录控制)
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
session_token TEXT UNIQUE NOT NULL,
device_info TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 3. 社交媒体账号绑定表
CREATE TABLE IF NOT EXISTS social_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
platform TEXT NOT NULL CHECK (platform IN ('bilibili', 'douyin', 'xiaohongshu')),
logged_in BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, platform)
);
-- 4. 性能索引
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_social_user_platform ON social_accounts(user_id, platform);
```
### 2. 后端集成配置 (FastAPI)
修改 `ViGent2/backend/.env` 以连接到自托管的 Supabase
```ini
# =============== Supabase 配置 ===============
# 指向 Docker 部署的 API 端口 (内网直连推荐用 Localhost)
SUPABASE_URL=http://localhost:8008
# 使用生成的 SERVICE_ROLE_KEY (后端需要管理员权限)
SUPABASE_KEY=eyJhbGciOiJIUzI1Ni...
# =============== JWT 配置 ===============
# 必须与 Supabase .env 中的 JWT_SECRET 保持一致!
JWT_SECRET_KEY=填入_generate_keys.py_生成的_JWT_SECRET
JWT_ALGORITHM=HS256
JWT_EXPIRE_HOURS=168
```
---
## 第四部分:常用维护命令
**查看服务状态**:
```bash
cd /home/rongye/ProgramFiles/Supabase
docker compose ps
```
**查看密钥**:
```bash
grep -E "ANON|SERVICE|SECRET" .env
```
**重启服务**:
```bash
docker compose restart
```
**完全重置数据库 (慎用)**:
```bash
docker compose down -v
rm -rf volumes/db/data
docker compose up -d
```

View File

@@ -22,7 +22,7 @@
┌─────────────────────────────────────────────────────────┐
│ 后端 (FastAPI) │
├─────────────────────────────────────────────────────────┤
Celery 任务队列 (Redis) │
异步任务队列 (asyncio) │
│ ├── 视频生成任务 │
│ ├── TTS 配音任务 │
│ └── 自动发布任务 │
@@ -30,7 +30,7 @@
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
MuseTalk │ │ FFmpeg │ │Playwright│
LatentSync│ │ FFmpeg │ │Playwright│
│ 唇形同步 │ │ 视频合成 │ │ 自动发布 │
└──────────┘ └──────────┘ └──────────┘
```
@@ -45,7 +45,7 @@
| **UI 组件库** | Tailwind + shadcn/ui | Ant Design |
| **后端框架** | FastAPI | Flask |
| **任务队列** | Celery + Redis | RQ / Dramatiq |
| **唇形同步** | MuseTalk | Wav2Lip / SadTalker |
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
| **TTS 配音** | EdgeTTS | CosyVoice |
| **声音克隆** | GPT-SoVITS (可选) | - |
| **视频处理** | FFmpeg | MoviePy |
@@ -141,12 +141,12 @@ backend/
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/materials` | POST | 上传素材视频 |
| `/api/materials` | GET | 获取素材列表 |
| `/api/videos/generate` | POST | 创建视频生成任务 |
| `/api/tasks/{id}` | GET | 查询任务状态 |
| `/api/videos/{id}/download` | GET | 下载生成的视频 |
| `/api/publish` | POST | 发布到社交平台 |
| `/api/materials` | POST | 上传素材视频 | ✅ |
| `/api/materials` | GET | 获取素材列表 | ✅ |
| `/api/videos/generate` | POST | 创建视频生成任务 | ✅ |
| `/api/tasks/{id}` | GET | 查询任务状态 | ✅ |
| `/api/videos/{id}/download` | GET | 下载生成的视频 | ✅ |
| `/api/publish` | POST | 发布到社交平台 | ✅ |
#### 2.3 Celery 任务定义
@@ -221,7 +221,7 @@ cp -r SuperIPAgent/social-auto-upload backend/social_upload
| **声音克隆** | 集成 GPT-SoVITS用自己的声音 |
| **批量生成** | 上传 Excel/CSV批量生成视频 |
| **字幕编辑器** | 可视化调整字幕样式、位置 |
| **Docker 部署** | 一键部署到云服务器 |
| **Docker 部署** | 一键部署到云服务器 | ✅ |
---
@@ -269,6 +269,60 @@ cp -r SuperIPAgent/social-auto-upload backend/social_upload
- [x] **常驻模型服务** (Persistent Server, 0s 加载)
- [x] **GPU 并发控制** (串行队列防崩溃)
### 阶段十一:社交媒体发布完善 (Day 7) ✅
> **目标**:实现全自动扫码登录和多平台发布
- [x] QR码自动登录 (Playwright headless + Stealth)
- [x] 多平台上传器架构 (B站/抖音/小红书)
- [x] Cookie 自动管理
- [x] 定时发布功能
### 阶段十二:用户体验优化 (Day 8) ✅
> **目标**:提升文件管理和历史记录功能
- [x] 文件名保留 (时间戳前缀 + 原始名称)
- [x] 视频持久化 (历史视频列表 API)
- [x] 素材/视频删除功能
### 阶段十三:发布模块优化 (Day 9) ✅
> **目标**:代码质量优化 + 发布功能验证
- [x] B站/抖音登录+发布验证通过
- [x] 资源清理保障 (try-finally)
- [x] 超时保护 (消除无限循环)
- [x] 完整类型提示
### 阶段十四:用户认证系统 (Day 9) ✅
> **目标**:实现安全、隔离的多用户认证体系
- [x] Supabase 云数据库集成 (本地自托管)
- [x] JWT + HttpOnly Cookie 认证架构
- [x] 用户表与权限表设计 (RLS 准备)
- [x] 认证部署文档 (Docs/SUPABASE_DEPLOY.md)
### 阶段十五:部署稳定性优化 (Day 9) ✅
> **目标**:确保生产环境服务长期稳定
- [x] 依赖冲突修复 (bcrypt)
- [x] 前端构建修复 (Production Build)
- [x] PM2 进程守护配置
- [x] 部署手册更新 (Docs/DEPLOY_MANUAL.md)
### 阶段十六HTTPS 全栈部署 (Day 10) ✅
> **目标**:实现安全的公网 HTTPS 访问
- [x] 阿里云 Nginx 反向代理配置
- [x] Let's Encrypt SSL 证书集成
- [x] Supabase 自托管部署 (Docker)
- [x] 端口冲突解决 (3003/8008/8444)
- [x] Basic Auth 管理后台保护
---
## 项目目录结构 (最终)

View File

@@ -2,8 +2,8 @@
**项目**ViGent2 数字人口播视频生成系统
**服务器**Dell R730 (2× RTX 3090 24GB)
**更新时间**2026-01-21
**整体进度**100%Day 7 社交发布完成
**更新时间**2026-01-26
**整体进度**100%Day 10 HTTPS 部署与细节完善
## 📖 快速导航
@@ -16,7 +16,7 @@
| [时间线](#-时间线) | 开发历程 |
**相关文档**
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day7)
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day9)
- [部署指南](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
---
@@ -102,17 +102,62 @@
- [x] QR登录超时修复 (Stealth模式、多选择器fallback)
- [x] 文档规则优化 (智能修改标准、工具使用规范)
### 阶段十二:用户体验优化 (Day 8)
- [x] 文件名保留 (时间戳前缀 + 原始名称)
- [x] 视频持久化 (从文件系统读取历史)
- [x] 历史视频列表组件
- [x] 素材/视频删除功能
- [x] 登出功能 (Logout API + 前端按钮)
- [x] 前端 SWR 轮询优化
- [x] QR 登录状态检测修复
### 阶段十三:发布模块优化 (Day 9)
- [x] B站/抖音发布验证通过
- [x] 资源清理保障 (try-finally)
- [x] 超时保护 (消除无限循环)
- [x] 小红书 headless 模式修复
- [x] API 输入验证
- [x] 完整类型提示
- [x] 扫码登录等待界面 (加载动画)
- [x] 抖音/B站登录策略优化 (Text优先)
- [x] 发布成功审核提示
### 阶段十四:用户认证系统 (Day 9)
- [x] Supabase 数据库表设计与部署
- [x] JWT 认证 (HttpOnly Cookie)
- [x] 用户注册/登录/登出 API
- [x] 管理员权限控制 (is_active)
- [x] 单设备登录限制 (Session Token)
- [x] 防止 Supabase 暂停 (GitHub Actions/Crontab)
- [x] 认证部署文档 (AUTH_DEPLOY.md)
### 阶段十五:部署稳定性优化 (Day 9)
- [x] 后端依赖修复 (bcrypt/email-validator)
- [x] 前端生产环境构建修复 (npm run build)
- [x] LatentSync 性能卡顿修复 (OMP_NUM_THREADS限制)
- [x] 部署服务自愈 (PM2 配置优化)
- [x] 部署手册全量更新 (DEPLOY_MANUAL.md)
### 阶段十六HTTPS 部署与细节完善 (Day 10)
- [x] 隧道访问修复 (StaticFiles 挂载 + Rewrite)
- [x] 平台账号列表 500 错误修复 (paths.py)
- [x] Nginx HTTPS 配置 (反向代理 + SSL)
- [x] 浏览器标题修改 (ViGent)
- [x] 代码自适应 HTTPS 验证
- [x] **Supabase 自托管部署** (Docker, 3003/8008端口)
- [x] **安全加固** (Basic Auth 保护后台)
- [x] **端口冲突解决** (迁移 Analytics/Kong)
---
## 🛤️ 后续规划
### 🔴 优先待办
- [x] 视频合成最终验证 (MP4生成) ✅ Day 4 完成
- [x] 端到端流程完整测试 ✅ Day 4 完
- [ ] 社交媒体发布测试 (B站/抖音已登录)
- [ ] 批量视频生成架构设计
- [ ] 字幕样式编辑器集
### 🟠 功能完善
- [ ] 定时发布功能
- [x] 定时发布功能 ✅ Day 7 完成
- [ ] 批量视频生成
- [ ] 字幕样式编辑器
@@ -139,8 +184,9 @@
| TTS 配音 | 100% | ✅ 完成 |
| 视频合成 | 100% | ✅ 完成 |
| 唇形同步 | 100% | ✅ LatentSync 1.6 升级完成 |
| 社交发布 | 100% | ✅ 完成 (待验证) |
| 服务器部署 | 100% | ✅ 完成 |
| 社交发布 | 100% | ✅ Day 9 验证通过 |
| 用户认证 | 100% | ✅ Day 9 Supabase+JWT |
| 服务器部署 | 100% | ✅ Day 9 稳定性优化完成 |
---
@@ -175,6 +221,22 @@
- Latent Diffusion 架构升级
- 性能优化 (视频预压缩、进度更新)
### Milestone 5: 用户认证系统 ✅
**完成时间**: Day 9
**成果**:
- Supabase 云数据库集成
- 安全的 JWT + HttpOnly Cookie 认证
- 管理员后台与用户隔离
- 完善的部署与保活方案
### Milestone 6: 生产环境部署稳定化 ✅
**完成时间**: Day 9
**成果**:
- 修复了后端 (bcrypt) 和前端 (build) 的启动崩溃问题
- 解决了 LatentSync 占用全量 CPU 导致服务器卡顿的严重问题
- 完善了部署手册,记录了关键的 Troubleshooting 步骤
- 实现了服务 Long-term 稳定运行 (Reset PM2 counter)
---
## 📅 时间线
@@ -224,5 +286,36 @@ Day 7: 社交媒体发布完善 ✅ 完成
- 多平台发布 (B站/抖音/小红书)
- UI 一致性优化
- 文档规则体系优化
Day 8: 用户体验优化 ✅ 完成
- 文件名保留 (时间戳前缀)
- 视频持久化 (历史视频API)
- 历史视频列表组件
- 素材/视频删除功能
Day 9: 发布模块优化 ✅ 完成
- B站/抖音登录+发布验证通过
- 资源清理保障 (try-finally)
- 超时保护 (消除无限循环)
- 小红书 headless 模式修复
- 扫码登录等待界面 (加载动画)
- 抖音/B站登录策略优化 (Text优先)
- 发布成功审核提示
- 用户认证系统规划 (FastAPI+Supabase)
- Supabase 表结构设计 (users/sessions)
- 后端 JWT 认证实现 (auth.py/deps.py)
- 数据库配置与 SQL 部署
- 独立认证部署文档 (AUTH_DEPLOY.md)
- 自动保活机制 (Crontab/Actions)
- 部署稳定性优化 (Backend依赖修复)
- 前端生产构建流程修复
- LatentSync 严重卡顿修复 (线程数限制)
- 部署手册全量更新
Day 10: HTTPS 部署与细节完善 ✅ 完成
- 隧道访问视频修正 (挂载 uploads)
- 账号列表 Bug 修复 (paths.py 白名单)
- 阿里云 Nginx HTTPS 部署
- UI 细节优化 (Title 更新)
```

View File

@@ -12,6 +12,7 @@
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
- 📱 **全自动发布** - 扫码登录 + Cookie持久化支持多平台(B站/抖音/小红书)定时发布
- 🖥️ **Web UI** - Next.js 现代化界面
- 🔐 **用户系统** - Supabase + JWT 认证,支持管理员后台、注册/登录、账号隔离
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)
## 🛠️ 技术栈
@@ -20,6 +21,8 @@
|------|------|
| 前端 | Next.js 14 + TypeScript + TailwindCSS |
| 后端 | FastAPI + Python 3.10 |
| 数据库 | **Supabase** (PostgreSQL) Local Docker |
| 认证 | **JWT** + HttpOnly Cookie |
| 唇形同步 | **LatentSync 1.6** (Latent Diffusion, 512×512) |
| TTS | EdgeTTS |
| 视频处理 | FFmpeg |
@@ -45,6 +48,7 @@ ViGent2/
│ └── DEPLOY.md # LatentSync 部署指南
└── Docs/ # 文档
├── DEPLOY_MANUAL.md # 部署手册
├── AUTH_DEPLOY.md # 认证部署指南
├── task_complete.md
└── DevLogs/
```
@@ -129,12 +133,13 @@ nohup python -m scripts.server > server.log 2>&1 &
## 🌐 访问地址
| 服务 | 地址 |
|------|------|
| 视频生成 | http://服务器IP:3002 |
| 发布管理 | http://服务器IP:3002/publish |
| API 文档 | http://服务器IP:8006/docs |
| 模型API | http://服务器IP:8007/docs |
| 服务 | 地址 | 说明 |
|------|------|------|
| **视频生成 (UI)** | `https://vigent.hbyrkj.top` | 用户访问入口 |
| **API 服务** | `http://<服务器IP>:8006` | 后端 Swagger |
| **认证管理 (Studio)** | `https://supabase.hbyrkj.top` | 需要 Basic Auth |
| **认证 API (Kong)** | `https://api.hbyrkj.top` | Supabase 接口 |
| **模型服务** | `http://<服务器IP>:8007` | LatentSync |
---
@@ -142,6 +147,9 @@ nohup python -m scripts.server > server.log 2>&1 &
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md)
- [手动部署指南](Docs/DEPLOY_MANUAL.md)
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md)
- [手动部署指南](Docs/DEPLOY_MANUAL.md)
- [Supabase 部署指南](Docs/SUPABASE_DEPLOY.md)
- [开发日志](Docs/DevLogs/)
- [任务进度](Docs/task_complete.md)

View File

@@ -13,9 +13,8 @@ DEFAULT_TTS_VOICE=zh-CN-YunxiNeural
# =============== LatentSync 配置 ===============
# GPU 选择 (0=第一块GPU, 1=第二块GPU)
LATENTSYNC_GPU_ID=1
LATENTSYNC_GPU_ID=0
# 使用本地模式 (true) 或远程 API (false)
# 使用本地模式 (true) 或远程 API (false)
LATENTSYNC_LOCAL=true
@@ -35,7 +34,7 @@ LATENTSYNC_GUIDANCE_SCALE=1.5
LATENTSYNC_ENABLE_DEEPCACHE=true
# 随机种子 (设为 -1 则随机)
LATENTSYNC_SEED=1247
LATENTSYNC_SEED=-1
# =============== 上传配置 ===============
# 最大上传文件大小 (MB)
@@ -45,3 +44,18 @@ MAX_UPLOAD_SIZE_MB=500
# FFmpeg 路径 (如果不在系统 PATH 中)
# FFMPEG_PATH=/usr/bin/ffmpeg
# =============== Supabase 配置 ===============
# 从 Supabase 项目设置 > API 获取
SUPABASE_URL=your_supabase_url_here
SUPABASE_KEY=your_supabase_anon_key_here
# =============== JWT 配置 ===============
# 用于签名 JWT Token 的密钥 (请更换为随机字符串)
JWT_SECRET_KEY=generate_your_secure_random_key_here
JWT_ALGORITHM=HS256
JWT_EXPIRE_HOURS=168
# =============== 管理员配置 ===============
# 服务启动时自动创建的管理员账号
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change_this_password_immediately

185
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,185 @@
"""
管理员 API用户管理
"""
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timezone, timedelta
from app.core.supabase import get_supabase
from app.core.deps import get_current_admin
from loguru import logger
router = APIRouter(prefix="/api/admin", tags=["管理"])
class UserListItem(BaseModel):
id: str
email: str
username: Optional[str]
role: str
is_active: bool
expires_at: Optional[str]
created_at: str
class ActivateRequest(BaseModel):
expires_days: Optional[int] = None # 授权天数None 表示永久
@router.get("/users", response_model=List[UserListItem])
async def list_users(admin: dict = Depends(get_current_admin)):
"""获取所有用户列表"""
try:
supabase = get_supabase()
result = supabase.table("users").select("*").order("created_at", desc=True).execute()
return [
UserListItem(
id=u["id"],
email=u["email"],
username=u.get("username"),
role=u["role"],
is_active=u["is_active"],
expires_at=u.get("expires_at"),
created_at=u["created_at"]
)
for u in result.data
]
except Exception as e:
logger.error(f"获取用户列表失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="获取用户列表失败"
)
@router.post("/users/{user_id}/activate")
async def activate_user(
user_id: str,
request: ActivateRequest,
admin: dict = Depends(get_current_admin)
):
"""
激活用户
Args:
user_id: 用户 ID
request.expires_days: 授权天数 (None 表示永久)
"""
try:
supabase = get_supabase()
# 计算过期时间
expires_at = None
if request.expires_days:
expires_at = (datetime.now(timezone.utc) + timedelta(days=request.expires_days)).isoformat()
# 更新用户
result = supabase.table("users").update({
"is_active": True,
"role": "user",
"expires_at": expires_at
}).eq("id", user_id).execute()
if not result.data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
logger.info(f"管理员 {admin['email']} 激活用户 {user_id}, 有效期: {request.expires_days or '永久'}")
return {
"success": True,
"message": f"用户已激活,有效期: {request.expires_days or '永久'}"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"激活用户失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="激活用户失败"
)
@router.post("/users/{user_id}/deactivate")
async def deactivate_user(
user_id: str,
admin: dict = Depends(get_current_admin)
):
"""停用用户"""
try:
supabase = get_supabase()
# 不能停用管理员
user_result = supabase.table("users").select("role").eq("id", user_id).single().execute()
if user_result.data and user_result.data["role"] == "admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="不能停用管理员账号"
)
# 更新用户
result = supabase.table("users").update({
"is_active": False
}).eq("id", user_id).execute()
# 清除用户 session
supabase.table("user_sessions").delete().eq("user_id", user_id).execute()
logger.info(f"管理员 {admin['email']} 停用用户 {user_id}")
return {"success": True, "message": "用户已停用"}
except HTTPException:
raise
except Exception as e:
logger.error(f"停用用户失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="停用用户失败"
)
@router.post("/users/{user_id}/extend")
async def extend_user(
user_id: str,
request: ActivateRequest,
admin: dict = Depends(get_current_admin)
):
"""延长用户授权期限"""
try:
supabase = get_supabase()
if not request.expires_days:
# 设为永久
expires_at = None
else:
# 获取当前过期时间
user_result = supabase.table("users").select("expires_at").eq("id", user_id).single().execute()
user = user_result.data
if user and user.get("expires_at"):
current_expires = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00"))
base_time = max(current_expires, datetime.now(timezone.utc))
else:
base_time = datetime.now(timezone.utc)
expires_at = (base_time + timedelta(days=request.expires_days)).isoformat()
result = supabase.table("users").update({
"expires_at": expires_at
}).eq("id", user_id).execute()
logger.info(f"管理员 {admin['email']} 延长用户 {user_id} 授权 {request.expires_days or '永久'}")
return {
"success": True,
"message": f"授权已延长 {request.expires_days or '永久'}"
}
except Exception as e:
logger.error(f"延长授权失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="延长授权失败"
)

223
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,223 @@
"""
认证 API注册、登录、登出
"""
from fastapi import APIRouter, HTTPException, Response, status, Request
from pydantic import BaseModel, EmailStr
from app.core.supabase import get_supabase
from app.core.security import (
get_password_hash,
verify_password,
create_access_token,
generate_session_token,
set_auth_cookie,
clear_auth_cookie,
decode_access_token
)
from loguru import logger
from typing import Optional
router = APIRouter(prefix="/api/auth", tags=["认证"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str
username: Optional[str] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: str
email: str
username: Optional[str]
role: str
is_active: bool
@router.post("/register")
async def register(request: RegisterRequest):
"""
用户注册
注册后状态为 pending需要管理员激活
"""
try:
supabase = get_supabase()
# 检查邮箱是否已存在
existing = supabase.table("users").select("id").eq(
"email", request.email
).execute()
if existing.data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已注册"
)
# 创建用户
password_hash = get_password_hash(request.password)
result = supabase.table("users").insert({
"email": request.email,
"password_hash": password_hash,
"username": request.username or request.email.split("@")[0],
"role": "pending",
"is_active": False
}).execute()
logger.info(f"新用户注册: {request.email}")
return {
"success": True,
"message": "注册成功,请等待管理员审核激活"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"注册失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="注册失败,请稍后重试"
)
@router.post("/login")
async def login(request: LoginRequest, response: Response):
"""
用户登录
- 验证密码
- 检查是否激活
- 实现"后踢前"单设备登录
"""
try:
supabase = get_supabase()
# 查找用户
user_result = supabase.table("users").select("*").eq(
"email", request.email
).single().execute()
user = user_result.data
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="邮箱或密码错误"
)
# 验证密码
if not verify_password(request.password, user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="邮箱或密码错误"
)
# 检查是否激活
if not user["is_active"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号未激活,请等待管理员审核"
)
# 检查授权是否过期
if user.get("expires_at"):
from datetime import datetime, timezone
expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00"))
if datetime.now(timezone.utc) > expires_at:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="授权已过期,请联系管理员续期"
)
# 生成新的 session_token (后踢前)
session_token = generate_session_token()
# 删除旧 session插入新 session
supabase.table("user_sessions").delete().eq(
"user_id", user["id"]
).execute()
supabase.table("user_sessions").insert({
"user_id": user["id"],
"session_token": session_token,
"device_info": None # 可以从 request headers 获取
}).execute()
# 生成 JWT Token
token = create_access_token(user["id"], session_token)
# 设置 HttpOnly Cookie
set_auth_cookie(response, token)
logger.info(f"用户登录: {request.email}")
return {
"success": True,
"message": "登录成功",
"user": UserResponse(
id=user["id"],
email=user["email"],
username=user.get("username"),
role=user["role"],
is_active=user["is_active"]
)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"登录失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="登录失败,请稍后重试"
)
@router.post("/logout")
async def logout(response: Response):
"""用户登出"""
clear_auth_cookie(response)
return {"success": True, "message": "已登出"}
@router.get("/me")
async def get_me(request: Request):
"""获取当前用户信息"""
# 从 Cookie 获取用户
token = request.cookies.get("access_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录"
)
token_data = decode_access_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效"
)
supabase = get_supabase()
user_result = supabase.table("users").select("*").eq(
"id", token_data.user_id
).single().execute()
user = user_result.data
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
return UserResponse(
id=user["id"],
email=user["email"],
username=user.get("username"),
role=user["role"],
is_active=user["is_active"]
)

View File

@@ -19,7 +19,7 @@ def sanitize_filename(filename: str) -> str:
return safe_name
@router.post("/")
@router.post("")
async def upload_material(file: UploadFile = File(...)):
if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')):
raise HTTPException(400, "Invalid format")
@@ -47,7 +47,7 @@ async def upload_material(file: UploadFile = File(...)):
"type": "video"
}
@router.get("/")
@router.get("")
async def list_materials():
materials_dir = settings.UPLOAD_DIR / "materials"
files = []

View File

@@ -1,17 +1,19 @@
"""
发布管理 API
发布管理 API (支持用户认证)
"""
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from loguru import logger
from app.services.publish_service import PublishService
from app.core.deps import get_current_user_optional
router = APIRouter()
publish_service = PublishService()
class PublishRequest(BaseModel):
"""Video publish request model"""
video_path: str
platform: str
title: str
@@ -20,13 +22,43 @@ class PublishRequest(BaseModel):
publish_time: Optional[datetime] = None
class PublishResponse(BaseModel):
"""Video publish response model"""
success: bool
message: str
platform: str
url: Optional[str] = None
@router.post("/", response_model=PublishResponse)
async def publish_video(request: PublishRequest, background_tasks: BackgroundTasks):
# Supported platforms for validation
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
def _get_user_id(request: Request) -> Optional[str]:
"""从请求中获取用户 ID (兼容未登录场景)"""
try:
from app.core.security import decode_access_token
token = request.cookies.get("access_token")
if token:
token_data = decode_access_token(token)
if token_data:
return token_data.user_id
except Exception:
pass
return None
@router.post("", response_model=PublishResponse)
async def publish_video(request: PublishRequest, req: Request, background_tasks: BackgroundTasks):
"""发布视频到指定平台"""
# Validate platform
if request.platform not in SUPPORTED_PLATFORMS:
raise HTTPException(
status_code=400,
detail=f"不支持的平台: {request.platform}。支持的平台: {', '.join(SUPPORTED_PLATFORMS)}"
)
# 获取用户 ID (可选)
user_id = _get_user_id(req)
try:
result = await publish_service.publish(
video_path=request.video_path,
@@ -34,7 +66,8 @@ async def publish_video(request: PublishRequest, background_tasks: BackgroundTas
title=request.title,
tags=request.tags,
description=request.description,
publish_time=request.publish_time
publish_time=request.publish_time,
user_id=user_id
)
return PublishResponse(
success=result.get("success", False),
@@ -48,33 +81,48 @@ async def publish_video(request: PublishRequest, background_tasks: BackgroundTas
@router.get("/platforms")
async def list_platforms():
return {"platforms": [{"id": pid, **pinfo} for pid, pinfo in publish_service.PLATFORMS.items()]}
return {"platforms": [{**pinfo, "id": pid} for pid, pinfo in publish_service.PLATFORMS.items()]}
@router.get("/accounts")
async def list_accounts():
return {"accounts": publish_service.get_accounts()}
async def list_accounts(req: Request):
user_id = _get_user_id(req)
return {"accounts": publish_service.get_accounts(user_id)}
@router.post("/login/{platform}")
async def login_platform(platform: str):
result = await publish_service.login(platform)
async def login_platform(platform: str, req: Request):
"""触发平台QR码登录"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
user_id = _get_user_id(req)
result = await publish_service.login(platform, user_id)
if result.get("success"):
return result
else:
raise HTTPException(status_code=400, detail=result.get("message"))
@router.get("/login/status/{platform}")
async def get_login_status(platform: str):
"""检查登录状态"""
# 这里简化处理,实际应该维护一个登录会话字典
cookie_file = publish_service.cookies_dir / f"{platform}_cookies.json"
@router.post("/logout/{platform}")
async def logout_platform(platform: str, req: Request):
"""注销平台登录"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
if cookie_file.exists():
return {"success": True, "message": "已登录"}
else:
return {"success": False, "message": "未登录"}
user_id = _get_user_id(req)
result = publish_service.logout(platform, user_id)
return result
@router.get("/login/status/{platform}")
async def get_login_status(platform: str, req: Request):
"""检查登录状态 (优先检查活跃的扫码会话)"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
user_id = _get_user_id(req)
return publish_service.get_login_session_status(platform, user_id)
@router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict):
async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
"""
保存从客户端浏览器提取的Cookie
@@ -82,8 +130,15 @@ async def save_platform_cookie(platform: str, cookie_data: dict):
platform: 平台ID
cookie_data: {"cookie_string": "document.cookie的内容"}
"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
cookie_string = cookie_data.get("cookie_string", "")
result = await publish_service.save_cookie_string(platform, cookie_string)
if not cookie_string:
raise HTTPException(status_code=400, detail="cookie_string 不能为空")
user_id = _get_user_id(req)
result = await publish_service.save_cookie_string(platform, cookie_string, user_id)
if result.get("success"):
return result

View File

@@ -26,6 +26,19 @@ class Settings(BaseSettings):
LATENTSYNC_SEED: int = 1247 # 随机种子 (-1 则随机)
LATENTSYNC_USE_SERVER: bool = False # 使用常驻服务 (Persistent Server) 加速
# Supabase 配置
SUPABASE_URL: str = ""
SUPABASE_KEY: str = ""
# JWT 配置
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_HOURS: int = 24
# 管理员配置
ADMIN_EMAIL: str = ""
ADMIN_PASSWORD: str = ""
@property
def LATENTSYNC_DIR(self) -> Path:
"""LatentSync 目录路径 (动态计算)"""

141
backend/app/core/deps.py Normal file
View File

@@ -0,0 +1,141 @@
"""
依赖注入模块:认证和用户获取
"""
from typing import Optional
from fastapi import Request, HTTPException, Depends, status
from app.core.security import decode_access_token, TokenData
from app.core.supabase import get_supabase
from loguru import logger
async def get_token_from_cookie(request: Request) -> Optional[str]:
"""从 Cookie 中获取 Token"""
return request.cookies.get("access_token")
async def get_current_user_optional(
request: Request
) -> Optional[dict]:
"""
获取当前用户 (可选,未登录返回 None)
"""
token = await get_token_from_cookie(request)
if not token:
return None
token_data = decode_access_token(token)
if not token_data:
return None
# 验证 session_token 是否有效 (单设备登录检查)
try:
supabase = get_supabase()
result = supabase.table("user_sessions").select("*").eq(
"user_id", token_data.user_id
).eq(
"session_token", token_data.session_token
).execute()
if not result.data:
logger.warning(f"Session token 无效: user_id={token_data.user_id}")
return None
# 获取用户信息
user_result = supabase.table("users").select("*").eq(
"id", token_data.user_id
).single().execute()
return user_result.data
except Exception as e:
logger.error(f"获取用户信息失败: {e}")
return None
async def get_current_user(
request: Request
) -> dict:
"""
获取当前用户 (必须登录)
Raises:
HTTPException 401: 未登录
HTTPException 403: 会话失效或授权过期
"""
token = await get_token_from_cookie(request)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录,请先登录"
)
token_data = decode_access_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期"
)
try:
supabase = get_supabase()
# 验证 session_token (单设备登录)
session_result = supabase.table("user_sessions").select("*").eq(
"user_id", token_data.user_id
).eq(
"session_token", token_data.session_token
).execute()
if not session_result.data:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="会话已失效,请重新登录(可能已在其他设备登录)"
)
# 获取用户信息
user_result = supabase.table("users").select("*").eq(
"id", token_data.user_id
).single().execute()
user = user_result.data
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在"
)
# 检查授权是否过期
if user.get("expires_at"):
from datetime import datetime, timezone
expires_at = datetime.fromisoformat(user["expires_at"].replace("Z", "+00:00"))
if datetime.now(timezone.utc) > expires_at:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="授权已过期,请联系管理员续期"
)
return user
except HTTPException:
raise
except Exception as e:
logger.error(f"获取用户信息失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="服务器错误"
)
async def get_current_admin(
current_user: dict = Depends(get_current_user)
) -> dict:
"""
获取当前管理员用户
Raises:
HTTPException 403: 非管理员
"""
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
return current_user

98
backend/app/core/paths.py Normal file
View File

@@ -0,0 +1,98 @@
"""
路径规范化模块:按用户隔离 Cookie 存储
"""
from pathlib import Path
import re
from typing import Set
# 基础目录
BASE_DIR = Path(__file__).parent.parent.parent
USER_DATA_DIR = BASE_DIR / "user_data"
# 有效的平台列表
VALID_PLATFORMS: Set[str] = {"bilibili", "douyin", "xiaohongshu", "weixin", "kuaishou"}
# UUID 格式正则
UUID_PATTERN = re.compile(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', re.IGNORECASE)
def validate_user_id(user_id: str) -> bool:
"""验证 user_id 格式 (防止路径遍历攻击)"""
return bool(UUID_PATTERN.match(user_id))
def validate_platform(platform: str) -> bool:
"""验证平台名称"""
return platform in VALID_PLATFORMS
def get_user_data_dir(user_id: str) -> Path:
"""
获取用户数据根目录
Args:
user_id: 用户 UUID
Returns:
用户数据目录路径
Raises:
ValueError: user_id 格式无效
"""
if not validate_user_id(user_id):
raise ValueError(f"Invalid user_id format: {user_id}")
user_dir = USER_DATA_DIR / user_id
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir
def get_user_cookie_dir(user_id: str) -> Path:
"""
获取用户 Cookie 目录
Args:
user_id: 用户 UUID
Returns:
Cookie 目录路径
"""
cookie_dir = get_user_data_dir(user_id) / "cookies"
cookie_dir.mkdir(parents=True, exist_ok=True)
return cookie_dir
def get_platform_cookie_path(user_id: str, platform: str) -> Path:
"""
获取平台 Cookie 文件路径
Args:
user_id: 用户 UUID
platform: 平台名称 (bilibili/douyin/xiaohongshu)
Returns:
Cookie 文件路径
Raises:
ValueError: 平台名称无效
"""
if not validate_platform(platform):
raise ValueError(f"Invalid platform: {platform}. Valid: {VALID_PLATFORMS}")
return get_user_cookie_dir(user_id) / f"{platform}_cookies.json"
# === 兼容旧代码的路径 (无用户隔离) ===
def get_legacy_cookie_dir() -> Path:
"""获取旧版 Cookie 目录 (无用户隔离)"""
cookie_dir = BASE_DIR / "app" / "cookies"
cookie_dir.mkdir(parents=True, exist_ok=True)
return cookie_dir
def get_legacy_cookie_path(platform: str) -> Path:
"""获取旧版 Cookie 路径 (无用户隔离)"""
if not validate_platform(platform):
raise ValueError(f"Invalid platform: {platform}")
return get_legacy_cookie_dir() / f"{platform}_cookies.json"

View File

@@ -0,0 +1,112 @@
"""
安全工具模块JWT Token 和密码处理
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Any
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel
from fastapi import Response
from app.core.config import settings
import uuid
# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class TokenData(BaseModel):
"""JWT Token 数据结构"""
user_id: str
session_token: str
exp: datetime
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""生成密码哈希"""
return pwd_context.hash(password)
def create_access_token(user_id: str, session_token: str) -> str:
"""
创建 JWT Access Token
Args:
user_id: 用户 ID
session_token: 会话 Token (用于单设备登录验证)
"""
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
to_encode = {
"sub": user_id,
"session_token": session_token,
"exp": expire
}
return jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
def decode_access_token(token: str) -> Optional[TokenData]:
"""
解码并验证 JWT Token
Returns:
TokenData 或 None (如果验证失败)
"""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
user_id = payload.get("sub")
session_token = payload.get("session_token")
exp = payload.get("exp")
if not user_id or not session_token:
return None
return TokenData(
user_id=user_id,
session_token=session_token,
exp=datetime.fromtimestamp(exp, tz=timezone.utc)
)
except JWTError:
return None
def generate_session_token() -> str:
"""生成新的会话 Token"""
return str(uuid.uuid4())
def set_auth_cookie(response: Response, token: str) -> None:
"""
设置 HttpOnly Cookie
Args:
response: FastAPI Response 对象
token: JWT Token
"""
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=not settings.DEBUG, # 开发/测试环境(DEBUG=True)允许非HTTPS
samesite="lax",
max_age=settings.JWT_EXPIRE_HOURS * 3600
)
def clear_auth_cookie(response: Response) -> None:
"""清除认证 Cookie"""
response.delete_cookie(key="access_token")

View File

@@ -0,0 +1,26 @@
"""
Supabase 客户端初始化
"""
from supabase import create_client, Client
from app.core.config import settings
from loguru import logger
from typing import Optional
_supabase_client: Optional[Client] = None
def get_supabase() -> Client:
"""获取 Supabase 客户端单例"""
global _supabase_client
if _supabase_client is None:
if not settings.SUPABASE_URL or not settings.SUPABASE_KEY:
raise ValueError("SUPABASE_URL 和 SUPABASE_KEY 必须在 .env 中配置")
_supabase_client = create_client(
settings.SUPABASE_URL,
settings.SUPABASE_KEY
)
logger.info("Supabase 客户端已初始化")
return _supabase_client

View File

@@ -2,7 +2,9 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.core import config
from app.api import materials, videos, publish, login_helper
from app.api import materials, videos, publish, login_helper, auth, admin
from loguru import logger
import os
settings = config.settings
@@ -22,11 +24,56 @@ settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
(settings.UPLOAD_DIR / "materials").mkdir(exist_ok=True)
app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs")
app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads")
# 注册路由
app.include_router(materials.router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos.router, prefix="/api/videos", tags=["Videos"])
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"])
app.include_router(auth.router) # /api/auth
app.include_router(admin.router) # /api/admin
@app.on_event("startup")
async def init_admin():
"""
服务启动时初始化管理员账号
"""
admin_email = settings.ADMIN_EMAIL
admin_password = settings.ADMIN_PASSWORD
if not admin_email or not admin_password:
logger.warning("未配置 ADMIN_EMAIL 和 ADMIN_PASSWORD跳过管理员初始化")
return
try:
from app.core.supabase import get_supabase
from app.core.security import get_password_hash
supabase = get_supabase()
# 检查是否已存在
existing = supabase.table("users").select("id").eq("email", admin_email).execute()
if existing.data:
logger.info(f"管理员账号已存在: {admin_email}")
return
# 创建管理员
supabase.table("users").insert({
"email": admin_email,
"password_hash": get_password_hash(admin_password),
"username": "Admin",
"role": "admin",
"is_active": True,
"expires_at": None # 永不过期
}).execute()
logger.success(f"管理员账号已创建: {admin_email}")
except Exception as e:
logger.error(f"初始化管理员失败: {e}")
@app.get("/health")
def health():

View File

@@ -1,11 +1,13 @@
"""
发布服务 (基于 social-auto-upload 架构)
发布服务 (支持用户隔离)
"""
import json
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from typing import Optional, List, Dict, Any
from loguru import logger
from app.core.config import settings
from app.core.paths import get_user_cookie_dir, get_platform_cookie_path, get_legacy_cookie_dir, get_legacy_cookie_path
# Import platform uploaders
from .uploader.bilibili_uploader import BilibiliUploader
@@ -14,30 +16,50 @@ from .uploader.xiaohongshu_uploader import XiaohongshuUploader
class PublishService:
"""Social media publishing service"""
"""Social media publishing service (with user isolation)"""
PLATFORMS = {
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame"},
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/"},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/"},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/"},
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/"},
# 支持的平台配置
PLATFORMS: Dict[str, Dict[str, Any]] = {
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/", "enabled": False},
}
def __init__(self):
self.cookies_dir = settings.BASE_DIR / "cookies"
self.cookies_dir.mkdir(exist_ok=True)
def __init__(self) -> None:
# 存储活跃的登录会话,用于跟踪登录状态
# key 格式: "{user_id}_{platform}" 或 "{platform}" (兼容旧版)
self.active_login_sessions: Dict[str, Any] = {}
def get_accounts(self):
def _get_cookies_dir(self, user_id: Optional[str] = None) -> Path:
"""获取 Cookie 目录 (支持用户隔离)"""
if user_id:
return get_user_cookie_dir(user_id)
return get_legacy_cookie_dir()
def _get_cookie_path(self, platform: str, user_id: Optional[str] = None) -> Path:
"""获取 Cookie 文件路径 (支持用户隔离)"""
if user_id:
return get_platform_cookie_path(user_id, platform)
return get_legacy_cookie_path(platform)
def _get_session_key(self, platform: str, user_id: Optional[str] = None) -> str:
"""获取会话 key"""
if user_id:
return f"{user_id}_{platform}"
return platform
def get_accounts(self, user_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get list of platform accounts with login status"""
accounts = []
for pid, pinfo in self.PLATFORMS.items():
cookie_file = self.cookies_dir / f"{pid}_cookies.json"
cookie_file = self._get_cookie_path(pid, user_id)
accounts.append({
"platform": pid,
"name": pinfo["name"],
"logged_in": cookie_file.exists(),
"enabled": True
"enabled": pinfo.get("enabled", True)
})
return accounts
@@ -49,8 +71,9 @@ class PublishService:
tags: List[str],
description: str = "",
publish_time: Optional[datetime] = None,
**kwargs
):
user_id: Optional[str] = None,
**kwargs: Any
) -> Dict[str, Any]:
"""
Publish video to specified platform
@@ -61,6 +84,7 @@ class PublishService:
tags: List of tags
description: Video description
publish_time: Scheduled publish time (None = immediate)
user_id: User ID for cookie isolation
**kwargs: Additional platform-specific parameters
Returns:
@@ -75,30 +99,38 @@ class PublishService:
"platform": platform
}
# Get account file path
account_file = self.cookies_dir / f"{platform}_cookies.json"
# Get account file path (with user isolation)
account_file = self._get_cookie_path(platform, user_id)
if not account_file.exists():
return {
"success": False,
"message": f"请先登录 {self.PLATFORMS[platform]['name']}",
"platform": platform
}
logger.info(f"[发布] 平台: {self.PLATFORMS[platform]['name']}")
logger.info(f"[发布] 视频: {video_path}")
logger.info(f"[发布] 标题: {title}")
logger.info(f"[发布] 用户: {user_id or 'legacy'}")
try:
# Select appropriate uploader
if platform == "bilibili":
uploader = BilibiliUploader(
title=title,
file_path=str(settings.BASE_DIR / video_path), # Convert to absolute path
file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
tid=kwargs.get('tid', 122), # Category ID
copyright=kwargs.get('copyright', 1) # 1=original
tid=kwargs.get('tid', 122),
copyright=kwargs.get('copyright', 1)
)
elif platform == "douyin":
uploader = DouyinUploader(
title=title,
file_path=str(settings.BASE_DIR / video_path),
file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
@@ -107,7 +139,7 @@ class PublishService:
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
title=title,
file_path=str(settings.BASE_DIR / video_path),
file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
@@ -134,10 +166,14 @@ class PublishService:
"platform": platform
}
async def login(self, platform: str):
async def login(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
启动QR码登录流程
Args:
platform: 平台 ID
user_id: 用户 ID (用于 Cookie 隔离)
Returns:
dict: 包含二维码base64图片
"""
@@ -147,8 +183,15 @@ class PublishService:
try:
from .qr_login_service import QRLoginService
# 获取用户专属的 Cookie 目录
cookies_dir = self._get_cookies_dir(user_id)
# 创建QR登录服务
qr_service = QRLoginService(platform, self.cookies_dir)
qr_service = QRLoginService(platform, cookies_dir)
# 存储活跃会话 (带用户隔离)
session_key = self._get_session_key(platform, user_id)
self.active_login_sessions[session_key] = qr_service
# 启动登录并获取二维码
result = await qr_service.start_login()
@@ -161,17 +204,67 @@ class PublishService:
"success": False,
"message": f"登录失败: {str(e)}"
}
def get_login_session_status(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""获取活跃登录会话的状态"""
session_key = self._get_session_key(platform, user_id)
# 1. 如果有活跃的扫码会话,优先检查它
if session_key in self.active_login_sessions:
qr_service = self.active_login_sessions[session_key]
status = qr_service.get_login_status()
# 如果登录成功且Cookie已保存清理会话
if status["success"] and status["cookies_saved"]:
del self.active_login_sessions[session_key]
return {"success": True, "message": "登录成功"}
return {"success": False, "message": "等待扫码..."}
# 2. 检查本地Cookie文件是否存在
cookie_file = self._get_cookie_path(platform, user_id)
if cookie_file.exists():
return {"success": True, "message": "已登录 (历史状态)"}
return {"success": False, "message": "未登录"}
async def save_cookie_string(self, platform: str, cookie_string: str):
def logout(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
Logout from platform (delete cookie file)
"""
if platform not in self.PLATFORMS:
return {"success": False, "message": "不支持的平台"}
try:
session_key = self._get_session_key(platform, user_id)
# 1. 移除活跃会话
if session_key in self.active_login_sessions:
del self.active_login_sessions[session_key]
# 2. 删除Cookie文件
cookie_file = self._get_cookie_path(platform, user_id)
if cookie_file.exists():
cookie_file.unlink()
logger.info(f"[登出] {platform} Cookie已删除 (user: {user_id or 'legacy'})")
return {"success": True, "message": "已注销"}
except Exception as e:
logger.exception(f"[登出] 失败: {e}")
return {"success": False, "message": f"注销失败: {str(e)}"}
async def save_cookie_string(self, platform: str, cookie_string: str, user_id: Optional[str] = None) -> Dict[str, Any]:
"""
保存从客户端浏览器提取的Cookie字符串
Args:
platform: 平台ID
cookie_string: document.cookie 格式的Cookie字符串
user_id: 用户 ID (用于 Cookie 隔离)
"""
try:
account_file = self.cookies_dir / f"{platform}_cookies.json"
account_file = self._get_cookie_path(platform, user_id)
# 解析Cookie字符串
cookie_dict = {}
@@ -180,7 +273,7 @@ class PublishService:
name, value = item.split('=', 1)
cookie_dict[name] = value
# 对B站进行特殊处理提取biliup需要的字段
# 对B站进行特殊处理
if platform == "bilibili":
bilibili_cookies = {}
required_fields = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
@@ -189,7 +282,7 @@ class PublishService:
if field in cookie_dict:
bilibili_cookies[field] = cookie_dict[field]
if len(bilibili_cookies) < 3: # 至少需要3个关键字段
if len(bilibili_cookies) < 3:
return {
"success": False,
"message": "Cookie不完整请确保已登录"
@@ -197,12 +290,14 @@ class PublishService:
cookie_dict = bilibili_cookies
# 确保目录存在
account_file.parent.mkdir(parents=True, exist_ok=True)
# 保存Cookie
import json
with open(account_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
logger.success(f"[登录] {platform} Cookie已保存")
logger.success(f"[登录] {platform} Cookie已保存 (user: {user_id or 'legacy'})")
return {
"success": True,

View File

@@ -4,22 +4,30 @@ QR码自动登录服务
"""
import asyncio
import base64
from pathlib import Path
from playwright.async_api import async_playwright, Page
from loguru import logger
import json
import os
from pathlib import Path
from typing import Optional, Dict, Any, List
from playwright.async_api import async_playwright, Page, BrowserContext, Browser, Playwright as PW
from loguru import logger
class QRLoginService:
"""QR码登录服务"""
def __init__(self, platform: str, cookies_dir: Path):
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 120
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
self.cookies_dir = cookies_dir
self.qr_code_image = None
self.login_success = False
self.cookies_data = None
self.qr_code_image: Optional[str] = None
self.login_success: bool = False
self.cookies_data: Optional[Dict[str, Any]] = None
# Playwright 资源 (手动管理生命周期)
self.playwright: Optional[PW] = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
# 每个平台使用多个选择器 (使用逗号分隔Playwright会同时等待它们)
self.platform_configs = {
@@ -56,7 +64,7 @@ class QRLoginService:
}
}
async def start_login(self):
async def start_login(self) -> Dict[str, Any]:
"""
启动登录流程
@@ -129,109 +137,112 @@ class QRLoginService:
await self._cleanup()
return {"success": False, "message": f"启动失败: {str(e)}"}
async def _extract_qr_code(self, page: Page, selectors: list) -> str:
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
"""
提取二维码图片(并行执行 CSS策略 和 文本策略)
提取二维码图片 (优化策略顺序)
根据日志分析抖音和B站使用 Text 策略成功率最高
"""
async def strategy_css():
qr_element = None
# 针对抖音和B站优先使用 Text 策略 (成功率最高,速度最快)
if self.platform in ("douyin", "bilibili"):
# 尝试最多2次 (首次 + 1次重试)
for attempt in range(2):
if attempt > 0:
logger.info(f"[{self.platform}] 等待页面加载后重试...")
await asyncio.sleep(2)
# 策略1: Text (优先,成功率最高)
qr_element = await self._try_text_strategy(page)
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
qr_element = None
# 策略2: CSS (备用)
if not qr_element:
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
# 增加超时到5秒抖音页面加载较慢
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
screenshot = await el.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
# 如果已成功,退出循环
if qr_element:
break
else:
# 其他平台 (小红书等):保持原顺序 CSS -> Text
# 策略1: CSS 选择器
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
return el
except:
pass
return None
async def strategy_text():
# 扩展支持 Bilibili 和 Douyin
if self.platform not in ["bilibili", "douyin"]: return None
try:
logger.debug(f"[{self.platform}] 策略2(Text): 开始搜索...")
# 关键词列表
keywords = ["扫码登录", "打开抖音", "抖音APP", "使用手机抖音扫码"]
scan_text = None
# 遍历尝试关键词 (带等待)
for kw in keywords:
try:
t = page.get_by_text(kw, exact=False).first
# 稍微等待一下文字渲染
await t.wait_for(state="visible", timeout=2000)
scan_text = t
logger.debug(f"[{self.platform}] 找到关键词: {kw}")
break
except:
continue
if scan_text:
# 尝试定位周边的图片
parent_locator = scan_text
# 向上查找5层扩大范围
for _ in range(5):
parent_locator = parent_locator.locator("..")
# 找图片
img = parent_locator.locator("img").first
if await img.is_visible():
# 过滤掉头像等小图标,确保尺寸足够大
bbox = await img.bounding_box()
if bbox and bbox['width'] > 100:
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Img)")
return img
# 找Canvas
canvas = parent_locator.locator("canvas").first
if await canvas.is_visible():
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Canvas)")
return canvas
qr_element = el
except Exception as e:
logger.warning(f"[{self.platform}] 策略2异常: {e}")
return None
# 并行执行两个策略,谁先找到算谁的
tasks = [
asyncio.create_task(strategy_css()),
asyncio.create_task(strategy_text())
]
qr_element = None
pending = set(tasks)
while pending:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
for task in done:
result = await task
if result:
qr_element = result
break
# 策略2: Text
if not qr_element:
qr_element = await self._try_text_strategy(page)
# 如果找到元素,截图返回
if qr_element:
break
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.error(f"[{self.platform}] 截图失败: {e}")
# 取消剩下的任务 (如果找到了)
for task in pending:
task.cancel()
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.error(f"[{self.platform}] 截图失败: {e}")
# 失败处理
logger.warning(f"[{self.platform}] 所有策略失败,保存全页截图")
# 所有策略失败
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
# 保存调试截图
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
debug_dir.mkdir(exist_ok=True)
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
screenshot = await page.screenshot()
return base64.b64encode(screenshot).decode()
return None
async def _try_text_strategy(self, page: Page) -> Optional[Any]:
"""基于文本查找二维码图片"""
try:
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP", "使用APP扫码"]
for kw in keywords:
try:
text_el = page.get_by_text(kw, exact=False).first
await text_el.wait_for(state="visible", timeout=2000)
# 向上查找图片
parent = text_el
for _ in range(5):
parent = parent.locator("..")
imgs = parent.locator("img")
for i in range(await imgs.count()):
img = imgs.nth(i)
if await img.is_visible():
bbox = await img.bounding_box()
if bbox and bbox['width'] > 100:
logger.info(f"[{self.platform}] 策略Text: 成功")
return img
except Exception:
continue
except Exception as e:
logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
return None
async def _monitor_login_status(self, page: Page, success_url: str):
"""监控登录状态"""
@@ -240,7 +251,7 @@ class QRLoginService:
key_cookies = {"bilibili": "SESSDATA", "douyin": "sessionid", "xiaohongshu": "web_session"}
target_cookie = key_cookies.get(self.platform, "")
for i in range(120):
for i in range(self.LOGIN_TIMEOUT):
await asyncio.sleep(1)
try:
@@ -275,37 +286,57 @@ class QRLoginService:
finally:
await self._cleanup()
async def _cleanup(self):
async def _cleanup(self) -> None:
"""清理资源"""
if hasattr(self, 'context') and self.context:
try: await self.context.close()
except: pass
if hasattr(self, 'browser') and self.browser:
try: await self.browser.close()
except: pass
if hasattr(self, 'playwright') and self.playwright:
try: await self.playwright.stop()
except: pass
if self.context:
try:
await self.context.close()
except Exception:
pass
self.context = None
if self.browser:
try:
await self.browser.close()
except Exception:
pass
self.browser = None
if self.playwright:
try:
await self.playwright.stop()
except Exception:
pass
self.playwright = None
async def _save_cookies(self, cookies: list):
async def _save_cookies(self, cookies: List[Dict[str, Any]]) -> None:
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
cookie_dict = {c['name']: c['value'] for c in cookies}
if self.platform == "bilibili":
# Bilibili 使用简单格式 (biliup库需要)
cookie_dict = {c['name']: c['value'] for c in cookies}
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
self.cookies_data = cookie_dict
else:
# Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
# 这样可以直接用 browser.new_context(storage_state=file)
storage_state = {
"cookies": cookies,
"origins": []
}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(storage_state, f, indent=2)
self.cookies_data = storage_state
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
self.cookies_data = cookie_dict
logger.success(f"[{self.platform}] Cookie已保存")
except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
def get_login_status(self):
def get_login_status(self) -> Dict[str, Any]:
"""获取登录状态"""
return {
"success": self.login_success,

View File

@@ -3,7 +3,7 @@ Base uploader class for all social media platforms
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
@@ -38,7 +38,7 @@ class BaseUploader(ABC):
self.description = description
@abstractmethod
async def main(self):
async def main(self) -> Dict[str, Any]:
"""
Main upload method - must be implemented by subclasses
@@ -50,7 +50,7 @@ class BaseUploader(ABC):
"""
pass
def _get_timestamp(self, dt):
def _get_timestamp(self, dt: Union[datetime, int]) -> int:
"""
Convert datetime to Unix timestamp

View File

@@ -2,9 +2,11 @@
Bilibili uploader using biliup library
"""
import json
import asyncio
from pathlib import Path
from typing import Optional, List
from typing import Optional, List, Dict, Any
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
try:
from biliup.plugins.bili_webup import BiliBili, Data
@@ -15,6 +17,9 @@ except ImportError:
from loguru import logger
from .base_uploader import BaseUploader
# Thread pool for running sync biliup code
_executor = ThreadPoolExecutor(max_workers=2)
class BilibiliUploader(BaseUploader):
"""Bilibili video uploader using biliup library"""
@@ -46,13 +51,19 @@ class BilibiliUploader(BaseUploader):
"biliup library not installed. Please run: pip install biliup"
)
async def main(self):
async def main(self) -> Dict[str, Any]:
"""
Upload video to Bilibili
Returns:
dict: Upload result
"""
# Run sync upload in thread pool to avoid asyncio.run() conflict
loop = asyncio.get_event_loop()
return await loop.run_in_executor(_executor, self._upload_sync)
def _upload_sync(self) -> Dict[str, Any]:
"""Synchronous upload logic (runs in thread pool)"""
try:
# 1. Load cookie data
if not self.account_file or not Path(self.account_file).exists():
@@ -66,6 +77,22 @@ class BilibiliUploader(BaseUploader):
with open(self.account_file, 'r', encoding='utf-8') as f:
cookie_data = json.load(f)
# Convert simple cookie format to biliup format if needed
if 'cookie_info' not in cookie_data and 'SESSDATA' in cookie_data:
# Transform to biliup expected format
cookie_data = {
'cookie_info': {
'cookies': [
{'name': k, 'value': v} for k, v in cookie_data.items()
]
},
'token_info': {
'access_token': cookie_data.get('access_token', ''),
'refresh_token': cookie_data.get('refresh_token', '')
}
}
logger.info("[B站] Cookie格式已转换")
# 2. Prepare video data
data = Data()
data.copyright = self.copyright
@@ -97,17 +124,39 @@ class BilibiliUploader(BaseUploader):
# Submit
ret = bili.submit()
# Debug: log full response
logger.debug(f"[B站] API响应: {ret}")
if ret.get('code') == 0:
bvid = ret.get('bvid', '')
logger.success(f"[B站] 上传成功: {bvid}")
return {
"success": True,
"message": "上传成功" if data.dtime == 0 else "已设置定时发布",
"url": f"https://www.bilibili.com/video/{bvid}" if bvid else None
}
# Try multiple keys for bvid (API may vary)
bvid = ret.get('data', {}).get('bvid') or ret.get('bvid', '')
aid = ret.get('data', {}).get('aid') or ret.get('aid', '')
if bvid:
logger.success(f"[B站] 上传成功: {bvid}")
return {
"success": True,
"message": "发布成功,待审核" if data.dtime == 0 else "已设置定时发布",
"url": f"https://www.bilibili.com/video/{bvid}"
}
elif aid:
logger.success(f"[B站] 上传成功: av{aid}")
return {
"success": True,
"message": "发布成功,待审核" if data.dtime == 0 else "已设置定时发布",
"url": f"https://www.bilibili.com/video/av{aid}"
}
else:
# No bvid/aid but code=0, still consider success
logger.warning(f"[B站] 上传返回code=0但无bvid/aid: {ret}")
return {
"success": True,
"message": "发布成功,待审核",
"url": None
}
else:
error_msg = ret.get('message', '未知错误')
logger.error(f"[B站] 上传失败: {error_msg}")
logger.error(f"[B站] 上传失败: {error_msg} (完整响应: {ret})")
return {
"success": False,
"message": f"上传失败: {error_msg}",

View File

@@ -1,169 +1,585 @@
"""
Douyin (抖音) uploader using Playwright
Based on social-auto-upload implementation
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List
import asyncio
from playwright.async_api import Playwright, async_playwright
from loguru import logger
from .base_uploader import BaseUploader
from .cookie_utils import set_init_script
class DouyinUploader(BaseUploader):
"""Douyin video uploader using Playwright"""
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = ""
):
super().__init__(title, file_path, tags, publish_date, account_file, description)
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
async def set_schedule_time(self, page, publish_date):
"""Set scheduled publish time"""
try:
# Click "定时发布" radio button
label_element = page.locator("[class^='radio']:has-text('定时发布')")
await label_element.click()
await asyncio.sleep(1)
# Format time
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
# Fill datetime input
await page.locator('.semi-input[placeholder="日期和时间"]').click()
await page.keyboard.press("Control+KeyA")
await page.keyboard.type(str(publish_date_hour))
await page.keyboard.press("Enter")
await asyncio.sleep(1)
logger.info(f"[抖音] 已设置定时发布: {publish_date_hour}")
except Exception as e:
logger.error(f"[抖音] 设置定时发布失败: {e}")
async def upload(self, playwright: Playwright):
"""Main upload logic"""
try:
# Launch browser
browser = await playwright.chromium.launch(headless=False)
context = await browser.new_context(storage_state=self.account_file)
context = await set_init_script(context)
page = await context.new_page()
# Go to upload page
await page.goto(self.upload_url)
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
# Upload video file
await page.set_input_files("input[type='file']", str(self.file_path))
# Wait for redirect to publish page
while True:
try:
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/publish?enter_from=publish_page",
timeout=3000
)
logger.info("[抖音] 成功进入发布页面")
break
except:
try:
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
timeout=3000
)
logger.info("[抖音] 成功进入发布页面 (版本2)")
break
except:
await asyncio.sleep(0.5)
# Fill title
await asyncio.sleep(1)
logger.info("[抖音] 正在填充标题和话题...")
title_container = page.get_by_text('作品描述').locator("..").locator("..").locator(
"xpath=following-sibling::div[1]").locator("input")
if await title_container.count():
await title_container.fill(self.title[:30])
# Add tags
css_selector = ".zone-container"
for tag in self.tags:
await page.type(css_selector, "#" + tag)
await page.press(css_selector, "Space")
logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
# Wait for upload to complete
while True:
try:
number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
if number > 0:
logger.success("[抖音] 视频上传完毕")
break
else:
logger.info("[抖音] 正在上传视频中...")
await asyncio.sleep(2)
except:
await asyncio.sleep(2)
# Set scheduled publish time if needed
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# Click publish button
while True:
try:
publish_button = page.get_by_role('button', name="发布", exact=True)
if await publish_button.count():
await publish_button.click()
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/manage**",
timeout=3000
)
logger.success("[抖音] 视频发布成功")
break
except:
logger.info("[抖音] 视频正在发布中...")
await asyncio.sleep(0.5)
# Save updated cookies
await context.storage_state(path=self.account_file)
logger.success("[抖音] Cookie 更新完毕")
await asyncio.sleep(2)
await context.close()
await browser.close()
return {
"success": True,
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
"url": None
}
except Exception as e:
logger.exception(f"[抖音] 上传失败: {e}")
return {
"success": False,
"message": f"上传失败: {str(e)}",
"url": None
}
async def main(self):
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)
"""
Douyin (抖音) uploader using Playwright
Based on social-auto-upload implementation
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List, Dict, Any
import asyncio
import time
from playwright.async_api import Playwright, async_playwright
from loguru import logger
from .base_uploader import BaseUploader
from .cookie_utils import set_init_script
class DouyinUploader(BaseUploader):
"""Douyin video uploader using Playwright"""
# 超时配置 (秒)
UPLOAD_TIMEOUT = 300 # 视频上传超时
PUBLISH_TIMEOUT = 180 # 发布检测超时
PAGE_REDIRECT_TIMEOUT = 60 # 页面跳转超时
POLL_INTERVAL = 2 # 轮询间隔
MAX_CLICK_RETRIES = 3 # 按钮点击重试次数
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = ""
):
super().__init__(title, file_path, tags, publish_date, account_file, description)
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
async def _is_text_visible(self, page, text: str, exact: bool = False) -> bool:
try:
return await page.get_by_text(text, exact=exact).first.is_visible()
except Exception:
return False
async def _first_visible_locator(self, locator, timeout: int = 1000):
try:
if await locator.count() == 0:
return None
candidate = locator.first
if await candidate.is_visible(timeout=timeout):
return candidate
except Exception:
return None
return None
async def _wait_for_publish_result(self, page, max_wait_time: int = 180):
success_texts = ["发布成功", "作品已发布", "再发一条", "查看作品", "审核中", "待审核"]
weak_texts = ["发布完成"]
failure_texts = ["发布失败", "发布异常", "发布出错", "请完善", "请补充", "请先上传"]
start_time = time.time()
poll_interval = 2
weak_reason = None
while time.time() - start_time < max_wait_time:
if page.is_closed():
return False, "页面已关闭", False
current_url = page.url
if "content/manage" in current_url:
return True, f"已跳转到管理页面 (URL: {current_url})", False
for text in success_texts:
if await self._is_text_visible(page, text, exact=False):
return True, f"检测到成功提示: {text}", False
for text in failure_texts:
if await self._is_text_visible(page, text, exact=False):
return False, f"检测到失败提示: {text}", False
for text in weak_texts:
if await self._is_text_visible(page, text, exact=False):
weak_reason = text
logger.info("[抖音] 视频正在发布中...")
await asyncio.sleep(poll_interval)
if weak_reason:
return False, f"检测到提示: {weak_reason}", True
return False, "发布检测超时", True
async def _fill_title(self, page, title: str) -> bool:
title_text = title[:30]
locator_candidates = []
try:
label_locator = page.get_by_text("作品描述").locator("..").locator("..").locator(
"xpath=following-sibling::div[1]"
).locator("textarea, input, div[contenteditable='true']")
locator_candidates.append(label_locator)
except Exception:
pass
locator_candidates.extend([
page.locator("textarea[placeholder*='作品描述']"),
page.locator("textarea[placeholder*='描述']"),
page.locator("input[placeholder*='作品描述']"),
page.locator("input[placeholder*='描述']"),
page.locator("div[contenteditable='true']"),
])
for locator in locator_candidates:
try:
if await locator.count() > 0:
target = locator.first
await target.fill(title_text)
return True
except Exception:
continue
return False
async def _select_cover_if_needed(self, page) -> bool:
try:
cover_button = page.get_by_text("选择封面", exact=False).first
if await cover_button.is_visible():
await cover_button.click()
logger.info("[抖音] 尝试选择封面")
await asyncio.sleep(0.5)
dialog = page.locator(
"div.dy-creator-content-modal-wrap, div[role='dialog'], "
"div[class*='modal'], div[class*='dialog']"
).last
scopes = [dialog] if await dialog.count() > 0 else [page]
switched = False
for scope in scopes:
for selector in [
"button:has-text('设置横封面')",
"div:has-text('设置横封面')",
"span:has-text('设置横封面')",
]:
try:
button = await self._first_visible_locator(scope.locator(selector))
if button:
await button.click()
logger.info("[抖音] 已切换到横封面设置")
await asyncio.sleep(0.5)
switched = True
break
except Exception:
continue
if switched:
break
selected = False
for scope in scopes:
for selector in [
"div[class*='cover'] img",
"div[class*='cover']",
"div[class*='frame'] img",
"div[class*='frame']",
"div[class*='preset']",
"img",
]:
try:
candidate = await self._first_visible_locator(scope.locator(selector))
if candidate:
await candidate.click()
logger.info("[抖音] 已选择封面帧")
selected = True
break
except Exception:
continue
if selected:
break
confirm_selectors = [
"button:has-text('完成')",
"button:has-text('确定')",
"button:has-text('保存')",
"button:has-text('确认')",
]
for selector in confirm_selectors:
try:
button = await self._first_visible_locator(page.locator(selector))
if button:
if not await button.is_enabled():
for _ in range(8):
if await button.is_enabled():
break
await asyncio.sleep(0.5)
await button.click()
logger.info(f"[抖音] 封面已确认: {selector}")
await asyncio.sleep(0.5)
if await dialog.count() > 0:
try:
await dialog.wait_for(state="hidden", timeout=5000)
except Exception:
pass
return True
except Exception:
continue
return selected
except Exception as e:
logger.warning(f"[抖音] 选择封面失败: {e}")
return False
async def _click_publish_confirm_modal(self, page):
confirm_selectors = [
"button:has-text('确认发布')",
"button:has-text('继续发布')",
"button:has-text('确定发布')",
"button:has-text('发布确认')",
]
for selector in confirm_selectors:
try:
button = page.locator(selector).first
if await button.is_visible():
await button.click()
logger.info(f"[抖音] 点击了发布确认按钮: {selector}")
await asyncio.sleep(1)
return True
except Exception:
continue
return False
async def _dismiss_blocking_modal(self, page) -> bool:
modal_locator = page.locator(
"div.dy-creator-content-modal-wrap, div[role='dialog'], "
"div[class*='modal'], div[class*='dialog']"
)
try:
count = await modal_locator.count()
except Exception:
return False
if count == 0:
return False
button_texts = [
"我知道了",
"知道了",
"确定",
"继续",
"继续发布",
"确认",
"同意并继续",
"完成",
"好的",
"明白了",
]
close_selectors = [
"button[class*='close']",
"span[class*='close']",
"i[class*='close']",
]
for index in range(count):
modal = modal_locator.nth(index)
try:
if not await modal.is_visible():
continue
for text in button_texts:
try:
button = modal.get_by_role("button", name=text).first
if await button.is_visible():
await button.click()
logger.info(f"[抖音] 关闭弹窗: {text}")
await asyncio.sleep(0.5)
return True
except Exception:
continue
for selector in close_selectors:
try:
close_button = modal.locator(selector).first
if await close_button.is_visible():
await close_button.click()
logger.info("[抖音] 关闭弹窗: close")
await asyncio.sleep(0.5)
return True
except Exception:
continue
except Exception:
continue
return False
async def _verify_publish_in_manage(self, page):
manage_url = "https://creator.douyin.com/creator-micro/content/manage"
try:
await page.goto(manage_url)
await page.wait_for_load_state("domcontentloaded")
await asyncio.sleep(2)
title_text = self.title[:30]
title_locator = page.get_by_text(title_text, exact=False).first
if await title_locator.is_visible():
return True, "内容管理中检测到新作品"
if await self._is_text_visible(page, "审核中", exact=False):
return True, "内容管理显示审核中"
except Exception as e:
return False, f"无法验证内容管理: {e}"
return False, "内容管理中未找到视频"
async def set_schedule_time(self, page, publish_date):
"""Set scheduled publish time"""
try:
# Click "定时发布" radio button
label_element = page.locator("[class^='radio']:has-text('定时发布')")
await label_element.click()
await asyncio.sleep(1)
# Format time
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
# Fill datetime input
await page.locator('.semi-input[placeholder="日期和时间"]').click()
await page.keyboard.press("Control+KeyA")
await page.keyboard.type(str(publish_date_hour))
await page.keyboard.press("Enter")
await asyncio.sleep(1)
logger.info(f"[抖音] 已设置定时发布: {publish_date_hour}")
except Exception as e:
logger.error(f"[抖音] 设置定时发布失败: {e}")
async def upload(self, playwright: Playwright) -> dict:
"""Main upload logic with guaranteed resource cleanup"""
browser = None
context = None
try:
# Launch browser in headless mode for server deployment
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(storage_state=self.account_file)
context = await set_init_script(context)
page = await context.new_page()
# Go to upload page
await page.goto(self.upload_url)
await page.wait_for_load_state('domcontentloaded')
await asyncio.sleep(2)
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
# Check if redirected to login page (more reliable than text detection)
current_url = page.url
if "login" in current_url or "passport" in current_url:
logger.error("[抖音] Cookie 已失效,被重定向到登录页")
return {
"success": False,
"message": "Cookie 已失效,请重新登录",
"url": None
}
# Ensure we're on the upload page
if "content/upload" not in page.url:
logger.info("[抖音] 当前不在上传页面,强制跳转...")
await page.goto(self.upload_url)
await asyncio.sleep(2)
# Try multiple selectors for the file input (page structure varies)
file_uploaded = False
selectors = [
"div[class^='container'] input", # Primary selector from SuperIPAgent
"input[type='file']", # Fallback selector
"div[class^='upload'] input[type='file']", # Alternative
]
for selector in selectors:
try:
logger.info(f"[抖音] 尝试选择器: {selector}")
locator = page.locator(selector).first
if await locator.count() > 0:
await locator.set_input_files(str(self.file_path))
file_uploaded = True
logger.info(f"[抖音] 文件上传成功使用选择器: {selector}")
break
except Exception as e:
logger.warning(f"[抖音] 选择器 {selector} 失败: {e}")
continue
if not file_uploaded:
logger.error("[抖音] 所有选择器都失败,无法上传文件")
return {
"success": False,
"message": "无法找到上传按钮,页面可能已更新",
"url": None
}
# Wait for redirect to publish page (with timeout)
redirect_start = time.time()
while time.time() - redirect_start < self.PAGE_REDIRECT_TIMEOUT:
current_url = page.url
if "content/publish" in current_url or "content/post/video" in current_url:
logger.info("[抖音] 成功进入发布页面")
break
await asyncio.sleep(0.5)
else:
logger.error("[抖音] 等待发布页面超时")
return {
"success": False,
"message": "等待发布页面超时",
"url": None
}
# Fill title
await asyncio.sleep(1)
logger.info("[抖音] 正在填充标题和话题...")
if not await self._fill_title(page, self.title):
logger.warning("[抖音] 未找到作品描述输入框")
# Add tags
css_selector = ".zone-container"
for tag in self.tags:
await page.type(css_selector, "#" + tag)
await page.press(css_selector, "Space")
logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
cover_selected = await self._select_cover_if_needed(page)
if not cover_selected:
logger.warning("[抖音] 未确认封面选择,可能影响发布")
# Wait for upload to complete (with timeout)
upload_start = time.time()
while time.time() - upload_start < self.UPLOAD_TIMEOUT:
try:
number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
if number > 0:
logger.success("[抖音] 视频上传完毕")
break
else:
logger.info("[抖音] 正在上传视频中...")
await asyncio.sleep(self.POLL_INTERVAL)
except Exception:
await asyncio.sleep(self.POLL_INTERVAL)
else:
logger.error("[抖音] 视频上传超时")
return {
"success": False,
"message": "视频上传超时",
"url": None
}
# Set scheduled publish time if needed
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# Click publish button
# 使用更稳健的点击逻辑
try:
publish_label = "定时发布" if self.publish_date != 0 else "发布"
publish_button = page.get_by_role('button', name=publish_label, exact=True)
# 等待按钮出现
await publish_button.wait_for(state="visible", timeout=10000)
if not await publish_button.is_enabled():
logger.error("[抖音] 发布按钮不可点击,可能需要补充封面或确认信息")
return {
"success": False,
"message": "发布按钮不可点击,请检查封面/声明等必填项",
"url": None
}
await asyncio.sleep(1) # 额外等待以确保可交互
clicked = False
for attempt in range(self.MAX_CLICK_RETRIES):
await self._dismiss_blocking_modal(page)
try:
await publish_button.click(timeout=5000)
logger.info(f"[抖音] 点击了{publish_label}按钮")
clicked = True
break
except Exception as click_error:
logger.warning(f"[抖音] 点击发布按钮失败,重试 {attempt + 1}/{self.MAX_CLICK_RETRIES}: {click_error}")
try:
await page.keyboard.press("Escape")
except Exception:
pass
await asyncio.sleep(1)
if not clicked:
raise RuntimeError("点击发布按钮失败")
except Exception as e:
logger.error(f"[抖音] 点击发布按钮失败: {e}")
# 尝试备用选择器
try:
fallback_selectors = ["button:has-text('发布')", "button:has-text('定时发布')"]
clicked = False
for selector in fallback_selectors:
try:
await page.click(selector, timeout=5000)
logger.info(f"[抖音] 使用备用选择器点击了按钮: {selector}")
clicked = True
break
except Exception:
continue
if not clicked:
return {
"success": False,
"message": "无法点击发布按钮,请检查页面状态",
"url": None
}
except Exception:
return {
"success": False,
"message": "无法点击发布按钮,请检查页面状态",
"url": None
}
await self._click_publish_confirm_modal(page)
# 4. 检测发布完成
publish_success, publish_reason, is_timeout = await self._wait_for_publish_result(page)
if not publish_success and is_timeout:
verify_success, verify_reason = await self._verify_publish_in_manage(page)
if verify_success:
publish_success = True
publish_reason = verify_reason
else:
publish_reason = f"{publish_reason}; {verify_reason}"
if publish_success:
logger.success(f"[抖音] 发布成功: {publish_reason}")
else:
if is_timeout:
logger.warning("[抖音] 发布检测超时,但这不一定代表失败")
else:
logger.warning(f"[抖音] 发布未成功: {publish_reason}")
# Save updated cookies
await context.storage_state(path=self.account_file)
logger.success("[抖音] Cookie 更新完毕")
await asyncio.sleep(2)
if publish_success:
return {
"success": True,
"message": "发布成功,待审核",
"url": None
}
if is_timeout:
return {
"success": True,
"message": "发布检测超时,请到抖音后台确认",
"url": None
}
return {
"success": False,
"message": f"发布失败: {publish_reason}",
"url": None
}
except Exception as e:
logger.exception(f"[抖音] 上传失败: {e}")
return {
"success": False,
"message": f"上传失败: {str(e)}",
"url": None
}
finally:
# 确保资源释放
if context:
try:
await context.close()
except Exception:
pass
if browser:
try:
await browser.close()
except Exception:
pass
async def main(self) -> Dict[str, Any]:
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)

View File

@@ -4,7 +4,7 @@ Based on social-auto-upload implementation
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from typing import Optional, List, Dict, Any
import asyncio
from playwright.async_api import Playwright, async_playwright
@@ -17,6 +17,11 @@ from .cookie_utils import set_init_script
class XiaohongshuUploader(BaseUploader):
"""Xiaohongshu video uploader using Playwright"""
# 超时配置 (秒)
UPLOAD_TIMEOUT = 300 # 视频上传超时
PUBLISH_TIMEOUT = 120 # 发布检测超时
POLL_INTERVAL = 1 # 轮询间隔
def __init__(
self,
title: str,
@@ -54,11 +59,13 @@ class XiaohongshuUploader(BaseUploader):
except Exception as e:
logger.error(f"[小红书] 设置定时发布失败: {e}")
async def upload(self, playwright: Playwright):
"""Main upload logic"""
async def upload(self, playwright: Playwright) -> dict:
"""Main upload logic with guaranteed resource cleanup"""
browser = None
context = None
try:
# Launch browser
browser = await playwright.chromium.launch(headless=False)
# Launch browser (headless for server deployment)
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(
viewport={"width": 1600, "height": 900},
storage_state=self.account_file
@@ -74,8 +81,10 @@ class XiaohongshuUploader(BaseUploader):
# Upload video file
await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(str(self.file_path))
# Wait for upload to complete
while True:
# Wait for upload to complete (with timeout)
import time
upload_start = time.time()
while time.time() - upload_start < self.UPLOAD_TIMEOUT:
try:
upload_input = await page.wait_for_selector('input.upload-input', timeout=3000)
preview_new = await upload_input.query_selector(
@@ -100,11 +109,18 @@ class XiaohongshuUploader(BaseUploader):
else:
logger.info("[小红书] 未找到预览元素,继续等待...")
await asyncio.sleep(1)
await asyncio.sleep(self.POLL_INTERVAL)
except Exception as e:
logger.info(f"[小红书] 检测过程: {str(e)},重新尝试...")
await asyncio.sleep(0.5)
else:
logger.error("[小红书] 视频上传超时")
return {
"success": False,
"message": "视频上传超时",
"url": None
}
# Fill title and tags
await asyncio.sleep(1)
@@ -126,8 +142,9 @@ class XiaohongshuUploader(BaseUploader):
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# Click publish button
while True:
# Click publish button (with timeout)
publish_start = time.time()
while time.time() - publish_start < self.PUBLISH_TIMEOUT:
try:
if self.publish_date != 0:
await page.locator('button:has-text("定时发布")').click()
@@ -140,21 +157,21 @@ class XiaohongshuUploader(BaseUploader):
)
logger.success("[小红书] 视频发布成功")
break
except:
except Exception:
logger.info("[小红书] 视频正在发布中...")
await asyncio.sleep(0.5)
else:
logger.warning("[小红书] 发布检测超时,请手动确认")
# Save updated cookies
await context.storage_state(path=self.account_file)
logger.success("[小红书] Cookie 更新完毕")
await asyncio.sleep(2)
await context.close()
await browser.close()
return {
"success": True,
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
"message": "发布成功,待审核" if self.publish_date == 0 else "已设置定时发布",
"url": None
}
@@ -165,8 +182,20 @@ class XiaohongshuUploader(BaseUploader):
"message": f"上传失败: {str(e)}",
"url": None
}
finally:
# 确保资源释放
if context:
try:
await context.close()
except Exception:
pass
if browser:
try:
await browser.close()
except Exception:
pass
async def main(self):
async def main(self) -> Dict[str, Any]:
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)

View File

@@ -0,0 +1,73 @@
-- ViGent 用户认证系统数据库表
-- 在 Supabase SQL Editor 中执行
-- 1. 创建 users 表
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
username TEXT,
role TEXT DEFAULT 'pending' CHECK (role IN ('pending', 'user', 'admin')),
is_active BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 2. 创建 user_sessions 表 (单设备登录)
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
session_token TEXT UNIQUE NOT NULL,
device_info TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 3. 创建 social_accounts 表 (社交账号绑定)
CREATE TABLE IF NOT EXISTS social_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
platform TEXT NOT NULL CHECK (platform IN ('bilibili', 'douyin', 'xiaohongshu')),
logged_in BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, platform)
);
-- 4. 创建索引
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_social_user_platform ON social_accounts(user_id, platform);
-- 5. 启用 RLS (行级安全)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE social_accounts ENABLE ROW LEVEL SECURITY;
-- 6. RLS 策略 (Service Role 可以绑过 RLS所以后端使用 service_role key 时不受限)
-- 以下策略仅对 anon key 生效
-- users: 仅管理员可查看所有用户,普通用户只能查看自己
CREATE POLICY "Users can view own profile" ON users
FOR SELECT USING (auth.uid()::text = id::text);
-- user_sessions: 用户只能访问自己的 session
CREATE POLICY "Users can access own sessions" ON user_sessions
FOR ALL USING (user_id::text = auth.uid()::text);
-- social_accounts: 用户只能访问自己的社交账号
CREATE POLICY "Users can access own social accounts" ON social_accounts
FOR ALL USING (user_id::text = auth.uid()::text);
-- 7. 更新时间自动更新触发器
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();

93
backend/generate_keys.py Normal file
View File

@@ -0,0 +1,93 @@
import hmac
import hashlib
import base64
import json
import time
import secrets
import string
def generate_secure_secret(length=64):
"""生成安全的随机十六进制字符串"""
return secrets.token_hex(length // 2)
def generate_random_string(length=32):
"""生成包含字母数字的随机字符串 (用于密码等)"""
chars = string.ascii_letters + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
def base64url_encode(input_bytes):
return base64.urlsafe_b64encode(input_bytes).decode('utf-8').rstrip('=')
def generate_jwt(role, secret):
# 1. Header
header = {
"alg": "HS256",
"typ": "JWT"
}
# 2. Payload
now = int(time.time())
payload = {
"role": role,
"iss": "supabase",
"iat": now,
"exp": now + 315360000 # 10年有效期
}
# Encode parts
header_b64 = base64url_encode(json.dumps(header).encode('utf-8'))
payload_b64 = base64url_encode(json.dumps(payload).encode('utf-8'))
# 3. Signature
signing_input = f"{header_b64}.{payload_b64}".encode('utf-8')
signature = hmac.new(
secret.encode('utf-8'),
signing_input,
hashlib.sha256
).digest()
signature_b64 = base64url_encode(signature)
return f"{header_b64}.{payload_b64}.{signature_b64}"
if __name__ == "__main__":
print("=" * 60)
print("🔐 Supabase 全自动配置生成器 (Zero Dependency)")
print("=" * 60)
print("正在生成所有密钥...\n")
# 1. 自动生成主密钥
jwt_secret = generate_secure_secret(64)
# 2. 基于主密钥生成 JWT
anon_key = generate_jwt("anon", jwt_secret)
service_key = generate_jwt("service_role", jwt_secret)
# 3. 生成其他加密 Key和密码
vault_key = generate_secure_secret(32)
meta_key = generate_secure_secret(32)
secret_key_base = generate_secure_secret(64)
db_password = generate_random_string(20)
dashboard_password = generate_random_string(16)
# 4. 输出结果
print(f"✅ 生成完成!请直接复制以下内容覆盖您的 .env 文件中的对应部分:\n")
print("-" * 20 + " [ 复制开始 ] " + "-" * 20)
print(f"# === 数据库安全配置 ===")
print(f"POSTGRES_PASSWORD={db_password}")
print(f"JWT_SECRET={jwt_secret}")
print(f"ANON_KEY={anon_key}")
print(f"SERVICE_ROLE_KEY={service_key}")
print(f"SECRET_KEY_BASE={secret_key_base}")
print(f"VAULT_ENC_KEY={vault_key}")
print(f"PG_META_CRYPTO_KEY={meta_key}")
print(f"\n# === 管理后台配置 ===")
print(f"DASHBOARD_USERNAME=admin")
print(f"DASHBOARD_PASSWORD={dashboard_password}")
print("-" * 20 + " [ 复制结束 ] " + "-" * 20)
print("\n💡 提示:")
print(f"1. 数据库密码: {db_password}")
print(f"2. 后台登录密码: {dashboard_password}")
print("请妥善保管这些密码!")

View File

@@ -21,3 +21,10 @@ requests>=2.31.0
# 社交媒体发布
biliup>=0.4.0
# 用户认证
email-validator>=2.1.0
supabase>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
bcrypt==4.0.1

View File

@@ -8,6 +8,14 @@ const nextConfig: NextConfig = {
source: '/api/:path*',
destination: 'http://localhost:8006/api/:path*', // 服务器本地代理
},
{
source: '/uploads/:path*',
destination: 'http://localhost:8006/uploads/:path*', // 转发上传的素材
},
{
source: '/outputs/:path*',
destination: 'http://localhost:8006/outputs/:path*', // 转发生成的视频
},
];
},
};

View File

@@ -10,7 +10,8 @@
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"swr": "^2.3.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -2759,6 +2760,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -6027,6 +6037,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swr": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz",
"integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -6387,6 +6410,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -11,7 +11,8 @@
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"swr": "^2.3.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -23,4 +24,4 @@
"tailwindcss": "^4",
"typescript": "^5"
}
}
}

View File

@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentUser, User } from '@/lib/auth';
const API_BASE = typeof window === 'undefined'
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
: '';
interface UserListItem {
id: string;
email: string;
username: string | null;
role: string;
is_active: boolean;
expires_at: string | null;
created_at: string;
}
export default function AdminPage() {
const router = useRouter();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [users, setUsers] = useState<UserListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activatingId, setActivatingId] = useState<string | null>(null);
const [expireDays, setExpireDays] = useState<number>(30);
useEffect(() => {
checkAdmin();
fetchUsers();
}, []);
const checkAdmin = async () => {
const user = await getCurrentUser();
if (!user || user.role !== 'admin') {
router.push('/login');
return;
}
setCurrentUser(user);
};
const fetchUsers = async () => {
try {
const res = await fetch(`${API_BASE}/api/admin/users`, {
credentials: 'include'
});
if (!res.ok) throw new Error('获取用户列表失败');
const data = await res.json();
setUsers(data);
} catch (err) {
setError('获取用户列表失败');
} finally {
setLoading(false);
}
};
const activateUser = async (userId: string) => {
setActivatingId(userId);
try {
const res = await fetch(`${API_BASE}/api/admin/users/${userId}/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ expires_days: expireDays || null })
});
if (res.ok) {
fetchUsers();
}
} finally {
setActivatingId(null);
}
};
const deactivateUser = async (userId: string) => {
if (!confirm('确定要停用该用户吗?')) return;
try {
await fetch(`${API_BASE}/api/admin/users/${userId}/deactivate`, {
method: 'POST',
credentials: 'include'
});
fetchUsers();
} catch (err) {
alert('操作失败');
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '永久';
return new Date(dateStr).toLocaleDateString('zh-CN');
};
const getRoleBadge = (role: string, isActive: boolean) => {
if (role === 'admin') {
return <span className="px-2 py-1 text-xs rounded-full bg-purple-500/20 text-purple-300"></span>;
}
if (role === 'pending') {
return <span className="px-2 py-1 text-xs rounded-full bg-yellow-500/20 text-yellow-300"></span>;
}
if (!isActive) {
return <span className="px-2 py-1 text-xs rounded-full bg-red-500/20 text-red-300"></span>;
}
return <span className="px-2 py-1 text-xs rounded-full bg-green-500/20 text-green-300"></span>;
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-900">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900/20 to-slate-900 p-8">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white"></h1>
<a href="/" className="text-purple-300 hover:text-purple-200">
</a>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-200">
{error}
</div>
)}
<div className="mb-4 flex items-center gap-4">
<label className="text-gray-300"></label>
<input
type="number"
value={expireDays}
onChange={(e) => setExpireDays(parseInt(e.target.value) || 0)}
className="w-24 px-3 py-2 bg-white/5 border border-white/10 rounded text-white"
placeholder="0=永久"
/>
<span className="text-gray-400 text-sm">(0 )</span>
</div>
<div className="bg-white/5 backdrop-blur-lg rounded-xl border border-white/10 overflow-hidden">
<table className="w-full">
<thead className="bg-white/5">
<tr>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{users.map((user) => (
<tr key={user.id} className="hover:bg-white/5">
<td className="px-6 py-4">
<div>
<div className="text-white font-medium">{user.username || user.email.split('@')[0]}</div>
<div className="text-gray-400 text-sm">{user.email}</div>
</div>
</td>
<td className="px-6 py-4">
{getRoleBadge(user.role, user.is_active)}
</td>
<td className="px-6 py-4 text-gray-300">
{formatDate(user.expires_at)}
</td>
<td className="px-6 py-4 text-gray-400 text-sm">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4">
{user.role !== 'admin' && (
<div className="flex gap-2">
{!user.is_active || user.role === 'pending' ? (
<button
onClick={() => activateUser(user.id)}
disabled={activatingId === user.id}
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded disabled:opacity-50"
>
{activatingId === user.id ? '...' : '激活'}
</button>
) : (
<button
onClick={() => deactivateUser(user.id)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-sm rounded"
>
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "ViGent",
description: "ViGent Talking Head Agent",
};
export default function RootLayout({

View File

@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { login } from '@/lib/auth';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await login(email, password);
if (result.success) {
router.push('/');
} else {
setError(result.message || '登录失败');
}
} catch (err) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">ViGent</h1>
<p className="text-gray-300">AI </p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="your@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-200 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-semibold rounded-lg shadow-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</span>
) : '登录'}
</button>
</form>
<div className="mt-6 text-center">
<a href="/register" className="text-purple-300 hover:text-purple-200 text-sm">
</a>
</div>
</div>
</div>
);
}

View File

@@ -4,10 +4,9 @@
import { useState, useEffect } from "react";
import Link from "next/link";
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
const API_BASE = typeof window !== 'undefined'
? `http://${window.location.hostname}:8006`
: 'http://localhost:8006';
const API_BASE = typeof window === 'undefined'
? 'http://localhost:8006'
: '';
// 类型定义
interface Material {
@@ -73,7 +72,7 @@ export default function Home() {
setDebugData("Loading...");
// Add timestamp to prevent caching
const url = `${API_BASE}/api/materials/?t=${new Date().getTime()}`;
const url = `${API_BASE}/api/materials?t=${new Date().getTime()}`;
const res = await fetch(url);
if (!res.ok) {
@@ -197,7 +196,7 @@ export default function Home() {
setUploadError('网络错误,上传失败');
};
xhr.open('POST', `${API_BASE}/api/materials/`);
xhr.open('POST', `${API_BASE}/api/materials`);
xhr.send(formData);
// 清空 input 以便可以再次选择同一文件
@@ -299,6 +298,21 @@ export default function Home() {
>
</Link>
<button
onClick={async () => {
if (confirm('确定要退出登录吗?')) {
try {
await fetch(`${API_BASE}/api/auth/logout`, { method: 'POST' });
window.location.href = '/login';
} catch (e) {
window.location.href = '/login';
}
}
}}
className="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-200 rounded-lg transition-colors"
>
退
</button>
</div>
</div>
</header>

View File

@@ -1,12 +1,15 @@
"use client";
import { useState, useEffect } from "react";
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
import Link from "next/link";
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
const API_BASE = typeof window !== 'undefined'
? `http://${window.location.hostname}:8006`
: 'http://localhost:8006';
const API_BASE = typeof window === 'undefined'
? 'http://localhost:8006'
: '';
interface Account {
platform: string;
@@ -33,6 +36,7 @@ export default function PublishPage() {
const [publishTime, setPublishTime] = useState<string>("");
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
const [isLoadingQR, setIsLoadingQR] = useState(false);
// 加载账号和视频列表
useEffect(() => {
@@ -91,7 +95,7 @@ export default function PublishPage() {
for (const platform of selectedPlatforms) {
try {
const res = await fetch(`${API_BASE}/api/publish/`, {
const res = await fetch(`${API_BASE}/api/publish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -108,6 +112,12 @@ export default function PublishPage() {
const result = await res.json();
setPublishResults((prev) => [...prev, result]);
// 发布成功后10秒自动清除结果
if (result.success) {
setTimeout(() => {
setPublishResults((prev) => prev.filter((r) => r !== result));
}, 10000);
}
} catch (error) {
setPublishResults((prev) => [
...prev,
@@ -119,7 +129,42 @@ export default function PublishPage() {
setIsPublishing(false);
};
// SWR Polling for Login Status
const { data: loginStatus } = useSWR(
qrPlatform ? `${API_BASE}/api/publish/login/status/${qrPlatform}` : null,
fetcher,
{
refreshInterval: 2000,
onSuccess: (data) => {
if (data.success) {
setQrCodeImage(null);
setQrPlatform(null);
alert('✅ 登录成功!');
fetchAccounts();
}
}
}
);
// Timeout logic for QR code (business logic: stop after 2 mins)
useEffect(() => {
let timer: NodeJS.Timeout;
if (qrPlatform) {
timer = setTimeout(() => {
if (qrPlatform) { // Double check active
setQrPlatform(null);
setQrCodeImage(null);
alert('登录超时,请重试');
}
}, 120000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
const handleLogin = async (platform: string) => {
setIsLoadingQR(true);
setQrPlatform(platform); // 立即显示加载弹窗
setQrCodeImage(null); // 清空旧二维码
try {
const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, {
method: 'POST'
@@ -127,37 +172,35 @@ export default function PublishPage() {
const result = await res.json();
if (result.success && result.qr_code) {
// 显示二维码
setQrCodeImage(result.qr_code);
setQrPlatform(platform);
// 轮询登录状态
const checkInterval = setInterval(async () => {
const statusRes = await fetch(`${API_BASE}/api/publish/login/status/${platform}`);
const statusData = await statusRes.json();
if (statusData.success) {
clearInterval(checkInterval);
setQrCodeImage(null);
setQrPlatform(null);
alert('✅ 登录成功!');
fetchAccounts(); // 刷新账号状态
}
}, 2000); // 每2秒检查一次
// 2分钟后停止轮询
setTimeout(() => {
clearInterval(checkInterval);
if (qrCodeImage) {
setQrCodeImage(null);
alert('登录超时,请重试');
}
}, 120000);
// SWR hook will automatically start polling since qrPlatform is set
} else {
setQrPlatform(null); // 失败时关闭弹窗
alert(result.message || '登录失败');
}
} catch (error) {
setQrPlatform(null); // 失败时关闭弹窗
alert(`登录失败: ${error}`);
} finally {
setIsLoadingQR(false);
}
};
const handleLogout = async (platform: string) => {
if (!confirm('确定要注销登录吗?')) return;
try {
const res = await fetch(`${API_BASE}/api/publish/logout/${platform}`, {
method: 'POST'
});
const result = await res.json();
if (result.success) {
alert('已注销');
fetchAccounts();
} else {
alert(result.message || '注销失败');
}
} catch (error) {
alert(`注销失败: ${error}`);
}
};
@@ -172,20 +215,29 @@ export default function PublishPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900">
{/* QR码弹窗 */}
{qrCodeImage && (
{qrPlatform && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-8 max-w-md">
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
<h2 className="text-2xl font-bold mb-4 text-center">🔐 {qrPlatform}</h2>
<img
src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code"
className="w-full h-auto"
/>
<p className="text-center text-gray-600 mt-4">
使
</p>
{isLoadingQR ? (
<div className="flex flex-col items-center py-8">
<div className="animate-spin w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full" />
<p className="text-gray-600 mt-4">...</p>
</div>
) : qrCodeImage ? (
<>
<img
src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code"
className="w-full h-auto"
/>
<p className="text-center text-gray-600 mt-4">
使
</p>
</>
) : null}
<button
onClick={() => setQrCodeImage(null)}
onClick={() => { setQrCodeImage(null); setQrPlatform(null); }}
className="w-full mt-4 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
>
@@ -206,11 +258,26 @@ export default function PublishPage() {
href="/"
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</Link>
<span className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
</span>
<button
onClick={async () => {
if (confirm('确定要退出登录吗?')) {
try {
await fetch(`${API_BASE}/api/auth/logout`, { method: 'POST' });
window.location.href = '/login';
} catch (e) {
window.location.href = '/login';
}
}
}}
className="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-200 rounded-lg transition-colors"
>
退
</button>
</div>
</div>
</header>
@@ -248,12 +315,31 @@ export default function PublishPage() {
</div>
</div>
</div>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
>
🔐
</button>
<div className="flex gap-2">
{account.logged_in ? (
<>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors"
>
</button>
<button
onClick={() => handleLogout(account.platform)}
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors"
>
</button>
</>
) : (
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
>
🔐
</button>
)}
</div>
</div>
))}
</div>
@@ -415,6 +501,11 @@ export default function PublishPage() {
<span className="text-white">
{platformIcons[result.platform]} {result.message}
</span>
{result.success && (
<p className="text-green-400/80 text-sm mt-1">
</p>
)}
</div>
))}
</div>

View File

@@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { register } from '@/lib/auth';
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [username, setUsername] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
return;
}
if (password.length < 6) {
setError('密码长度至少 6 位');
return;
}
setLoading(true);
try {
const result = await register(email, password, username || undefined);
if (result.success) {
setSuccess(true);
} else {
setError(result.message || '注册失败');
}
} catch (err) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20 text-center">
<div className="mb-6">
<svg className="w-16 h-16 mx-auto text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-white mb-4"></h2>
<p className="text-gray-300 mb-6">
</p>
<a
href="/login"
className="inline-block py-3 px-6 bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold rounded-lg"
>
</a>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="w-full max-w-md p-8 bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl border border-white/20">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2"></h1>
<p className="text-gray-300"> ViGent </p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
<span className="text-red-400">*</span>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="your@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
<span className="text-gray-500">()</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="您的昵称"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
<span className="text-red-400">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="至少 6 位"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-200 mb-2">
<span className="text-red-400">*</span>
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="再次输入密码"
/>
</div>
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-200 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-semibold rounded-lg shadow-lg transition-all duration-200 disabled:opacity-50"
>
{loading ? '注册中...' : '注册'}
</button>
</form>
<div className="mt-6 text-center">
<a href="/login" className="text-purple-300 hover:text-purple-200 text-sm">
</a>
</div>
</div>
</div>
);
}

89
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* 认证工具函数
*/
const API_BASE = typeof window === 'undefined'
? (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8006')
: '';
export interface User {
id: string;
email: string;
username: string | null;
role: string;
is_active: boolean;
}
export interface AuthResponse {
success: boolean;
message: string;
user?: User;
}
/**
* 用户注册
*/
export async function register(email: string, password: string, username?: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password, username })
});
return res.json();
}
/**
* 用户登录
*/
export async function login(email: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password })
});
return res.json();
}
/**
* 用户登出
*/
export async function logout(): Promise<AuthResponse> {
const res = await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
credentials: 'include'
});
return res.json();
}
/**
* 获取当前用户
*/
export async function getCurrentUser(): Promise<User | null> {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
credentials: 'include'
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
/**
* 检查是否已登录
*/
export async function isAuthenticated(): Promise<boolean> {
const user = await getCurrentUser();
return user !== null;
}
/**
* 检查是否是管理员
*/
export async function isAdmin(): Promise<boolean> {
const user = await getCurrentUser();
return user?.role === 'admin';
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 需要登录才能访问的路径
const protectedPaths = ['/', '/publish', '/admin'];
// 公开路径 (无需登录)
const publicPaths = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 检查是否有 access_token cookie
const token = request.cookies.get('access_token');
// 访问受保护页面但未登录 → 重定向到登录页
if (protectedPaths.some(path => pathname === path || pathname.startsWith(path + '/')) && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录用户访问登录/注册页 → 重定向到首页
if (publicPaths.includes(pathname) && token) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/', '/publish/:path*', '/admin/:path*', '/login', '/register']
};

View File

@@ -14,6 +14,12 @@
import argparse
import os
# --- 性能优化: 限制 CPU 线程数 ---
os.environ["OMP_NUM_THREADS"] = "8"
os.environ["MKL_NUM_THREADS"] = "8"
os.environ["TORCH_NUM_THREADS"] = "8"
from omegaconf import OmegaConf
import torch
from diffusers import AutoencoderKL, DDIMScheduler

View File

@@ -37,6 +37,14 @@ def load_gpu_config():
load_gpu_config()
# --- 性能优化: 限制 CPU 线程数 ---
# 防止 PyTorch 默认占用所有 CPU 核心 (56线程) 导致系统卡顿
# 预留资源给 Backend, Frontend 和 SSH
os.environ["OMP_NUM_THREADS"] = "8"
os.environ["MKL_NUM_THREADS"] = "8"
os.environ["TORCH_NUM_THREADS"] = "8"
print("⚙️ 已限制 PyTorch CPU 线程数为 8防止系统卡顿")
import torch
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException

4
run_backend.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# 启动 ViGent2 后端 (FastAPI)
cd "$(dirname "$0")/backend"
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006

17
run_latentsync.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# 启动 LatentSync 模型服务
# 注意: 需要根据服务器实际情况修改 Python 路径
cd "$(dirname "$0")/models/LatentSync"
# 请根据您的 Miniconda/Anaconda 安装路径修改此处
PYTHON_PATH="/home/rongye/ProgramFiles/miniconda3/envs/latentsync/bin/python"
if [ -f "$PYTHON_PATH" ]; then
"$PYTHON_PATH" -m scripts.server
else
echo "❌ 错误: 找不到 Python 解释器: $PYTHON_PATH"
echo "请编辑此脚本 (run_latentsync.sh) 修改 PYTHON_PATH 为您的实际路径:"
echo "conda activate latentsync && which python"
exit 1
fi