diff --git a/Docs/ALIPAY_DEPLOY.md b/Docs/ALIPAY_DEPLOY.md new file mode 100644 index 0000000..8735c8c --- /dev/null +++ b/Docs/ALIPAY_DEPLOY.md @@ -0,0 +1,278 @@ +# 支付宝付费开通会员 — 部署指南 + +本文档涵盖支付宝电脑网站支付功能的完整部署流程。用户注册后通过支付宝付费自动激活会员,有效期 1 年。 + +--- + +## 前置条件 + +- 支付宝企业/个体商户账号 +- 已在 [支付宝开放平台](https://open.alipay.com) 创建应用并获取 APPID +- 应用已开通 **「电脑网站支付」** 产品权限(`alipay.trade.page.pay` 接口) +- 服务器域名已配置 HTTPS(支付宝回调要求公网可达) + +--- + +## 第一部分:支付宝开放平台配置 + +### 1. 创建应用 + +登录 https://open.alipay.com → 控制台 → 创建应用(或使用已有应用)。 + +### 2. 开通「电脑网站支付」产品 + +进入应用详情 → 产品绑定/产品管理 → 添加 **「电脑网站支付」** → 提交审核。 + +> **注意**:未开通此产品会导致 `ACQ.ACCESS_FORBIDDEN` 错误。 + +### 3. 生成密钥对 + +进入应用详情 → 开发设置 → 接口加签方式 → 选择 **RSA2(SHA256)**: + +1. 使用支付宝官方密钥工具生成 RSA2048 密钥对 +2. 将 **应用公钥** 上传到开放平台 +3. 上传后平台会显示 **支付宝公钥**(`alipayPublicKey_RSA2`) + +最终你会得到两样东西: +- **应用私钥**:你本地保存,代码用来签名请求 +- **支付宝公钥**:平台返回给你,代码用来验证回调签名 + +> 应用公钥只是上传用的中间产物,代码中不需要。 + +--- + +## 第二部分:服务器配置 + +### 1. 放置密钥文件 + +将密钥保存为标准 PEM 格式,放到 `backend/keys/` 目录: + +```bash +mkdir -p /home/rongye/ProgramFiles/ViGent2/backend/keys +``` + +**`backend/keys/app_private_key.pem`**(应用私钥): + +``` +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...(你的私钥内容) +... +-----END PRIVATE KEY----- +``` + +**`backend/keys/alipay_public_key.pem`**(支付宝公钥): + +``` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...(支付宝公钥内容) +... +-----END PUBLIC KEY----- +``` + +#### PEM 格式要求 + +支付宝密钥工具导出的是一行纯文本,需要转换为标准 PEM 格式: + +- 必须有头尾标记(`-----BEGIN/END ...-----`) +- 密钥内容每 64 字符换行 +- 私钥头标记为 `-----BEGIN PRIVATE KEY-----`(PKCS#8 格式) +- 公钥头标记为 `-----BEGIN PUBLIC KEY-----` + +如果你拿到的是一行裸密钥,用以下命令转换: + +```bash +# 私钥格式化(假设裸密钥在 raw_private.txt 中) +echo "-----BEGIN PRIVATE KEY-----" > app_private_key.pem +cat raw_private.txt | fold -w 64 >> app_private_key.pem +echo "-----END PRIVATE KEY-----" >> app_private_key.pem + +# 公钥格式化 +echo "-----BEGIN PUBLIC KEY-----" > alipay_public_key.pem +cat raw_public.txt | fold -w 64 >> alipay_public_key.pem +echo "-----END PUBLIC KEY-----" >> alipay_public_key.pem +``` + +> `backend/keys/` 目录已加入 `.gitignore`,不会被提交到仓库。 + +### 2. 配置环境变量 + +在 `backend/.env` 中添加: + +```ini +# =============== 支付宝配置 =============== +ALIPAY_APP_ID=你的应用APPID +ALIPAY_PRIVATE_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/app_private_key.pem +ALIPAY_PUBLIC_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/alipay_public_key.pem +ALIPAY_NOTIFY_URL=https://vigent.hbyrkj.top/api/payment/notify +ALIPAY_RETURN_URL=https://vigent.hbyrkj.top/pay +``` + +| 变量 | 说明 | +|------|------| +| `ALIPAY_APP_ID` | 支付宝开放平台应用 APPID | +| `ALIPAY_PRIVATE_KEY_PATH` | 应用私钥 PEM 文件绝对路径 | +| `ALIPAY_PUBLIC_KEY_PATH` | 支付宝公钥 PEM 文件绝对路径 | +| `ALIPAY_NOTIFY_URL` | 异步回调地址(服务器间通信),必须公网 HTTPS 可达 | +| `ALIPAY_RETURN_URL` | 同步跳转地址(用户支付完成后浏览器跳转回的页面) | + +`config.py` 中还有几个可调参数(已有默认值,一般不需要加到 .env): + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `ALIPAY_SANDBOX` | `false` | 是否使用沙箱环境 | +| `PAYMENT_AMOUNT` | `999.00` | 会员价格(元) | +| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 | + +### 3. 创建数据库表 + +通过 Docker 在本地 Supabase 中执行: + +```bash +docker exec -i supabase-db psql -U postgres -c " +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + out_trade_no TEXT UNIQUE NOT NULL, + amount DECIMAL(10, 2) NOT NULL DEFAULT 999.00, + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')), + trade_no TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + paid_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_orders_out_trade_no ON orders(out_trade_no); +" +``` + +### 4. 安装依赖 + +```bash +# 后端(在 venv 中) +cd /home/rongye/ProgramFiles/ViGent2/backend +venv/bin/pip install python-alipay-sdk +``` + +> 前端无额外依赖需要安装。 + +### 5. Nginx 配置 + +确保 Nginx 将 `/api/payment/notify` 代理到后端。如果现有配置已覆盖 `/api/` 前缀,则无需额外修改: + +```nginx +location /api/ { + proxy_pass http://localhost:8006; + # ... 现有配置 +} +``` + +### 6. 重启服务 + +```bash +# 构建前端 +cd /home/rongye/ProgramFiles/ViGent2/frontend +npx next build + +# 重启 +pm2 restart vigent2-backend +pm2 restart vigent2-frontend +``` + +--- + +## 第三部分:正式上线 + +测试通过后,将 `backend/app/core/config.py` 中的测试金额改为正式价格: + +```python +PAYMENT_AMOUNT: float = 999.00 # 正式价格 +``` + +或在 `backend/.env` 中添加覆盖: + +```ini +PAYMENT_AMOUNT=999.00 +``` + +然后重启后端: + +```bash +pm2 restart vigent2-backend +``` + +--- + +## 支付流程说明 + +``` +用户注册 → 登录(密码正确但 is_active=false) + → 后端返回 403 + payment_token + → 前端跳转 /pay 页面 + → POST /api/payment/create-order → 返回支付宝收银台 URL + → 前端重定向到支付宝收银台页面(支持扫码、账号登录、余额等多种支付方式) + → 用户完成支付 + → 支付宝异步回调 POST /api/payment/notify + → 后端验签 → 更新订单 → 激活用户(is_active=true, expires_at=+365天) + → 支付宝同步跳转回 /pay?out_trade_no=xxx + → 前端轮询 GET /api/payment/status/{out_trade_no} + → 轮询到 paid → 提示成功 → 跳转登录页 + → 用户重新登录 → 成功进入系统 +``` + +**电脑网站支付 vs 当面付**:电脑网站支付(`alipay.trade.page.pay`)会跳转到支付宝官方收银台页面,用户可以选择扫码、支付宝账号登录、余额等多种方式支付,体验更好。当面付(`alipay.trade.precreate`)仅生成一个二维码,只能扫码支付。 + +会员到期续费同流程:登录时检测到过期 → 返回 PAYMENT_REQUIRED → 跳转 /pay。 + +管理员手动激活功能不受影响,两种方式并存。 + +--- + +## 涉及文件 + +| 文件 | 变更类型 | 说明 | +|------|---------|------| +| `backend/requirements.txt` | 修改 | 添加 `python-alipay-sdk` | +| `backend/database/schema.sql` | 修改 | 新增 `orders` 表 | +| `backend/app/core/config.py` | 修改 | 支付宝配置项 | +| `backend/app/core/security.py` | 修改 | payment_token 函数 | +| `backend/app/core/deps.py` | 修改 | is_active 安全兜底 | +| `backend/app/repositories/orders.py` | 新建 | orders 数据层 | +| `backend/app/modules/payment/__init__.py` | 新建 | 模块初始化 | +| `backend/app/modules/payment/schemas.py` | 新建 | 请求/响应模型 | +| `backend/app/modules/payment/service.py` | 新建 | 支付业务逻辑(电脑网站支付) | +| `backend/app/modules/payment/router.py` | 新建 | 3 个 API 端点 | +| `backend/app/modules/auth/router.py` | 修改 | 登录返回 PAYMENT_REQUIRED | +| `backend/app/main.py` | 修改 | 注册 payment_router | +| `backend/.env` | 修改 | 支付宝环境变量 | +| `backend/keys/` | 新建 | PEM 密钥文件 | +| `frontend/src/shared/lib/auth.ts` | 修改 | login() 处理 paymentToken | +| `frontend/src/shared/api/axios.ts` | 修改 | PUBLIC_PATHS 加 /pay | +| `frontend/src/app/login/page.tsx` | 修改 | paymentToken 跳转 | +| `frontend/src/app/register/page.tsx` | 修改 | 注册成功提示文案 | +| `frontend/src/app/pay/page.tsx` | 新建 | 付费页面(重定向到支付宝收银台) | + +--- + +## 常见问题 + +### RSA key format is not supported + +密钥文件缺少 PEM 头尾标记或未按 64 字符换行。参考「PEM 格式要求」重新格式化。 + +### ACQ.ACCESS_FORBIDDEN + +应用未开通「电脑网站支付」产品。在支付宝开放平台 → 应用详情 → 产品管理中添加并开通。 + +### 支付宝回调不到 + +1. 检查 `ALIPAY_NOTIFY_URL` 是否公网 HTTPS 可达 +2. 检查 Nginx 是否将 `/api/payment/notify` 代理到后端 +3. 支付宝回调超时(15s 未响应)会重试,共重试 8 次,持续 24 小时 + +### 支付完成后页面未跳转回来 + +检查 `ALIPAY_RETURN_URL` 配置是否正确,必须是前端 `/pay` 页面的完整 URL(如 `https://vigent.hbyrkj.top/pay`)。支付宝会在用户支付完成后将浏览器重定向到此地址,并附带 `out_trade_no` 等参数。 + +### 前端显示"网络错误"而非具体错误 + +API 函数缺少 try/catch 捕获 axios 异常。已在 `auth.ts` 的 `register()` 和 `login()` 中修复。 diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index 45f8176..d50a188 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -39,6 +39,7 @@ backend/ │ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service) │ │ ├── login_helper/ # 扫码登录辅助 │ │ ├── tools/ # 工具接口(router/schemas/service) +│ │ ├── payment/ # 支付宝付费开通(router/schemas/service) │ │ └── admin/ # 管理员功能 │ ├── repositories/ # Supabase 数据访问 │ ├── services/ # 外部服务集成 @@ -168,6 +169,13 @@ backend/user_data/{user_uuid}/cookies/ - `DOUYIN_DEBUG_ARTIFACTS` / `DOUYIN_RECORD_VIDEO` / `DOUYIN_KEEP_SUCCESS_VIDEO` - `DOUYIN_COOKIE` (抖音视频下载 Cookie) +### 支付宝 +- `ALIPAY_APP_ID` / `ALIPAY_PRIVATE_KEY_PATH` / `ALIPAY_PUBLIC_KEY_PATH` +- `ALIPAY_NOTIFY_URL` / `ALIPAY_RETURN_URL` +- `ALIPAY_SANDBOX` (沙箱模式,默认 false) +- `PAYMENT_AMOUNT` (会员价格,默认 999.00) +- `PAYMENT_EXPIRE_DAYS` (会员有效天数,默认 365) + --- ## 10. Playwright 发布调试 diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 2779c87..1c779cb 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -25,6 +25,7 @@ backend/ │ │ ├── generated_audios/ # 预生成配音管理(router/schemas/service) │ │ ├── login_helper/ # 扫码登录辅助 │ │ ├── tools/ # 工具接口(router/schemas/service) +│ │ ├── payment/ # 支付宝付费开通(router/schemas/service) │ │ └── admin/ # 管理员功能 │ ├── repositories/ # Supabase 数据访问 │ ├── services/ # 外部服务集成 (TTS/Remotion/Storage/Uploader 等) @@ -103,6 +104,13 @@ backend/ * `GET /api/lipsync/health`: LatentSync 服务健康状态 * `GET /api/voiceclone/health`: CosyVoice 3.0 服务健康状态 +11. **支付 (Payment)** + * `POST /api/payment/create-order`: 创建支付宝电脑网站支付订单(需 payment_token) + * `POST /api/payment/notify`: 支付宝异步通知回调(返回纯文本 success/fail) + * `GET /api/payment/status/{out_trade_no}`: 查询订单支付状态(前端轮询) + +> 登录时若账号未激活或已过期,返回 403 + `payment_token`,前端跳转 `/pay` 页面完成付费。详见 [支付宝部署指南](ALIPAY_DEPLOY.md)。 + ### 统一响应结构 ```json diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md index e432e91..5be1b76 100644 --- a/Docs/DEPLOY_MANUAL.md +++ b/Docs/DEPLOY_MANUAL.md @@ -213,6 +213,15 @@ cp .env.example .env | `DOUYIN_KEEP_SUCCESS_VIDEO` | false | 成功后保留录屏 | | `CORS_ORIGINS` | `*` | CORS 允许源 (生产环境建议白名单) | | `DOUYIN_COOKIE` | 空 | 抖音视频下载 Cookie (文案提取功能) | +| `ALIPAY_APP_ID` | 空 | 支付宝应用 APPID | +| `ALIPAY_PRIVATE_KEY_PATH` | 空 | 应用私钥 PEM 文件路径 | +| `ALIPAY_PUBLIC_KEY_PATH` | 空 | 支付宝公钥 PEM 文件路径 | +| `ALIPAY_NOTIFY_URL` | 空 | 支付宝异步回调地址 (公网 HTTPS) | +| `ALIPAY_RETURN_URL` | 空 | 支付完成后浏览器跳转地址 | +| `PAYMENT_AMOUNT` | `999.00` | 会员价格 (元) | +| `PAYMENT_EXPIRE_DAYS` | `365` | 会员有效天数 | + +> 支付宝完整配置步骤(密钥生成、PEM 格式、产品开通等)请参考 **[支付宝部署指南](ALIPAY_DEPLOY.md)**。 --- @@ -558,6 +567,7 @@ pm2 logs vigent2-cosyvoice | `playwright` | 社交媒体自动发布 | | `biliup` | B站视频上传 | | `loguru` | 日志管理 | +| `python-alipay-sdk` | 支付宝支付集成 | ### 前端关键依赖 diff --git a/Docs/Doc_Rules.md b/Docs/Doc_Rules.md index c4417e1..8a6c205 100644 --- a/Docs/Doc_Rules.md +++ b/Docs/Doc_Rules.md @@ -196,6 +196,7 @@ ViGent2/Docs/ ├── SUPABASE_DEPLOY.md # Supabase 部署文档 ├── LATENTSYNC_DEPLOY.md # LatentSync 部署文档 ├── COSYVOICE3_DEPLOY.md # 声音克隆部署文档 +├── ALIPAY_DEPLOY.md # 支付宝付费部署文档 ├── SUBTITLE_DEPLOY.md # 字幕系统部署文档 └── DevLogs/ ├── Day1.md # 开发日志 @@ -304,4 +305,4 @@ ViGent2/Docs/ --- -**最后更新**:2026-02-08 +**最后更新**:2026-02-11 diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index bf269cd..de7f04e 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -10,8 +10,9 @@ frontend/src/ │ ├── page.tsx # 首页(视频生成) │ ├── publish/ # 发布管理页 │ ├── admin/ # 管理员页面 -│ ├── login/ # 登录 -│ └── register/ # 注册 +│ ├── login/ # 登录 +│ ├── register/ # 注册 +│ └── pay/ # 付费开通会员 ├── features/ # 功能模块(按业务拆分) │ ├── home/ │ │ ├── model/ # 业务逻辑 hooks diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index ef4bc6f..f903b3a 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -69,6 +69,12 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **账户下拉菜单**: 显示有效期 + 修改密码 + 安全退出。 - **修改密码**: 弹窗输入当前密码与新密码,修改后强制重新登录。 +### 8. 付费开通会员 (`/pay`) +- **支付宝电脑网站支付**: 跳转支付宝官方收银台,支持扫码/账号登录/余额等多种支付方式。 +- **自动激活**: 支付成功后异步回调自动激活会员(有效期 1 年),前端轮询检测支付结果。 +- **到期续费**: 会员到期后登录自动跳转付费页续费,流程与首次开通一致。 +- **管理员激活**: 管理员手动激活功能并存,两种方式互不影响。 + ### 8. 文案提取助手 (`ScriptExtractionModal`) [Day 15 新增] - **多源提取**: 支持文件拖拽上传与 URL 粘贴 (B站/抖音/TikTok)。 - **AI 洗稿**: 集成 GLM-4.7-Flash,自动改写为口播文案。 @@ -109,6 +115,8 @@ src/ │ ├── page.tsx # 视频生成主页 │ ├── publish/ # 发布管理页 │ │ └── page.tsx +│ ├── pay/ # 付费开通会员页 +│ │ └── page.tsx │ └── layout.tsx # 全局布局 (导航栏) ├── features/ │ ├── home/ diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 9c52eea..663b19e 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -1,7 +1,7 @@ # ViGent2 开发任务清单 (Task Log) **项目**: ViGent2 数字人口播视频生成系统 -**进度**: 100% (Day 24 - 鉴权到期治理 + 多素材时间轴稳定性修复) +**进度**: 100% (Day 25 - 支付宝付费开通会员) **更新时间**: 2026-02-11 --- @@ -10,17 +10,27 @@ > 这里记录了每一天的核心开发内容与 milestone。 -### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复 (Current) -- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session,并返回“会员已到期,请续费”。 -- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。 -- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。 -- [x] **标题显示模式**: 标题行新增“短暂显示/常驻显示”下拉;默认短暂显示(4 秒),用户选择持久化并透传至 Remotion 渲染链路。 -- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize,修复“编码横屏+旋转元数据”导致的竖屏判断偏差。 -- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFR,concat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。 -- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。 -- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。 - -### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强 +### Day 25: 支付宝付费开通会员 (Current) +- [x] **支付宝电脑网站支付**: 集成 `python-alipay-sdk`,支持 `alipay.trade.page.pay` 跳转支付宝收银台。 +- [x] **payment_token 机制**: 登录时未激活/已过期用户返回 403 + 短时效 JWT(30 分钟),安全传递身份到付费页。 +- [x] **异步通知回调**: `POST /api/payment/notify` 验签 → 更新订单 → 激活用户(is_active=true, expires_at=+365天)。 +- [x] **前端付费页**: `/pay` 页面,首次访问创建订单并跳转收银台,支付完成返回后轮询状态。 +- [x] **is_active 安全兜底**: `deps.py` 在登录和鉴权两处均检查 is_active,到期自动停用并清理 session。 +- [x] **orders 数据层**: 新增 `repositories/orders.py` + `orders` 数据库表。 +- [x] **登录流程适配**: 登录接口返回 PAYMENT_REQUIRED,前端 auth.ts 处理 paymentToken 跳转。 +- [x] **部署文档**: 新增 `Docs/ALIPAY_DEPLOY.md`,含密钥配置、PEM 格式、产品开通等完整指南。 + +### Day 24: 鉴权到期治理 + 多素材时间轴稳定性修复 +- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session,并返回“会员已到期,请续费”。 +- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。 +- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。 +- [x] **标题显示模式**: 标题行新增“短暂显示/常驻显示”下拉;默认短暂显示(4 秒),用户选择持久化并透传至 Remotion 渲染链路。 +- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize,修复“编码横屏+旋转元数据”导致的竖屏判断偏差。 +- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFR,concat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。 +- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。 +- [x] **交互细节优化**: 页面刷新回顶部;素材/历史列表首轮自动滚动抑制,减少恢复状态时页面跳动。 + +### Day 23: 配音前置重构 + 素材时间轴编排 + UI 体验优化 + 声音克隆增强 #### 第一阶段:配音前置 - [x] **配音生成独立化**: 新增 `generated_audios` 后端模块(router/schemas/service),5 个 API 端点,复用现有 TTSService / voice_clone_service / task_store。 @@ -213,6 +223,7 @@ | **TTS 配音** | 100% | ✅ EdgeTTS + CosyVoice 3.0 + 配音前置 + 时间轴编排 + 自动转写 + 语速控制 | | **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 | | **用户认证** | 100% | ✅ 手机号 + JWT | +| **付费会员** | 100% | ✅ 支付宝电脑网站支付 + 自动激活 | | **部署运维** | 100% | ✅ PM2 + Watchdog | --- diff --git a/README.md b/README.md index e8db277..566fe38 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ - 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。 - 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。 - 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。 -- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。 -- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`,默认短暂显示(4秒),用户偏好自动持久化。 -- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。 -- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。 -- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。 +- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。 +- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`,默认短暂显示(4秒),用户偏好自动持久化。 +- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。 +- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。 +- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。 - 💾 **用户偏好持久化** - 首页状态统一恢复/保存,刷新后延续上次配置。历史文案手动保存与加载。 - 🎵 **背景音乐** - 试听 + 音量控制 + 混音,保持配音音量稳定。 - 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成、9 语言翻译。 @@ -33,6 +33,7 @@ - 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。 - 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。 - 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。 +- 💳 **付费会员** - 支付宝电脑网站支付自动开通会员,到期自动停用并引导续费,管理员手动激活并存。 - 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。 - 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。 - 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。 @@ -62,6 +63,7 @@ - [参考音频服务部署 (COSYVOICE3_DEPLOY.md)](Docs/COSYVOICE3_DEPLOY.md) - 声音克隆模型部署指南。 - [LatentSync 部署指南](models/LatentSync/DEPLOY.md) - 唇形同步模型独立部署。 - [Supabase 部署指南 (SUPABASE_DEPLOY.md)](Docs/SUPABASE_DEPLOY.md) - Supabase 与认证系统配置。 +- [支付宝部署指南 (ALIPAY_DEPLOY.md)](Docs/ALIPAY_DEPLOY.md) - 支付宝付费开通会员配置。 ### 开发文档 - [后端开发指南](Docs/BACKEND_README.md) - 接口规范与开发流程。 diff --git a/backend/.env.example b/backend/.env.example index bf98eb7..f57e5fd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -73,3 +73,10 @@ SUPABASE_STORAGE_LOCAL_PATH=/home/rongye/ProgramFiles/Supabase/volumes/storage/s # =============== 抖音视频下载 Cookie =============== # 用于从抖音 URL 提取视频文案功能,会过期需要定期更新 DOUYIN_COOKIE=douyin.com; device_web_cpu_core=10; device_web_memory_size=8; __ac_nonce=06760391f00b9b51264ae; __ac_signature=_02B4Z6wo00f019a5ceAAAIDAhEZR-X3jjWfWmXVAAJLXd4; ttwid=1%7C7MTKBSMsP4eOv9h5NAh8p0E-NYIud09ftNmB0mjLpWc%7C1734359327%7C8794abeabbd47447e1f56e5abc726be089f2a0344d6343b5f75f23e7b0f0028f; UIFID_TEMP=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff1396912bcb2af71efee56a14a2a9f37b74010d0a0413795262f6d4afe02a032ac7ab; s_v_web_id=verify_m4r4ribr_c7krmY1z_WoeI_43po_ATpO_I4o8U1bex2D7; hevc_supported=true; home_can_add_dy_2_desktop=%220%22; dy_swidth=2560; dy_sheight=1440; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A2560%2C%5C%22screen_height%5C%22%3A1440%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A10%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A50%7D%22; strategyABtestKey=%221734359328.577%22; csrf_session_id=2f53aed9aa6974e83aa9a1014180c3a4; fpk1=U2FsdGVkX1/IpBh0qdmlKAVhGyYHgur4/VtL9AReZoeSxadXn4juKvsakahRGqjxOPytHWspYoBogyhS/V6QSw==; fpk2=0845b309c7b9b957afd9ecf775a4c21f; passport_csrf_token=d80e0c5b2fa2328219856be5ba7e671e; passport_csrf_token_default=d80e0c5b2fa2328219856be5ba7e671e; odin_tt=3c891091d2eb0f4718c1d5645bc4a0017032d4d5aa989decb729e9da2ad570918cbe5e9133dc6b145fa8c758de98efe32ff1f81aa0d611e838cc73ab08ef7d3f6adf66ab4d10e8372ddd628f94f16b8e; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.5%7D; bd_ticket_guard_client_web_domain=2; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; UIFID=0de8750d2b188f4235dbfd208e44abbb976428f0720eb983255afefa45d39c0c6532e1d4768dd8587bf919f866ff139655a3c2b735923234f371c699560c657923fd3d6c5b63ab7bb9b83423b6cb4787e2ce66a7fbc4ecb24c8570f520fe6de068bbb95115023c0c6c1b6ee31b49fb7e3996fb8349f43a3fd8b7a61cd9e18e8fe65eb6a7c13de4c0960d84e344b644725db3eb2fa6b7caf821de1b50527979f2; is_dash_user=1; biz_trace_id=b57a241f; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTEo2R0lDalVoWW1XcHpGOFdrN0Vrc0dXcCtaUzNKY1g4NGNGY2k0TTl1TEowNjdUb21mbFU5aDdvWVBGamhNRWNRQWtKdnN1MnM3RmpTWnlJQXpHMjA9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; download_guide=%221%2F20241216%2F0%22; sdk_source_info=7e276470716a68645a606960273f276364697660272927676c715a6d6069756077273f276364697660272927666d776a68605a607d71606b766c6a6b5a7666776c7571273f275e58272927666a6b766a69605a696c6061273f27636469766027292762696a6764695a7364776c6467696076273f275e5827292771273f273d33323131333c3036313632342778; bit_env=RiOY4jzzpxZoVCl6zdVSVhVRjdwHRTxqcqWdqMBZLPGjMdB4Tax1kAELHNTVAAh72KuhumewE4Lq6f0-VJ2UpJrkrhSxoPw9LUb3zQrq1OSwbeSPHkRlRgRQvO89sItdGUyq1oFr0XyRCnMYG87KSeWyc4x0czGR0o50hTDoDLG5rJVoRcdQOLvjiAegsqyytKF59sPX_QM9qffK2SqYsg0hCggURc_AI6kguDDE5DvG0bnyz1utw4z1eEnIoLrkGDqzqBZj4dOAr0BVU6ofbsS-pOQ2u2PM1dLP9FlBVBlVaqYVgHJeSLsR5k76BRTddUjTb4zEilVIEwAMJWGN4I1BxVt6fC9B5tBQpuT0lj3n3eKXCKXZsd8FrEs5_pbfDsxV-e_WMiXI2ff4qxiTC0U73sfo9OpicKICtZjdq8qsHxJuu6wVR36zvXeL2Wch5C6MzprNvkivv0l8nbh2mSgy1nabZr3dmU6NcR-Bg3Q3xTWUlR9aAUmpopC-cNuXjgLpT-Lw1AYGilSUnCvosth1Gfypq-b0MpgmdSDgTrQ%3D; gulu_source_res=eyJwX2luIjoiMDhjOGQ3ZTJiODQyNjZkZWI5Y2VkMGJiODNlNmY1ZWY0ZjMyNTE2ZmYyZjAzNDMzZjI0OWU1Y2Q1NTczNTk5NyJ9; passport_auth_mix_state=hp9bc3dgb1tm5wd8p82zawus27g0e3ue; IsDouyinActive=false + +# =============== 支付宝配置 =============== +ALIPAY_APP_ID=******** +ALIPAY_PRIVATE_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/app_private_key.pem +ALIPAY_PUBLIC_KEY_PATH=/home/rongye/ProgramFiles/ViGent2/backend/keys/alipay_public_key.pem +ALIPAY_NOTIFY_URL=https://vigent.hbyrkj.top/api/payment/notify +ALIPAY_RETURN_URL=https://vigent.hbyrkj.top/pay diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 31c1156..dc21846 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -76,6 +76,16 @@ class Settings(BaseSettings): GLM_API_KEY: str = "" GLM_MODEL: str = "glm-4.7-flash" + # 支付宝配置 + ALIPAY_APP_ID: str = "" + ALIPAY_PRIVATE_KEY_PATH: str = "" # 应用私钥 PEM 文件路径 + ALIPAY_PUBLIC_KEY_PATH: str = "" # 支付宝公钥 PEM 文件路径 + ALIPAY_NOTIFY_URL: str = "" # 异步通知回调地址(公网可达) + ALIPAY_RETURN_URL: str = "" # 支付成功后同步跳转地址 + ALIPAY_SANDBOX: bool = False # 是否使用沙箱环境 + PAYMENT_AMOUNT: float = 999.00 # 会员价格(元) + PAYMENT_EXPIRE_DAYS: int = 365 # 会员有效天数 + # CORS 配置 (逗号分隔的域名列表,* 表示允许所有) CORS_ORIGINS: str = "*" diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 5fcc41f..1542b5b 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,12 +1,12 @@ """ 依赖注入模块:认证和用户获取 """ -from typing import Optional, Any, Dict, cast -from fastapi import Request, HTTPException, Depends, status -from app.core.security import decode_access_token -from app.repositories.sessions import get_session, delete_sessions -from app.repositories.users import get_user_by_id, deactivate_user_if_expired -from loguru import logger +from typing import Optional, Any, Dict, cast +from fastapi import Request, HTTPException, Depends, status +from app.core.security import decode_access_token +from app.repositories.sessions import get_session, delete_sessions +from app.repositories.users import get_user_by_id, deactivate_user_if_expired +from loguru import logger async def get_token_from_cookie(request: Request) -> Optional[str]: @@ -14,9 +14,9 @@ async def get_token_from_cookie(request: Request) -> Optional[str]: return request.cookies.get("access_token") -async def get_current_user_optional( - request: Request -) -> Optional[Dict[str, Any]]: +async def get_current_user_optional( + request: Request +) -> Optional[Dict[str, Any]]: """ 获取当前用户 (可选,未登录返回 None) """ @@ -29,26 +29,30 @@ async def get_current_user_optional( return None # 验证 session_token 是否有效 (单设备登录检查) - try: - session = get_session(token_data.user_id, token_data.session_token) - if not session: - logger.warning(f"Session token 无效: user_id={token_data.user_id}") - return None - - user = cast(Optional[Dict[str, Any]], get_user_by_id(token_data.user_id)) - if user and deactivate_user_if_expired(user): - delete_sessions(token_data.user_id) - return None - - return user - except Exception as e: - logger.error(f"获取用户信息失败: {e}") - return None + try: + session = get_session(token_data.user_id, token_data.session_token) + if not session: + logger.warning(f"Session token 无效: user_id={token_data.user_id}") + return None + + user = cast(Optional[Dict[str, Any]], get_user_by_id(token_data.user_id)) + if user and deactivate_user_if_expired(user): + delete_sessions(token_data.user_id) + return None + + if user and not user.get("is_active"): + delete_sessions(token_data.user_id) + return None + + return user + except Exception as e: + logger.error(f"获取用户信息失败: {e}") + return None -async def get_current_user( - request: Request -) -> Dict[str, Any]: +async def get_current_user( + request: Request +) -> Dict[str, Any]: """ 获取当前用户 (必须登录) @@ -70,38 +74,45 @@ async def get_current_user( detail="Token 无效或已过期" ) - try: - session = get_session(token_data.user_id, token_data.session_token) - if not session: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="会话已失效,请重新登录(可能已在其他设备登录)" - ) - - user = get_user_by_id(token_data.user_id) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户不存在" - ) - user = cast(Dict[str, Any], user) - - if deactivate_user_if_expired(user): - delete_sessions(token_data.user_id) - 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="服务器错误" - ) + try: + session = get_session(token_data.user_id, token_data.session_token) + if not session: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="会话已失效,请重新登录(可能已在其他设备登录)" + ) + + user = get_user_by_id(token_data.user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在" + ) + user = cast(Dict[str, Any], user) + + if deactivate_user_if_expired(user): + delete_sessions(token_data.user_id) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="会员已到期,请续费" + ) + + if not user.get("is_active"): + delete_sessions(token_data.user_id) + 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( diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 119bfba..fa28f6a 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -110,3 +110,28 @@ def set_auth_cookie(response: Response, token: str) -> None: def clear_auth_cookie(response: Response) -> None: """清除认证 Cookie""" response.delete_cookie(key="access_token") + + +def create_payment_token(user_id: str) -> str: + """生成付费专用短期 JWT token(30 分钟有效)""" + payload = { + "sub": user_id, + "purpose": "payment", + "exp": datetime.now(timezone.utc) + timedelta(minutes=30), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_payment_token(token: str) -> str | None: + """解析 payment_token,返回 user_id(仅 purpose=payment 有效)""" + try: + data = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + ) + if data.get("purpose") != "payment": + return None + return data.get("sub") + except JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py index 6f76016..570fe54 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.modules.ai.router import router as ai_router from app.modules.tools.router import router as tools_router from app.modules.assets.router import router as assets_router from app.modules.generated_audios.router import router as generated_audios_router +from app.modules.payment.router import router as payment_router from loguru import logger import os @@ -126,6 +127,7 @@ app.include_router(ai_router) # /api/ai app.include_router(tools_router, prefix="/api/tools", tags=["Tools"]) app.include_router(assets_router, prefix="/api/assets", tags=["Assets"]) app.include_router(generated_audios_router, prefix="/api/generated-audios", tags=["GeneratedAudios"]) +app.include_router(payment_router) # /api/payment @app.on_event("startup") diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py index d2912f6..0fc54a8 100644 --- a/backend/app/modules/auth/router.py +++ b/backend/app/modules/auth/router.py @@ -1,30 +1,32 @@ """ 认证 API:注册、登录、登出、修改密码 """ -from fastapi import APIRouter, HTTPException, Response, status, Request, Depends +from fastapi import APIRouter, HTTPException, Response, status, Request, Depends +from fastapi.responses import JSONResponse from pydantic import BaseModel, field_validator -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 app.repositories.sessions import create_session, delete_sessions -from app.repositories.users import ( - create_user, - get_user_by_id, - get_user_by_phone, - user_exists_by_phone, - update_user, - deactivate_user_if_expired, -) -from app.core.deps import get_current_user -from app.core.response import success_response +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, + create_payment_token, +) +from app.repositories.sessions import create_session, delete_sessions +from app.repositories.users import ( + create_user, + get_user_by_id, + get_user_by_phone, + user_exists_by_phone, + update_user, + deactivate_user_if_expired, +) +from app.core.deps import get_current_user +from app.core.response import success_response from loguru import logger -from typing import Optional, Any, cast +from typing import Optional, Any, cast import re router = APIRouter(prefix="/api/auth", tags=["认证"]) @@ -84,26 +86,26 @@ async def register(request: RegisterRequest): 注册后状态为 pending,需要管理员激活 """ try: - if user_exists_by_phone(request.phone): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="该手机号已注册" - ) + if user_exists_by_phone(request.phone): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该手机号已注册" + ) # 创建用户 password_hash = get_password_hash(request.password) - create_user({ - "phone": request.phone, - "password_hash": password_hash, - "username": request.username or f"用户{request.phone[-4:]}", - "role": "pending", - "is_active": False - }) + create_user({ + "phone": request.phone, + "password_hash": password_hash, + "username": request.username or f"用户{request.phone[-4:]}", + "role": "pending", + "is_active": False + }) logger.info(f"新用户注册: {request.phone}") - return success_response(message="注册成功,请等待管理员审核激活") + return success_response(message="注册成功,请等待管理员审核激活") except HTTPException: raise except Exception as e: @@ -124,12 +126,12 @@ async def login(request: LoginRequest, response: Response): - 实现"后踢前"单设备登录 """ try: - user = cast(dict[str, Any], get_user_by_phone(request.phone) or {}) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="手机号或密码错误" - ) + user = cast(dict[str, Any], get_user_by_phone(request.phone) or {}) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="手机号或密码错误" + ) # 验证密码 if not verify_password(request.password, user["password_hash"]): @@ -138,27 +140,33 @@ async def login(request: LoginRequest, response: Response): detail="手机号或密码错误" ) - # 授权过期时自动停用账号 - if deactivate_user_if_expired(user): - delete_sessions(user["id"]) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="会员已到期,请续费" - ) - - # 检查是否激活 - if not user["is_active"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="账号未激活,请等待管理员审核" - ) + # 过期自动停用(注意:只更新 DB,不修改内存中的 user 字典) + expired = deactivate_user_if_expired(user) + if expired: + delete_sessions(user["id"]) + + # 过期 或 未激活(新注册)→ 返回付费指引 + if expired or not user["is_active"]: + payment_token = create_payment_token(user["id"]) + return JSONResponse( + status_code=403, + content={ + "success": False, + "message": "请付费开通会员", + "code": 403, + "data": { + "reason": "PAYMENT_REQUIRED", + "payment_token": payment_token, + } + } + ) # 生成新的 session_token (后踢前) session_token = generate_session_token() # 删除旧 session,插入新 session - delete_sessions(user["id"]) - create_session(user["id"], session_token, None) + delete_sessions(user["id"]) + create_session(user["id"], session_token, None) # 生成 JWT Token token = create_access_token(user["id"], session_token) @@ -168,19 +176,19 @@ async def login(request: LoginRequest, response: Response): logger.info(f"用户登录: {request.phone}") - return success_response( - data={ - "user": UserResponse( - id=user["id"], - phone=user["phone"], - username=user.get("username"), - role=user["role"], - is_active=user["is_active"], - expires_at=user.get("expires_at") - ).model_dump() - }, - message="登录成功", - ) + return success_response( + data={ + "user": UserResponse( + id=user["id"], + phone=user["phone"], + username=user.get("username"), + role=user["role"], + is_active=user["is_active"], + expires_at=user.get("expires_at") + ).model_dump() + }, + message="登录成功", + ) except HTTPException: raise except Exception as e: @@ -192,10 +200,10 @@ async def login(request: LoginRequest, response: Response): @router.post("/logout") -async def logout(response: Response): - """用户登出""" - clear_auth_cookie(response) - return success_response(message="已登出") +async def logout(response: Response): + """用户登出""" + clear_auth_cookie(response) + return success_response(message="已登出") @router.post("/change-password") @@ -223,12 +231,12 @@ async def change_password(request: ChangePasswordRequest, req: Request, response ) try: - user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {}) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户不存在" - ) + user = cast(dict[str, Any], get_user_by_id(token_data.user_id) or {}) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在" + ) # 验证当前密码 if not verify_password(request.old_password, user["password_hash"]): @@ -239,13 +247,13 @@ async def change_password(request: ChangePasswordRequest, req: Request, response # 更新密码 new_password_hash = get_password_hash(request.new_password) - update_user(user["id"], {"password_hash": new_password_hash}) + update_user(user["id"], {"password_hash": new_password_hash}) # 生成新的 session token,使旧 token 失效 new_session_token = generate_session_token() - delete_sessions(user["id"]) - create_session(user["id"], new_session_token, None) + delete_sessions(user["id"]) + create_session(user["id"], new_session_token, None) # 生成新的 JWT Token new_token = create_access_token(user["id"], new_session_token) @@ -253,7 +261,7 @@ async def change_password(request: ChangePasswordRequest, req: Request, response logger.info(f"用户修改密码: {user['phone']}") - return success_response(message="密码修改成功") + return success_response(message="密码修改成功") except HTTPException: raise except Exception as e: @@ -264,14 +272,14 @@ async def change_password(request: ChangePasswordRequest, req: Request, response ) -@router.get("/me") -async def get_me(user: dict = Depends(get_current_user)): - """获取当前用户信息""" - return success_response(UserResponse( - id=user["id"], - phone=user["phone"], - username=user.get("username"), - role=user["role"], - is_active=user["is_active"], - expires_at=user.get("expires_at") - ).model_dump()) +@router.get("/me") +async def get_me(user: dict = Depends(get_current_user)): + """获取当前用户信息""" + return success_response(UserResponse( + id=user["id"], + phone=user["phone"], + username=user.get("username"), + role=user["role"], + is_active=user["is_active"], + expires_at=user.get("expires_at") + ).model_dump()) diff --git a/backend/app/modules/payment/__init__.py b/backend/app/modules/payment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/payment/router.py b/backend/app/modules/payment/router.py new file mode 100644 index 0000000..dfff456 --- /dev/null +++ b/backend/app/modules/payment/router.py @@ -0,0 +1,52 @@ +""" +支付 API:创建订单、异步通知、状态查询 + +遵循 BACKEND_DEV.md 规范:router 只做参数校验、调用 service、返回统一响应 +""" +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import PlainTextResponse + +from app.core.response import success_response +from .schemas import CreateOrderRequest, CreateOrderResponse, OrderStatusResponse +from . import service + +router = APIRouter(prefix="/api/payment", tags=["支付"]) + + +@router.post("/create-order") +async def create_payment_order(request: CreateOrderRequest): + """创建支付宝电脑网站支付订单,返回收银台 URL""" + try: + result = service.create_payment_order(request.payment_token) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + return success_response( + CreateOrderResponse(**result).model_dump() + ) + + +@router.post("/notify") +async def payment_notify(request: Request): + """ + 支付宝异步通知回调 + + 必须返回纯文本 "success"(不是 JSON),否则支付宝会重复推送。 + """ + form_data = await request.form() + verified = service.handle_payment_notify(dict(form_data)) + return PlainTextResponse("success" if verified else "fail") + + +@router.get("/status/{out_trade_no}") +async def check_payment_status(out_trade_no: str): + """查询订单支付状态(前端轮询)""" + order_status = service.get_order_status(out_trade_no) + if order_status is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="订单不存在") + + return success_response( + OrderStatusResponse(status=order_status).model_dump() + ) diff --git a/backend/app/modules/payment/schemas.py b/backend/app/modules/payment/schemas.py new file mode 100644 index 0000000..e2d6fba --- /dev/null +++ b/backend/app/modules/payment/schemas.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class CreateOrderRequest(BaseModel): + payment_token: str + + +class CreateOrderResponse(BaseModel): + pay_url: str + out_trade_no: str + amount: float + + +class OrderStatusResponse(BaseModel): + status: str diff --git a/backend/app/modules/payment/service.py b/backend/app/modules/payment/service.py new file mode 100644 index 0000000..e8d44c6 --- /dev/null +++ b/backend/app/modules/payment/service.py @@ -0,0 +1,137 @@ +""" +支付业务服务 + +职责:Alipay SDK 封装、创建订单、处理支付通知、查询状态 +遵循 BACKEND_DEV.md "薄路由 + 厚服务" 原则 +""" +from datetime import datetime, timezone, timedelta +import uuid + +from alipay import AliPay +from loguru import logger + +from app.core.config import settings +from app.core.security import decode_payment_token +from app.repositories.orders import create_order, get_order_by_trade_no, update_order_status +from app.repositories.users import update_user + +# 支付宝网关地址 +ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do" +ALIPAY_GATEWAY_SANDBOX = "https://openapi-sandbox.dl.alipaydev.com/gateway.do" + + +def _get_alipay_client() -> AliPay: + """延迟初始化 Alipay 客户端""" + return AliPay( + appid=settings.ALIPAY_APP_ID, + app_notify_url=settings.ALIPAY_NOTIFY_URL, + app_private_key_string=open(settings.ALIPAY_PRIVATE_KEY_PATH).read(), + alipay_public_key_string=open(settings.ALIPAY_PUBLIC_KEY_PATH).read(), + sign_type="RSA2", + debug=settings.ALIPAY_SANDBOX, + ) + + +def _create_page_pay_url(out_trade_no: str, amount: float, subject: str) -> str | None: + """调用 alipay.trade.page.pay,返回支付宝收银台 URL""" + client = _get_alipay_client() + order_string = client.api_alipay_trade_page_pay( + subject=subject, + out_trade_no=out_trade_no, + total_amount=amount, + return_url=settings.ALIPAY_RETURN_URL, + ) + if not order_string: + logger.error(f"电脑网站支付下单失败: {out_trade_no}") + return None + + gateway = ALIPAY_GATEWAY_SANDBOX if settings.ALIPAY_SANDBOX else ALIPAY_GATEWAY + pay_url = f"{gateway}?{order_string}" + logger.info(f"电脑网站支付下单成功: {out_trade_no}") + return pay_url + + +def _verify_signature(data: dict, signature: str) -> bool: + """验证支付宝异步通知签名""" + client = _get_alipay_client() + return client.verify(data, signature) + + +def create_payment_order(payment_token: str) -> dict: + """ + 创建支付订单完整流程 + + Returns: {"pay_url": str, "out_trade_no": str, "amount": float} + Raises: ValueError (token 无效), RuntimeError (API 失败) + """ + user_id = decode_payment_token(payment_token) + if not user_id: + raise ValueError("付费凭证无效或已过期,请重新登录") + + out_trade_no = f"VG_{int(datetime.now().timestamp())}_{uuid.uuid4().hex[:8]}" + amount = settings.PAYMENT_AMOUNT + + create_order(user_id, out_trade_no, amount) + + pay_url = _create_page_pay_url(out_trade_no, amount, "IPAgent 会员开通") + if not pay_url: + raise RuntimeError("创建支付订单失败,请稍后重试") + + logger.info(f"用户 {user_id} 创建支付订单: {out_trade_no}") + + return {"pay_url": pay_url, "out_trade_no": out_trade_no, "amount": amount} + + +def handle_payment_notify(form_data: dict) -> bool: + """ + 处理支付宝异步通知完整流程 + + Returns: True=验签通过, False=验签失败 + """ + data = dict(form_data) + + signature = data.pop("sign", "") + data.pop("sign_type", None) + + if not _verify_signature(data, signature): + logger.warning(f"支付宝通知验签失败: {data.get('out_trade_no')}") + return False + + out_trade_no = data.get("out_trade_no", "") + trade_status = data.get("trade_status", "") + trade_no = data.get("trade_no", "") + + logger.info(f"收到支付宝通知: {out_trade_no}, status={trade_status}, trade_no={trade_no}") + + if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"): + return True + + order = get_order_by_trade_no(out_trade_no) + if not order: + logger.warning(f"订单不存在: {out_trade_no}") + return True + + if order["status"] == "paid": + logger.info(f"订单已处理过: {out_trade_no}") + return True + + update_order_status(out_trade_no, "paid", trade_no) + + user_id = order["user_id"] + expires_at = (datetime.now(timezone.utc) + timedelta(days=settings.PAYMENT_EXPIRE_DAYS)).isoformat() + update_user(user_id, { + "is_active": True, + "role": "user", + "expires_at": expires_at, + }) + + logger.success(f"用户 {user_id} 支付成功,已激活,有效期至 {expires_at}") + return True + + +def get_order_status(out_trade_no: str) -> str | None: + """查询订单支付状态""" + order = get_order_by_trade_no(out_trade_no) + if not order: + return None + return order["status"] diff --git a/backend/app/repositories/orders.py b/backend/app/repositories/orders.py new file mode 100644 index 0000000..8ffdb74 --- /dev/null +++ b/backend/app/repositories/orders.py @@ -0,0 +1,34 @@ +""" +订单数据访问层 +""" +from datetime import datetime, timezone +from typing import Any, Dict, Optional, cast + +from app.core.supabase import get_supabase + + +def create_order(user_id: str, out_trade_no: str, amount: float) -> Dict[str, Any]: + supabase = get_supabase() + result = supabase.table("orders").insert({ + "user_id": user_id, + "out_trade_no": out_trade_no, + "amount": amount, + "status": "pending", + }).execute() + return cast(Dict[str, Any], (result.data or [{}])[0]) + + +def get_order_by_trade_no(out_trade_no: str) -> Optional[Dict[str, Any]]: + supabase = get_supabase() + result = supabase.table("orders").select("*").eq("out_trade_no", out_trade_no).single().execute() + return cast(Optional[Dict[str, Any]], result.data or None) + + +def update_order_status(out_trade_no: str, status: str, trade_no: str | None = None) -> None: + supabase = get_supabase() + payload: Dict[str, Any] = {"status": status} + if trade_no: + payload["trade_no"] = trade_no + if status == "paid": + payload["paid_at"] = datetime.now(timezone.utc).isoformat() + supabase.table("orders").update(payload).eq("out_trade_no", out_trade_no).execute() diff --git a/backend/database/schema.sql b/backend/database/schema.sql index c0116ca..66a83f2 100644 --- a/backend/database/schema.sql +++ b/backend/database/schema.sql @@ -71,3 +71,18 @@ CREATE TRIGGER users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- 8. 订单表(支付宝付费) +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + out_trade_no TEXT UNIQUE NOT NULL, + amount DECIMAL(10, 2) NOT NULL DEFAULT 999.00, + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed')), + trade_no TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + paid_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_orders_out_trade_no ON orders(out_trade_no); diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..c214b8b --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "qrcode.react": "^4.2.0" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..29135d9 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "qrcode.react": "^4.2.0" + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f9ae40..0eee641 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "axios": "^1.13.4", "lucide-react": "^0.563.0", "next": "16.1.1", + "qrcode.react": "^4.2.0", "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", @@ -5618,6 +5619,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 60b049c..03059ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "axios": "^1.13.4", "lucide-react": "^0.563.0", "next": "16.1.1", + "qrcode.react": "^4.2.0", "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 65855bf..0b1319b 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -25,7 +25,10 @@ export default function LoginPage() { try { const result = await login(phone, password); - if (result.success) { + if (result.paymentToken) { + sessionStorage.setItem('payment_token', result.paymentToken); + router.push('/pay'); + } else if (result.success) { router.push('/'); } else { setError(result.message || '登录失败'); diff --git a/frontend/src/app/pay/page.tsx b/frontend/src/app/pay/page.tsx new file mode 100644 index 0000000..f905ae3 --- /dev/null +++ b/frontend/src/app/pay/page.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { Suspense, useState, useEffect, useRef } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import api from '@/shared/api/axios'; + +type PageStatus = 'loading' | 'redirecting' | 'checking' | 'success' | 'error'; + +function PayContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState('loading'); + const [errorMsg, setErrorMsg] = useState(''); + const pollRef = useRef | null>(null); + + useEffect(() => { + const outTradeNo = searchParams.get('out_trade_no'); + if (outTradeNo) { + setStatus('checking'); + startPolling(outTradeNo); + return; + } + + const token = sessionStorage.getItem('payment_token'); + if (!token) { + router.replace('/login'); + return; + } + createOrder(token); + + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + const createOrder = async (token: string) => { + try { + const { data } = await api.post('/api/payment/create-order', { payment_token: token }); + const { pay_url } = data.data; + setStatus('redirecting'); + window.location.href = pay_url; + } catch (err: any) { + setStatus('error'); + setErrorMsg(err.response?.data?.message || '创建订单失败,请重新登录'); + } + }; + + const startPolling = (tradeNo: string) => { + checkStatus(tradeNo); + pollRef.current = setInterval(() => checkStatus(tradeNo), 3000); + }; + + const checkStatus = async (tradeNo: string) => { + try { + const { data } = await api.get(`/api/payment/status/${tradeNo}`); + if (data.data.status === 'paid') { + if (pollRef.current) clearInterval(pollRef.current); + setStatus('success'); + sessionStorage.removeItem('payment_token'); + setTimeout(() => router.replace('/login'), 3000); + } + } catch { + // ignore polling errors + } + }; + + return ( +
+ {(status === 'loading' || status === 'redirecting') && ( +
+
+ + + + +
+

+ {status === 'loading' ? '正在创建订单...' : '正在跳转到支付宝...'} +

+
+ )} + + {status === 'checking' && ( +
+

支付确认中

+
+ + + + + 正在确认支付结果... +
+

如果您已完成支付,请稍候

+
+ )} + + {status === 'success' && ( +
+
+ + + +
+

支付成功!

+

会员已开通,即将跳转到登录页...

+

请重新登录即可使用

+
+ )} + + {status === 'error' && ( +
+
+ + + +
+

创建订单失败

+

{errorMsg}

+ +
+ )} + + {status === 'checking' && ( +
+ +
+ )} +
+ ); +} + +export default function PayPage() { + return ( +
+ + + + + +
+ }> + + + + ); +} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index e9a3e25..d9d5f78 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -61,7 +61,7 @@ export default function RegisterPage() {

注册成功!

- 您的账号已创建,请等待管理员审核激活后即可登录。 + 注册成功!请返回登录页,登录后完成付费即可开通。

{ @@ -25,20 +26,41 @@ interface ApiResponse { * 用户注册 */ export async function register(phone: string, password: string, username?: string): Promise { - const { data: payload } = await api.post>('/api/auth/register', { - phone, password, username - }); - return { success: payload.success, message: payload.message }; + try { + const { data: payload } = await api.post>('/api/auth/register', { + phone, password, username + }); + return { success: payload.success, message: payload.message }; + } catch (err: any) { + return { + success: false, + message: err.response?.data?.message || '注册失败', + }; + } } /** * 用户登录 */ export async function login(phone: string, password: string): Promise { - const { data: payload } = await api.post>('/api/auth/login', { - phone, password - }); - return { success: payload.success, message: payload.message, user: payload.data?.user }; + try { + const { data: payload } = await api.post>('/api/auth/login', { + phone, password + }); + return { success: payload.success, message: payload.message, user: payload.data?.user }; + } catch (err: any) { + if (err.response?.status === 403 && err.response?.data?.data?.reason === 'PAYMENT_REQUIRED') { + return { + success: false, + message: err.response.data.message, + paymentToken: err.response.data.data.payment_token, + }; + } + return { + success: false, + message: err.response?.data?.message || '登录失败', + }; + } } /** diff --git a/run_qwen_tts.sh b/run_qwen_tts.sh index 4033821..8e1e4cf 100644 --- a/run_qwen_tts.sh +++ b/run_qwen_tts.sh @@ -1,11 +1,14 @@ #!/bin/bash -# Qwen3-TTS 声音克隆服务启动脚本 -# 端口: 8009 +# Qwen3-TTS voice clone startup script +# Port: 8009 # GPU: 0 cd /home/rongye/ProgramFiles/ViGent2/models/Qwen3-TTS -# 确保 conda env 的 bin 目录在 PATH 中,让 sox 等工具可被找到 +# Ensure conda env bin is in PATH (for sox) export PATH="/home/rongye/ProgramFiles/miniconda3/envs/qwen-tts/bin:$PATH" -python qwen_tts_server.py +# Default model (can be overridden externally) +export QWEN_TTS_MODEL="${QWEN_TTS_MODEL:-1.7B-Base}" + +exec python qwen_tts_server.py