更新
This commit is contained in:
222
Docs/AUTH_DEPLOY.md
Normal file
222
Docs/AUTH_DEPLOY.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 用户认证系统部署指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本文档描述如何在 Ubuntu 服务器上部署 ViGent2 用户认证系统。
|
||||
|
||||
| 组件 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 数据库 | Supabase (PostgreSQL) | 云端免费版 |
|
||||
| 认证 | FastAPI + JWT | HttpOnly Cookie |
|
||||
| 密码 | bcrypt | 单向哈希 |
|
||||
|
||||
---
|
||||
|
||||
## 步骤 1: 配置 Supabase
|
||||
|
||||
### 1.1 创建项目
|
||||
|
||||
1. 访问 [supabase.com](https://supabase.com)
|
||||
2. 创建免费项目
|
||||
3. 记录以下信息:
|
||||
- **Project URL**: `https://xxx.supabase.co`
|
||||
- **anon public key**: `eyJhbGciOiJIUzI1NiIs...`
|
||||
|
||||
### 1.2 创建数据库表
|
||||
|
||||
1. 进入 **SQL Editor**
|
||||
2. 执行以下 SQL:
|
||||
|
||||
```sql
|
||||
-- 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);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 2: 配置后端环境变量
|
||||
|
||||
编辑 `/home/rongye/ProgramFiles/ViGent2/backend/.env`:
|
||||
|
||||
```env
|
||||
# =============== Supabase 配置 ===============
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIs...
|
||||
|
||||
# =============== JWT 配置 ===============
|
||||
JWT_SECRET_KEY=随机生成的32位以上字符串
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_HOURS=168 # 7天
|
||||
|
||||
# =============== 管理员配置 ===============
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=YourSecurePassword123!
|
||||
```
|
||||
|
||||
### 生成 JWT 密钥
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 3: 安装依赖
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
source venv/bin/activate
|
||||
|
||||
pip install supabase python-jose[cryptography] passlib[bcrypt]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 4: 启动服务
|
||||
|
||||
```bash
|
||||
# 重启后端服务
|
||||
pm2 restart vigent2-backend
|
||||
```
|
||||
|
||||
首次启动时,管理员账号会自动创建。查看日志确认:
|
||||
|
||||
```bash
|
||||
pm2 logs vigent2-backend | grep "管理员"
|
||||
```
|
||||
|
||||
应该看到:`管理员账号已创建: admin@example.com`
|
||||
|
||||
---
|
||||
|
||||
## 步骤 5: 验证
|
||||
|
||||
### API 测试
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://localhost:8006/health
|
||||
|
||||
# 注册测试
|
||||
curl -X POST http://localhost:8006/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"123456"}'
|
||||
|
||||
# 登录测试 (管理员)
|
||||
curl -X POST http://localhost:8006/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@example.com","password":"YourSecurePassword123!"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 6: 防止 Supabase 7 天暂停
|
||||
|
||||
Supabase 免费版 7 天无活动会暂停。推荐使用服务器 crontab 方案。
|
||||
|
||||
### 方案 A: 服务器 crontab(推荐)
|
||||
|
||||
在 Ubuntu 服务器上执行:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
添加以下行(每天凌晨 1 点执行):
|
||||
|
||||
```cron
|
||||
0 1 * * * curl -s -X GET "https://zcmitzlqlyzxlgwagouf.supabase.co/rest/v1/" -H "apikey: YOUR_SUPABASE_ANON_KEY" > /dev/null
|
||||
```
|
||||
|
||||
> 将 `YOUR_SUPABASE_ANON_KEY` 替换为实际的 anon key
|
||||
|
||||
### 方案 B: GitHub Actions
|
||||
|
||||
如果服务器可能长期关闭,可使用 GitHub Actions。
|
||||
|
||||
1. 创建独立仓库:`supabase-keep-alive`
|
||||
2. 上传 `.github/workflows/keep-supabase-alive.yml`
|
||||
3. 配置 Secrets:`SUPABASE_URL`, `SUPABASE_KEY`
|
||||
|
||||
> ⚠️ 需要 GitHub 账户有付款信息(免费计划也需要)
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── auth.py # 注册/登录/登出
|
||||
│ │ └── admin.py # 用户管理
|
||||
│ └── core/
|
||||
│ ├── supabase.py # Supabase 客户端
|
||||
│ ├── security.py # JWT + 密码
|
||||
│ ├── paths.py # Cookie 路径隔离
|
||||
│ └── deps.py # 认证依赖
|
||||
├── database/
|
||||
│ └── schema.sql # 数据库表定义
|
||||
└── user_data/ # 用户 Cookie (按 user_id 隔离)
|
||||
└── {user-uuid}/
|
||||
└── cookies/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 用户管理
|
||||
|
||||
### 在 Supabase Dashboard 中管理
|
||||
|
||||
1. 进入 **Table Editor > users**
|
||||
2. 激活用户:设置 `is_active = true`, `role = user`
|
||||
3. 设置过期时间:填写 `expires_at` 字段
|
||||
|
||||
### 使用 API 管理
|
||||
|
||||
需要管理员 Cookie:
|
||||
|
||||
```bash
|
||||
# 获取用户列表
|
||||
curl http://localhost:8006/api/admin/users -b "access_token=..."
|
||||
|
||||
# 激活用户 (30天有效期)
|
||||
curl -X POST http://localhost:8006/api/admin/users/{user_id}/activate \
|
||||
-H "Content-Type: application/json" \
|
||||
-b "access_token=..." \
|
||||
-d '{"expires_days": 30}'
|
||||
```
|
||||
@@ -98,7 +98,16 @@ playwright install chromium
|
||||
|
||||
---
|
||||
|
||||
## 步骤 5: 配置环境变量
|
||||
## 步骤 5: 部署用户认证系统 (Supabase + Auth)
|
||||
|
||||
> 🔐 **包含**: 登录/注册、Supabase 数据库配置、JWT 认证、管理员后台
|
||||
|
||||
请参考独立的认证系统部署指南:
|
||||
**[用户认证系统部署指南](AUTH_DEPLOY.md)**
|
||||
|
||||
---
|
||||
|
||||
## 步骤 6: 配置环境变量
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
@@ -120,7 +129,7 @@ cp .env.example .env
|
||||
|
||||
---
|
||||
|
||||
## 步骤 6: 安装前端依赖
|
||||
## 步骤 7: 安装前端依赖
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/frontend
|
||||
@@ -134,7 +143,7 @@ npm run build
|
||||
|
||||
---
|
||||
|
||||
## 步骤 7: 测试运行
|
||||
## 步骤 8: 测试运行
|
||||
|
||||
> 💡 先手动启动测试,确认一切正常后再配置 pm2 常驻服务。
|
||||
|
||||
@@ -169,61 +178,66 @@ python -m scripts.server
|
||||
|
||||
---
|
||||
|
||||
## 步骤 8: 使用 pm2 管理常驻服务
|
||||
## 步骤 9: 使用 pm2 管理常驻服务
|
||||
|
||||
> 推荐使用 pm2 管理所有服务,支持自动重启和日志管理。
|
||||
|
||||
### 创建 pm2 配置文件
|
||||
### 1. 启动后端服务 (FastAPI)
|
||||
|
||||
创建 `/home/rongye/ProgramFiles/ViGent2/ecosystem.config.js`:
|
||||
建议使用 Shell 脚本启动以避免环境问题。
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'vigent2-backend',
|
||||
cwd: '/home/rongye/ProgramFiles/ViGent2/backend',
|
||||
script: 'venv/bin/uvicorn',
|
||||
args: 'app.main:app --host 0.0.0.0 --port 8006',
|
||||
interpreter: 'none',
|
||||
env: {
|
||||
PATH: '/home/rongye/ProgramFiles/ViGent2/backend/venv/bin:' + process.env.PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'vigent2-frontend',
|
||||
cwd: '/home/rongye/ProgramFiles/ViGent2/frontend',
|
||||
script: 'npm',
|
||||
args: 'run start',
|
||||
env: {
|
||||
PORT: 3002
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'vigent2-latentsync',
|
||||
cwd: '/home/rongye/ProgramFiles/ViGent2/models/LatentSync',
|
||||
script: 'python',
|
||||
args: '-m scripts.server',
|
||||
interpreter: '/home/rongye/miniconda3/envs/latentsync/bin/python'
|
||||
}
|
||||
]
|
||||
};
|
||||
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
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
2. 使用 pm2 启动:
|
||||
```bash
|
||||
pm2 start ./run_backend.sh --name vigent2-backend
|
||||
```
|
||||
|
||||
### 2. 启动前端服务 (Next.js)
|
||||
|
||||
⚠️ **注意**:生产模式启动前必须先进行构建。
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
cd /home/rongye/ProgramFiles/ViGent2/frontend
|
||||
|
||||
# 启动所有服务
|
||||
pm2 start ecosystem.config.js
|
||||
# 1. 构建项目 (如果之前没跑过或代码有更新)
|
||||
npm run build
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
# 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 startup
|
||||
```
|
||||
|
||||
### pm2 常用命令
|
||||
@@ -267,6 +281,19 @@ 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 核心导致系统假死。
|
||||
|
||||
---
|
||||
|
||||
## 依赖清单
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
| API 输入验证 | ✅ 完成 |
|
||||
| 类型提示完善 | ✅ 完成 |
|
||||
| 服务层代码优化 | ✅ 完成 |
|
||||
| 扫码登录等待界面 | ✅ 完成 |
|
||||
| 抖音登录策略优化 | ✅ 完成 |
|
||||
| 发布成功审核提示 | ✅ 完成 |
|
||||
| 用户认证系统规划 | ✅ 计划完成 |
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +92,54 @@ if platform not in SUPPORTED_PLATFORMS:
|
||||
|
||||
---
|
||||
|
||||
## 🎨 用户体验优化
|
||||
|
||||
### 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 秒后自动消失
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件列表
|
||||
|
||||
### 后端
|
||||
@@ -96,11 +148,17 @@ if platform not in SUPPORTED_PLATFORMS:
|
||||
|------|----------|
|
||||
| `app/api/publish.py` | 输入验证、平台常量、文档改进 |
|
||||
| `app/services/publish_service.py` | 类型提示、平台 enabled 标记 |
|
||||
| `app/services/qr_login_service.py` | 类型提示、修复裸 except、超时常量 |
|
||||
| `app/services/qr_login_service.py` | **策略顺序优化**、超时缩短 |
|
||||
| `app/services/uploader/base_uploader.py` | 类型提示 |
|
||||
| `app/services/uploader/bilibili_uploader.py` | bvid提取修复、类型提示 |
|
||||
| `app/services/uploader/douyin_uploader.py` | 资源清理、超时保护、类型提示 |
|
||||
| `app/services/uploader/xiaohongshu_uploader.py` | headless模式、资源清理、超时保护 |
|
||||
| `app/services/uploader/bilibili_uploader.py` | **发布消息改为"待审核"** |
|
||||
| `app/services/uploader/douyin_uploader.py` | **发布消息改为"待审核"** |
|
||||
| `app/services/uploader/xiaohongshu_uploader.py` | **发布消息改为"待审核"** |
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `src/app/publish/page.tsx` | **加载动画、审核提示、结果自动消失** |
|
||||
|
||||
---
|
||||
|
||||
@@ -110,10 +168,153 @@ if platform not in SUPPORTED_PLATFORMS:
|
||||
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 恢复流畅。
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**项目**:ViGent2 数字人口播视频生成系统
|
||||
**服务器**:Dell R730 (2× RTX 3090 24GB)
|
||||
**更新时间**:2026-01-23
|
||||
**整体进度**:100%(Day 9 发布模块优化完成)
|
||||
**整体进度**:100%(Day 9 部署稳定性优化完成)
|
||||
|
||||
## 📖 快速导航
|
||||
|
||||
@@ -118,15 +118,33 @@
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## 🛤️ 后续规划
|
||||
|
||||
### 🔴 优先待办
|
||||
- [x] 视频合成最终验证 (MP4生成) ✅ Day 4 完成
|
||||
- [x] 端到端流程完整测试 ✅ Day 4 完成
|
||||
- [x] 社交媒体发布测试 ✅ Day 9 完成 (B站/抖音登录+发布)
|
||||
- [ ] 批量视频生成架构设计
|
||||
- [ ] 字幕样式编辑器集成
|
||||
|
||||
### 🟠 功能完善
|
||||
- [x] 定时发布功能 ✅ Day 7 完成
|
||||
@@ -157,7 +175,8 @@
|
||||
| 视频合成 | 100% | ✅ 完成 |
|
||||
| 唇形同步 | 100% | ✅ LatentSync 1.6 升级完成 |
|
||||
| 社交发布 | 100% | ✅ Day 9 验证通过 |
|
||||
| 服务器部署 | 100% | ✅ 完成 |
|
||||
| 用户认证 | 100% | ✅ Day 9 Supabase+JWT |
|
||||
| 服务器部署 | 100% | ✅ Day 9 稳定性优化完成 |
|
||||
|
||||
---
|
||||
|
||||
@@ -192,6 +211,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)
|
||||
|
||||
---
|
||||
|
||||
## 📅 时间线
|
||||
@@ -253,6 +288,18 @@ Day 9: 发布模块优化 ✅ 完成
|
||||
- 资源清理保障 (try-finally)
|
||||
- 超时保护 (消除无限循环)
|
||||
- 小红书 headless 模式修复
|
||||
- 完整类型提示
|
||||
- 扫码登录等待界面 (加载动画)
|
||||
- 抖音/B站登录策略优化 (Text优先)
|
||||
- 发布成功审核提示
|
||||
- 用户认证系统规划 (FastAPI+Supabase)
|
||||
- Supabase 表结构设计 (users/sessions)
|
||||
- 后端 JWT 认证实现 (auth.py/deps.py)
|
||||
- 数据库配置与 SQL 部署
|
||||
- 独立认证部署文档 (AUTH_DEPLOY.md)
|
||||
- 自动保活机制 (Crontab/Actions)
|
||||
- 部署稳定性优化 (Backend依赖修复)
|
||||
- 前端生产构建流程修复
|
||||
- LatentSync 严重卡顿修复 (线程数限制)
|
||||
- 部署手册全量更新
|
||||
```
|
||||
|
||||
|
||||
@@ -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) + Redis |
|
||||
| 认证 | **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/
|
||||
```
|
||||
@@ -142,6 +146,7 @@ nohup python -m scripts.server > server.log 2>&1 &
|
||||
|
||||
- [LatentSync 部署指南](models/LatentSync/DEPLOY.md)
|
||||
- [手动部署指南](Docs/DEPLOY_MANUAL.md)
|
||||
- [认证部署指南](Docs/AUTH_DEPLOY.md)
|
||||
- [开发日志](Docs/DevLogs/)
|
||||
- [任务进度](Docs/task_complete.md)
|
||||
|
||||
|
||||
@@ -45,3 +45,18 @@ MAX_UPLOAD_SIZE_MB=500
|
||||
# FFmpeg 路径 (如果不在系统 PATH 中)
|
||||
# FFMPEG_PATH=/usr/bin/ffmpeg
|
||||
|
||||
# =============== Supabase 配置 ===============
|
||||
# 从 Supabase 项目设置 > API 获取
|
||||
SUPABASE_URL=https://zcmitzlqlyzxlgwagouf.supabase.co
|
||||
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpjbWl0emxxbHl6eGxnd2Fnb3VmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkxMzkwNzEsImV4cCI6MjA4NDcxNTA3MX0.2NNkkR0cowopcsCs5bP-DTCksiOuqNjmhfyXGmLdTrM
|
||||
|
||||
# =============== JWT 配置 ===============
|
||||
# 用于签名 JWT Token 的密钥 (请更换为随机字符串)
|
||||
JWT_SECRET_KEY=F4MagRkf7nJsN-ag9AB7Q-30MbZRe7Iu4E9p9xRzyic
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRE_HOURS=168
|
||||
|
||||
# =============== 管理员配置 ===============
|
||||
# 服务启动时自动创建的管理员账号
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
185
backend/app/api/admin.py
Normal file
185
backend/app/api/admin.py
Normal 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
223
backend/app/api/auth.py
Normal 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"]
|
||||
)
|
||||
@@ -1,12 +1,13 @@
|
||||
"""
|
||||
发布管理 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()
|
||||
@@ -30,8 +31,23 @@ class PublishResponse(BaseModel):
|
||||
# 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, background_tasks: BackgroundTasks):
|
||||
async def publish_video(request: PublishRequest, req: Request, background_tasks: BackgroundTasks):
|
||||
"""发布视频到指定平台"""
|
||||
# Validate platform
|
||||
if request.platform not in SUPPORTED_PLATFORMS:
|
||||
@@ -40,6 +56,9 @@ async def publish_video(request: PublishRequest, background_tasks: BackgroundTas
|
||||
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,
|
||||
@@ -47,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),
|
||||
@@ -61,43 +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):
|
||||
async def login_platform(platform: str, req: Request):
|
||||
"""触发平台QR码登录"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
|
||||
result = await publish_service.login(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.post("/logout/{platform}")
|
||||
async def logout_platform(platform: str):
|
||||
async def logout_platform(platform: str, req: Request):
|
||||
"""注销平台登录"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
|
||||
result = publish_service.logout(platform)
|
||||
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):
|
||||
async def get_login_status(platform: str, req: Request):
|
||||
"""检查登录状态 (优先检查活跃的扫码会话)"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
|
||||
return publish_service.get_login_session_status(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
|
||||
|
||||
@@ -112,7 +137,8 @@ async def save_platform_cookie(platform: str, cookie_data: dict):
|
||||
if not cookie_string:
|
||||
raise HTTPException(status_code=400, detail="cookie_string 不能为空")
|
||||
|
||||
result = await publish_service.save_cookie_string(platform, 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
|
||||
|
||||
@@ -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
141
backend/app/core/deps.py
Normal 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
98
backend/app/core/paths.py
Normal 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"}
|
||||
|
||||
# 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"
|
||||
112
backend/app/core/security.py
Normal file
112
backend/app/core/security.py
Normal 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=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")
|
||||
26
backend/app/core/supabase.py
Normal file
26
backend/app/core/supabase.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -23,10 +25,54 @@ settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs")
|
||||
|
||||
# 注册路由
|
||||
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():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
发布服务 (基于 social-auto-upload 架构)
|
||||
发布服务 (支持用户隔离)
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
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
|
||||
@@ -15,7 +16,7 @@ from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
|
||||
|
||||
class PublishService:
|
||||
"""Social media publishing service"""
|
||||
"""Social media publishing service (with user isolation)"""
|
||||
|
||||
# 支持的平台配置
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
@@ -27,16 +28,33 @@ class PublishService:
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.cookies_dir = settings.BASE_DIR / "cookies"
|
||||
self.cookies_dir.mkdir(exist_ok=True)
|
||||
# 存储活跃的登录会话,用于跟踪登录状态
|
||||
# key 格式: "{user_id}_{platform}" 或 "{platform}" (兼容旧版)
|
||||
self.active_login_sessions: Dict[str, Any] = {}
|
||||
|
||||
def get_accounts(self) -> List[Dict[str, Any]]:
|
||||
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"],
|
||||
@@ -53,6 +71,7 @@ class PublishService:
|
||||
tags: List[str],
|
||||
description: str = "",
|
||||
publish_time: Optional[datetime] = None,
|
||||
user_id: Optional[str] = None,
|
||||
**kwargs: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -65,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:
|
||||
@@ -79,25 +99,33 @@ 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.parent / 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(
|
||||
@@ -138,10 +166,14 @@ class PublishService:
|
||||
"platform": platform
|
||||
}
|
||||
|
||||
async def login(self, platform: str) -> Dict[str, Any]:
|
||||
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图片
|
||||
"""
|
||||
@@ -151,11 +183,15 @@ class PublishService:
|
||||
try:
|
||||
from .qr_login_service import QRLoginService
|
||||
|
||||
# 创建QR登录服务
|
||||
qr_service = QRLoginService(platform, self.cookies_dir)
|
||||
# 获取用户专属的 Cookie 目录
|
||||
cookies_dir = self._get_cookies_dir(user_id)
|
||||
|
||||
# 存储活跃会话
|
||||
self.active_login_sessions[platform] = qr_service
|
||||
# 创建QR登录服务
|
||||
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()
|
||||
@@ -169,30 +205,30 @@ class PublishService:
|
||||
"message": f"登录失败: {str(e)}"
|
||||
}
|
||||
|
||||
def get_login_session_status(self, platform: str) -> Dict[str, Any]:
|
||||
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 platform in self.active_login_sessions:
|
||||
qr_service = self.active_login_sessions[platform]
|
||||
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[platform]
|
||||
del self.active_login_sessions[session_key]
|
||||
return {"success": True, "message": "登录成功"}
|
||||
|
||||
return {"success": False, "message": "等待扫码..."}
|
||||
|
||||
# 2. 如果没有活跃会话,检查本地Cookie文件是否存在 (用于页面初始加载)
|
||||
# 注意:这无法检测Cookie是否过期,只能检测文件在不在
|
||||
# 在扫码流程中,前端应该依赖上面第1步的返回
|
||||
cookie_file = self.cookies_dir / f"{platform}_cookies.json"
|
||||
# 2. 检查本地Cookie文件是否存在
|
||||
cookie_file = self._get_cookie_path(platform, user_id)
|
||||
if cookie_file.exists():
|
||||
return {"success": True, "message": "已登录 (历史状态)"}
|
||||
|
||||
return {"success": False, "message": "未登录"}
|
||||
|
||||
def logout(self, platform: str) -> Dict[str, Any]:
|
||||
def logout(self, platform: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Logout from platform (delete cookie file)
|
||||
"""
|
||||
@@ -200,15 +236,17 @@ class PublishService:
|
||||
return {"success": False, "message": "不支持的平台"}
|
||||
|
||||
try:
|
||||
session_key = self._get_session_key(platform, user_id)
|
||||
|
||||
# 1. 移除活跃会话
|
||||
if platform in self.active_login_sessions:
|
||||
del self.active_login_sessions[platform]
|
||||
if session_key in self.active_login_sessions:
|
||||
del self.active_login_sessions[session_key]
|
||||
|
||||
# 2. 删除Cookie文件
|
||||
cookie_file = self.cookies_dir / f"{platform}_cookies.json"
|
||||
cookie_file = self._get_cookie_path(platform, user_id)
|
||||
if cookie_file.exists():
|
||||
cookie_file.unlink()
|
||||
logger.info(f"[登出] {platform} Cookie已删除")
|
||||
logger.info(f"[登出] {platform} Cookie已删除 (user: {user_id or 'legacy'})")
|
||||
|
||||
return {"success": True, "message": "已注销"}
|
||||
|
||||
@@ -216,16 +254,17 @@ class PublishService:
|
||||
logger.exception(f"[登出] 失败: {e}")
|
||||
return {"success": False, "message": f"注销失败: {str(e)}"}
|
||||
|
||||
async def save_cookie_string(self, platform: str, cookie_string: str) -> Dict[str, Any]:
|
||||
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 = {}
|
||||
@@ -234,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']
|
||||
@@ -243,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不完整,请确保已登录"
|
||||
@@ -251,11 +290,14 @@ class PublishService:
|
||||
|
||||
cookie_dict = bilibili_cookies
|
||||
|
||||
# 确保目录存在
|
||||
account_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存Cookie
|
||||
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,
|
||||
|
||||
73
backend/database/schema.sql
Normal file
73
backend/database/schema.sql
Normal 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();
|
||||
@@ -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
|
||||
|
||||
201
frontend/src/app/admin/page.tsx
Normal file
201
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getCurrentUser, User } from '@/lib/auth';
|
||||
|
||||
const API_BASE = 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>
|
||||
);
|
||||
}
|
||||
101
frontend/src/app/login/page.tsx
Normal file
101
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
frontend/src/app/register/page.tsx
Normal file
158
frontend/src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/lib/auth.ts
Normal file
87
frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 认证工具函数
|
||||
*/
|
||||
|
||||
const API_BASE = 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';
|
||||
}
|
||||
33
frontend/src/middleware.ts
Normal file
33
frontend/src/middleware.ts
Normal 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']
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
run_backend.sh
Normal 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
17
run_latentsync.sh
Normal 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
|
||||
Reference in New Issue
Block a user