From c918dc6faff4b88e6a198593dfa8485910d99c46 Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Fri, 23 Jan 2026 18:09:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/AUTH_DEPLOY.md | 222 +++++++++++++++++++++++ Docs/DEPLOY_MANUAL.md | 119 ++++++++----- Docs/DevLogs/Day9.md | 209 +++++++++++++++++++++- Docs/Doc_Rules.md | 40 ++--- Docs/task_complete.md | 59 ++++++- README.md | 5 + backend/.env.example | 15 ++ backend/app/api/admin.py | 185 ++++++++++++++++++++ backend/app/api/auth.py | 223 ++++++++++++++++++++++++ backend/app/api/publish.py | 56 ++++-- backend/app/core/config.py | 13 ++ backend/app/core/deps.py | 141 +++++++++++++++ backend/app/core/paths.py | 98 +++++++++++ backend/app/core/security.py | 112 ++++++++++++ backend/app/core/supabase.py | 26 +++ backend/app/main.py | 48 ++++- backend/app/services/publish_service.py | 110 ++++++++---- backend/database/schema.sql | 73 ++++++++ backend/requirements.txt | 7 + frontend/src/app/admin/page.tsx | 201 +++++++++++++++++++++ frontend/src/app/login/page.tsx | 101 +++++++++++ frontend/src/app/register/page.tsx | 158 +++++++++++++++++ frontend/src/lib/auth.ts | 87 +++++++++ frontend/src/middleware.ts | 33 ++++ models/LatentSync/scripts/inference.py | 6 + models/LatentSync/scripts/server.py | 8 + run_backend.sh | 4 + run_latentsync.sh | 17 ++ 28 files changed, 2250 insertions(+), 126 deletions(-) create mode 100644 Docs/AUTH_DEPLOY.md create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/paths.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/core/supabase.py create mode 100644 backend/database/schema.sql create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/middleware.ts create mode 100644 run_backend.sh create mode 100644 run_latentsync.sh diff --git a/Docs/AUTH_DEPLOY.md b/Docs/AUTH_DEPLOY.md new file mode 100644 index 0000000..ab533c5 --- /dev/null +++ b/Docs/AUTH_DEPLOY.md @@ -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}' +``` diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md index c1caad7..356fb1f 100644 --- a/Docs/DEPLOY_MANUAL.md +++ b/Docs/DEPLOY_MANUAL.md @@ -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 常驻服务。 @@ -160,7 +169,7 @@ cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync conda activate latentsync python -m scripts.server ``` - + ### 验证 1. 访问 http://服务器IP:3002 查看前端 @@ -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 核心导致系统假死。 + --- ## 依赖清单 diff --git a/Docs/DevLogs/Day9.md b/Docs/DevLogs/Day9.md index e031cb9..47a200f 100644 --- a/Docs/DevLogs/Day9.md +++ b/Docs/DevLogs/Day9.md @@ -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 恢复流畅。 + + diff --git a/Docs/Doc_Rules.md b/Docs/Doc_Rules.md index 8d52136..3b5e184 100644 --- a/Docs/Doc_Rules.md +++ b/Docs/Doc_Rules.md @@ -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 @@ --- -## �️ 工具使用规范 +## ️ 工具使用规范 > **核心原则**:使用正确的工具,避免字符编码问题 @@ -185,7 +200,7 @@ replace_file_content( --- -## �📁 文件结构 +## 📁 文件结构 ``` 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 diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 13361a9..6bb4a3b 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -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 严重卡顿修复 (线程数限制) + - 部署手册全量更新 ``` diff --git a/README.md b/README.md index 6d4f2c9..e7954b9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/.env.example b/backend/.env.example index 3c5109e..bbf14ee 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..8d48eba --- /dev/null +++ b/backend/app/api/admin.py @@ -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="延长授权失败" + ) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..35f3513 --- /dev/null +++ b/backend/app/api/auth.py @@ -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"] + ) diff --git a/backend/app/api/publish.py b/backend/app/api/publish.py index c1dcc2d..50202bd 100644 --- a/backend/app/api/publish.py +++ b/backend/app/api/publish.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5ca61af..cb07cfd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 目录路径 (动态计算)""" diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..fbcaccc --- /dev/null +++ b/backend/app/core/deps.py @@ -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 diff --git a/backend/app/core/paths.py b/backend/app/core/paths.py new file mode 100644 index 0000000..b05105f --- /dev/null +++ b/backend/app/core/paths.py @@ -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" diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..b6905c5 --- /dev/null +++ b/backend/app/core/security.py @@ -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") diff --git a/backend/app/core/supabase.py b/backend/app/core/supabase.py new file mode 100644 index 0000000..b16ec25 --- /dev/null +++ b/backend/app/core/supabase.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index bd4584a..7c05871 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(): diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 72fc950..195e9b4 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -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, diff --git a/backend/database/schema.sql b/backend/database/schema.sql new file mode 100644 index 0000000..ef12fc6 --- /dev/null +++ b/backend/database/schema.sql @@ -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(); diff --git a/backend/requirements.txt b/backend/requirements.txt index 283078e..a1e2e46 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..e0ac01e --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -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(null); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [activatingId, setActivatingId] = useState(null); + const [expireDays, setExpireDays] = useState(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 管理员; + } + if (role === 'pending') { + return 待审核; + } + if (!isActive) { + return 已停用; + } + return 正常; + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

用户管理

+ + ← 返回首页 + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + 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=永久" + /> + (0 表示永久) +
+ +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
用户状态过期时间注册时间操作
+
+
{user.username || user.email.split('@')[0]}
+
{user.email}
+
+
+ {getRoleBadge(user.role, user.is_active)} + + {formatDate(user.expires_at)} + + {formatDate(user.created_at)} + + {user.role !== 'admin' && ( +
+ {!user.is_active || user.role === 'pending' ? ( + + ) : ( + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..15e6863 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -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 ( +
+
+
+

ViGent

+

AI 视频生成平台

+
+ +
+
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + +
+
+ ); +} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx new file mode 100644 index 0000000..9309c0c --- /dev/null +++ b/frontend/src/app/register/page.tsx @@ -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 ( +
+
+
+ + + +
+

注册成功!

+

+ 您的账号已创建,请等待管理员审核激活后即可登录。 +

+ + 返回登录 + +
+
+ ); + } + + return ( +
+
+
+

注册账号

+

创建您的 ViGent 账号

+
+ +
+
+ + 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" + /> +
+ +
+ + 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="您的昵称" + /> +
+ +
+ + 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 位" + /> +
+ +
+ + 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="再次输入密码" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + +
+
+ ); +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..bb0c601 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -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 { + 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 { + 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 { + const res = await fetch(`${API_BASE}/api/auth/logout`, { + method: 'POST', + credentials: 'include' + }); + return res.json(); +} + +/** + * 获取当前用户 + */ +export async function getCurrentUser(): Promise { + 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 { + const user = await getCurrentUser(); + return user !== null; +} + +/** + * 检查是否是管理员 + */ +export async function isAdmin(): Promise { + const user = await getCurrentUser(); + return user?.role === 'admin'; +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 0000000..e2e27cd --- /dev/null +++ b/frontend/src/middleware.ts @@ -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'] +}; diff --git a/models/LatentSync/scripts/inference.py b/models/LatentSync/scripts/inference.py index ae2ff67..283743a 100644 --- a/models/LatentSync/scripts/inference.py +++ b/models/LatentSync/scripts/inference.py @@ -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 diff --git a/models/LatentSync/scripts/server.py b/models/LatentSync/scripts/server.py index ab64c30..838bfaa 100644 --- a/models/LatentSync/scripts/server.py +++ b/models/LatentSync/scripts/server.py @@ -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 diff --git a/run_backend.sh b/run_backend.sh new file mode 100644 index 0000000..53d70f9 --- /dev/null +++ b/run_backend.sh @@ -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 diff --git a/run_latentsync.sh b/run_latentsync.sh new file mode 100644 index 0000000..ddea5d2 --- /dev/null +++ b/run_latentsync.sh @@ -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