Compare commits

..

1 Commits

Author SHA1 Message Date
Kevin Wong
71b45852bf 更新 2026-03-04 17:35:59 +08:00
23 changed files with 250 additions and 72 deletions

View File

@@ -110,6 +110,8 @@ backend/
- 认证方式:**HttpOnly Cookie** (`access_token`)。
- `get_current_user` / `get_current_user_optional` 位于 `core/deps.py`
- Session 单设备校验使用 `repositories/sessions.py`
- AI/Tools 等高成本接口必须强制鉴权(`Depends(get_current_user)`),禁止匿名调用消耗外部 API 配额。
- 生产环境要求 `DEBUG=false` + 非默认 `JWT_SECRET_KEY`;默认密钥在生产模式下必须阻止服务启动。
---
@@ -127,6 +129,14 @@ backend/
- 需要重命名时使用 `move_file`,避免直接读写 Storage。
- `delete_file` 必须向上抛出异常,不允许静默吞错(避免清理接口出现“假成功”)。
- `list_files` 默认容错返回空列表;清理等强一致场景应使用 `strict=True`
- 所有用户输入的文件路径/ID 必须做防御校验:
- `material_id` 拒绝 `..` 序列,避免路径穿越
- `video_id` 等资源 ID 使用白名单(如 `^[A-Za-z0-9_-]+$`
- 上传/下载链路必须有体积上限:
- 素材上传遵循 `MAX_UPLOAD_SIZE_MB`
- 参考音频上限 5MB
- 文案提取工具文件上传与 URL 下载结果均上限 500MB
- 面向前端的错误返回默认使用通用文案;内部堆栈只写服务端日志,避免泄露路径/实现细节。
### Cookie 存储(用户隔离)

View File

@@ -78,7 +78,7 @@ backend/
* `POST /api/materials`: 上传素材
* `GET /api/materials`: 获取素材列表
* `PUT /api/materials/{material_id}`: 重命名素材
* `GET /api/materials/stream/{material_id}`: 同源流式返回素材文件(用于前端 canvas 截帧,避免跨域 CORS taint
* `GET /api/materials/stream/{material_id}`: 同源流式返回素材文件(用于前端 canvas 截帧,避免跨域 CORS taint;服务端会拒绝 `..` 路径
4. **社交发布 (Publish)**
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
@@ -104,8 +104,9 @@ backend/
* `POST /api/ref-audios/{id}/retranscribe`: 重新识别参考音频文字Whisper 转写 + 超 10s 自动截取)
7. **AI 功能 (AI)**
* `POST /api/ai/generate-meta`: AI 生成标题和标签
* `POST /api/ai/translate`: AI 多语言翻译(支持 9 种目标语言)
* `POST /api/ai/generate-meta`: AI 生成标题和标签(需登录)
* `POST /api/ai/translate`: AI 多语言翻译(支持 9 种目标语言,需登录
* `POST /api/ai/rewrite`: AI 改写文案(需登录)
8. **预生成配音 (Generated Audios)**
* `POST /api/generated-audios/generate`: 异步生成配音(返回 task_id
@@ -115,11 +116,11 @@ backend/
* `PUT /api/generated-audios/{audio_id}`: 重命名配音
9. **工具 (Tools)**
* `POST /api/tools/extract-script`: 从视频链接提取文案
* `POST /api/tools/extract-script`: 从视频链接提取文案(需登录)
10. **健康检查**
* `GET /api/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值)
* `GET /api/voiceclone/health`: CosyVoice 3.0 服务健康状态
* `GET /api/videos/lipsync/health`: 唇形同步服务健康状态(含 LatentSync + MuseTalk + 混合路由阈值)
* `GET /api/videos/voiceclone/health`: CosyVoice 3.0 服务健康状态
11. **支付 (Payment)**
* `POST /api/payment/create-order`: 创建支付宝电脑网站支付订单(需 payment_token
@@ -128,6 +129,16 @@ backend/
> 登录时若账号未激活或已过期,返回 403 + `payment_token`,前端跳转 `/pay` 页面完成付费。详见 [支付宝部署指南](ALIPAY_DEPLOY.md)。
### 安全基线(生产环境)
- `DEBUG` 必须设为 `false`:认证 Cookie 会带 `Secure`,仅在 HTTPS 下发送。
- `JWT_SECRET_KEY` 必须是强随机值且不能使用默认值;当 `DEBUG=false` 且仍为默认值时,后端会在启动阶段直接拒绝启动。
- 上传体积限制:
- `POST /api/materials`:受 `MAX_UPLOAD_SIZE_MB` 限制(默认 500MB
- `POST /api/ref-audios`5MB
- `POST /api/tools/extract-script`:文件上传与 URL 下载结果均限制 500MB
- `video_id` 在下载/删除接口使用白名单校验(`^[A-Za-z0-9_-]+$`),非法值直接返回 400。
### 统一响应结构
```json

View File

@@ -154,12 +154,12 @@ playwright install chromium
---
### 可选AI 标题/标签生成
### 可选AI 标题/标签生成
> ✅ 如需启用“AI 标题/标签生成”功能,请确保后端可访问外网 API。
- 需要可访问 `https://open.bigmodel.cn`
- API Key 配置在 `backend/app/services/glm_service.py`(建议替换为自己的密钥)
- 需要可访问 `https://open.bigmodel.cn`
- API Key 配置在 `backend/.env``GLM_API_KEY`
---
@@ -214,10 +214,11 @@ cd /home/rongye/ProgramFiles/ViGent2/backend
| `LATENTSYNC_USE_SERVER` | true | 设为 true 以启用常驻服务加速 |
| `LATENTSYNC_INFERENCE_STEPS` | 30 | 推理步数 (16-50) |
| `LATENTSYNC_GUIDANCE_SCALE` | 1.9 | 引导系数 (1.0-3.0) |
| `LATENTSYNC_ENABLE_DEEPCACHE` | true | DeepCache 推理加速 |
| `LATENTSYNC_SEED` | 1247 | 固定随机种子(可复现) |
| `DEBUG` | true | 生产环境为 false |
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
| `LATENTSYNC_ENABLE_DEEPCACHE` | true | DeepCache 推理加速 |
| `LATENTSYNC_SEED` | 1247 | 固定随机种子(可复现) |
| `DEBUG` | false | 生产环境必须为 false(仅开发环境可设 true |
| `JWT_SECRET_KEY` | 强随机值 | 生产环境禁止默认值;默认值在 `DEBUG=false` 下会阻止后端启动 |
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
@@ -258,7 +259,9 @@ cd /home/rongye/ProgramFiles/ViGent2/backend
| `PAYMENT_AMOUNT` | `999.00` | 会员价格 (元) |
| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 |
> 支付宝完整配置步骤密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。
> 支付宝完整配置步骤密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。
> 认证相关强约束:当 `DEBUG=false` 时,后端登录 Cookie 会带 `Secure`,前端必须通过 HTTPS 域名访问HTTP 端口直连无法保持登录态。
---
@@ -316,11 +319,11 @@ cd /home/rongye/ProgramFiles/ViGent2/models/MuseTalk
/home/rongye/ProgramFiles/miniconda3/envs/musetalk/bin/python scripts/server.py
```
### 验证
1. 访问 http://服务器IP:3002 查看前端
2. 访问 http://服务器IP:8006/docs 查看 API 文档
3. 上传测试视频,生成口播视频
### 验证
1. 访问 `https://你的前端域名` 查看前端(生产环境不要用 HTTP 端口直连)
2. 访问 `http://服务器IP:8006/docs` 查看 API 文档(仅内网/运维调试)
3. 上传测试视频,生成口播视频
---
@@ -540,8 +543,8 @@ server {
GLM_API_KEY=your_zhipu_api_key
```
3. **验证**:
访问 `http://localhost:8006/docs`,测试 `/api/tools/extract-script` 接口
3. **验证**:
访问 `http://localhost:8006/docs`在已登录会话下测试 `/api/tools/extract-script`(该接口需认证)
---

View File

@@ -1,11 +1,13 @@
## 视频下载同源修复 + Day 日志拆分归档 (Day 32)
## 视频下载同源修复 + 安全漏洞第一批修复 (Day 32)
### 概述
今天主要处理“下载行为不符合预期”的问题
今天的工作聚焦四件事
1. 修复首页与发布成功弹窗点击下载时被浏览器当作在线播放(新开标签页)的问题。
2. 将下载修复开始后的开发内容从 `Day31` 拆分到 `Day32`,保持日志按天清晰归档。
3. 根据安全审计报告(`Temp/安全审计报告.md`),实施第一批 6 项无功能风险的安全修复。
4. 统一弹窗关闭交互:默认支持点空白关闭,发布成功清理弹窗保持强制留存。
---
@@ -42,15 +44,87 @@
---
## ✅ 3) 安全漏洞第一批修复6 项,无功能风险)
根据安全审计报告,实施第一批 6 项可直接修复的安全加固项。
### 3.1 JWT 默认密钥启动拦截
- **文件**`backend/app/main.py`
- 新增 `check_jwt_secret` startup 事件(在 `init_admin` 之前)
-`JWT_SECRET_KEY` 仍为默认值 `"your-secret-key-change-in-production"` 时:
- **生产环境**`DEBUG=False``raise RuntimeError` 直接阻止服务启动
- **开发环境**`DEBUG=True`):输出 `CRITICAL` 级别日志告警,不阻止启动
### 3.2 AI / Tools 接口加认证
- **文件**`backend/app/modules/ai/router.py``backend/app/modules/tools/router.py`
- AI 路由 3 个端点(`/translate``/generate-meta``/rewrite`)均增加 `Depends(get_current_user)`
- Tools 路由 1 个端点(`/extract-script`)增加 `Depends(get_current_user)`
- 前端 axios 已有 `withCredentials: true`401 自动跳登录页,无需前端改动
### 3.3 素材路径穿越修复
- **文件**`backend/app/modules/materials/router.py``backend/app/modules/materials/service.py`
- `stream``delete_material``rename_material` 三处在 `startswith(user_id)` 校验之前新增 `..` 拒绝
-`..``material_id` 直接返回 400
- `delete_material` 路由补充 `except ValueError` → 400原先仅 catch `PermissionError``ValueError` 会被 `Exception` 兜底返回 500
### 3.4 video_id 白名单校验
- **文件**`backend/app/modules/videos/router.py`
- `download_generated``delete_generated` 两个端点在函数开头增加正则校验
- 仅允许 `^[A-Za-z0-9_-]+$`,不符合直接返回 400
### 3.5 上传/下载大小限制
- **materials/service.py**(流式上传):在 chunk 累加后检查 `MAX_UPLOAD_SIZE_MB`(默认 500MB超限抛 `ValueError`
- **ref_audios/service.py**(参考音频):`await file.read()` 后检查 5MB 上限
- **tools/service.py**(文案提取文件上传):将 `shutil.copyfileobj` 替换为分块拷贝 + 500MB 限制
- **tools/service.py**URL 下载分支):`_download_video` 返回后检查文件体积,超 500MB 删除临时文件并拒绝
### 3.6 错误信息通用化
- **ai/router.py**3 处 `detail=str(e)` 分别改为"翻译服务暂时不可用"、"生成标题标签失败"、"改写服务暂时不可用"
- **tools/router.py**:保留 "Fresh cookies" 特定分支提示fallback 改为"文案提取失败,请稍后重试"
- **generated_audios/service.py**:任务失败 `error` 字段从 `traceback.format_exc()` 改为 `str(e)`traceback 仅写入服务端日志
---
## ✅ 4) 弹窗关闭交互统一UX
### 目标
- 保持统一交互预期:业务弹窗默认可通过 `X` 与点击遮罩关闭。
- 保留关键流程保护:发布成功清理弹窗继续禁止遮罩关闭,避免误触导致流程中断。
### 调整内容
- 文案提取弹窗(`ScriptExtractionModal`)支持点击遮罩关闭。
- AI 改写弹窗(`RewriteModal`)支持点击遮罩关闭。
- 发布页扫码登录弹窗支持点击遮罩关闭。
- 修改密码弹窗支持点击遮罩关闭。
- 录音弹窗采用动态策略:`closeOnOverlay={!isRecording}`
- 未录音:允许遮罩关闭
- 录音中:禁止遮罩关闭(防误触);`X` 关闭仍可用,且会先停止录音再关闭
- 发布成功清理弹窗维持 `closeOnOverlay=false`,并且不提供 `onClose`(无右上角关闭按钮)。
---
## 📁 今日主要修改文件
| 文件 | 改动 |
|------|------|
| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应 |
| `backend/app/modules/videos/router.py` | 新增 `GET /api/videos/generated/{video_id}/download`,返回 `attachment` 下载响应;新增 `video_id` 白名单正则校验(`^[A-Za-z0-9_-]+$` |
| `frontend/src/features/publish/model/usePublishController.ts` | 发布成功后 `triggerCleanup()``video.id`(替换签名 URL |
| `frontend/src/shared/contexts/CleanupContext.tsx` | 下载字段改为 `videoId`;兼容旧 `videoDownloadUrl` 回填;下载按钮改同源路径 |
| `frontend/src/features/home/ui/PreviewPanel.tsx` | 首页下载改为同源下载接口 |
| `frontend/src/features/home/ui/HomePage.tsx` | 透传 `generatedVideoId``PreviewPanel` |
| `frontend/src/features/home/ui/ScriptExtractionModal.tsx` | 弹窗支持点击遮罩关闭(`closeOnOverlay` |
| `frontend/src/features/home/ui/RewriteModal.tsx` | 弹窗支持点击遮罩关闭(`closeOnOverlay` |
| `frontend/src/features/publish/ui/PublishPage.tsx` | 扫码登录弹窗支持点击遮罩关闭 |
| `frontend/src/components/AccountSettingsDropdown.tsx` | 修改密码弹窗支持点击遮罩关闭 |
| `frontend/src/features/home/ui/RefAudioPanel.tsx` | 录音弹窗改为 `closeOnOverlay={!isRecording}`(录音中禁遮罩关闭) |
| `Docs/DevLogs/Day31.md` | 移除下载修复章节与对应验证/覆盖项(迁入 Day32 |
| `Docs/TASK_COMPLETE.md` | 新增 Day32 Current 区块Day31 取消 Current |
| `Docs/BACKEND_README.md` | 补充 `/api/videos/generated/{video_id}/download` 接口说明 |
@@ -58,7 +132,15 @@
| `Docs/FRONTEND_README.md` | 补充首页/发布弹窗下载统一同源接口说明 |
| `Docs/FRONTEND_DEV.md` | 补充 CleanupContext 下载策略规范 |
| `Docs/PUBLISH_DEPLOY.md` | 补充发布成功后同源下载联动说明 |
| `README.md` | 补充一键下载直达(同源 attachment”能力描述 |
| `README.md` | 补充一键下载直达(同源 attachment”能力描述 |
| `backend/app/main.py` | `check_jwt_secret` startup 事件:生产环境(`DEBUG=False`)强拦截启动,开发环境 `CRITICAL` 告警 |
| `backend/app/modules/ai/router.py` | 3 个端点加 `Depends(get_current_user)` 认证;错误返回改为通用消息 |
| `backend/app/modules/tools/router.py` | `extract-script` 端点加 `Depends(get_current_user)` 认证;错误返回改为通用消息 |
| `backend/app/modules/materials/router.py` | `stream` 端点新增 `..` 路径穿越拒绝;`delete` 端点补充 `except ValueError` → 400 |
| `backend/app/modules/materials/service.py` | `delete_material` / `rename_material` 新增 `..` 路径穿越拒绝;流式上传增加 `MAX_UPLOAD_SIZE_MB` 大小限制 |
| `backend/app/modules/ref_audios/service.py` | 参考音频上传增加 5MB 大小限制 |
| `backend/app/modules/tools/service.py` | 文案提取文件上传替换为限大小分块拷贝500MBURL 下载分支增加下载后体积检查500MB |
| `backend/app/modules/generated_audios/service.py` | 任务失败错误字段从 `traceback.format_exc()` 改为 `str(e)`,避免泄露内部路径 |
---
@@ -66,6 +148,11 @@
- `python -m py_compile backend/app/modules/videos/router.py`
- `npm run build`frontend
- `npm run build`frontend弹窗关闭策略调整后复验
- `pm2 restart vigent2-frontend`
- `pm2 restart vigent2-backend`
- `curl http://127.0.0.1:8006/health` 返回 `{"status":"ok"}`
- 安全修复第一批语法验证:`python -m py_compile backend/app/main.py backend/app/modules/materials/router.py backend/app/modules/tools/service.py backend/app/modules/ai/router.py backend/app/modules/tools/router.py backend/app/modules/materials/service.py backend/app/modules/ref_audios/service.py backend/app/modules/videos/router.py backend/app/modules/generated_audios/service.py`
- 未登录调用 `/api/ai/translate` → 返回 401 ✅
- 未登录调用 `/api/tools/extract-script` → 返回 401 ✅
- 收尾三刀语法验证:`python -m py_compile backend/app/main.py backend/app/modules/materials/router.py backend/app/modules/tools/service.py`

View File

@@ -219,6 +219,9 @@ body {
- 统一挂载位置:通过 Portal 挂载到 `document.body`,避免局部容器/层叠上下文影响,确保是全页面弹窗
- 统一容器风格:`border-white/10`、深色半透明背景、圆角 `rounded-2xl`、重阴影
- 统一关闭行为:支持 `ESC`;是否允许点击遮罩关闭通过 `closeOnOverlay` 显式配置
- 默认策略:除关键流程外,`closeOnOverlay` 默认应为 `true`,并通过 `AppModalHeader onClose` 提供右上角 `X` 关闭入口
- 关键流程例外:发布成功清理弹窗(`CleanupContext`)必须保持 `closeOnOverlay=false`,且不提供右上角关闭按钮
- 录音弹窗例外:使用 `closeOnOverlay={!isRecording}`,录音中禁止遮罩关闭,避免误触中断
- 统一滚动策略:弹窗打开时锁定背景滚动(`lockBodyScroll`),内容区自行滚动
- 特殊层级场景(例如视频预览压过下拉)使用更高 `z-index`(如 `z-[320]`
@@ -245,6 +248,7 @@ body {
所有需要认证的 API 请求**必须**使用 `@/shared/api/axios` 导出的 axios 实例。该实例已配置:
- 自动携带 `credentials: include`
- 遇到 401/403 时自动清除 cookie 并跳转登录页
- AI/Tools 接口(如 `/api/ai/*``/api/tools/extract-script`)现为强制鉴权,禁止匿名 `fetch` 直调
**使用方式:**
@@ -523,6 +527,7 @@ await api.post('/api/videos/generate', {
- 录音入口放在“我的参考音频”区域底部右侧(与“上传音频”并排)。
- 录音交互使用弹窗:开始/停止 -> 试听 -> 使用此录音 / 弃用本次录音。
- 关闭录音弹窗时如仍在录制,会先停止录音再关闭。
- 录音中禁止点击遮罩关闭(`closeOnOverlay={!isRecording}`);未录音时允许遮罩关闭。
```typescript
// 录音需要用户授权麦克风

View File

@@ -47,6 +47,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **音色试听**: EdgeTTS 音色列表支持一键试听,按音色 locale 自动选择对应语言的固定示例文案。
- **参考音频管理**: 上传/列表/重命名/删除参考音频,上传后自动 Whisper 转写 ref_text + 超 10s 自动截取。
- **录音入口**: 参考音频区域底部右侧提供“上传音频 / 录音”入口;录音采用弹窗流程(录制 -> 试听 -> 使用/弃用)。
- **录音防误触**: 录音中禁用遮罩关闭(避免误触中断);未录音时可点空白关闭。
- **重新识别**: 旧参考音频可重新转写并截取 (RotateCw 按钮)。
- **一键克隆**: 选择参考音频后自动调用 CosyVoice 3.0 服务。
- **语速控制**: 声音克隆模式下支持 5 档语速 (0.8-1.2),统一下拉,选择持久化。
@@ -93,6 +94,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
### 9. 文案提取助手 (`ScriptExtractionModal`)
- **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。
- **AI 智能改写**: 集成 GLM-4.7-Flash自动改写为口播文案。
- **登录鉴权**: 依赖后端受保护接口(`/api/tools/extract-script`),未登录会触发全局 401 跳转登录。
- **自定义提示词**: 可自定义改写提示词,留空使用默认;设置持久化到 localStorage。
- **一键填入**: 提取结果直接填充至视频生成输入框。
- **智能交互**: 实时进度展示,防误触设计。
@@ -158,6 +160,7 @@ src/
- 业务选择器统一使用 `SelectPopover`(桌面 Popover / 移动端 BottomSheet`ScriptEditor` 的“历史文案 / AI多语言”保留原轻量菜单。
- 业务弹窗统一使用 `AppModal`(统一遮罩、头部、关闭行为与滚动策略)。
- 弹窗关闭策略:默认支持 `ESC` / `X` / 点击空白关闭;仅发布成功清理弹窗为强制流程(不允许空白关闭,也不显示 `X`)。
- 视频预览弹窗层级高于下拉菜单;下拉内支持连续预览。
- 页面同时适配桌面端与移动端;长列表统一隐藏滚动条。
- 详细 UI 规范、持久化规范与交互约束请查看 `Docs/FRONTEND_DEV.md`

View File

@@ -1,7 +1,7 @@
# ViGent2 开发任务清单 (Task Log)
**项目**: ViGent2 数字人口播视频生成系统
**进度**: 100% (Day 32 - 视频下载同源修复 + 清理链路体验收敛)
**进度**: 100% (Day 32 - 视频下载同源修复 + 安全整改第一批)
**更新时间**: 2026-03-04
---
@@ -10,12 +10,15 @@
> 这里记录了每一天的核心开发内容与 milestone。
### Day 32: 视频下载同源修复 + Day 日志拆分归档 (Current)
### Day 32: 视频下载同源修复 + 安全整改第一批 + Day 日志拆分归档 (Current)
- [x] **下载链路修复**: 新增 `GET /api/videos/generated/{video_id}/download`,统一返回 `Content-Disposition: attachment`,修复“点击下载却新开标签页播放”问题。
- [x] **发布成功弹窗下载改造**: `CleanupContext` 从传 URL 改为传 `videoId`,下载按钮改走同源接口,去掉 `target="_blank"`
- [x] **首页下载改造**: `PreviewPanel` 同步切换到同源下载接口,首页与发布页下载行为一致。
- [x] **兼容旧持久化状态**: `CleanupContext` 对旧 `videoDownloadUrl``videoId` 解析回填,避免旧 pending 状态失效。
- [x] **文档拆分归档**: 将“下载修复开始后的今日内容”归档到 `Docs/DevLogs/Day32.md`,并从 `Day31.md` 移除对应章节与验证记录。
- [x] **安全第一批修复**: JWT 默认密钥生产拦截、AI/Tools 接口强制鉴权、materials 路径穿越拦截、video_id 白名单、上传体积限制、错误信息通用化。
- [x] **安全收尾三刀**: `delete_material``ValueError -> 400``tools` URL 下载分支 500MB 限制、`DEBUG=false` 下默认 JWT 密钥阻断启动。
- [x] **弹窗关闭策略收敛**: 默认支持 `ESC/X/遮罩` 关闭;发布成功清理弹窗保持强制流程不允许遮罩关闭;录音弹窗录音中禁遮罩关闭(防误触)。
### Day 31: 文档体系收敛 + 音色试听 + 录音弹窗重构 + 发布登录稳定性修复
- [x] **文档体系收敛**: README/DEV 职责边界明确部署参数与代码对齐Qwen3-TTS 文档归档至历史状态。

View File

@@ -37,9 +37,10 @@
- 🧹 **发布后工作区清理引导** - 全平台发布成功后弹出不可误关清理弹窗(失败可重试,达到阈值可暂不清理),仅清输入内容并保留用户偏好。
- ⬇️ **一键下载直达** - 首页与发布成功弹窗下载统一走同源 `attachment` 接口,不再新开标签页播放视频。
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行
- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **安全基线** - AI/Tools 接口强制登录鉴权、关键上传链路体积限制、生产环境默认密钥启动拦截
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **性能优化** - 编码流水线从 5-6 次有损编码精简至 3 次prepare_segment → 模型输出 → Remotion、compose 流复制免重编码、同分辨率跳过 scale、FFmpeg 超时保护、全局视频生成并发限制 (Semaphore(2))、Remotion 4 并发渲染、MuseTalk rawvideo 管道直编码(消除中间有损文件)、模型常驻服务、双 GPU 流水线并发、Redis 任务 TTL 自动清理、workflow 阻塞调用线程池化。
---

View File

@@ -2,7 +2,7 @@
# 复制此文件为 .env 并填入实际值
# 调试模式
DEBUG=true
DEBUG=false
# Redis 配置 (Celery 任务队列)
REDIS_URL=redis://localhost:6379/0

View File

@@ -130,6 +130,20 @@ app.include_router(generated_audios_router, prefix="/api/generated-audios", tags
app.include_router(payment_router) # /api/payment
@app.on_event("startup")
async def check_jwt_secret():
if settings.JWT_SECRET_KEY == "your-secret-key-change-in-production":
if not settings.DEBUG:
raise RuntimeError(
"JWT_SECRET_KEY is still the default value! "
"Set a strong random secret in .env before running in production (DEBUG=False)."
)
logger.critical(
"JWT_SECRET_KEY is still the default value! "
"Set a strong random secret in .env for production."
)
@app.on_event("startup")
async def init_admin():
"""

View File

@@ -4,11 +4,12 @@ AI 相关 API 路由
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from loguru import logger
from app.services.glm_service import glm_service
from app.core.deps import get_current_user
from app.core.response import success_response
@@ -40,7 +41,7 @@ class TranslateRequest(BaseModel):
@router.post("/translate")
async def translate_text(req: TranslateRequest):
async def translate_text(req: TranslateRequest, current_user: dict = Depends(get_current_user)):
"""
AI 翻译文案
@@ -57,11 +58,11 @@ async def translate_text(req: TranslateRequest):
return success_response({"translated_text": translated})
except Exception as e:
logger.error(f"Translate failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail="翻译服务暂时不可用,请稍后重试")
@router.post("/generate-meta")
async def generate_meta(req: GenerateMetaRequest):
async def generate_meta(req: GenerateMetaRequest, current_user: dict = Depends(get_current_user)):
"""
AI 生成视频标题和标签
@@ -80,11 +81,11 @@ async def generate_meta(req: GenerateMetaRequest):
).model_dump())
except Exception as e:
logger.error(f"Generate meta failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail="生成标题标签失败,请稍后重试")
@router.post("/rewrite")
async def rewrite_script(req: RewriteRequest):
async def rewrite_script(req: RewriteRequest, current_user: dict = Depends(get_current_user)):
"""AI 改写文案"""
if not req.text or not req.text.strip():
raise HTTPException(status_code=400, detail="文案不能为空")
@@ -95,4 +96,4 @@ async def rewrite_script(req: RewriteRequest):
return success_response({"rewritten_text": rewritten})
except Exception as e:
logger.error(f"Rewrite failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail="改写服务暂时不可用,请稍后重试")

View File

@@ -152,9 +152,9 @@ async def generate_audio_task(task_id: str, req: GenerateAudioRequest, user_id:
task_store.update(task_id, {
"status": "failed",
"message": f"配音生成失败: {str(e)}",
"error": traceback.format_exc(),
"error": str(e),
})
logger.error(f"Generate audio failed: {e}")
logger.error(f"Generate audio failed: {e}\n{traceback.format_exc()}")
async def list_generated_audios(user_id: str) -> dict:
@@ -215,28 +215,28 @@ async def list_generated_audios(user_id: str) -> dict:
return GeneratedAudioListResponse(items=items).model_dump()
async def delete_all_generated_audios(user_id: str) -> tuple[int, int]:
"""删除用户所有生成的配音(.wav + .json返回 (删除数量, 失败数量)"""
try:
files = await storage_service.list_files(BUCKET, user_id, strict=True)
deleted_count = 0
failed_count = 0
for f in files:
name = f.get("name", "")
if not name or name == ".emptyFolderPlaceholder":
continue
if name.endswith("_audio.wav") or name.endswith("_audio.json"):
full_path = f"{user_id}/{name}"
try:
await storage_service.delete_file(BUCKET, full_path)
deleted_count += 1
except Exception as e:
failed_count += 1
logger.warning(f"Delete audio file failed: {full_path}, {e}")
return deleted_count, failed_count
except Exception as e:
logger.error(f"Delete all generated audios failed: {e}")
return 0, 1
async def delete_all_generated_audios(user_id: str) -> tuple[int, int]:
"""删除用户所有生成的配音(.wav + .json返回 (删除数量, 失败数量)"""
try:
files = await storage_service.list_files(BUCKET, user_id, strict=True)
deleted_count = 0
failed_count = 0
for f in files:
name = f.get("name", "")
if not name or name == ".emptyFolderPlaceholder":
continue
if name.endswith("_audio.wav") or name.endswith("_audio.json"):
full_path = f"{user_id}/{name}"
try:
await storage_service.delete_file(BUCKET, full_path)
deleted_count += 1
except Exception as e:
failed_count += 1
logger.warning(f"Delete audio file failed: {full_path}, {e}")
return deleted_count, failed_count
except Exception as e:
logger.error(f"Delete all generated audios failed: {e}")
return 0, 1
async def delete_generated_audio(audio_id: str, user_id: str) -> None:

View File

@@ -14,6 +14,8 @@ router = APIRouter()
@router.get("/stream/{material_id:path}")
async def stream_material(material_id: str, current_user: dict = Depends(get_current_user)):
"""直接流式返回素材文件(同源,避免 CORS canvas taint"""
if ".." in material_id:
raise HTTPException(400, "非法素材ID")
user_id = current_user["id"]
if not material_id.startswith(f"{user_id}/"):
raise HTTPException(403, "无权访问此素材")
@@ -52,6 +54,8 @@ async def delete_material(material_id: str, current_user: dict = Depends(get_cur
try:
await service.delete_material(material_id, user_id)
return success_response(message="素材已删除")
except ValueError as e:
raise HTTPException(400, str(e))
except PermissionError as e:
raise HTTPException(403, str(e))
except Exception as e:

View File

@@ -7,6 +7,7 @@ import aiofiles
from pathlib import Path
from loguru import logger
from app.core.config import settings as app_settings
from app.services.storage import storage_service
@@ -123,6 +124,9 @@ async def upload_material(request, user_id: str) -> dict:
async for chunk in request.stream():
await f.write(chunk)
total_size += len(chunk)
max_bytes = app_settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
if total_size > max_bytes:
raise ValueError(f"文件大小超过限制 ({app_settings.MAX_UPLOAD_SIZE_MB}MB)")
if total_size - last_log > 20 * 1024 * 1024:
logger.info(f"Receiving stream... Processed {total_size / (1024*1024):.2f} MB")
@@ -239,6 +243,8 @@ async def list_materials(user_id: str) -> list[dict]:
async def delete_material(material_id: str, user_id: str) -> None:
"""删除素材"""
if ".." in material_id:
raise ValueError("非法素材ID")
if not material_id.startswith(f"{user_id}/"):
raise PermissionError("无权删除此素材")
await storage_service.delete_file(
@@ -249,6 +255,8 @@ async def delete_material(material_id: str, user_id: str) -> None:
async def rename_material(material_id: str, new_name_raw: str, user_id: str) -> dict:
"""重命名素材,返回更新后的素材信息"""
if ".." in material_id:
raise ValueError("非法素材ID")
if not material_id.startswith(f"{user_id}/"):
raise PermissionError("无权重命名此素材")

View File

@@ -104,6 +104,8 @@ async def upload_ref_audio(file, ref_text: str, user_id: str) -> dict:
# 创建临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_input:
content = await file.read()
if len(content) > 5 * 1024 * 1024:
raise ValueError("参考音频文件大小不能超过 5MB")
tmp_input.write(content)
tmp_input_path = tmp_input.name

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from typing import Optional
import traceback
from loguru import logger
from app.core.deps import get_current_user
from app.core.response import success_response
from app.modules.tools import service
@@ -14,7 +15,8 @@ async def extract_script_tool(
file: Optional[UploadFile] = File(None),
url: Optional[str] = Form(None),
rewrite: bool = Form(True),
custom_prompt: Optional[str] = Form(None)
custom_prompt: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
):
"""独立文案提取工具"""
try:
@@ -29,5 +31,5 @@ async def extract_script_tool(
logger.error(traceback.format_exc())
msg = str(e)
if "Fresh cookies" in msg:
msg = "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。"
raise HTTPException(500, f"提取失败: {msg}")
raise HTTPException(500, "下载失败:目标平台开启了反爬验证,请过段时间重试或直接上传视频文件。")
raise HTTPException(500, "文案提取失败,请稍后重试")

View File

@@ -41,7 +41,19 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T
raise ValueError("文件名无效")
safe_filename = Path(filename).name.replace(" ", "_")
temp_path = temp_dir / f"tool_extract_{timestamp}_{safe_filename}"
await loop.run_in_executor(None, lambda: shutil.copyfileobj(file.file, open(temp_path, "wb")))
max_bytes = 500 * 1024 * 1024 # 500MB
total_written = 0
with open(temp_path, "wb") as dst:
while True:
chunk = file.file.read(1024 * 1024)
if not chunk:
break
total_written += len(chunk)
if total_written > max_bytes:
dst.close()
os.remove(temp_path)
raise ValueError("上传文件大小不能超过 500MB")
dst.write(chunk)
logger.info(f"Tool processing upload file: {temp_path}")
else:
temp_path = await _download_video(url, temp_dir, timestamp)
@@ -49,6 +61,13 @@ async def extract_script(file=None, url: Optional[str] = None, rewrite: bool = T
if not temp_path or not temp_path.exists():
raise ValueError("文件获取失败")
# 下载文件体积检查500MB 上限)
max_download_bytes = 500 * 1024 * 1024
file_size = temp_path.stat().st_size
if file_size > max_download_bytes:
os.remove(temp_path)
raise ValueError(f"下载的文件过大({file_size / (1024*1024):.0f}MB上限 500MB")
# 1.5 安全转换: 强制转为 WAV (16k)
audio_path = temp_dir / f"extract_audio_{timestamp}.wav"
try:

View File

@@ -1,4 +1,5 @@
import os
import re
import tempfile
import uuid
@@ -144,6 +145,8 @@ async def list_generated(current_user: dict = Depends(get_current_user)):
@router.get("/generated/{video_id}/download")
async def download_generated(video_id: str, current_user: dict = Depends(get_current_user)):
if not re.match(r'^[A-Za-z0-9_-]+$', video_id):
raise HTTPException(status_code=400, detail="非法 video_id")
user_id = current_user["id"]
storage_path = f"{user_id}/{video_id}.mp4"
local_path = storage_service.get_local_file_path(
@@ -162,6 +165,8 @@ async def download_generated(video_id: str, current_user: dict = Depends(get_cur
@router.delete("/generated/{video_id}")
async def delete_generated(video_id: str, current_user: dict = Depends(get_current_user)):
if not re.match(r'^[A-Za-z0-9_-]+$', video_id):
raise HTTPException(status_code=400, detail="非法 video_id")
result = await delete_generated_video(current_user["id"], video_id)
return success_response(result, message="视频已删除")

View File

@@ -152,7 +152,7 @@ export default function AccountSettingsDropdown() {
onClose={closePasswordModal}
zIndexClassName="z-[200]"
panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
closeOnOverlay={false}
closeOnOverlay
>
<AppModalHeader
title="修改密码"

View File

@@ -398,7 +398,7 @@ export function RefAudioPanel({
isOpen={recordingModalOpen}
onClose={closeRecordingModal}
panelClassName="w-full max-w-lg rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
closeOnOverlay={false}
closeOnOverlay={!isRecording}
>
<AppModalHeader
title="🎤 在线录音"

View File

@@ -90,7 +90,7 @@ export default function RewriteModal({
isOpen={isOpen}
onClose={onClose}
panelClassName="w-full max-w-2xl max-h-[90vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
closeOnOverlay={false}
closeOnOverlay
>
<AppModalHeader
title="AI 智能改写"

View File

@@ -71,7 +71,7 @@ export default function ScriptExtractionModal({
isOpen={isOpen}
onClose={onClose}
panelClassName="w-full max-w-2xl max-h-[90vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
closeOnOverlay={false}
closeOnOverlay
>
<AppModalHeader title="📜 文案提取助手" onClose={onClose} />

View File

@@ -67,7 +67,7 @@ export function PublishPage() {
isOpen={Boolean(qrPlatform)}
onClose={closeQrModal}
panelClassName="w-full max-w-md rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden"
closeOnOverlay={false}
closeOnOverlay
>
<AppModalHeader
title={`🔐 扫码登录 ${qrPlatform}`}