Răsfoiți Sursa

feat: 支付流程优化 + 日志服务重构

- 支付宝/微信支付 controller/service 重构
- 会员页面和播放器页面优化
- 日志服务改为结构化格式,减少冗余日志写入
- 新增 MP_verify 微信验证文件
- 更新 deploy-prod.sh 部署脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 luni în urmă
părinte
comite
543061ac33
51 a modificat fișierele cu 3478 adăugiri și 105058 ștergeri
  1. 1 0
      MP_verify_Fkoo44GtmUOaz2KI.txt
  2. BIN
      after-buy.png
  3. BIN
      alipay-qrcode-test.png
  4. BIN
      billing-page.png
  5. 20 12
      deploy-prod.sh
  6. 1471 0
      docs/文本自动生成视频系统详细设计方案.md
  7. 66 0
      docs/织音品牌命名与推广介绍.md
  8. BIN
      login-page.png
  9. BIN
      my-uniapp-vue3/headed-test.png
  10. 2 0
      my-uniapp-vue3/index.html
  11. 5 2
      my-uniapp-vue3/src/pages/alipay-qrcode/index.vue
  12. 2 29
      my-uniapp-vue3/src/pages/member/index.vue
  13. 317 1
      my-uniapp-vue3/src/pages/payment-confirm/index.vue
  14. 38 30
      my-uniapp-vue3/src/pages/player/index.vue
  15. BIN
      my-uniapp-vue3/wechat-test-final.png
  16. BIN
      payment-confirm.png
  17. BIN
      payment-page-prod.png
  18. BIN
      payment-page.png
  19. 12 0
      player-5173.yml
  20. 110 0
      player-after-3s.yml
  21. BIN
      player-before.png
  22. 99 0
      player-fixed.yml
  23. 110 0
      player-initial.yml
  24. 102 0
      player-test-46-2s.yml
  25. 102 0
      player-test-46.yml
  26. BIN
      qrcode-test-online.png
  27. 54 2
      server/.env.production
  28. 116 0
      server/_deep_diag.mjs
  29. 18 0
      server/_delete_test_plans.js
  30. 1 0
      server/_final_url.txt
  31. 1 0
      server/_newkeys.txt
  32. 1 0
      server/_priv.txt
  33. 9 0
      server/_pub.txt
  34. 32 0
      server/_test.cjs
  35. 0 62
      server/_test_alipay_sdk.js
  36. 1 0
      server/_url.txt
  37. 1 0
      server/cert/alipay/密钥20260525204200/应用公钥RSA2048.txt
  38. 1 0
      server/cert/alipay/密钥20260525204200/应用私钥RSA2048-敏感数据,请妥善保管.txt
  39. 58 0
      server/diag.cjs
  40. 24 0
      server/genkeys.js
  41. 0 104834
      server/logs/requests.json
  42. 3 0
      server/src/app.ts
  43. 164 4
      server/src/modules/payment/payment.controller.ts
  44. 288 49
      server/src/modules/payment/payment.service.ts
  45. 23 11
      server/src/modules/subscription/subscription.service.ts
  46. 155 21
      server/src/services/log.service.ts
  47. 71 1
      server/src/services/requestLogger.ts
  48. BIN
      token-packs.png
  49. BIN
      vip-page.png
  50. BIN
      wechat-fixed.png
  51. BIN
      wechat-test.png

+ 1 - 0
MP_verify_Fkoo44GtmUOaz2KI.txt

@@ -0,0 +1 @@
+Fkoo44GtmUOaz2KI

BIN
after-buy.png


BIN
alipay-qrcode-test.png


BIN
billing-page.png


+ 20 - 12
deploy-prod.sh

@@ -38,26 +38,29 @@ echo "=== 2. 同步文件到服务器 ==="
 TEMP_DIR="/tmp/deploy_audio_$(date +%Y%m%d_%H%M%S)"
 ssh -i "$SSH_KEY" -p "$SSH_PORT" root@$REMOTE_HOST "mkdir -p $TEMP_DIR"
 
+# 本地临时目录
+mkdir -p "$LOCAL_PROJECT/tmp"
+
 # 打包后端(使用已有的 dist,打包所有必要文件,排除 node_modules, .git, *.ts)
 echo "打包后端文件..."
 cd "$LOCAL_PROJECT/server"
-tar --exclude='node_modules' --exclude='.git' --exclude='test-*.js' -czf /tmp/server.tar.gz .
+tar --exclude='node_modules' --exclude='.git' --exclude='test-*.js' -czf "$LOCAL_PROJECT/tmp/server.tar.gz" .
 cd "$LOCAL_PROJECT"
 
-# 打包前端
+# 打包前端(只打包 build/h5 目录内容,对接 nginx root)
 echo "打包前端文件..."
-cd "$LOCAL_PROJECT/my-uniapp-vue3/dist"
-tar -czf /tmp/frontend.tar.gz .
+cd "$LOCAL_PROJECT/my-uniapp-vue3/dist/build/h5"
+tar -czf "$LOCAL_PROJECT/tmp/frontend.tar.gz" .
 cd "$LOCAL_PROJECT"
 
 # 上传到服务器
 echo "上传后端..."
-scp -i "$SSH_KEY" -P "$SSH_PORT" /tmp/server.tar.gz root@$REMOTE_HOST:$TEMP_DIR/
+scp -i "$SSH_KEY" -P "$SSH_PORT" "$LOCAL_PROJECT/tmp/server.tar.gz" root@$REMOTE_HOST:$TEMP_DIR/
 echo "上传前端..."
-scp -i "$SSH_KEY" -P "$SSH_PORT" /tmp/frontend.tar.gz root@$REMOTE_HOST:$TEMP_DIR/
+scp -i "$SSH_KEY" -P "$SSH_PORT" "$LOCAL_PROJECT/tmp/frontend.tar.gz" root@$REMOTE_HOST:$TEMP_DIR/
 
 # 清理本地临时文件
-rm -f /tmp/server.tar.gz /tmp/frontend.tar.gz
+rm -rf "$LOCAL_PROJECT/tmp"
 
 # 3. 在服务器上安装 Node.js(如需要)
 echo ""
@@ -101,10 +104,15 @@ mkdir -p server frontend
 tar -xzf server.tar.gz -C server/
 tar -xzf frontend.tar.gz -C frontend/
 
-echo "移动到目标目录..."
-rm -rf $REMOTE_PATH/server $REMOTE_PATH/frontend
+echo "部署后端到目标目录..."
+rm -rf $REMOTE_PATH/server
 mv server $REMOTE_PATH/
-mv frontend $REMOTE_PATH/
+
+echo "部署前端到 nginx 目录..."
+rm -rf /data/ai/frontend/build/h5/*
+mkdir -p /data/ai/frontend/build/h5
+cp -r frontend/* /data/ai/frontend/build/h5/
+rm -rf frontend
 
 echo "验证关键文件..."
 ls -la $REMOTE_PATH/server/dist/ 2>/dev/null || echo "dist 目录不存在"
@@ -150,8 +158,8 @@ fi
 # 停止旧服务
 pm2 delete server 2>/dev/null || true
 
-# 设置环境变量(使用 22622 端口
-export SERVER_PORT=22622
+# 设置环境变量(使用 3100 端口,避免与 SSH 22622 冲突
+export SERVER_PORT=3100
 
 # 启动服务
 pm2 start dist/app.js --name server --env production

+ 1471 - 0
docs/文本自动生成视频系统详细设计方案.md

@@ -0,0 +1,1471 @@
+# 文本自动生成视频系统 — 详细技术设计方案
+
+> 版本: v1.0  
+> 日期: 2026-05-29  
+> 状态: 设计阶段
+
+---
+
+## 目录
+
+1. [概述与目标](#一概述与目标)
+2. [核心链路](#二核心链路)
+3. [第一层:文本解析层](#三第一层文本解析层)
+4. [第二层:素材生成层](#四第二层素材生成层)
+5. [第三层:视频合成层](#五第三层视频合成层)
+6. [第四层:输出交付层](#六第四层输出交付层)
+7. [服务端架构](#七服务端架构)
+8. [数据库设计](#八数据库设计)
+9. [API 设计](#九api-设计)
+10. [核心难点与解决方案](#十核心难点与解决方案)
+11. [与现有项目融合](#十一与现有项目融合)
+12. [MVP 实施计划](#十二mvp-实施计划)
+13. [成本估算](#十三成本估算)
+14. [附录](#十四附录)
+
+---
+
+## 一、概述与目标
+
+### 1.1 产品定义
+
+用户输入一段文本(故事、新闻、教程、文案),系统自动生成一段完整视频,包含:
+- 多个画面分镜
+- AI 配音旁白
+- 背景音乐
+- 字幕
+- 转场特效
+
+### 1.2 目标用户
+
+| 用户类型 | 典型场景 | 视频时长 | 频率 |
+|---------|---------|---------|------|
+| 内容创作者 | 小说转视频发抖音/快手 | 1-5分钟 | 高频 |
+| 自媒体运营 | 新闻/干货转短视频 | 30秒-2分钟 | 高频 |
+| 教育培训 | 教材/教程转教学视频 | 5-30分钟 | 中频 |
+| 企业 | 产品介绍/宣传视频 | 1-3分钟 | 低频 |
+
+### 1.3 设计原则
+
+1. **先出草稿,再精修** — 30 秒生成低分辨率预览,用户确认后再高清渲染
+2. **每个阶段可干预** — 分镜脚本、配音、画面、配乐全部支持用户调整
+3. **多模型热备** — 任何 AI 服务挂了,自动切换到备用方案
+4. **按需计费** — 按视频时长和分辨率阶梯定价
+
+---
+
+## 二、核心链路
+
+```
+输入文本
+  │
+  ▼
+┌──────────────────────────────────────────────────────────────┐
+│ 第一层:文本解析(Scripting Layer)                           │
+│ LLM 理解文本 → 规划分镜 → 输出结构化脚本 JSON                │
+│ 耗时: 3-10s                                                  │
+└──────────────────────────┬───────────────────────────────────┘
+                           │ 分镜脚本 (JSON)
+                           ▼
+┌──────────────────────────────────────────────────────────────┐
+│ 第二层:素材生成(Asset Generation Layer)                    │
+│ 每个分镜并行生成:图片(T2I) + 配音(TTS) + 动态视频(I2V)       │
+│ 耗时: 每个分镜 5-30s(并行后总耗时 ≈ 最慢的那个)             │
+└──────────────────────────┬───────────────────────────────────┘
+                           │ 素材文件 (图片/音频/视频片段)
+                           ▼
+┌──────────────────────────────────────────────────────────────┐
+│ 第三层:视频合成(Composition Layer)                         │
+│ FFmpeg 编排:拼接片段 + 叠加音频 + 添加字幕 + 转场 + BGM      │
+│ 耗时: 视频时长的 0.5-2x                                      │
+└──────────────────────────┬───────────────────────────────────┘
+                           │ .mp4 文件
+                           ▼
+┌──────────────────────────────────────────────────────────────┐
+│ 第四层:输出交付(Delivery Layer)                            │
+│ 上传 OSS → 生成 CDN 链接 → 通知用户                          │
+│ 耗时: 数秒                                                   │
+└──────────────────────────────────────────────────────────────┘
+```
+
+**端到端耗时预估**(3 分钟视频,10 个分镜):
+
+| 阶段 | 预览模式(512p) | 高清模式(1080p) |
+|------|:------------:|:-------------:|
+| 文本解析 | 5s | 10s |
+| 素材生成 | 30s | 120s |
+| 视频合成 | 60s | 180s |
+| 输出交付 | 5s | 10s |
+| **总计** | **~100s** | **~320s** |
+
+---
+
+## 三、第一层:文本解析层
+
+### 3.1 处理流程
+
+```
+原始文本
+  │
+  ├── (1) 文本预处理
+  │     ├── 分段(按段落/话题自动切分)
+  │     ├── 字数统计、预估总时长
+  │     └── 语言检测
+  │
+  ├── (2) 核心 LLM 调用(Prompt 工程关键)
+  │     ├── 内容分析:主题、类型、情绪基调
+  │     ├── 角色提取:名称、性别、年龄、外貌、性格
+  │     ├── 场景提取:时间、地点、季节、氛围、色调
+  │     └── 分镜拆解:每个分镜的详细参数
+  │
+  └── (3) 结构化校验
+        ├── JSON Schema 校验
+        ├── 时长合理性检查(总时长 = Σ各分镜时长)
+        └── 兜底处理(LLM 输出异常时的降级方案)
+```
+
+### 3.2 分镜脚本数据结构
+
+```typescript
+// SceneScript - 完整分镜脚本
+interface VideoScript {
+  scriptId: string;
+  title: string;
+  textOriginal: string;       // 用户原始文本
+  textCharCount: number;
+  estimatedDuration: number;  // 预估总时长(秒)
+  
+  // 全局设定
+  globalStyle: {
+    genre: 'story' | 'news' | 'tutorial' | 'marketing' | 'poetry';
+    visualStyle: string;      // 如 "中国水墨画风"、"3D皮克斯风"
+    colorPalette: string[];   // 主色调
+    aspectRatio: '16:9' | '9:16' | '1:1';  // 横屏/竖屏/方屏
+    resolution: '720p' | '1080p' | '4K';
+  };
+  
+  // 角色设定(贯穿全片)
+  characters: Character[];
+  
+  // 分镜列表
+  scenes: Scene[];
+  
+  // TTS 配置
+  ttsConfig: {
+    provider: 'minimax' | 'aliyun' | 'volcano';
+    voiceId: string;
+    speed: number;        // 0.5 - 2.0
+    volume: number;       // 0 - 1
+  };
+  
+  // BGM 配置
+  bgmConfig: {
+    style: string;        // 如 "史诗管弦"、"轻快电子"、"中国古风"
+    tempo: 'slow' | 'medium' | 'fast';
+    volumeRatio: number;  // 相对于旁白的音量,0.1 - 0.5
+  };
+}
+
+interface Character {
+  id: string;
+  name: string;
+  gender: 'male' | 'female';
+  ageRange: string;
+  appearance: string;     // 外貌描述(用于生成一致性画面)
+  personality: string;
+  referenceImageUrl?: string;  // 用户上传的参考图
+}
+
+interface Scene {
+  id: number;             // 分镜序号 1,2,3...
+  duration: number;       // 时长(秒)
+  
+  // 画面相关
+  imagePrompt: string;          // 完整的 T2I prompt(英文)
+  imagePromptZh: string;        // 中文版 prompt(供用户查看和编辑)
+  negativePrompt: string;       // 反向 prompt
+  charactersInScene: string[];  // 此分镜出现的角色 ID
+  sceneDescription: string;     // 场景描述
+  
+  // 镜头相关
+  cameraMotion: CameraMotion;   // 镜头运动
+  composition: string;          // 构图(如 "三分法"、"中心对称")
+  
+  // 配音相关
+  narration: string;            // 旁白文本
+  narrationStyle: string;       // 朗读风格描述
+  
+  // 转场
+  transitionIn: Transition;     // 入场转场
+  transitionOut: Transition;    // 出场转场
+  
+  // 情绪
+  mood: string;                 // 情绪标签
+  intensity: number;            // 情绪强度 1-10
+}
+
+type CameraMotion = 
+  | { type: 'static' }
+  | { type: 'zoom'; direction: 'in' | 'out'; speed: 'slow' | 'medium' | 'fast' }
+  | { type: 'pan'; direction: 'left' | 'right' | 'up' | 'down'; speed: 'slow' | 'medium' | 'fast' }
+  | { type: 'tilt'; direction: 'up' | 'down' }
+  | { type: 'track'; direction: 'left' | 'right' | 'forward' | 'backward' };
+
+type Transition = 
+  | { type: 'cut' }
+  | { type: 'fade'; duration: number }
+  | { type: 'dissolve'; duration: number }
+  | { type: 'wipe'; direction: 'left' | 'right' | 'up' | 'down'; duration: number }
+  | { type: 'slide'; direction: 'left' | 'right' | 'up' | 'down'; duration: number };
+```
+
+### 3.3 LLM Prompt 模板
+
+```
+你是一个专业的视频导演和分镜师。请将以下文本转化为视频分镜脚本。
+
+## 全局设定
+- 视频类型:[根据文本自动判断:故事/新闻/教程/营销]
+- 视觉风格:[根据文本主题推荐]
+- 画面比例:16:9(横屏)
+- 目标总时长:[根据字数估算]
+
+## 角色设定
+从文本中提取所有角色,描述其外貌特征(用于保证画面一致性)。
+
+## 分镜要求
+1. 每个分镜时长 5-15 秒
+2. 画面描述用英文(供 AI 生图),要详细描述:主体、动作、背景、光线、色调、构图
+3. 旁白文本要口语化,适合朗读
+4. 镜头运动要配合情节节奏
+5. 转场要自然,高潮部分可以切换更快
+
+## 输出格式
+严格按以下 JSON 格式输出:
+{
+  "title": "视频标题",
+  "genre": "...",
+  "visualStyle": "...",
+  "colorPalette": ["#1a1a2e", "#e94560"],
+  "characters": [{ "id": "c1", "name": "...", ... }],
+  "scenes": [
+    {
+      "id": 1,
+      "duration": 8,
+      "imagePrompt": "cinematic shot of..., 8K, photorealistic",
+      "narration": "旁白文本...",
+      "mood": "神秘",
+      "cameraMotion": {"type":"slow_zoom","direction":"in"},
+      "transitionIn": {"type":"fade","duration":1}
+    }
+  ]
+}
+
+## 输入文本
+{text_content}
+```
+
+### 3.4 技术选型
+
+| 组件 | 方案 | 原因 |
+|------|------|------|
+| LLM | Claude 3.5 Sonnet / GPT-4o | 结构化输出能力强 |
+| 备选 LLM | 阿里百炼 Qwen-Max | 成本低,中文理解好 |
+| Prompt 管理 | LangSmith / 自建模板系统 | 版本化 prompt,A/B 测试 |
+| 输出校验 | Zod Schema + 自定义规则 | 保证下游不收到脏数据 |
+
+---
+
+## 四、第二层:素材生成层
+
+### 4.1 并行流水线架构
+
+```
+                  ┌─────────────────────┐
+                  │   BullMQ 任务队列     │
+                  │   (Redis-backed)     │
+                  └──────────┬──────────┘
+                             │
+            ┌────────────────┼────────────────┐
+            ▼                ▼                ▼
+   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
+   │  Image Worker │  │  Audio Worker │  │  Video Worker │
+   │  (T2I Pool)   │  │  (TTS Pool)   │  │  (I2V Pool)   │
+   │  concurrency=4│  │  concurrency=4│  │  concurrency=2│
+   └──────┬───────┘  └──────┬───────┘  └──────┬───────┘
+          │                 │                 │
+   ┌──────▼───────┐  ┌──────▼───────┐  ┌──────▼───────┐
+   │ ProviderA     │  │ ProviderA     │  │ ProviderA     │
+   │ (主)          │  │ (主)          │  │ (主)          │
+   ├──────────────┤  ├──────────────┤  ├──────────────┤
+   │ ProviderB     │  │ ProviderB     │  │ ProviderB     │
+   │ (备用/failover)│  │ (备用/failover)│  │ (备用/failover)│
+   └──────────────┘  └──────────────┘  └──────────────┘
+```
+
+### 4.2 文生图 (T2I) 模块
+
+#### 实时进度设计
+
+```typescript
+interface GenerationProgress {
+  taskId: string;
+  stage: 'scripting' | 'generating' | 'composing' | 'done' | 'failed';
+  progress: number;  // 0-100
+  stageDetail: {
+    current: number;
+    total: number;
+    label: string;    // "正在生成第 3/10 个画面..."
+  };
+  estimatedRemaining: number; // 秒
+  error?: string;
+}
+```
+
+**进度推送方式**:
+1. **轮询**:`GET /api/video/:id/progress` — 每 2 秒请求一次
+2. **WebSocket**:`ws://host/ws/video/:id` — 实时推送(推荐)
+3. **SSE**:备选方案,兼容性好
+
+**前端效果**:
+```
+[████████░░░░░░░░] 45%  正在生成第 5/10 个画面...
+├── ✅ 分镜脚本已生成    (3s)
+├── ⏳ 画面生成中...     (5/10)
+├── ⬜ 配音生成          (等待中)
+├── ⬜ 视频合成          (等待中)
+└── 预计剩余: 2 分 30 秒
+```
+
+#### 多方案对比
+
+| 方案 | API 服务 | 单张成本 | 速度 | 质量 | 中文理解 |
+|------|---------|:------:|:----:|:----:|:------:|
+| A1 | 通义万相 (阿里) | ¥0.06-0.12 | 3-8s | ⭐⭐⭐⭐ | ✅ 原生支持 |
+| A2 | Stable Diffusion (自部署) | ¥0.01 | 5-15s | ⭐⭐⭐ | 需英文 |
+| A3 | Midjourney API | ¥0.08-0.15 | 20-60s | ⭐⭐⭐⭐⭐ | 需英文 |
+| A4 | DALL-E 3 (OpenAI) | ¥0.30-0.60 | 5-15s | ⭐⭐⭐⭐⭐ | 支持 |
+| A5 | 即梦 (字节) | ¥0.04-0.08 | 3-5s | ⭐⭐⭐⭐ | ✅ 原生支持 |
+
+**推荐方案**:**通义万相 主 + 即梦 备用**(成本低、中文友好、速度快)
+
+#### 画面一致性保障
+
+这是最核心的难点。同一个角色在不同分镜里要保持脸一致。
+
+**方案一:IP-Adapter + FaceID (推荐)**
+
+```
+用户参考图 (或第一帧生成的角色图)
+  │
+  ▼
+IP-Adapter FaceID 提取面部特征向量
+  │
+  ▼
+每个分镜的 T2I 请求附带特征向量作为 condition
+  │
+  ▼
+生成的所有图片保持面部一致
+```
+
+实现路径:ComfyUI + IP-Adapter-FaceID 工作流(自部署)
+
+**方案二:GPT-4o 验证 + 重试**
+
+```
+生成第 N 个分镜图片
+  │
+  ▼
+GPT-4o 对比:这张图里的角色和前面一致吗?
+  ├── 一致 → 通过
+  └── 不一致 → 调整 prompt → 重新生成(最多 3 次)
+```
+
+**方案三:Seed 锁定 + LoRA**
+
+预训练角色的 LoRA 模型,所有分镜共用同一个 seed 和 LoRA。
+
+### 4.3 语音合成 (TTS) 模块
+
+#### 现有能力复用
+
+当前项目已集成 3 家 TTS 服务,可直接复用:
+
+| 服务商 | 音色数 | 单字成本 | 中文质量 | 感情表达 |
+|--------|:----:|:------:|:------:|:------:|
+| MiniMax | 30+ | ¥0.015/字 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
+| 阿里百炼 | 50+ | ¥0.01/字 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
+| 火山引擎 | 20+ | ¥0.012/字 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
+
+**选择策略**:
+- 故事类 → MiniMax(感情表达最好)
+- 教程类 → 阿里百炼(最清晰)
+- 新闻类 → 火山引擎(语速可控)
+
+**音色一致性**:全片使用同一个 `voiceId`,无需额外处理。
+
+#### 长文本分段处理
+
+```typescript
+async function generateNarration(scenes: Scene[]): Promise<AudioSegment[]> {
+  const results: AudioSegment[] = [];
+  
+  for (const scene of scenes) {
+    const audio = await ttsProvider.synthesize({
+      text: scene.narration,
+      voiceId: ttsConfig.voiceId,
+      speed: scene.mood === '紧张' ? 1.2 : 1.0,
+      format: 'mp3',
+    });
+    
+    // 根据实际音频时长校准分镜时长
+    scene.duration = audio.duration;
+    
+    results.push({
+      sceneId: scene.id,
+      audioUrl: await uploadToOss(audio.buffer),
+      duration: audio.duration,
+      text: scene.narration,
+    });
+  }
+  
+  return results;
+}
+```
+
+### 4.4 图生视频 (I2V) 模块(可选,非 MVP 必须)
+
+如果预算允许,可以在 MVP 之后加入动态效果:
+
+| 方案 | API | 单秒成本 | 速度 | 效果 |
+|------|-----|:------:|:----:|:----:|
+| 可灵 (快手) | 国内 | ¥0.50-1.00 | 60s/秒 | ⭐⭐⭐⭐⭐ |
+| 即梦 (字节) | 国内 | ¥0.30-0.60 | 30s/秒 | ⭐⭐⭐⭐ |
+| Runway Gen-3 | 海外 | ¥0.80-1.50 | 60s/秒 | ⭐⭐⭐⭐⭐ |
+| SVD (开源) | 自部署 | ¥0.01 | 120s/秒 | ⭐⭐⭐ |
+
+**MVP 替代方案**:Ken Burns 效果
+
+不动态生成视频,只对静态图片应用 FFmpeg 的缩放/平移效果:
+
+```bash
+# Ken Burns: 缓慢放大 + 平移
+ffmpeg -loop 1 -i scene1.png \
+  -vf "zoompan=z='min(zoom+0.0015,1.5)':d=250:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1920x1080" \
+  -t 8 -c:v libx264 scene1_animated.mp4
+```
+
+效果类似纪录片风格,成本几乎为零,对于 MVP 完全够用。
+
+---
+
+## 五、第三层:视频合成层
+
+### 5.1 FFmpeg 合成流水线
+
+```
+所有素材就绪
+  │
+  ├── (1) 图片转视频片段(Ken Burns 效果)
+  ├── (2) 视频片段 + 配音音频 对齐
+  ├── (3) 各分镜拼接 + 转场效果
+  ├── (4) 叠加字幕(SRT/ASS)
+  ├── (5) 混合背景音乐
+  ├── (6) 添加片头片尾(可选)
+  └── (7) 编码输出
+       │
+       ▼
+     output.mp4
+```
+
+### 5.2 完整的 FFmpeg 合成脚本生成器
+
+```typescript
+class FFmpegComposer {
+  /**
+   * 根据分镜脚本生成 FFmpeg 命令
+   */
+  buildCommand(params: ComposeParams): string {
+    const { scenes, bgmUrl, watermarkUrl, outputPath, resolution } = params;
+    
+    // 构建 filter_complex
+    const filters: string[] = [];
+    const inputs: string[] = [];
+    let inputIndex = 0;
+    let videoStreams = '';
+    let audioStreams = '';
+    
+    for (const scene of scenes) {
+      // 输入:图片 + 音频
+      inputs.push(`-loop 1 -t ${scene.duration} -i ${scene.imagePath}`);
+      inputs.push(`-i ${scene.audioPath}`);
+      
+      const vIdx = inputIndex;
+      const aIdx = inputIndex + 1;
+      inputIndex += 2;
+      
+      // Ken Burns 效果
+      filters.push(
+        `[${vIdx}:v]zoompan=z='min(zoom+0.0015,1.5)':d=1:` +
+        `s=${resolution.width}x${resolution.height}:` +
+        `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'[v${scene.id}]`
+      );
+      
+      videoStreams += `[v${scene.id}]`;
+      audioStreams += `[${aIdx}:a]`;
+    }
+    
+    // 视频拼接
+    const concatN = scenes.length;
+    filters.push(`${videoStreams}concat=n=${concatN}:v=1:a=0 [vout]`);
+    filters.push(`${audioStreams}concat=n=${concatN}:v=0:a=1 [aout]`);
+    
+    // 字幕叠加
+    let currentTime = 0;
+    for (const scene of scenes) {
+      const subtitleText = this.escapeText(scene.narration);
+      filters.push(
+        `[vout]drawtext=text='${subtitleText}':` +
+        `fontfile=/usr/share/fonts/NotoSansSC-Regular.ttf:` +
+        `fontsize=24:fontcolor=white:borderw=2:bordercolor=black@0.5:` +
+        `x=(w-text_w)/2:y=h-120:` +
+        `enable='between(t,${currentTime},${currentTime + scene.duration})'[vsub]`
+      );
+      currentTime += scene.duration;
+    }
+    
+    // BGM 混合
+    inputs.push(`-i ${bgmUrl}`);
+    const bgmIdx = inputIndex;
+    const totalDuration = scenes.reduce((sum, s) => sum + s.duration, 0);
+    filters.push(
+      `[aout][${bgmIdx}:a]amix=inputs=2:duration=first:weights='1 0.3' [afinal]`
+    );
+    
+    // 构建命令
+    const filterComplex = filters.join('; ');
+    return [
+      ...inputs,
+      `-filter_complex "${filterComplex}"`,
+      `-map "[vsub]" -map "[afinal]"`,
+      `-c:v libx264 -preset medium -crf 20`,
+      `-c:a aac -b:a 192k`,
+      `-pix_fmt yuv420p`,
+      `-movflags +faststart`,
+      `-y ${outputPath}`,
+    ].join(' ');
+  }
+  
+  private escapeText(text: string): string {
+    return text
+      .replace(/\\/g, '\\\\')
+      .replace(/'/g, "\\'")
+      .replace(/:/g, '\\:')
+      .replace(/,/g, '\\,');
+  }
+}
+```
+
+### 5.3 字幕生成
+
+```typescript
+interface SubtitleLine {
+  index: number;
+  startTime: number;  // 秒
+  endTime: number;    // 秒
+  text: string;
+}
+
+/**
+ * 从分镜脚本生成 SRT 字幕文件
+ */
+function generateSRT(scenes: Scene[], audioSegments: AudioSegment[]): string {
+  let srt = '';
+  let index = 1;
+  let currentTime = 0;
+  
+  for (const scene of scenes) {
+    const words = scene.narration.split('');
+    const charsPerSecond = words.length / scene.duration;
+    
+    // 每 15-20 字一行
+    const chunkSize = 18;
+    let pos = 0;
+    
+    while (pos < words.length) {
+      const chunk = words.slice(pos, pos + chunkSize).join('');
+      const chunkDuration = chunk.length / charsPerSecond;
+      
+      srt += `${index}\n`;
+      srt += `${this.formatSrtTime(currentTime)} --> ${this.formatSrtTime(currentTime + chunkDuration)}\n`;
+      srt += `${chunk}\n\n`;
+      
+      currentTime += chunkDuration;
+      index++;
+      pos += chunkSize;
+    }
+  }
+  
+  return srt;
+}
+
+function formatSrtTime(seconds: number): string {
+  const h = Math.floor(seconds / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  const s = Math.floor(seconds % 60);
+  const ms = Math.floor((seconds % 1) * 1000);
+  return `${pad(h)}:${pad(m)}:${pad(s)},${String(ms).padEnd(3, '0')}`;
+}
+```
+
+### 5.4 性能优化
+
+| 优化策略 | 效果 |
+|---------|------|
+| GPU 编码 (NVENC) | 合成速度 3-5x 提升 |
+| 分段合成 + 最后拼接 | 内存可控,支持长视频 |
+| 预览模式 512p | 比 1080p 快 4x |
+| 复用缓存 | 同一图片多个分镜只生成一次 |
+
+---
+
+## 六、第四层:输出交付层
+
+```typescript
+class VideoDeliveryService {
+  async deliver(taskId: string, videoPath: string) {
+    // 1. 上传到 OSS
+    const ossKey = `videos/${taskId}/output_${Date.now()}.mp4`;
+    await this.oss.upload(videoPath, ossKey, {
+      headers: { 'Content-Type': 'video/mp4' },
+    });
+    
+    // 2. 生成 CDN 签名 URL
+    const cdnUrl = this.oss.generateSignedUrl(ossKey, {
+      expires: 7 * 24 * 3600,  // 7 天有效期
+    });
+    
+    // 3. 生成封面图
+    const coverKey = await this.generateCover(videoPath);
+    
+    // 4. 更新数据库
+    await this.db.videoTask.update(taskId, {
+      status: 'done',
+      videoUrl: cdnUrl,
+      coverUrl: coverKey,
+      fileSize: fs.statSync(videoPath).size,
+      completedAt: new Date(),
+    });
+    
+    // 5. 推送通知
+    await this.notification.send(taskId, {
+      type: 'video_completed',
+      title: '视频生成完成',
+      videoUrl: cdnUrl,
+    });
+    
+    // 6. 清理本地文件(保留 1 小时后删除)
+    setTimeout(() => fs.unlinkSync(videoPath), 3600000);
+  }
+}
+```
+
+---
+
+## 七、服务端架构
+
+### 7.1 整体架构图
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                       Nginx (反向代理 + HTTPS)                │
+└──────────────┬──────────────────────────────┬───────────────┘
+               │                              │
+┌──────────────▼──────────┐     ┌────────────▼──────────────┐
+│   Nest.js API Server     │     │   Frontend (Vue/UniApp)    │
+│   (主要业务逻辑)          │     │                           │
+│   - 用户认证              │     │   - 文本输入页             │
+│   - 任务创建/查询          │     │   - 分镜编辑页             │
+│   - 支付/订阅             │     │   - 进度展示页             │
+│   - 素材管理              │     │   - 视频播放页             │
+└──────────────┬──────────┘     └──────────────────────────────┘
+               │
+┌──────────────▼─────────────────────────────────────────────┐
+│                     BullMQ Task Queue (Redis)                │
+│                                                              │
+│  ┌───────────────┐  ┌──────────────┐  ┌─────────────────┐  │
+│  │ Scripting      │  │ Asset Gen     │  │ Composition     │  │
+│  │ Workers (x2)   │  │ Workers (x4)  │  │ Workers (x2)    │  │
+│  │                │  │               │  │ (需 GPU 加速)    │  │
+│  │ CPU: 2核       │  │ CPU: 2核      │  │ GPU: T4 x1      │  │
+│  │ Mem: 4GB       │  │ Mem: 8GB      │  │ Mem: 16GB       │  │
+│  └───────────────┘  └──────────────┘  └─────────────────┘  │
+└──────────────┬──────────────────────────────────────────────┘
+               │
+┌──────────────▼──────────────────────────────────────────────┐
+│                       Storage Layer                          │
+│  ┌──────────────────┐  ┌──────────────┐  ┌───────────────┐ │
+│  │  MySQL (任务/用户) │  │ Redis (队列   │  │ OSS (素材/视频)│ │
+│  │                  │  │  /缓存/进度) │  │               │ │
+│  └──────────────────┘  └──────────────┘  └───────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 7.2 Worker 详细设计
+
+```typescript
+// === Scripting Worker ===
+@Processor('scripting')
+class ScriptingWorker {
+  @Process('generate')
+  async generateScript(job: Job<{ text: string; config: ScriptConfig }>) {
+    const { text, config } = job.data;
+    
+    // 更新进度
+    await job.updateProgress({ stage: 'scripting', progress: 0 });
+    
+    // 调用 LLM 生成分镜
+    const script = await this.llmService.generateScript(text, config);
+    
+    await job.updateProgress({ stage: 'scripting', progress: 100 });
+    
+    // 创建素材生成子任务
+    for (const scene of script.scenes) {
+      await this.assetQueue.add('generateImage', { scene, scriptId: script.id });
+      await this.assetQueue.add('generateAudio', { scene, scriptId: script.id });
+    }
+    
+    return script;
+  }
+}
+
+// === Asset Worker ===
+@Processor('asset-generation')
+class AssetWorker {
+  @Process('generateImage')
+  async generateImage(job: Job<{ scene: Scene; scriptId: string }>) {
+    const { scene } = job.data;
+    
+    // 重试机制 + provider failover
+    let image: Buffer;
+    const providers = ['tongyi', 'jimeng', 'openai'];
+    
+    for (const provider of providers) {
+      try {
+        image = await this.imageProviders[provider].generate(scene.imagePrompt, {
+          negativePrompt: scene.negativePrompt,
+          size: '1024x1024',
+        });
+        break;
+      } catch (e) {
+        this.logger.warn(`Provider ${provider} failed, trying next...`, e);
+      }
+    }
+    
+    if (!image) throw new Error('All image providers failed');
+    
+    // 上传到 OSS
+    const url = await this.oss.upload(
+      `scripts/${job.data.scriptId}/scene_${scene.id}.png`,
+      image
+    );
+    
+    // 更新场景状态
+    await this.db.scene.update(scene.id, { imageUrl: url, imageStatus: 'done' });
+    
+    return { sceneId: scene.id, imageUrl: url };
+  }
+  
+  @Process('generateAudio')
+  async generateAudio(job: Job<{ scene: Scene; scriptId: string }>) {
+    const { scene } = job.data;
+    
+    const providers = ['minimax', 'aliyun', 'volcano'];
+    let audio: Buffer;
+    
+    for (const provider of providers) {
+      try {
+        audio = await this.ttsProviders[provider].synthesize({
+          text: scene.narration,
+          voiceId: job.data.script.ttsConfig.voiceId,
+          speed: job.data.script.ttsConfig.speed,
+        });
+        break;
+      } catch (e) {
+        this.logger.warn(`TTS provider ${provider} failed, trying next...`, e);
+      }
+    }
+    
+    if (!audio) throw new Error('All TTS providers failed');
+    
+    const url = await this.oss.upload(
+      `scripts/${job.data.scriptId}/scene_${scene.id}_audio.mp3`,
+      audio
+    );
+    
+    await this.db.scene.update(scene.id, { audioUrl: url, audioStatus: 'done' });
+    
+    return { sceneId: scene.id, audioUrl: url };
+  }
+}
+
+// === Composition Worker ===
+@Processor('composition')
+class CompositionWorker {
+  @Process('compose')
+  async composeVideo(job: Job<{ scriptId: string; options: ComposeOptions }>) {
+    const { scriptId, options } = job.data;
+    
+    await job.updateProgress({ stage: 'composing', progress: 0 });
+    
+    // 加载所有素材
+    const scenes = await this.db.scene.findByScriptId(scriptId);
+    const script = await this.db.script.findById(scriptId);
+    
+    // 确保所有素材就绪
+    const allReady = scenes.every(s => s.imageStatus === 'done' && s.audioStatus === 'done');
+    if (!allReady) throw new Error('Not all assets are ready');
+    
+    // 下载素材到本地(OSS → tmp dir)
+    const tmpDir = `/tmp/video-${scriptId}`;
+    await this.downloadAssets(scenes, tmpDir);
+    
+    // 生成 FFmpeg 命令
+    const composer = new FFmpegComposer();
+    const cmd = composer.buildCommand({
+      scenes: scenes.map(s => ({
+        ...s,
+        imagePath: `${tmpDir}/scene_${s.id}.png`,
+        audioPath: `${tmpDir}/scene_${s.id}_audio.mp3`,
+      })),
+      bgmUrl: script.bgmConfig.audioUrl,
+      outputPath: `${tmpDir}/output.mp4`,
+      resolution: { width: 1920, height: 1080 },
+    });
+    
+    await job.updateProgress({ progress: 20 });
+    
+    // 执行 FFmpeg
+    await this.execFFmpeg(cmd, (progress) => {
+      job.updateProgress({ progress: 20 + Math.round(progress * 0.7) });
+    });
+    
+    await job.updateProgress({ progress: 90 });
+    
+    // 交付
+    await this.deliveryService.deliver(scriptId, `${tmpDir}/output.mp4`);
+    
+    await job.updateProgress({ progress: 100 });
+  }
+}
+```
+
+### 7.3 WebSocket 进度推送
+
+```typescript
+@WebSocketGateway({ namespace: '/ws/video' })
+class VideoProgressGateway {
+  @WebSocketServer()
+  server: Server;
+  
+  // 客户端订阅任务进度
+  @SubscribeMessage('subscribe')
+  async handleSubscribe(
+    @MessageBody() data: { taskId: string },
+    @ConnectedSocket() client: Socket
+  ) {
+    client.join(`task:${data.taskId}`);
+    
+    // 发送当前进度
+    const task = await this.videoService.getTask(data.taskId);
+    client.emit('progress', task);
+  }
+  
+  // Worker 端推送进度更新
+  pushProgress(taskId: string, progress: VideoProgress) {
+    this.server.to(`task:${taskId}`).emit('progress', progress);
+  }
+}
+```
+
+---
+
+## 八、数据库设计
+
+### 8.1 核心表
+
+```sql
+-- 视频任务主表
+CREATE TABLE video_tasks (
+  id VARCHAR(36) PRIMARY KEY,
+  user_id INT NOT NULL,
+  title VARCHAR(200),
+  text_original TEXT NOT NULL,
+  text_char_count INT DEFAULT 0,
+  status ENUM('pending','scripting','generating','composing','done','failed') DEFAULT 'pending',
+  progress JSON,                     -- {stage, progress, detail}
+  video_url VARCHAR(500),
+  cover_url VARCHAR(500),
+  duration INT DEFAULT 0,            -- 实际时长(秒)
+  file_size BIGINT DEFAULT 0,        -- 字节
+  resolution VARCHAR(10) DEFAULT '1080p',
+  estimated_cost DECIMAL(10,4),      -- 预估费用
+  actual_cost DECIMAL(10,4),         -- 实际费用
+  error_message TEXT,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+  completed_at TIMESTAMP NULL,
+  INDEX idx_user_status (user_id, status),
+  INDEX idx_created (created_at)
+);
+
+-- 分镜脚本表
+CREATE TABLE video_scripts (
+  id VARCHAR(36) PRIMARY KEY,
+  task_id VARCHAR(36) NOT NULL,
+  title VARCHAR(200),
+  genre VARCHAR(20),
+  visual_style VARCHAR(100),
+  color_palette JSON,
+  aspect_ratio VARCHAR(5) DEFAULT '16:9',
+  tts_config JSON,                    -- {provider, voiceId, speed, volume}
+  bgm_config JSON,                    -- {style, tempo, volumeRatio}
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+  FOREIGN KEY (task_id) REFERENCES video_tasks(id)
+);
+
+-- 角色表
+CREATE TABLE video_characters (
+  id VARCHAR(36) PRIMARY KEY,
+  script_id VARCHAR(36) NOT NULL,
+  name VARCHAR(100),
+  gender ENUM('male','female'),
+  age_range VARCHAR(20),
+  appearance TEXT,
+  personality TEXT,
+  reference_image_url VARCHAR(500),
+  face_feature_vector BLOB,          -- IP-Adapter 提取的特征向量
+  FOREIGN KEY (script_id) REFERENCES video_scripts(id)
+);
+
+-- 分镜表
+CREATE TABLE video_scenes (
+  id VARCHAR(36) PRIMARY KEY,
+  script_id VARCHAR(36) NOT NULL,
+  scene_order INT NOT NULL,
+  duration DECIMAL(5,1) DEFAULT 8.0,
+  image_prompt TEXT,
+  image_prompt_zh TEXT,
+  negative_prompt TEXT,
+  narration TEXT,
+  narration_style VARCHAR(100),
+  camera_motion JSON,
+  transition_in JSON,
+  transition_out JSON,
+  mood VARCHAR(50),
+  intensity TINYINT DEFAULT 5,
+  
+  -- 生成结果
+  image_url VARCHAR(500),
+  image_status ENUM('pending','generating','done','failed') DEFAULT 'pending',
+  audio_url VARCHAR(500),
+  audio_status ENUM('pending','generating','done','failed') DEFAULT 'pending',
+  video_url VARCHAR(500),            -- I2V 扩展
+  video_status ENUM('pending','generating','done','failed') DEFAULT 'pending',
+  
+  retry_count INT DEFAULT 0,
+  error_message TEXT,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+  FOREIGN KEY (script_id) REFERENCES video_scripts(id),
+  INDEX idx_script_order (script_id, scene_order)
+);
+
+-- 场景中出现的角色关联
+CREATE TABLE video_scene_characters (
+  scene_id VARCHAR(36) NOT NULL,
+  character_id VARCHAR(36) NOT NULL,
+  PRIMARY KEY (scene_id, character_id)
+);
+```
+
+### 8.2 费用记录表
+
+```sql
+CREATE TABLE video_costs (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  task_id VARCHAR(36) NOT NULL,
+  scene_id VARCHAR(36),              -- NULL 表示整任务费用
+  cost_type ENUM('scripting','image','audio','video_gen','composition','delivery'),
+  provider VARCHAR(50),              -- 如 'tongyi', 'minimax', 'ffmpeg'
+  units DECIMAL(10,2),               -- 使用量(张数/字数/秒数)
+  unit_price DECIMAL(10,6),
+  total_cost DECIMAL(10,4),
+  detail JSON,                       -- 附加信息
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+  FOREIGN KEY (task_id) REFERENCES video_tasks(id),
+  INDEX idx_task (task_id)
+);
+```
+
+---
+
+## 九、API 设计
+
+### 9.1 RESTful API
+
+```typescript
+// === 视频任务 ===
+
+// 创建视频任务
+POST /api/video/create
+Body: {
+  text: "输入文本...",
+  config?: {
+    style?: "水墨画风" | "3D卡通" | "写实" | "二次元",
+    resolution?: "720p" | "1080p" | "4K",
+    aspectRatio?: "16:9" | "9:16" | "1:1",
+    voice?: "male_young" | "female_warm" | "male_deep",
+    bgm?: "epic" | "light" | "none",
+    subtitleEnabled?: true
+  }
+}
+Response: {
+  code: 0,
+  data: {
+    taskId: "uuid",
+    estimatedCost: 0.85,     // 预估费用
+    estimatedDuration: 180,  // 预估耗时(秒)
+    status: "pending"
+  }
+}
+
+// 查询任务状态
+GET /api/video/:id
+Response: {
+  code: 0,
+  data: {
+    taskId: "uuid",
+    status: "generating",
+    progress: { stage: "generating", progress: 45, detail: "正在生成第5/10个画面..." },
+    script: { ... },         // status >= scripting 时返回
+    scenes: [ ... ],         // 含素材 URL
+    videoUrl: "https://...", // status === done 时返回
+    coverUrl: "https://...",
+    duration: 180,
+    actualCost: 0.82
+  }
+}
+
+// 查询任务列表
+GET /api/video/list?page=1&pageSize=20&status=done
+Response: {
+  code: 0,
+  data: {
+    list: [{ taskId, title, coverUrl, duration, status, createdAt }],
+    total: 42
+  }
+}
+
+// 重试失败任务
+POST /api/video/:id/retry
+
+// 删除任务
+DELETE /api/video/:id
+
+// === 分镜编辑(草稿阶段干预) ===
+
+// 更新分镜脚本
+PUT /api/video/:id/script
+Body: { scenes: [{ id: 1, imagePrompt: "修改后的prompt", narration: "修改后的旁白" }] }
+
+// 重新生成单个分镜
+POST /api/video/:id/scene/:sceneId/regenerate
+Body: { type: "image" | "audio" | "all" }
+
+// === 费用 ===
+
+// 预估费用
+POST /api/video/estimate
+Body: { text: "...", config: { resolution: "1080p" } }
+Response: { code: 0, data: { estimatedCost: 0.85 } }
+
+// 查询费用明细
+GET /api/video/:id/costs
+```
+
+### 9.2 WebSocket 事件
+
+```
+Client → Server:
+  subscribe { taskId: "uuid" }        // 订阅任务进度
+  unsubscribe { taskId: "uuid" }      // 取消订阅
+
+Server → Client:
+  progress {                          // 进度更新
+    taskId: "uuid",
+    stage: "generating",
+    progress: 67,
+    detail: { current: 7, total: 10, label: "正在生成第7/10个画面..." },
+    estimatedRemaining: 45
+  }
+  completed {                         // 任务完成
+    taskId: "uuid",
+    videoUrl: "https://...",
+    duration: 180
+  }
+  failed {                            // 任务失败
+    taskId: "uuid",
+    error: "视频合成失败:内存不足"
+  }
+```
+
+---
+
+## 十、核心难点与解决方案
+
+### 10.1 画面一致性
+
+**问题**:同一个角色在不同分镜里脸不一样,看起来像不同的人。
+
+**解决方案**:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 三层保障机制                                                  │
+│                                                              │
+│ Layer 1: Prompt 工程                                         │
+│   所有分镜的 imagePrompt 前缀统一角色描述                      │
+│   例: "A young Chinese woman, long black hair, round face,   │
+│         wearing a red hanfu, consistent character..."        │
+│                                                              │
+│ Layer 2: IP-Adapter + FaceID (推荐)                           │
+│   - 第一帧生成角色 → 提取 FaceID embedding                   │
+│   - 后续分镜附带 embedding 作为条件注入                       │
+│   - 技术栈: ComfyUI + IP-Adapter-FaceID-Plus                │
+│                                                              │
+│ Layer 3: GPT-4o 质量验证                                      │
+│   - 每个分镜生成后 → GPT-4o 对比角色一致性                    │
+│   - 不一致 → 自动调整 prompt → 重新生成(最多 3 次)           │
+│   - Cost: ~$0.01/次验证                                      │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 10.2 长文本处理
+
+**问题**:用户输入 5000 字,需要 20+ 个分镜,LLM 输出可能失控。
+
+**解决方案**:
+
+1. **分段处理**:超过 2000 字自动切分成 N 段,每段独立生成分镜
+2. **全局上下文注入**:每段生成时携带角色设定的摘要
+3. **分镜合并**:所有段的分镜按序拼接,保证连贯性
+
+### 10.3 成本控制
+
+```
+预估成本公式:
+  Cost = N_scenes × (T2I_cost + TTS_cost) + BGM_cost + Compute_cost
+
+以 3 分钟视频(10 个分镜)为例:
+  通义万相 T2I: 10 × ¥0.10 = ¥1.00
+  MiniMax TTS:  500字 × ¥0.015 = ¥7.50
+  BGM:          ¥0.50
+  FFmpeg 合成:   ¥0.20 (GPU 租用分摊)
+  LLM 分镜:     ¥0.05
+  ─────────────────────────
+  总计:         ¥9.25
+
+用户定价建议:
+  1080p: ¥2.99/次 (约 3x 成本覆盖)
+  4K:    ¥5.99/次
+  VIP 月度: ¥29.99/月(不限次数,限 1080p)
+```
+
+### 10.4 容错设计
+
+```typescript
+// 多层容错机制
+class ResilienceManager {
+  // 1. Provider Failover
+  async generateWithRetry<T>(
+    providers: string[],
+    generateFn: (provider: string) => Promise<T>,
+    maxRetries = 3
+  ): Promise<T> {
+    for (const provider of providers) {
+      for (let retry = 0; retry < maxRetries; retry++) {
+        try {
+          return await generateFn(provider);
+        } catch (e) {
+          if (retry < maxRetries - 1) {
+            await this.sleep(Math.pow(2, retry) * 1000); // 指数退避
+          }
+        }
+      }
+    }
+    throw new Error('All providers exhausted');
+  }
+  
+  // 2. 单个分镜失败不阻塞全部
+  async generateAllScenes(scenes: Scene[]) {
+    const results = await Promise.allSettled(
+      scenes.map(s => this.generateScene(s))
+    );
+    
+    // 统计失败率
+    const failed = results.filter(r => r.status === 'rejected');
+    if (failed.length / scenes.length > 0.5) {
+      throw new Error('Too many scenes failed');
+    }
+    
+    // 对失败的用 placeholder 替代
+    return results.map((r, i) => {
+      if (r.status === 'fulfilled') return r.value;
+      return this.generatePlaceholder(scenes[i]);
+    });
+  }
+  
+  // 3. 任务幂等性
+  async createTask(text: string): Promise<string> {
+    const hash = crypto.createHash('md5').update(text).digest('hex');
+    const existing = await this.db.videoTask.findByHash(hash);
+    
+    if (existing && existing.status === 'done') {
+      return existing.id; // 相同文本直接返回缓存结果
+    }
+    
+    return this.createNewTask(text, hash);
+  }
+}
+```
+
+### 10.5 用户交互设计
+
+```
+用户编辑流程(保证可控性):
+  
+  输入文本 → [生成分镜预览] → 用户确认/编辑分镜 → [生成素材] → 用户预览 →
+  [调整/重生成单个分镜] → [确认合成] → 等待 → 播放/下载
+
+关键:分镜草稿阶段就允许用户干预,避免生成完才发现不满意
+```
+
+---
+
+## 十一、与现有项目融合
+
+### 11.1 现有能力映射
+
+| 现有模块 | 路径 | 复用方式 |
+|---------|------|---------|
+| Nest.js 服务 | `server/src/` | 新增 `modules/video/` 目录 |
+| TTS 多模型 | `server/src/modules/tts/` | 封装为统一 TTS Provider 接口 |
+| 阿里云 OSS | 现有 OSS 配置 | 视频素材 + 成品存储 |
+| 支付系统 | `server/src/modules/payment/` | 按视频收费,复用支付宝/微信 |
+| 用户认证 | `server/src/modules/auth/` | 复用 JWT 认证 + VIP 体系 |
+| 任务队列 | 需要新增 | 引入 BullMQ + Redis |
+| FFmpeg | 需要新增 | 服务器安装 + 封装 |
+| 前端 UniApp | `my-uniapp-vue3/` | 新增视频生成页面 |
+
+### 11.2 新模块目录结构
+
+```
+server/src/modules/video/
+├── video.module.ts           # Nest.js 模块定义
+├── video.controller.ts       # API 控制器
+├── video.service.ts          # 业务逻辑
+├── video.gateway.ts          # WebSocket 进度推送
+├── scripting/
+│   ├── scripting.worker.ts   # 分镜生成 Worker
+│   ├── scripting.service.ts  # LLM 调用封装
+│   └── prompts/
+│       ├── story.ts          # 故事类 prompt 模板
+│       ├── news.ts           # 新闻类 prompt 模板
+│       └── tutorial.ts       # 教程类 prompt 模板
+├── assets/
+│   ├── image.worker.ts       # T2I Worker
+│   ├── image.service.ts      # 多 Provider 封装
+│   ├── audio.worker.ts       # TTS Worker (复用现有)
+│   ├── audio.service.ts      # TTS Provider 统一接口
+│   └── providers/
+│       ├── tongyi.provider.ts
+│       ├── jimeng.provider.ts
+│       ├── minimax.provider.ts
+│       └── aliyun-tts.provider.ts
+├── composition/
+│   ├── composition.worker.ts # 视频合成 Worker
+│   ├── composition.service.ts
+│   ├── ffmpeg-builder.ts     # FFmpeg 命令构建器
+│   └── subtitle.service.ts   # 字幕生成
+├── delivery/
+│   └── delivery.service.ts   # OSS 上传 + CDN
+├── entities/
+│   ├── video-task.entity.ts
+│   ├── video-script.entity.ts
+│   ├── video-character.entity.ts
+│   └── video-scene.entity.ts
+└── dto/
+    ├── create-video.dto.ts
+    ├── update-script.dto.ts
+    └── video-progress.dto.ts
+```
+
+### 11.3 前端新页面
+
+```
+my-uniapp-vue3/src/pages/
+├── video-create/             # 视频创建页
+│   └── index.vue             # 输入文本 + 参数选择
+├── video-script/             # 分镜编辑页
+│   └── index.vue             # 分镜预览 + 调整
+├── video-progress/           # 进度页
+│   └── index.vue             # 实时进度 + 素材预览
+├── video-player/             # 视频播放页
+│   └── index.vue             # 播放 + 分享 + 下载
+└── video-list/               # 我的视频列表
+    └── index.vue             # 历史记录
+```
+
+---
+
+## 十二、MVP 实施计划
+
+### 12.1 MVP 范围
+
+**包含**:
+- 输入文本 → 自动生成分镜脚本
+- 通义万相 T2I 生成画面
+- 现有 TTS 生成配音
+- Ken Burns 效果(静态图缓慢运动)
+- FFmpeg 合成 + 字幕 + BGM
+- 1080p 输出
+- 基本进度展示
+
+**不包含**:
+- IP-Adapter 角色一致性(用 prompt 工程替代)
+- I2V 动态视频生成
+- 复杂转场(仅淡入淡出+硬切)
+- 分镜编辑功能(MVP 不支持调整,直接生成)
+- 4K 输出
+- BGM 智能匹配(手动选风格)
+
+### 12.2 7 天开发计划
+
+| 天数 | 任务 | 产出 |
+|:--:|------|------|
+| **Day 1** | LLM 分镜脚本生成 | Prompt 模板 + JSON Schema 校验 + API 端点 |
+| **Day 2** | T2I 接入(通义万相) | Provider 封装 + 批量生成 + OSS 上传 |
+| **Day 3** | TTS 模块统一封装 | Provider 接口 + 现有三家的适配层 |
+| **Day 4** | FFmpeg 合成引擎 | Ken Burns + 拼接 + 字幕 + BGM + 编码 |
+| **Day 5** | 任务编排 + 进度推送 | BullMQ 队列 + WebSocket Gateway + Worker 串联 |
+| **Day 6** | 前端页面 | 创建页 + 进度页 + 播放页 |
+| **Day 7** | 联调 + 测试 + 上线 | 端到端测试 + 性能调优 + 部署 |
+
+### 12.3 MVP 环境要求
+
+```bash
+# 新增依赖
+# server/package.json
+{
+  "dependencies": {
+    "bullmq": "^5.0",
+    "ioredis": "^5.3",
+    "@nestjs/bullmq": "^10.0",
+    "@nestjs/websockets": "^10.0",
+    "@nestjs/platform-socket.io": "^10.0",
+    "@alicloud/openapi-client": "^0.4",
+    "@alicloud/wanxiang-20240614": "^1.0",
+    "fluent-ffmpeg": "^2.1",
+    "zod": "^3.22"
+  }
+}
+
+# 服务端新增环境变量
+# server/.env
+# === Video Generation ===
+VIDEO_T2I_PROVIDER=tongyi
+VIDEO_T2I_BACKUP=jimeng
+VIDEO_LLM_PROVIDER=claude
+VIDEO_LLM_BACKUP=qwen-max
+VIDEO_MAX_DURATION=600          # 最长支持 10 分钟
+VIDEO_MAX_CHARS=10000           # 最多 1 万字
+VIDEO_PREVIEW_RESOLUTION=512p   # 预览分辨率
+
+# Redis (BullMQ 需要)
+REDIS_HOST=localhost
+REDIS_PORT=6379
+```
+
+---
+
+## 十三、成本估算
+
+### 13.1 单次生成成本(1080p, 3 分钟视频)
+
+| 环节 | 调用次数 | 单价 | 小计 |
+|------|:------:|:----:|:----:|
+| LLM 分镜 | 1 次 | ¥0.05 | ¥0.05 |
+| T2I 图片 | 10 张 | ¥0.10 | ¥1.00 |
+| TTS 配音 | 500 字 | ¥0.015 | ¥7.50 |
+| BGM | 1 首 | ¥0.50 | ¥0.50 |
+| FFmpeg 合成 | 1 次 | ¥0.20 | ¥0.20 |
+| **总计** | | | **¥9.25** |
+
+### 13.2 月度运营预估
+
+| 方案 | 月生成量 | 单次成本 | 月成本 | 定价 | 月利润 |
+|------|:------:|:------:|:------:|:----:|:-----:|
+| 保守 | 100 次 | ¥9.25 | ¥925 | ¥1,790 | ¥865 |
+| 中等 | 500 次 | ¥9.25 | ¥4,625 | ¥8,950 | ¥4,325 |
+| 乐观 | 2000 次 | ¥9.25 | ¥18,500 | ¥35,800 | ¥17,300 |
+
+### 13.3 服务器成本
+
+| 配置 | 用途 | 月费 |
+|------|------|:----:|
+| 4C8G 应用服务器 | Nest.js API | ¥200 |
+| 2C4G Redis | BullMQ 队列 | ¥100 |
+| 2C4G Worker x2 | 分镜 + 素材 | ¥200 |
+| GPU T4 Worker | FFmpeg 加速 | ¥500 |
+| OSS + CDN | 存储 + 分发 | ¥100 |
+| **总计** | | **¥1,100/月** |
+
+---
+
+## 十四、附录
+
+### A. 参考项目
+
+| 项目 | 特点 | 启发 |
+|------|------|------|
+| HeyGen | 数字人视频 | 用户可编辑分镜 |
+| Sora (OpenAI) | 文本 → 视频 | 端到端生成思路 |
+| 剪映 AI | 模板化视频 | Ken Burns + 转场的实用效果 |
+| RunwayML | AI 视频编辑 | 多模态视频生成 |
+| Invideo AI | 文本 → 营销视频 | 分镜 Pipeline 架构 |
+
+### B. 技术风险
+
+| 风险 | 概率 | 影响 | 应对 |
+|------|:----:|:----:|------|
+| AI 服务不稳定 | 高 | 中 | 多 Provider failover |
+| 画面质量差 | 中 | 高 | 分镜编辑 + 重新生成 |
+| 成本超预期 | 中 | 中 | 按用量计费 + 成本监控 |
+| 生成速度慢 | 低 | 中 | 并行 + GPU 加速 + 缓存 |
+| 用户生成违规内容 | 中 | 高 | 内容审核 + 敏感词过滤 |
+
+### C. 关键术语
+
+| 术语 | 英文 | 解释 |
+|------|------|------|
+| T2I | Text-to-Image | 文生图 |
+| TTS | Text-to-Speech | 文本转语音 |
+| I2V | Image-to-Video | 图生视频 |
+| LLM | Large Language Model | 大语言模型 |
+| Ken Burns | - | 静态图片缓慢缩放/平移的效果 |
+| IP-Adapter | - | 图像提示适配器,用于控制图像一致性 |
+| SRT | SubRip Text | 字幕文件格式 |
+
+---
+
+> **文档维护者**: AI Assistant  
+> **下次评审**: MVP 开发完成后

+ 66 - 0
docs/织音品牌命名与推广介绍.md

@@ -0,0 +1,66 @@
+# 织音 (Weava) — 品牌命名与推广介绍
+
+---
+
+## 命名
+
+**「织音」**
+
+- 「织」— AI 编织之意,代表技术驱动
+- 「音」— 音频/有声内容核心
+
+英文名:**Weava**(Weave + Audio)
+
+---
+
+## 一句话定位(Slogan)
+
+> **把文字织成声音,让人人都能创作有声内容。**
+
+---
+
+## 项目简介(短版,适用应用商店/小标题)
+
+**织音** 是一款 AI 驱动的一站式有声内容创作与分发平台。只需输入文本或上传文档,即可自动生成高质量有声书和配图视频——多音色、多语速、背景音乐随心搭配。创作者可将作品发布到"有声集市"进行付费分发,实现从创作到变现的完整闭环。让每一位内容创作者,都能用声音传递价值。
+
+---
+
+## 项目介绍(长版,适用官网/推广页)
+
+### 🎙️ 产品概述
+
+织音致力于**降低有声内容创作门槛**。无论你是自媒体博主、小说作者、知识付费讲师,还是想把学习资料转成音频的普通用户——无需专业设备,无需配音演员,输入文字,几分钟即可获得一部专业级有声作品。
+
+### ✨ 核心亮点
+
+| 能力 | 说明 |
+|------|------|
+| **智能 TTS 引擎** | 10+ 优质音色,支持语速/音调/音量精细调节,18 套预设模板一键套用 |
+| **AI 书籍生成** | 输入一句话描述,AI 自动生成三级目录 + 完整正文 + 合成音频,秒变"有声书作者" |
+| **文字转视频** | 音频自动配图 + Ken Burns 动画 + 字幕叠加 + BGM混音,多尺寸适配抖音/快手/B站 |
+| **专业级播放器** | 封面旋转动画、毛玻璃背景、定时关闭、断点续播,类头部音频平台体验 |
+| **有声集市** | 发布专辑、设定价格,在平台内实现内容付费分发 |
+| **文档直转** | 上传 TXT/Word/PDF,自动解析并生成音频,学习资料秒变"播客" |
+| **多端覆盖** | H5 / 微信小程序 / 支付宝小程序,随时随地创作收听 |
+
+### 🎯 适用场景
+
+- 📖 **小说/网文作者** → 一键将有声书推向读者
+- 📱 **短视频创作者** → 文字秒变配音配图视频,批量产出内容
+- 🎓 **知识付费博主** → 课程文稿 → 音频课程,上架集市变现
+- 📚 **学生/自学者** → 教材/笔记 → 音频,碎片时间听书学习
+
+### 💰 商业模式
+
+按音频时长灵活计费,提供免费版体验 + 5 档会员套餐(¥19~¥599/月)覆盖个人到企业用户。创作者可通过"有声集市"发布付费专辑,平台抽佣分成,实现创作者与平台共赢。
+
+---
+
+## 品牌延伸建议
+
+| 场景 | 名称 | 说明 |
+|------|------|------|
+| 产品名 | **织音** | 主品牌 |
+| 英文名 | **Weava** | Weave + Audio 合成词 |
+| 域名建议 | weava.cn / zhiyin.ai | 简短的拼音/英文 |
+| 标语 | 把文字织成声音 | 品牌主张 |

BIN
login-page.png


BIN
my-uniapp-vue3/headed-test.png


+ 2 - 0
my-uniapp-vue3/index.html

@@ -10,6 +10,8 @@
         (coverSupport ? ', viewport-fit=cover' : '') + '" />')
     </script>
     <title></title>
+    <!-- 微信 JS-SDK(仅 H5 公众号环境使用) -->
+    <script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
     <!--preload-links-->
     <!--app-context-->
   </head>

+ 5 - 2
my-uniapp-vue3/src/pages/alipay-qrcode/index.vue

@@ -110,13 +110,16 @@ watch(qrcodeUrl, (url) => {
 
 onMounted(async () => {
   // 获取 URL 参数
+  // #ifdef APP-PLUS
   const pages = getCurrentPages();
   const currentPage = pages[pages.length - 1] as any;
-  // #ifdef APP-PLUS
   const options = currentPage?.$page?.options || {};
   // #endif
   // #ifndef APP-PLUS
-  const options = currentPage?.options || {};
+  // H5 环境下解析 hash URL 参数
+  const hashParams = new URLSearchParams(window.location.hash.split('?')[1] || '');
+  const options: Record<string, string> = {};
+  hashParams.forEach((v, k) => options[k] = v);
   // #endif
 
   // 检测支付方式

+ 2 - 29
my-uniapp-vue3/src/pages/member/index.vue

@@ -31,10 +31,9 @@
           v-for="plan in plans"
           :key="plan.id"
           class="plan-card"
-          :class="{ active: selectedPlanId === plan.id, recommended: plan.isRecommended, free: plan.level === 0 }"
+          :class="{ active: selectedPlanId === plan.id, free: plan.level === 0 }"
           @click="selectedPlanId = plan.id"
         >
-          <view v-if="plan.isRecommended" class="recommended-badge">推荐</view>
           <view v-if="plan.level === 0" class="free-badge">免费</view>
 
           <view class="plan-header">
@@ -62,20 +61,6 @@
       </view>
     </view>
 
-    <view class="payment-section">
-      <text class="section-title">支付方式</text>
-      <view class="payment-methods">
-        <view class="payment-method" :class="{ active: paymentMethod === 'alipay' }" @click="paymentMethod = 'alipay'">
-          <text class="method-icon">💙</text>
-          <text class="method-name">支付宝</text>
-        </view>
-        <view class="payment-method" :class="{ active: paymentMethod === 'wechat' }" @click="paymentMethod = 'wechat'">
-          <text class="method-icon">🟢</text>
-          <text class="method-name">微信支付</text>
-        </view>
-      </view>
-    </view>
-
     <view class="bottom-section">
       <view class="total-info">
         <text class="total-label">应付金额</text>
@@ -96,8 +81,7 @@ import { get, post } from '../../utils/request';
 const userStore = useUserStore();
 
 const plans = ref<any[]>([]);
-const selectedPlanId = ref<number>(1);
-const paymentMethod = ref<'alipay' | 'wechat'>('alipay');
+const selectedPlanId = ref<number>(17);
 const loading = ref(false);
 const tokenBalance = ref({ totalTokens: 0, usedTokens: 0, remainingTokens: 0, isUnlimited: false });
 
@@ -119,8 +103,6 @@ async function fetchPlans() {
   try {
     const result = await get<{ plans: any[] }>('/subscription/plans');
     plans.value = result.plans;
-    const recommended = result.plans.find((p: any) => p.isRecommended);
-    if (recommended) selectedPlanId.value = recommended.id;
   } catch (error) {
     console.error('获取套餐失败:', error);
     uni.showToast({ title: '加载失败', icon: 'none' });
@@ -207,10 +189,8 @@ async function mockPay(orderNo: string) {
 .plans-list { display: flex; flex-direction: column; gap: 20rpx; }
 .plan-card { background: #fff; border-radius: 20rpx; padding: 24rpx; border: 3rpx solid #e5e7eb; position: relative; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transform-origin: center center; }
 .plan-card.active { border-color: #667eea; box-shadow: 0 8rpx 32rpx rgba(102, 126, 234, 0.35); transform: scale(1.02); }
-.plan-card.recommended { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-color: #fbbf24; }
 .plan-card.free { background: #f9fafb; }
 
-.recommended-badge { position: absolute; top: -14rpx; right: 20rpx; background: linear-gradient(135deg, #f97316 0%, #fb923c 100%); padding: 6rpx 20rpx; border-radius: 16rpx; font-size: 22rpx; color: #fff; font-weight: 600; }
 .free-badge { position: absolute; top: -14rpx; right: 20rpx; background: linear-gradient(135deg, #10b981 0%, #34d399 100%); padding: 6rpx 20rpx; border-radius: 16rpx; font-size: 22rpx; color: #fff; font-weight: 600; }
 
 .plan-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
@@ -235,13 +215,6 @@ async function mockPay(orderNo: string) {
   100% { transform: scale(1); }
 }
 
-.payment-section { padding: 32rpx; margin-top: 16rpx; }
-.payment-methods { display: flex; gap: 20rpx; }
-.payment-method { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24rpx; background: #fff; border: 3rpx solid #e5e7eb; border-radius: 16rpx; transition: all 0.3s; }
-.payment-method.active { border-color: #667eea; background: #f0f2ff; }
-.method-icon { font-size: 36rpx; margin-right: 12rpx; }
-.method-name { font-size: 28rpx; color: #1f2937; }
-
 .bottom-section { position: fixed; bottom: 0; left: 0; right: 0; padding: 24rpx 32rpx; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); background: #fff; box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.04); }
 .total-info { display: flex; align-items: baseline; margin-bottom: 16rpx; }
 .total-label { font-size: 28rpx; color: #6b7280; margin-right: 8rpx; }

+ 317 - 1
my-uniapp-vue3/src/pages/payment-confirm/index.vue

@@ -32,6 +32,11 @@
       <!-- 支付方式选择 -->
       <view class="payment-section">
         <text class="section-title">选择支付方式</text>
+        <!-- 公众号环境提示 -->
+        <view class="wechat-notice" v-if="inWechat">
+          <text class="wechat-notice-icon">💚</text>
+          <text class="wechat-notice-text">检测到微信环境,将自动使用微信支付</text>
+        </view>
         <view class="payment-methods">
           <view
             class="payment-method"
@@ -54,6 +59,7 @@
             </view>
             <text class="method-icon">🟢</text>
             <text class="method-name">微信支付</text>
+            <text class="method-badge" v-if="inWechat">推荐</text>
           </view>
         </view>
       </view>
@@ -79,6 +85,17 @@
 import { ref, onMounted } from 'vue';
 import { post, get } from '../../utils/request';
 
+// ==================== 微信浏览器检测 ====================
+function isInWechat(): boolean {
+  // #ifdef H5
+  const ua = navigator.userAgent.toLowerCase();
+  return ua.includes('micromessenger');
+  // #endif
+  // #ifndef H5
+  return false;
+  // #endif
+}
+
 const orderInfo = ref<{
   orderNo: string;
   amount: number;
@@ -91,9 +108,126 @@ const orderInfo = ref<{
 
 const planId = ref<number>(0);
 const paymentMethod = ref<'alipay' | 'wechat'>('alipay');
+const inWechat = ref(false);
+const openid = ref('');
+const wxSdkReady = ref(false);
+const paying = ref(false);
 let checkTimer: ReturnType<typeof setInterval> | null = null;
 
-onMounted(() => {
+// ==================== 微信 JS-SDK 初始化 ====================
+async function initWxJsSdk(): Promise<boolean> {
+  // #ifdef H5
+  const wx = (window as any).wx;
+  if (!wx) {
+    console.warn('[WeChat Pay] 微信 JS-SDK 未加载');
+    return false;
+  }
+
+  try {
+    // 获取 JS-SDK 配置签名
+    const configRes = await post<{
+      debug: boolean;
+      appId: string;
+      timestamp: string;
+      nonceStr: string;
+      signature: string;
+      jsApiList: string[];
+    }>('/payment/wechat/jsapi-config', {
+      // 使用当前页面的完整 URL(不含 OAuth code 参数)
+      url: window.location.origin + window.location.pathname + window.location.hash
+    });
+
+    return new Promise((resolve) => {
+      wx.config({
+        debug: false,
+        appId: configRes.appId,
+        timestamp: configRes.timestamp,
+        nonceStr: configRes.nonceStr,
+        signature: configRes.signature,
+        jsApiList: configRes.jsApiList || ['chooseWXPay'],
+      });
+
+      wx.ready(() => {
+        console.log('[WeChat Pay] JS-SDK 初始化成功');
+        wxSdkReady.value = true;
+        resolve(true);
+      });
+
+      wx.error((err: any) => {
+        console.error('[WeChat Pay] JS-SDK 初始化失败:', err);
+        resolve(false);
+      });
+    });
+  } catch (e) {
+    console.error('[WeChat Pay] 获取JS-SDK配置失败:', e);
+    return false;
+  }
+  // #endif
+  // #ifndef H5
+  return false;
+  // #endif
+}
+
+// ==================== 微信 OAuth 处理 ====================
+async function handleWechatOAuth() {
+  // #ifdef H5
+  // 检测 URL 中是否有 OAuth 回调的 code 参数
+  const urlParams = new URLSearchParams(window.location.search);
+  const code = urlParams.get('code');
+
+  if (code) {
+    // OAuth 回调:用 code 换取 openid
+    uni.showLoading({ title: '授权中...' });
+    try {
+      const result = await post<{ openid: string }>('/payment/wechat/openid', { code });
+      openid.value = result.openid;
+      // 保存 openid 到 localStorage(下次不用重新授权)
+      localStorage.setItem('wx_openid', result.openid);
+      console.log('[WeChat Pay] openid 获取成功');
+
+      // 清除 URL 中的 code 参数,避免重复处理
+      urlParams.delete('code');
+      urlParams.delete('state');
+      const newSearch = urlParams.toString();
+      const newUrl = window.location.origin + window.location.pathname
+        + (newSearch ? '?' + newSearch : '')
+        + window.location.hash;
+      window.history.replaceState({}, '', newUrl);
+    } catch (e) {
+      console.error('[WeChat Pay] 获取 openid 失败:', e);
+      uni.showToast({ title: '微信授权失败,请使用扫码支付', icon: 'none' });
+      inWechat.value = false;
+    } finally {
+      uni.hideLoading();
+    }
+    return;
+  }
+
+  // 没有 code 参数,检查是否有缓存的 openid
+  const cachedOpenid = localStorage.getItem('wx_openid');
+  if (cachedOpenid) {
+    openid.value = cachedOpenid;
+    console.log('[WeChat Pay] 使用缓存的 openid');
+  }
+  // #endif
+}
+
+// 跳转到微信 OAuth 授权
+async function redirectToWechatOAuth() {
+  // #ifdef H5
+  try {
+    const currentUrl = window.location.href;
+    const result = await get<{ oauthUrl: string; state: string }>('/payment/wechat/oauth-url', {
+      redirect: currentUrl
+    });
+    window.location.href = result.oauthUrl;
+  } catch (e) {
+    console.error('[WeChat Pay] 获取OAuth链接失败:', e);
+  }
+  // #endif
+}
+
+onMounted(async () => {
   const pages = getCurrentPages();
   const currentPage = pages[pages.length - 1] as any;
   // #ifdef APP-PLUS
@@ -108,14 +242,167 @@ onMounted(() => {
     orderInfo.value.planName = decodeURIComponent(options.planName || '会员订阅');
     orderInfo.value.amount = parseFloat(options.amount) || 0;
   }
+
+  // 检测是否在微信浏览器中
+  if (isInWechat()) {
+    inWechat.value = true;
+    // 默认选中微信支付
+    paymentMethod.value = 'wechat';
+    // 处理 OAuth 回调
+    await handleWechatOAuth();
+    // 初始化 JS-SDK(如果有 openid)
+    if (openid.value) {
+      initWxJsSdk(); // 异步初始化,不阻塞
+    }
+  }
+
+  // 进入页面时直接创建订单
+  if (planId.value) {
+    await createOrder();
+  }
 });
 
+async function createOrder() {
+  if (!planId.value) return;
+  try {
+    uni.showLoading({ title: '创建订单...' });
+    const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
+    const result = await post<{
+      orderNo: string;
+      amount: number;
+      paymentUrl?: string;
+      qrcode?: string;
+      planName?: string;
+    }>('/payment/create', {
+      planId: planId.value,
+      paymentMethod: paymentMethod.value,
+      returnUrl
+    });
+    orderInfo.value.orderNo = result.orderNo;
+    uni.hideLoading();
+  } catch (error) {
+    uni.hideLoading();
+    console.error('创建订单失败:', error);
+  }
+}
+
+// ==================== 微信 JSAPI 支付(公众号内) ====================
+async function handleWechatJsapiPay() {
+  // #ifdef H5
+  // 如果没有 openid,需要先授权
+  if (!openid.value) {
+    await redirectToWechatOAuth();
+    return;
+  }
+
+  paying.value = true;
+  uni.showLoading({ title: '拉起支付...' });
+
+  try {
+    // 创建 JSAPI 支付订单
+    const result = await post<{
+      orderNo: string;
+      amount: number;
+      planName: string;
+      payParams: {
+        appId: string;
+        timeStamp: string;
+        nonceStr: string;
+        package: string;
+        signType: string;
+        paySign: string;
+      };
+    }>('/payment/wechat/jsapi', {
+      planId: planId.value,
+      openid: openid.value
+    });
+
+    orderInfo.value.orderNo = result.orderNo;
+    uni.hideLoading();
+
+    const wx = (window as any).wx;
+
+    // 确保 JS-SDK 已初始化
+    if (!wxSdkReady.value) {
+      const ok = await initWxJsSdk();
+      if (!ok) {
+        uni.showToast({ title: '微信支付初始化失败,请使用扫码支付', icon: 'none' });
+        paying.value = false;
+        return;
+      }
+    }
+
+    // 调起微信支付
+    wx.chooseWXPay({
+      appId: result.payParams.appId,
+      timestamp: result.payParams.timeStamp,
+      nonceStr: result.payParams.nonceStr,
+      package: result.payParams.package,
+      signType: result.payParams.signType,
+      paySign: result.payParams.paySign,
+      success: () => {
+        console.log('[WeChat Pay] 支付成功');
+        paying.value = false;
+        uni.showModal({
+          title: '支付成功',
+          content: '恭喜您订阅成功!',
+          showCancel: false,
+          success: () => {
+            uni.switchTab({ url: '/pages/mine/index' });
+          }
+        });
+      },
+      cancel: () => {
+        console.log('[WeChat Pay] 用户取消支付');
+        paying.value = false;
+        uni.showToast({ title: '已取消支付', icon: 'none' });
+      },
+      fail: (err: any) => {
+        console.error('[WeChat Pay] 支付失败:', err);
+        paying.value = false;
+        // 可能是签名错误等,引导用户使用扫码支付
+        uni.showModal({
+          title: '支付失败',
+          content: '微信支付调起失败,是否使用扫码支付?',
+          confirmText: '扫码支付',
+          cancelText: '取消',
+          success: (res) => {
+            if (res.confirm && result.orderNo) {
+              inWechat.value = false;
+              handlePay();
+            }
+          }
+        });
+      }
+    });
+  } catch (error: any) {
+    paying.value = false;
+    uni.hideLoading();
+    console.error('[WeChat Pay] 创建JSAPI支付失败:', error);
+    // 如果 JSAPI 支付失败(可能是 openid 过期),清除缓存让用户重新授权
+    if (error.message && error.message.includes('openid')) {
+      localStorage.removeItem('wx_openid');
+      openid.value = '';
+      await redirectToWechatOAuth();
+      return;
+    }
+    uni.showToast({ title: error.message || '支付失败', icon: 'none' });
+  }
+  // #endif
+}
+
 async function handlePay() {
   if (!planId.value) {
     uni.showToast({ title: '参数错误', icon: 'none' });
     return;
   }
 
+  // 公众号内微信支付 → 走 JSAPI
+  if (inWechat.value && paymentMethod.value === 'wechat') {
+    await handleWechatJsapiPay();
+    return;
+  }
+
   try {
     uni.showLoading({ title: '创建订单...' });
 
@@ -379,6 +666,35 @@ function goBack() {
   flex: 1;
 }
 
+.method-badge {
+  font-size: 22rpx;
+  color: #ffffff;
+  background: #10b981;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+  margin-left: 8rpx;
+}
+
+.wechat-notice {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 20rpx;
+  background: #ecfdf5;
+  border: 1rpx solid #a7f3d0;
+  border-radius: 10rpx;
+  margin-bottom: 20rpx;
+}
+
+.wechat-notice-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+
+.wechat-notice-text {
+  font-size: 24rpx;
+  color: #065f46;
+}
+
 .actions {
   display: flex;
   gap: 24rpx;

+ 38 - 30
my-uniapp-vue3/src/pages/player/index.vue

@@ -290,7 +290,7 @@ function parseLrc(lrcText: string): { text: string; start: number; end: number }
   return timeline;
 }
 
-// 生成歌词时间线(按 Markdown 段落分段,与后端 generateLrc 降级算法一致
+// 生成歌词时间线(按句子切割,实现逐句高亮跟随
 function generateLyricsTimeline(text: string, totalDuration: number): { text: string; start: number; end: number }[] {
   if (!text || totalDuration <= 0) return [];
 
@@ -305,18 +305,34 @@ function generateLyricsTimeline(text: string, totalDuration: number): { text: st
       if (!trimmed) continue;
       const isHeading = /^#{1,2}\s/.test(trimmed);
       if (isHeading) {
-        sections.push(trimmed);
+        // 提取标题行和正文,分别处理(避免将标题+正文当作一整条歌词)
+        const titleMatch = trimmed.match(/^(#{1,2}\s+.+?)(\n|$)/);
+        if (titleMatch) {
+          sections.push(titleMatch[1].trim());
+          const rest = trimmed.substring(titleMatch[0].length).trim();
+          if (rest) {
+            const paras = rest.split(/\n{2,}/).filter(p => p.trim());
+            for (const para of paras) {
+              const sentences = para.split(/(?<=[。!?;])/).filter(s => s.trim());
+              sections.push(...sentences);
+            }
+          }
+        } else {
+          sections.push(trimmed);
+        }
       } else {
+        // 先按段落切,再按句子切 → 实现逐句级歌词跟随
         const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
         for (const para of paras) {
-          sections.push(para.trim());
+          const sentences = para.split(/(?<=[。!?;])/).filter(s => s.trim());
+          sections.push(...sentences);
         }
       }
     }
   }
-  // 降级:按句号分段
+  // 降级:纯文本没有标题时,直接按句子分段
   if (sections.length <= 1) {
-    const sentences = text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
+    const sentences = text.split(/(?<=[。!?;])/).filter(s => s.trim());
     sections.length = 0;
     sections.push(...sentences);
   }
@@ -352,17 +368,23 @@ function generateLyricsTimeline(text: string, totalDuration: number): { text: st
   return timeline;
 }
 
-// 根据当前播放时间获取当前句子索引,并自动滚动
-// 滚动到当前行的前一行,确保高亮行上下都有可见文本作为缓冲
-const currentLyricIndex = computed(() => {
-  const time = currentTime.value;
+// 当前高亮的歌词索引
+const currentLyricIndex = ref(-1);
+
+// 直接监听 store 中的 currentTime,驱动高亮和滚动
+watch(() => audioStore.currentTime, (time) => {
+  if (!lyrics.value.length) return;
   const index = lyrics.value.findIndex(l => time >= l.start && time < l.end);
+  currentLyricIndex.value = index;
+
   if (index >= 0) {
-    // 滚动到前一行(最小为 0,即缓冲空行),使当前行上下文本都可见
+    // 滚动到前一行(最小为 0),使高亮行上下都有可见文本作为缓冲
     const scrollIndex = Math.max(0, index - 1);
-    scrollIntoViewId.value = 'lyric-' + scrollIndex;
+    const targetId = 'lyric-' + scrollIndex;
+    if (scrollIntoViewId.value !== targetId) {
+      scrollIntoViewId.value = targetId;
+    }
   }
-  return index;
 });
 
 // 从 store 获取状态
@@ -397,13 +419,7 @@ onShow(() => {
   // 刷新 store 中的当前音频信息
   if (audioStore.currentAudio) {
     audio.value = audioStore.currentAudio;
-    // 同步进度条
-    if (audioStore.currentTime > 0) {
-      currentTime.value = audioStore.currentTime;
-    }
-    if (audioStore.duration > 0) {
-      duration.value = audioStore.duration;
-    }
+    // currentTime/duration 是 computed,自动从 store 读取,无需手动赋值
   }
 });
 
@@ -444,13 +460,9 @@ async function fetchAudio() {
     // 获取播放列表(使用专辑章节列表)
     await fetchAlbumPlaylist(result);
 
-    // 生成歌词时间线(优先使用真实 LRC,否则按句子估算
+    // 生成歌词时间线(始终使用前端按句子切割,比后端 LRC 更可靠
     const audioDuration = result.audioDuration || audioStore.duration || 60;
-    if (result.lrcLyrics) {
-      lyrics.value = parseLrc(result.lrcLyrics);
-    } else {
-      lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
-    }
+    lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
     // 在歌词列表前插入空行作为视觉缓冲,确保高亮当前行时前后文都可见
     lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
 
@@ -693,11 +705,7 @@ async function updateLyricsForAudio(audioItem: AudioItem) {
     }
   }
 
-  if (lrc) {
-    lyrics.value = parseLrc(lrc);
-  } else {
-    lyrics.value = generateLyricsTimeline(text, ad);
-  }
+  lyrics.value = generateLyricsTimeline(text, ad);
   lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
 }
 

BIN
my-uniapp-vue3/wechat-test-final.png


BIN
payment-confirm.png


BIN
payment-page-prod.png


BIN
payment-page.png


+ 12 - 0
player-5173.yml

@@ -0,0 +1,12 @@
+- generic [ref=e7]:
+  - generic [ref=e8]:
+    - generic [ref=e10]: ↓
+    - generic [ref=e12]: 正在播放
+    - generic [ref=e13]:
+      - generic [ref=e15]: ⏰
+      - generic [ref=e17]: 📖
+  - generic [ref=e18]:
+    - generic [ref=e19]: 🎵
+    - generic [ref=e20]: 暂无音频
+    - generic [ref=e21]: 该章节还没有生成音频
+    - generic [ref=e22] [cursor=pointer]: 返回

+ 110 - 0
player-after-3s.yml

@@ -0,0 +1,110 @@
+- generic [ref=e81]:
+  - generic [ref=e82]:
+    - generic [ref=e84]: ↓
+    - generic [ref=e86]: 绵绵的云朵梦
+    - generic [ref=e87]:
+      - generic [ref=e89]: ⏰
+      - generic [ref=e91]: 📖
+  - generic [ref=e92]:
+    - generic [ref=e94]: 🎵
+    - generic [ref=e95]: 绵绵的云朵梦
+  - generic [ref=e100]:
+    - generic [ref=e103]: "# 云端上的友谊"
+    - generic [ref=e106]:
+      - text: "## 一、绵绵的梦想"
+      - text: 小绵羊绵绵有一个美丽的梦想——她想跑到高高的云朵里去。
+      - text: 每天清晨,当第一缕阳光洒向大地,绵绵都会仰起头,眨巴着亮晶晶的大眼睛,望着天空中飘浮的云朵。那些云朵白白的、软软的,像一团团棉花糖,又像一个个大大的棉花糖枕头。绵绵想象着自己躺在云朵上的样子,那一定特别舒服,特别温暖。
+      - text: 可是,云朵离地面那么高那么高,绵绵使劲踮起脚尖,伸长脖子,小脑袋都快仰到后背上了,还是够不着。
+      - text: “唉……云朵什么时候能降下来一点呢?”绵绵轻轻地叹了口气。
+      - text: 她试过很多办法。她尝试跳起来去抓云朵的边缘,可是每次都只能碰到空气;她试着爬上家里最高的凳子,可是从凳子上往下看,云朵还是在很远很远的地方。
+      - text: 绵绵有点发愁了。她真的好想、好想到云朵里面去啊!
+    - generic [ref=e109]:
+      - text: "## 二、朋友们来了"
+      - text: 就在这时,绵绵的好朋友们听到了这个消息,纷纷赶来了。
+      - text: 小兔子啾啾第一个到达。他蹦蹦跳跳地来到绵绵面前,两只长长的耳朵竖得直直的:“绵绵,别担心!我来帮你想办法!”
+      - text: 小猫咪可可也迈着优雅的步伐走了过来。她的毛软软的,眼睛亮亮的:“我也想看看云朵是什么样的呢!”
+      - text: 远处传来扑棱扑棱的声音,那是粉红色的小鸟飞飞飞来了。她落在绵绵身旁,歪着脑袋说:“我可以在天上飞来飞去,但从来没到过云朵里面呢!”
+      - text: 小猪佩奇和她的弟弟乔治也跑来了。佩奇笑着说:“大家一起想办法,肯定能成功!”
+      - text: 乔治举起手里的小恐龙玩具,哼哼地说:“乔治也想去!”
+      - text: 看着这么多好朋友都来帮助自己,绵绵的心里暖暖的。
+    - generic [ref=e112]:
+      - text: "## 三、大家一起想办法"
+      - text: 大家围坐在一起,开动小脑袋,一起想办法。
+      - text: 啾啾先开口:“我们能不能堆一座高高的山坡,从山坡上跳起来?”
+      - text: 可可想了想,摇摇头:“山坡太难了,而且也不够高呢。”
+      - text: 飞飞拍拍翅膀说:“我可以飞到云朵旁边看看,可是我带不动大家呀。”
+      - text: 佩奇想了想,突然眼睛一亮:“对了!我们可以借助风的力量!”
+      - text: “风?”大家都好奇地看着佩奇。
+      - text: 佩奇解释道:“我观察过,当大风吹来的时候,地上的小草会被吹得弯腰,大树的叶子会沙沙作响。如果大风吹来的时候,我们一起使劲跳,就能跳得更高更高,说不定就能跳到云朵上了呢!”
+      - text: 乔治高兴地拍起手来:“跳!跳!跳!”
+      - text: 绵绵的眼睛也亮了起来:“这个主意太棒了!”
+    - generic [ref=e115]:
+      - text: "## 四、借风飞翔"
+      - text: 说干就干!大家耐心地等待着。
+      - text: 等啊等,大风终于来了!
+      - text: “准备好了吗?”佩奇大声喊道。
+      - text: “准备好了!”大家一起回答。
+      - text: “风来了!跳——”
+      - text: 一阵大风吹过,绵绵、啾啾、可可、飞飞、佩奇和乔治一起奋力跳起来。
+      - text: 奇妙的事情发生了!在大风的帮助下,他们觉得自己轻飘飘的,好像长了翅膀一样!绵绵感觉自己的身体越来越轻,脚下的草地离自己越来越远,白云就在眼前,伸手就能摸到!
+      - text: 飞飞高兴地喊:“太神奇了!我们真的飞起来了!”
+    - generic [ref=e118]:
+      - text: "## 五、软绵绵的云朵"
+      - text: “扑通!扑通!扑通!”
+      - text: 大家轻轻落在一朵巨大的白云上。
+      - text: “好软啊!”啾啾惊讶地说。他忍不住在云朵上蹦了几下,云朵像弹簧一样弹了起来。
+      - text: “好温暖啊!”可可舒服地趴在云朵上,把脸贴着白云,感觉像被暖暖的被子包裹着。
+      - text: 绵绵用小手摸了摸云朵,绒绒的、软软的,和想象中的一模一样呢!她开心地在云朵上打了个滚,云朵就像棉花糖一样陷了下去,又弹了起来。
+      - text: “哇!这比家里的床还要舒服呢!”佩奇躺下来,舒服地叹了口气。
+      - text: 乔治也在云朵上跳来跳去,笑得合不拢嘴。
+    - generic [ref=e121]:
+      - text: "## 六、云端的快乐时光"
+      - text: 在云朵上,大家做了好多好玩的事情。
+      - text: 飞飞带着大家在云朵间飞来飞去,从这朵云跳到那朵云,每一朵云都有不一样的感觉,有的像巧克力味,有的像草莓味,还有的像香草味。
+      - text: 绵绵和啾啾、可可一起堆云朵,他们用云朵堆出了一只小羊、一只兔子、一只小猫和一只小猪,还有一只小恐龙呢!
+      - text: 佩奇和乔治发现了一个秘密——云朵可以变成各种形状!只要用力捏一捏、按一按,云朵就会变成你想要的形状。乔治最喜欢捏云朵,他捏出了好多好多小恐龙。
+      - text: 大家还一起吃“白云冰激凌”——原来只要在云朵里找一找,就能找到藏在里面的云朵冰激凌,凉凉的、甜甜的,美味极了!
+      - text: 绵绵幸福地看着身边的朋友们,心里想:有了好朋友的陪伴,原来梦想真的可以实现的呢!
+    - generic [ref=e124]:
+      - text: "## 七、最美好的回忆"
+      - text: 夕阳西下,金色的阳光洒在云朵上,把白云染成了金黄色、粉红色和橘红色,好看极了。
+      - text: “时间不早了,我们该回家了。”佩奇说。
+      - text: 又是一阵大风吹来,大家乘着风轻轻飘落,回到了地上。
+      - text: 绵绵回头望了望天空中的云朵,开心地说:“今天是我最开心的一天!”
+      - text: 啾啾蹭了蹭绵绵:“因为有大家在一起啊!”
+      - text: 可可点点头:“是啊,一起想办法,一起努力,一起玩耍,特别快乐!”
+      - text: 飞飞在大家头顶飞了一圈:“以后我们还要一起做更多的事情呢!”
+      - text: 乔治举起小恐龙,哼哼地说:“乔治也是!”
+      - text: 绵绵笑了,她觉得心里暖暖的,比躺在云朵上还要温暖。
+      - text: 从此以后,绵绵明白了:有了好朋友的陪伴,再大的梦想也能实现。而和朋友们在一起的时光,就是最美好的时光。
+      - text: "---"
+      - text: "*小绵羊的云梦之旅,未完待续……*"
+  - generic [ref=e125]:
+    - generic [ref=e126]: 0:36
+    - generic [ref=e134]: 6:39
+  - generic [ref=e135]:
+    - generic [ref=e136]:
+      - generic [ref=e138]: 1x
+      - generic [ref=e141]: ⬇
+    - generic [ref=e142]:
+      - generic [ref=e144]: ⏮
+      - generic [ref=e146]: ⏸
+      - generic [ref=e148]: ⏭
+    - generic [ref=e149]:
+      - generic [ref=e151]: 🤍
+      - generic [ref=e152]:
+        - generic [ref=e153]: 📤
+        - generic [ref=e154]: 分享得时长
+  - generic [ref=e155]:
+    - generic [ref=e156]:
+      - generic [ref=e157]:
+        - generic [ref=e158]: 播放列表
+        - generic [ref=e159]: 1首
+      - generic [ref=e160]:
+        - generic [ref=e161]: 🔂
+        - generic [ref=e162]: 顺序
+    - generic [ref=e167]:
+      - generic [ref=e168]: "1"
+      - generic [ref=e169]:
+        - generic [ref=e170]: 绵绵的云朵梦
+        - generic [ref=e171]: 0:00

BIN
player-before.png


+ 99 - 0
player-fixed.yml

@@ -0,0 +1,99 @@
+- generic [ref=e7]:
+  - generic [ref=e8]:
+    - generic [ref=e10]: ↓
+    - generic [ref=e12]: �������
+    - generic [ref=e13]:
+      - generic [ref=e15]: ⏰
+      - generic [ref=e17]: 📖
+  - generic [ref=e18]:
+    - generic [ref=e20]: 🎵
+    - generic [ref=e21]: �������
+  - generic [ref=e64]:
+    - generic [ref=e67]: "# �������"
+    - generic [ref=e69]: "## һ�����յ��峿"
+    - generic [ref=e71]: �������ˡ��峿�����⴩�������ķ�϶�����ĵ������С���ķ��䡣�����С��ߴߴ�����ؽ��ţ��·���˵��"����������������"С���������۾����Ӵ��������������˸�����������
+    - generic [ref=e73]: ���ߵ���ǰ���ƿ�������һ�����µĿ���������������������ͻ����ζ����Ժ�����ǿ��ϻ����Ѿ���������̵���ѿ���ڳ���������ҡҷ��С������һ�������о������˶������˻�����
+    - generic [ref=e75]: "\"���裬��ȥ��԰�ܲ�����\"С��һ�ߴ�Ьһ�߳���������������̽��ͷ����Ц��˵��\"�ǵ����������緹���������㰮�Եļ�����ӡ�\"С�����˵ص��ͷ���ı������س��˼��š�"
+    - generic [ref=e77]: "## ������԰�ľ�ϲ"
+    - generic [ref=e79]: ��԰�����ַǷ����������ڴ�̫��ȭ���������������������������ܲ�����ˮ���������������⡣����Ȥ���Ƕ�ͯ��԰�DZߣ�����С���������滬�ݣ�Ц��һ���һ��ش�����
+    - generic [ref=e81]: С�����ź��ߵ�С·�������š���ˮ���̱��̵ģ���ֻ�׶����е�������ȥ��ͻȻ��ͣ���˽Ų�����Ϊ��������һ�������Ļ��桪��һ�����������˷ۺ�ɫ�Ļ���������΢���з׷�Ʈ�䣬��������һ�������ꡣ
+    - generic [ref=e83]: "\"��������\"С���鲻�Խ�����̾�������ó��ֻ����˺ü�����Ƭ���������Զ����һ�����е�үү���̿������룬үү���̿�����ô���Ĵ��죬һ����ܿ��İɡ�"
+    - generic [ref=e85]: "## ������ů�����"
+    - generic [ref=e87]: �ص���������Ѿ�����Ͱں��ˡ�����ִ�ļ�����ӡ�ð�������Ķ���������һС����ˬ������ƹϡ�С�����̻��ʵس���������������һ��Ц��˵��"����ԣ���ҭ�š�"
+    - generic [ref=e89]: �����緹��С��������ϴ���룬Ȼ����������ǰ��ʼд��ҵ�����������Խ��Խů�ͣ���������������ˡ�С�������д��ÿһ���֣�ż��̧ͷ���������������ƣ��о���һ���������ü��ˡ�
+    - generic [ref=e91]: ���룬��������һ������ļ��ڣ���������������������Ҳ���˸е��Ҹ���ϣ����Ҳ������Ǵ����͸�ÿһ���˵�����ɡ���һ���¿�ʼ��һ����ϣ����
+  - generic [ref=e23]:
+    - generic [ref=e24]: 0:12
+    - generic [ref=e31]: 1:15
+  - generic [ref=e32]:
+    - generic [ref=e33]:
+      - generic [ref=e35]: 1x
+      - generic [ref=e38]: ⬇
+    - generic [ref=e39]:
+      - generic [ref=e41]: ⏮
+      - generic [ref=e43]: ⏸
+      - generic [ref=e45]: ⏭
+    - generic [ref=e46]:
+      - generic [ref=e48]: 🤍
+      - generic [ref=e49]:
+        - generic [ref=e50]: 📤
+        - generic [ref=e51]: 分享得时长
+  - generic [ref=e52]:
+    - generic [ref=e53]:
+      - generic [ref=e54]:
+        - generic [ref=e55]: 播放列表
+        - generic [ref=e56]: 10首
+      - generic [ref=e57]:
+        - generic [ref=e58]: 🔂
+        - generic [ref=e59]: 顺序
+    - generic [ref=e95]:
+      - generic [ref=e96]:
+        - generic [ref=e97]: "1"
+        - generic [ref=e98]:
+          - generic [ref=e99]: �����ı�����һ�����ڲ��Ե���Ƶ�����...
+          - generic [ref=e100]: 0:02
+      - generic [ref=e101]:
+        - generic [ref=e102]: "2"
+        - generic [ref=e103]:
+          - generic [ref=e104]: ������������ʺϳ�ȥɢ���������ʿ���
+          - generic [ref=e105]: 0:02
+      - generic [ref=e106]:
+        - generic [ref=e107]: "3"
+        - generic [ref=e108]:
+          - generic [ref=e109]: 今天天气真好适合出去散步呼吸新鲜空气
+          - generic [ref=e110]: 0:04
+      - generic [ref=e111]:
+        - generic [ref=e112]: "4"
+        - generic [ref=e113]:
+          - generic [ref=e114]: 你好
+          - generic [ref=e115]: 0:01
+      - generic [ref=e116]:
+        - generic [ref=e117]: "5"
+        - generic [ref=e118]:
+          - generic [ref=e119]: ������������ʺϳ�ȥɢ���������ʿ���
+          - generic [ref=e120]: 0:02
+      - generic [ref=e121]:
+        - generic [ref=e122]: "6"
+        - generic [ref=e123]:
+          - generic [ref=e124]: ���߲���������������ҹ����������...
+          - generic [ref=e125]: 0:02
+      - generic [ref=e126]:
+        - generic [ref=e127]: "7"
+        - generic [ref=e128]:
+          - generic [ref=e129]: ��ǰ���¹����ǵ���˪��ͷ�����µ�ͷ˼��...
+          - generic [ref=e130]: 0:02
+      - generic [ref=e131]:
+        - generic [ref=e132]: "8"
+        - generic [ref=e133]:
+          - generic [ref=e134]: ���߲���������������ҹ����������...
+          - generic [ref=e135]: 0:01
+      - generic [ref=e136]:
+        - generic [ref=e137]: "9"
+        - generic [ref=e138]:
+          - generic [ref=e139]: ������Ƶ�����Ƿ�������ת��
+          - generic [ref=e140]: 0:02
+      - generic [ref=e141]:
+        - generic [ref=e142]: "10"
+        - generic [ref=e143]:
+          - generic [ref=e144]: �������
+          - generic [ref=e145]: 1:15

+ 110 - 0
player-initial.yml

@@ -0,0 +1,110 @@
+- generic [ref=e81]:
+  - generic [ref=e82]:
+    - generic [ref=e84]: ↓
+    - generic [ref=e86]: 绵绵的云朵梦
+    - generic [ref=e87]:
+      - generic [ref=e89]: ⏰
+      - generic [ref=e91]: 📖
+  - generic [ref=e92]:
+    - generic [ref=e94]: 🎵
+    - generic [ref=e95]: 绵绵的云朵梦
+  - generic [ref=e100]:
+    - generic [ref=e103]: "# 云端上的友谊"
+    - generic [ref=e106]:
+      - text: "## 一、绵绵的梦想"
+      - text: 小绵羊绵绵有一个美丽的梦想——她想跑到高高的云朵里去。
+      - text: 每天清晨,当第一缕阳光洒向大地,绵绵都会仰起头,眨巴着亮晶晶的大眼睛,望着天空中飘浮的云朵。那些云朵白白的、软软的,像一团团棉花糖,又像一个个大大的棉花糖枕头。绵绵想象着自己躺在云朵上的样子,那一定特别舒服,特别温暖。
+      - text: 可是,云朵离地面那么高那么高,绵绵使劲踮起脚尖,伸长脖子,小脑袋都快仰到后背上了,还是够不着。
+      - text: “唉……云朵什么时候能降下来一点呢?”绵绵轻轻地叹了口气。
+      - text: 她试过很多办法。她尝试跳起来去抓云朵的边缘,可是每次都只能碰到空气;她试着爬上家里最高的凳子,可是从凳子上往下看,云朵还是在很远很远的地方。
+      - text: 绵绵有点发愁了。她真的好想、好想到云朵里面去啊!
+    - generic [ref=e109]:
+      - text: "## 二、朋友们来了"
+      - text: 就在这时,绵绵的好朋友们听到了这个消息,纷纷赶来了。
+      - text: 小兔子啾啾第一个到达。他蹦蹦跳跳地来到绵绵面前,两只长长的耳朵竖得直直的:“绵绵,别担心!我来帮你想办法!”
+      - text: 小猫咪可可也迈着优雅的步伐走了过来。她的毛软软的,眼睛亮亮的:“我也想看看云朵是什么样的呢!”
+      - text: 远处传来扑棱扑棱的声音,那是粉红色的小鸟飞飞飞来了。她落在绵绵身旁,歪着脑袋说:“我可以在天上飞来飞去,但从来没到过云朵里面呢!”
+      - text: 小猪佩奇和她的弟弟乔治也跑来了。佩奇笑着说:“大家一起想办法,肯定能成功!”
+      - text: 乔治举起手里的小恐龙玩具,哼哼地说:“乔治也想去!”
+      - text: 看着这么多好朋友都来帮助自己,绵绵的心里暖暖的。
+    - generic [ref=e112]:
+      - text: "## 三、大家一起想办法"
+      - text: 大家围坐在一起,开动小脑袋,一起想办法。
+      - text: 啾啾先开口:“我们能不能堆一座高高的山坡,从山坡上跳起来?”
+      - text: 可可想了想,摇摇头:“山坡太难了,而且也不够高呢。”
+      - text: 飞飞拍拍翅膀说:“我可以飞到云朵旁边看看,可是我带不动大家呀。”
+      - text: 佩奇想了想,突然眼睛一亮:“对了!我们可以借助风的力量!”
+      - text: “风?”大家都好奇地看着佩奇。
+      - text: 佩奇解释道:“我观察过,当大风吹来的时候,地上的小草会被吹得弯腰,大树的叶子会沙沙作响。如果大风吹来的时候,我们一起使劲跳,就能跳得更高更高,说不定就能跳到云朵上了呢!”
+      - text: 乔治高兴地拍起手来:“跳!跳!跳!”
+      - text: 绵绵的眼睛也亮了起来:“这个主意太棒了!”
+    - generic [ref=e115]:
+      - text: "## 四、借风飞翔"
+      - text: 说干就干!大家耐心地等待着。
+      - text: 等啊等,大风终于来了!
+      - text: “准备好了吗?”佩奇大声喊道。
+      - text: “准备好了!”大家一起回答。
+      - text: “风来了!跳——”
+      - text: 一阵大风吹过,绵绵、啾啾、可可、飞飞、佩奇和乔治一起奋力跳起来。
+      - text: 奇妙的事情发生了!在大风的帮助下,他们觉得自己轻飘飘的,好像长了翅膀一样!绵绵感觉自己的身体越来越轻,脚下的草地离自己越来越远,白云就在眼前,伸手就能摸到!
+      - text: 飞飞高兴地喊:“太神奇了!我们真的飞起来了!”
+    - generic [ref=e118]:
+      - text: "## 五、软绵绵的云朵"
+      - text: “扑通!扑通!扑通!”
+      - text: 大家轻轻落在一朵巨大的白云上。
+      - text: “好软啊!”啾啾惊讶地说。他忍不住在云朵上蹦了几下,云朵像弹簧一样弹了起来。
+      - text: “好温暖啊!”可可舒服地趴在云朵上,把脸贴着白云,感觉像被暖暖的被子包裹着。
+      - text: 绵绵用小手摸了摸云朵,绒绒的、软软的,和想象中的一模一样呢!她开心地在云朵上打了个滚,云朵就像棉花糖一样陷了下去,又弹了起来。
+      - text: “哇!这比家里的床还要舒服呢!”佩奇躺下来,舒服地叹了口气。
+      - text: 乔治也在云朵上跳来跳去,笑得合不拢嘴。
+    - generic [ref=e121]:
+      - text: "## 六、云端的快乐时光"
+      - text: 在云朵上,大家做了好多好玩的事情。
+      - text: 飞飞带着大家在云朵间飞来飞去,从这朵云跳到那朵云,每一朵云都有不一样的感觉,有的像巧克力味,有的像草莓味,还有的像香草味。
+      - text: 绵绵和啾啾、可可一起堆云朵,他们用云朵堆出了一只小羊、一只兔子、一只小猫和一只小猪,还有一只小恐龙呢!
+      - text: 佩奇和乔治发现了一个秘密——云朵可以变成各种形状!只要用力捏一捏、按一按,云朵就会变成你想要的形状。乔治最喜欢捏云朵,他捏出了好多好多小恐龙。
+      - text: 大家还一起吃“白云冰激凌”——原来只要在云朵里找一找,就能找到藏在里面的云朵冰激凌,凉凉的、甜甜的,美味极了!
+      - text: 绵绵幸福地看着身边的朋友们,心里想:有了好朋友的陪伴,原来梦想真的可以实现的呢!
+    - generic [ref=e124]:
+      - text: "## 七、最美好的回忆"
+      - text: 夕阳西下,金色的阳光洒在云朵上,把白云染成了金黄色、粉红色和橘红色,好看极了。
+      - text: “时间不早了,我们该回家了。”佩奇说。
+      - text: 又是一阵大风吹来,大家乘着风轻轻飘落,回到了地上。
+      - text: 绵绵回头望了望天空中的云朵,开心地说:“今天是我最开心的一天!”
+      - text: 啾啾蹭了蹭绵绵:“因为有大家在一起啊!”
+      - text: 可可点点头:“是啊,一起想办法,一起努力,一起玩耍,特别快乐!”
+      - text: 飞飞在大家头顶飞了一圈:“以后我们还要一起做更多的事情呢!”
+      - text: 乔治举起小恐龙,哼哼地说:“乔治也是!”
+      - text: 绵绵笑了,她觉得心里暖暖的,比躺在云朵上还要温暖。
+      - text: 从此以后,绵绵明白了:有了好朋友的陪伴,再大的梦想也能实现。而和朋友们在一起的时光,就是最美好的时光。
+      - text: "---"
+      - text: "*小绵羊的云梦之旅,未完待续……*"
+  - generic [ref=e125]:
+    - generic [ref=e126]: 0:03
+    - generic [ref=e134]: 6:39
+  - generic [ref=e135]:
+    - generic [ref=e136]:
+      - generic [ref=e138]: 1x
+      - generic [ref=e141]: ⬇
+    - generic [ref=e142]:
+      - generic [ref=e144]: ⏮
+      - generic [ref=e146]: ⏸
+      - generic [ref=e148]: ⏭
+    - generic [ref=e149]:
+      - generic [ref=e151]: 🤍
+      - generic [ref=e152]:
+        - generic [ref=e153]: 📤
+        - generic [ref=e154]: 分享得时长
+  - generic [ref=e155]:
+    - generic [ref=e156]:
+      - generic [ref=e157]:
+        - generic [ref=e158]: 播放列表
+        - generic [ref=e159]: 1首
+      - generic [ref=e160]:
+        - generic [ref=e161]: 🔂
+        - generic [ref=e162]: 顺序
+    - generic [ref=e167]:
+      - generic [ref=e168]: "1"
+      - generic [ref=e169]:
+        - generic [ref=e170]: 绵绵的云朵梦
+        - generic [ref=e171]: 0:00

+ 102 - 0
player-test-46-2s.yml

@@ -0,0 +1,102 @@
+- generic [ref=e7]:
+  - generic [ref=e8]:
+    - generic [ref=e10]: ↓
+    - generic [ref=e12]: �������
+    - generic [ref=e13]:
+      - generic [ref=e15]: ⏰
+      - generic [ref=e17]: 📖
+  - generic [ref=e18]:
+    - generic [ref=e20]: 🎵
+    - generic [ref=e21]: �������
+  - generic [ref=e26]:
+    - generic [ref=e29]: "# �������"
+    - generic [ref=e32]:
+      - text: "## һ�����յ��峿"
+      - text: �������ˡ��峿�����⴩�������ķ�϶�����ĵ������С���ķ��䡣�����С��ߴߴ�����ؽ��ţ��·���˵��"����������������"С���������۾����Ӵ��������������˸�����������
+      - text: ���ߵ���ǰ���ƿ�������һ�����µĿ���������������������ͻ����ζ����Ժ�����ǿ��ϻ����Ѿ���������̵���ѿ���ڳ���������ҡҷ��С������һ�������о������˶������˻�����
+      - text: "\"���裬��ȥ��԰�ܲ�����\"С��һ�ߴ�Ьһ�߳���������������̽��ͷ����Ц��˵��\"�ǵ����������緹���������㰮�Եļ�����ӡ�\"С�����˵ص��ͷ���ı������س��˼��š�"
+    - generic [ref=e35]:
+      - text: "## ������԰�ľ�ϲ"
+      - text: ��԰�����ַǷ����������ڴ�̫��ȭ���������������������������ܲ�����ˮ���������������⡣����Ȥ���Ƕ�ͯ��԰�DZߣ�����С���������滬�ݣ�Ц��һ���һ��ش�����
+      - text: С�����ź��ߵ�С·�������š���ˮ���̱��̵ģ���ֻ�׶����е�������ȥ��ͻȻ��ͣ���˽Ų�����Ϊ��������һ�������Ļ��桪��һ�����������˷ۺ�ɫ�Ļ���������΢���з׷�Ʈ�䣬��������һ�������ꡣ
+      - text: "\"��������\"С���鲻�Խ�����̾�������ó��ֻ����˺ü�����Ƭ���������Զ����һ�����е�үү���̿������룬үү���̿�����ô���Ĵ��죬һ����ܿ��İɡ�"
+    - generic [ref=e38]:
+      - text: "## ������ů�����"
+      - text: �ص���������Ѿ�����Ͱں��ˡ�����ִ�ļ�����ӡ�ð�������Ķ���������һС����ˬ������ƹϡ�С�����̻��ʵس���������������һ��Ц��˵��"����ԣ���ҭ�š�"
+      - text: �����緹��С��������ϴ���룬Ȼ����������ǰ��ʼд��ҵ�����������Խ��Խů�ͣ���������������ˡ�С�������д��ÿһ���֣�ż��̧ͷ���������������ƣ��о���һ���������ü��ˡ�
+      - text: ���룬��������һ������ļ��ڣ���������������������Ҳ���˸е��Ҹ���ϣ����Ҳ������Ǵ����͸�ÿһ���˵�����ɡ���һ���¿�ʼ��һ����ϣ����
+  - generic [ref=e39]:
+    - generic [ref=e40]: 0:06
+    - generic [ref=e48]: 1:15
+  - generic [ref=e49]:
+    - generic [ref=e50]:
+      - generic [ref=e52]: 1x
+      - generic [ref=e55]: ⬇
+    - generic [ref=e56]:
+      - generic [ref=e58]: ⏮
+      - generic [ref=e60]: ⏸
+      - generic [ref=e62]: ⏭
+    - generic [ref=e63]:
+      - generic [ref=e65]: 🤍
+      - generic [ref=e66]:
+        - generic [ref=e67]: 📤
+        - generic [ref=e68]: 分享得时长
+  - generic [ref=e69]:
+    - generic [ref=e70]:
+      - generic [ref=e71]:
+        - generic [ref=e72]: 播放列表
+        - generic [ref=e73]: 10首
+      - generic [ref=e74]:
+        - generic [ref=e75]: 🔂
+        - generic [ref=e76]: 顺序
+    - generic [ref=e80]:
+      - generic [ref=e81]:
+        - generic [ref=e82]: "1"
+        - generic [ref=e83]:
+          - generic [ref=e84]: �����ı�����һ�����ڲ��Ե���Ƶ�����...
+          - generic [ref=e85]: 0:02
+      - generic [ref=e86]:
+        - generic [ref=e87]: "2"
+        - generic [ref=e88]:
+          - generic [ref=e89]: ������������ʺϳ�ȥɢ���������ʿ���
+          - generic [ref=e90]: 0:02
+      - generic [ref=e91]:
+        - generic [ref=e92]: "3"
+        - generic [ref=e93]:
+          - generic [ref=e94]: 今天天气真好适合出去散步呼吸新鲜空气
+          - generic [ref=e95]: 0:04
+      - generic [ref=e96]:
+        - generic [ref=e97]: "4"
+        - generic [ref=e98]:
+          - generic [ref=e99]: 你好
+          - generic [ref=e100]: 0:01
+      - generic [ref=e101]:
+        - generic [ref=e102]: "5"
+        - generic [ref=e103]:
+          - generic [ref=e104]: ������������ʺϳ�ȥɢ���������ʿ���
+          - generic [ref=e105]: 0:02
+      - generic [ref=e106]:
+        - generic [ref=e107]: "6"
+        - generic [ref=e108]:
+          - generic [ref=e109]: ���߲���������������ҹ����������...
+          - generic [ref=e110]: 0:02
+      - generic [ref=e111]:
+        - generic [ref=e112]: "7"
+        - generic [ref=e113]:
+          - generic [ref=e114]: ��ǰ���¹����ǵ���˪��ͷ�����µ�ͷ˼��...
+          - generic [ref=e115]: 0:02
+      - generic [ref=e116]:
+        - generic [ref=e117]: "8"
+        - generic [ref=e118]:
+          - generic [ref=e119]: ���߲���������������ҹ����������...
+          - generic [ref=e120]: 0:01
+      - generic [ref=e121]:
+        - generic [ref=e122]: "9"
+        - generic [ref=e123]:
+          - generic [ref=e124]: ������Ƶ�����Ƿ�������ת��
+          - generic [ref=e125]: 0:02
+      - generic [ref=e126]:
+        - generic [ref=e127]: "10"
+        - generic [ref=e128]:
+          - generic [ref=e129]: �������
+          - generic [ref=e130]: 1:15

+ 102 - 0
player-test-46.yml

@@ -0,0 +1,102 @@
+- generic [ref=e7]:
+  - generic [ref=e8]:
+    - generic [ref=e10]: ↓
+    - generic [ref=e12]: �������
+    - generic [ref=e13]:
+      - generic [ref=e15]: ⏰
+      - generic [ref=e17]: 📖
+  - generic [ref=e18]:
+    - generic [ref=e20]: 🎵
+    - generic [ref=e21]: �������
+  - generic [ref=e26]:
+    - generic [ref=e29]: "# �������"
+    - generic [ref=e32]:
+      - text: "## һ�����յ��峿"
+      - text: �������ˡ��峿�����⴩�������ķ�϶�����ĵ������С���ķ��䡣�����С��ߴߴ�����ؽ��ţ��·���˵��"����������������"С���������۾����Ӵ��������������˸�����������
+      - text: ���ߵ���ǰ���ƿ�������һ�����µĿ���������������������ͻ����ζ����Ժ�����ǿ��ϻ����Ѿ���������̵���ѿ���ڳ���������ҡҷ��С������һ�������о������˶������˻�����
+      - text: "\"���裬��ȥ��԰�ܲ�����\"С��һ�ߴ�Ьһ�߳���������������̽��ͷ����Ц��˵��\"�ǵ����������緹���������㰮�Եļ�����ӡ�\"С�����˵ص��ͷ���ı������س��˼��š�"
+    - generic [ref=e35]:
+      - text: "## ������԰�ľ�ϲ"
+      - text: ��԰�����ַǷ����������ڴ�̫��ȭ���������������������������ܲ�����ˮ���������������⡣����Ȥ���Ƕ�ͯ��԰�DZߣ�����С���������滬�ݣ�Ц��һ���һ��ش�����
+      - text: С�����ź��ߵ�С·�������š���ˮ���̱��̵ģ���ֻ�׶����е�������ȥ��ͻȻ��ͣ���˽Ų�����Ϊ��������һ�������Ļ��桪��һ�����������˷ۺ�ɫ�Ļ���������΢���з׷�Ʈ�䣬��������һ�������ꡣ
+      - text: "\"��������\"С���鲻�Խ�����̾�������ó��ֻ����˺ü�����Ƭ���������Զ����һ�����е�үү���̿������룬үү���̿�����ô���Ĵ��죬һ����ܿ��İɡ�"
+    - generic [ref=e38]:
+      - text: "## ������ů�����"
+      - text: �ص���������Ѿ�����Ͱں��ˡ�����ִ�ļ�����ӡ�ð�������Ķ���������һС����ˬ������ƹϡ�С�����̻��ʵس���������������һ��Ц��˵��"����ԣ���ҭ�š�"
+      - text: �����緹��С��������ϴ���룬Ȼ����������ǰ��ʼд��ҵ�����������Խ��Խů�ͣ���������������ˡ�С�������д��ÿһ���֣�ż��̧ͷ���������������ƣ��о���һ���������ü��ˡ�
+      - text: ���룬��������һ������ļ��ڣ���������������������Ҳ���˸е��Ҹ���ϣ����Ҳ������Ǵ����͸�ÿһ���˵�����ɡ���һ���¿�ʼ��һ����ϣ����
+  - generic [ref=e39]:
+    - generic [ref=e40]: 0:03
+    - generic [ref=e48]: 1:15
+  - generic [ref=e49]:
+    - generic [ref=e50]:
+      - generic [ref=e52]: 1x
+      - generic [ref=e55]: ⬇
+    - generic [ref=e56]:
+      - generic [ref=e58]: ⏮
+      - generic [ref=e60]: ⏸
+      - generic [ref=e62]: ⏭
+    - generic [ref=e63]:
+      - generic [ref=e65]: 🤍
+      - generic [ref=e66]:
+        - generic [ref=e67]: 📤
+        - generic [ref=e68]: 分享得时长
+  - generic [ref=e69]:
+    - generic [ref=e70]:
+      - generic [ref=e71]:
+        - generic [ref=e72]: 播放列表
+        - generic [ref=e73]: 10首
+      - generic [ref=e74]:
+        - generic [ref=e75]: 🔂
+        - generic [ref=e76]: 顺序
+    - generic [ref=e80]:
+      - generic [ref=e81]:
+        - generic [ref=e82]: "1"
+        - generic [ref=e83]:
+          - generic [ref=e84]: �����ı�����һ�����ڲ��Ե���Ƶ�����...
+          - generic [ref=e85]: 0:02
+      - generic [ref=e86]:
+        - generic [ref=e87]: "2"
+        - generic [ref=e88]:
+          - generic [ref=e89]: ������������ʺϳ�ȥɢ���������ʿ���
+          - generic [ref=e90]: 0:02
+      - generic [ref=e91]:
+        - generic [ref=e92]: "3"
+        - generic [ref=e93]:
+          - generic [ref=e94]: 今天天气真好适合出去散步呼吸新鲜空气
+          - generic [ref=e95]: 0:04
+      - generic [ref=e96]:
+        - generic [ref=e97]: "4"
+        - generic [ref=e98]:
+          - generic [ref=e99]: 你好
+          - generic [ref=e100]: 0:01
+      - generic [ref=e101]:
+        - generic [ref=e102]: "5"
+        - generic [ref=e103]:
+          - generic [ref=e104]: ������������ʺϳ�ȥɢ���������ʿ���
+          - generic [ref=e105]: 0:02
+      - generic [ref=e106]:
+        - generic [ref=e107]: "6"
+        - generic [ref=e108]:
+          - generic [ref=e109]: ���߲���������������ҹ����������...
+          - generic [ref=e110]: 0:02
+      - generic [ref=e111]:
+        - generic [ref=e112]: "7"
+        - generic [ref=e113]:
+          - generic [ref=e114]: ��ǰ���¹����ǵ���˪��ͷ�����µ�ͷ˼��...
+          - generic [ref=e115]: 0:02
+      - generic [ref=e116]:
+        - generic [ref=e117]: "8"
+        - generic [ref=e118]:
+          - generic [ref=e119]: ���߲���������������ҹ����������...
+          - generic [ref=e120]: 0:01
+      - generic [ref=e121]:
+        - generic [ref=e122]: "9"
+        - generic [ref=e123]:
+          - generic [ref=e124]: ������Ƶ�����Ƿ�������ת��
+          - generic [ref=e125]: 0:02
+      - generic [ref=e126]:
+        - generic [ref=e127]: "10"
+        - generic [ref=e128]:
+          - generic [ref=e129]: �������
+          - generic [ref=e130]: 1:15

BIN
qrcode-test-online.png


+ 54 - 2
server/.env.production

@@ -2,7 +2,7 @@
 NODE_ENV=production
 
 # 服务端口
-SERVER_PORT=22622
+SERVER_PORT=3100
 
 # 开发阶段跳过认证
 AUTH_ENABLED=false
@@ -20,7 +20,7 @@ DASHSCOPE_USE_REALTIME=true
 DASHSCOPE_REALTIME_MODEL=qwen3-tts-instruct-flash-realtime
 
 # MySQL 数据库配置
-DATABASE_URL="mysql://root:password@localhost:3306/audio_book"
+DATABASE_URL="mysql://root:123456@localhost:3306/audio_book?serverTimezone=Asia/Shanghai"
 
 # 存储类型配置(oss 或 local)
 STORAGE_TYPE=oss
@@ -38,5 +38,57 @@ OSS_ACCESS_KEY_SECRET=f0R5nOQf6E1lIGESENKiK8cNzFl8wd
 OSS_BUCKET_NAME=aaaa33dfsf32rfsf
 OSS_REGION=cn-hangzhou
 
+# 支付宝配置
+ALIPAY_APP_ID=2021006154659461
+ALIPAY_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC798Z+2//v0KPswSiczFy/oGv7M99FCgQ4nh4i/F8OtWDs37+Ts/5HD0Jo4wxgrlKHLtUmsEDH2hXn2/08wj0V2cDNgUe2IrOOrpmaiVnBczjilmheUZDtnG26I38WyE3viZ7zQ5ZfNTcVxRH1gbpSC512922Vi9lFRJycIlptvKzsov11RtE29NDoKcPxTo6gMa94hLEVsDRoJroGBlGjw27Pbynwdgcjb4URacUO4T8ZFTnuYZeTyZHqyJF41WUIwRfzRSrlFUb7oRfF4LC23eXELATtpL+28Y5k4CyA6uIOPKJ02CsmCg+VjYxvV/JEw8slm/IUJiLAfjoy8vf3AgMBAAECggEAL6OyuUBE86DrivvBsUvSTfb4pygUdEj/nOHmPXO9G9s4f5CN23+I+LWTXUX+Qxqra/ugpjeRk5Vga+1Dl3ybQsh3px4W8MCGxajW4WXiS7dKOZNpTE5b7vzwinaopTuLFSTKFlmQ6Pxxw6C41oJUmKq89APt0DMrk/S5xJCoXps86xp6zbXjlPm8HtLwuL0OP0WfgnwfXm8kOkaCswS4NXrz83DcR/tUrsdNmcOUmXXuXx+hwu/x0hgMOnSZYlrea/el6deM/giU+JPbM9BMrM8IeMW0MkLSBTikSsav+hetosxuPUrCebcK4QSUrTJ+mwqWNvFFeJuM/P6HbvfTQQKBgQD8GQlwpaQBRe2VXoapjB4ShL6ml+vVNwsjhR0WiwJHavGRS/hKIe/YLRqgfpHUR0/bUOFFxpkRApBT0LevL74c1HriXzHmGhC7DLmYQD+MZmTEbHNbEgVUsJeB04Cn+teWIsKYe80YYbvpwNvsX/Eu5FutOoZCNLrR5SAR7iZNZwKBgQC+4J39gO35W1Kc91tpX5UsmuGPdO6F89BFqvydkcYAYsEi3P92lrTXiplhNnGBDe7KoM8M7gWrnjvTr9S3ZLylKK7cgKn1VUYIqdAqyikiI7hVCfKZZeMa4TKZ0Os0PnGf6PqX3Rm/3yFZpXqD48QqVq2zLePWQGDXdd6BVFDW8QKBgEpBda5gm/ZkCp88wJ2gUBtUTnPzu9uJ4xYLV44UVA+FSLJrli7/4A3k+zrbDUlh1/vZzwEmhl+kSa7mg7Ruc6k6TRfwwlnsw5DVJrXZ9t38KyJQb4obvMY9XeM30WWpVWc7xeX0UW0lHJy12hG+EOSnDvnviyxlK8I3o0Kky1NxAoGAFsyVknYX9rTlxUH2dTupgc5uKsh4xR8+PVQTIG8NC3bAQv3KtcuChkxTzbzHCfc0Epfqo0y6+KjJ1Jsn+U/bCI83c7calec9mkNOTg4fo6hImD+77n6xBcDoXl8J+Q+MWuFlLUaHkNeCEEcnvyExiS0ZSlGs6SZ/arz1etUrYsECgYEA3D2gaibcyTAYrJ/mk5pu2M6g+Ga7A4TSwUCAwUIN0pYVCgFLvl6Faii5n9AzHRd9/H5CQKl2pqEw44tBYPNAyv8zGivyUAO2uDWpQYm7enZjJ/4eSKAJgoF2tGQEjOyORQub6i78P4vnW0yQLpfR7O73JGF9C+PohmfyDpSkbXY=
+ALIPAY_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAg2H/H1UoYUUSr3hdtgrMQqoFvHpWaz9nSOC9+rM2Csci9iRWW/K9mk4Vfawh9qwaRAv4OPB82arS7NSPUQirUraeGw3JbZ/HUhBY0blkES9GznhQzeht/IES2WccNC+nCprcXcvaLCqEx0gPTb1ujhKDZOkoc9H2JZCyPOiOsMlKdFJjk7jlR/Vn5aA+M9TaK8hOwCz02VCg7d/NfXZFwdg+88k9e4GLbdHI0MnmQ2Mg7DmuSM0TjSk6oPh3OlePMIPcvO/Wt/k2x6Fl2aVvH3FabtqZzNRtseKiTSzKCiNb7mJktkSsoE7lySXsUEP4X+hlesjNyIGRgf/yTCDlNwIDAQAB
+ALIPAY_NOTIFY_URL=https://book.rrbrr.com/api/payment/alipay/notify
+ALIPAY_RETURN_URL=https://book.rrbrr.com/#/pages/payment-result/index
+
+# 微信支付配置
+WECHAT_APP_ID=wx61df40b30256f09f
+WECHAT_APP_SECRET=your_wechat_app_secret_here
+WECHAT_MCH_ID=1113216359
+WECHAT_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC8Pb9U96Hsds+3
+NKO+TtqWYbIy7f7uM4gBdmRB6syxuVohU+cSzbbq+o0iWiSAwP8WSiPEgj6ZtBjV
+YlqrVqUsZVETP0FDPgqFyPt+XRmIiVrbbS1zMQ4qwelJ9xcnyvhWknW+r4VUZxm5
+uuUoTls5QnNTq1yZIrNJVjS38DkXY0NPqceoRJ0A8Q/XZJcrlsDghO6WWxlxtQCR
+6J1wIzjnXRNbAQQiFw9kPzMYQjHAtkvfaa7J41sm/3s8hkb4MVXuWSv5sAhV/p9H
+so2sLfI9bpFuavvDRUpk4RGY8ouKElZtVZt1JsOBPpxxq859naeSZpn/9dj4b4q0
+dM2gCnxNAgMBAAECggEBAJdP6ySZiYdSWdWicpzfNcX78goOVQWUVpcWW7DA1Jln
+rJNtpijvQ0HO9PPQEdeNS37XSbkEGN3kbrHXxZpEo7D0IB+gkQ1EKaEw1TfWDlg6
+UNZhBE+79u9Xv/a8r9hExyqPwCXvYPVfisZI/TtRAPMd/yYacOyHgCZLLUVNzBaH
+SAq8SS/bGj0NEz0xmDFaBj8Vlas7iVGZnmLMbTmyXdBS2/m2Hnyxrts8clmJc1qK
+NzF524nc/o9/M1h7HIQ41JyvgHu5XQ4GkaBPgeVVGbeZm0mPkevVr4HxElQCjr1t
+o7TInTUrV131AxyruhRr7FjYOYHBbkNY/Q3HiCLYOgECgYEA+d6azGpdyuuyra+3
+M+/RBSOxIPj1ims1EpZGmCRTvRnztLqkpxhNcEpNWQfJygv4AYiFGrwf5Isa1RRn
+86KWSTx23HYssJrtbu492NMSOw5/TwJU4BuySDtLWXc8HxUlv5PmDAuf+eA3Ei6I
+v89Lyu45eypxJh9Xw1e8xMKjgN0CgYEAwNwQUrF1uDDPbeJ0QJSYBFBr+wSvXM+r
+govjkN71gca4u+g7lv0w3AkOcAUPphU+9llMUIvqELs9ADwH1K3KM9EA/2m6qOhD
+OWm9UQd+E7mNFTuOECtnzPtHymD6t0Eyj5tKYoVb9nxjArtFKGqRdayDLlYHZoLA
+ncVGD0ST+jECgYArSe+TnpHlR/RKtviMXO3fcBGN3tPbSTuGntZYIWMIjeh01SkV
+cdZor9a6p+jDwlMTIO2+df0ogRkC9WgVA4P56CnHhAm2BwE/iLLaT6cG6DoSHBtM
+QEAg13T0aP3XfLhvelYOnCz5A2lHExzJ7kX5QFBhEDdOHXv88cRYakgdWQKBgFhH
+o5UmtDsW5Kxo/0vceOVoprTlhRwX12tPe2CCHqFEBKXG/Rf1V9swrhIqzun4SbTc
+VWl9UixMePTVhcQdtTQY1LsVe6L7hmIOe+FOa7C9u83fT/0o0T/sOgVydPFTf8Bq
+lhcY8mqWr3+9lz+T4Heb+wRHzuVmrqLJfXNZsd7RAoGBAPU53izlCy1mgS1h/jQE
+kcj/+f1eE8WbW5++HNyPnsyT42zVrAqW5ZiNo3FLjIiK3QaMxiGcI3jwbq5RxiqX
+mTtNK5ItL7N1Roqa/WdE+qApDd/oXR6rJCzCBqPsN0GsH0W0mNqW+v/pg5dmS5UK
+nHeMo5HTv2HCwU4olrlIAuAN
+-----END PRIVATE KEY-----
+WECHAT_APIV3_KEY=86236be3a84c7380245afd814da11918
+WECHAT_SERIAL_NO=353743C4C8AF68DB43346687068D7173314F2665
+WECHAT_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvD2/VPeh7HbPtzSjjvk7
+almGyMu3+7jOIAXZQerMsblaIVPnEs226vqNIlokgMD/FkojxII+mbQY1WJaq1al
+LGVREz9BQz4Khcj7fl0ZiIla220tczEOKsHpSfcXJ8r4VpJ1vq+FVGcZubrlKE5b
+OUJzU6tcmSKzSVY0t/A5F2NDT6nHqESdAPEP12SXK5bA4ITullsZcbUAkeiddcCM
+4510TWwEEIhcPZD8zGEIxwLZL32muyeNbJv97PIZG+DFV7lkr+bAIVf6fR7KNNrC
+3yPW6Rbmr7w0VKZOERmPKLihJWbVWbdSbDgT6ccavOfZ2nkmaZ//XY+G+KtHTNNo
+Ap8TQIDAQAB
+-----END PUBLIC KEY-----
+WECHAT_NOTIFY_URL=https://book.rrbrr.com/api/payment/wechat/notify
+
 # Minimax API 配置
 MINIMAX_API_KEY=sk-cp-s8hGJr4ACdlZ4GM0LjZ1l2ZNBUAMGwd2ahl0c3W2g6C0EiNH0UvwOBx2q0oJOYO6ca5HqJccP_KRUPucoBizavlwDSZp-ohOGeUw2ymAdXPuM6r3CIDmmlY

+ 116 - 0
server/_deep_diag.mjs

@@ -0,0 +1,116 @@
+import { config } from 'dotenv';
+import { resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import crypto from 'crypto';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+config({ path: resolve(__dirname, '.env') });
+
+const { AlipaySdk } = await import('alipay-sdk');
+
+const appId = process.env.ALIPAY_APP_ID;
+const rawPrivateKey = process.env.ALIPAY_PRIVATE_KEY;
+const rawPublicKey = process.env.ALIPAY_PUBLIC_KEY;
+
+// 转 PKCS#1 PEM
+const pkcs8Buf = Buffer.from(rawPrivateKey, 'base64');
+const privObj = crypto.createPrivateKey({ key: pkcs8Buf, format: 'der', type: 'pkcs8' });
+const pkcs1Pem = privObj.export({ format: 'pem', type: 'pkcs1' });
+
+const alipayPubPem = rawPublicKey.includes('BEGIN')
+  ? rawPublicKey
+  : `-----BEGIN PUBLIC KEY-----\n${rawPublicKey}\n-----END PUBLIC KEY-----`;
+
+// 用户应用公钥
+const userAppPubKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fGftv/79Cj7MEonMxcv6Br+zPfRQoEOJ4eIvxfDrVg7N+/k7P+Rw9CaOMMYK5Shy7VJrBAx9oV59v9PMI9FdnAzYFHtiKzjq6ZmolZwXM44pZoXlGQ7ZxtuiN/FshN74me80OWXzU3FcUR9YG6UguddvdtlYvZRUScnCJabbys7KL9dUbRNvTQ6CnD8U6OoDGveISxFbA0aCa6BgZRo8Nuz28p8HYHI2+FEWnFDuE/GRU57mGXk8mR6siReNVlCMEX80Uq5RVG+6EXxeCwtt3lxCwE7aS/tvGOZOAsgOriDjyidNgrJgoPlY2Mb1fyRMPLJZvyFCYiwH46MvL39wIDAQAB';
+
+// 方式1: SDK 签名
+const sdk = new AlipaySdk({
+  appId,
+  privateKey: pkcs1Pem,
+  keyType: 'PKCS1',
+  alipayPublicKey: alipayPubPem,
+  signType: 'RSA2',
+});
+
+const orderNo = 'DIAG' + Date.now();
+const form = sdk.pageExecute('alipay.trade.wap.pay', 'POST', {
+  bizContent: {
+    out_trade_no: orderNo,
+    total_amount: '0.01',
+    subject: '诊断测试',
+    product_code: 'QUICK_WAP_WAY',
+  },
+  returnUrl: 'https://book.rrbrr.com/#/pages/payment-result/index',
+  notifyUrl: 'https://book.rrbrr.com/api/payment/alipay/notify',
+});
+
+const formStr = String(form);
+const sdkSign = formStr.match(/sign=([^"&\s]+)/)?.[1] || '';
+console.log('SDK签名(前80):', sdkSign.substring(0, 80));
+console.log('SDK签名长度:', sdkSign.length);
+
+// 从URL中提取验签字符串
+const actionUrl = formStr.match(/action="([^"]+)"/)?.[1] || '';
+const urlParams = new URL(actionUrl);
+const paramMap = {};
+urlParams.searchParams.forEach((v, k) => {
+  if (k !== 'sign') paramMap[k] = v;
+});
+
+// 构建验签字符串(按支付宝规则:参数排序后用&连接)
+const sortedParams = Object.keys(paramMap).sort().map(k => `${k}=${paramMap[k]}`).join('&');
+const signString = decodeURIComponent(sortedParams);
+console.log('\nSDK生成的验签字符串:', signString.substring(0, 200));
+
+// 方式2: 手动RSA2签名
+const decoder = new TextDecoder();
+const signStrBytes = new TextEncoder().encode(signString);
+const sign2 = crypto.createSign('RSA-SHA256');
+sign2.update(signString);
+const manualSign = sign2.sign(pkcs1Pem, 'base64');
+console.log('\n手动签名(前80):', manualSign.substring(0, 80));
+console.log('手动签名长度:', manualSign.length);
+console.log('SDK签名 vs 手动签名:', sdkSign === manualSign ? '✅ 一致' : '❌ 不一致');
+
+// 方案3: 转成URL-safe base64后比较
+function toUrlSafe(b64) {
+  return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+console.log('\nURL-safe签名比较:');
+console.log('SDK:', toUrlSafe(sdkSign.substring(0, 50)));
+console.log('手动:', toUrlSafe(manualSign.substring(0, 50)));
+console.log('匹配:', toUrlSafe(sdkSign) === toUrlSafe(manualSign));
+
+// 方式4: 用应用公钥验SDK签名
+const appPubObj = crypto.createPublicKey({
+  key: Buffer.from(userAppPubKey, 'base64'),
+  format: 'der', type: 'spki'
+});
+const appPubPem = appPubObj.export({ format: 'pem', type: 'spki' });
+
+const verifySdk = crypto.createVerify('RSA-SHA256');
+verifySdk.update(signString);
+const sdkVerified = verifySdk.verify(appPubPem, Buffer.from(decodeURIComponent(sdkSign), 'base64'), 'base64');
+console.log('\n用应用公钥验SDK签名:', sdkVerified ? '✅ 通过' : '❌ 失败');
+
+const verifyManual = crypto.createVerify('RSA-SHA256');
+verifyManual.update(signString);
+const manualVerified = verifyManual.verify(appPubPem, Buffer.from(manualSign, 'base64'), 'base64');
+console.log('用应用公钥验手动签名:', manualVerified ? '✅ 通过' : '❌ 失败');
+
+// 方式5: 直接用curl(v3)试
+console.log('\n=== 用 curl(v3) 方式测试 ===');
+try {
+  const result = await sdk.curl('POST', '/v3/alipay/trade/precreate', {
+    body: {
+      out_trade_no: orderNo + 'C',
+      total_amount: '0.01',
+      subject: 'curl测试',
+    },
+    timeout: 10000,
+  });
+  console.log('curl结果:', JSON.stringify(result).substring(0, 300));
+} catch (e) {
+  console.log('curl错误:', e.message);
+}

+ 18 - 0
server/_delete_test_plans.js

@@ -0,0 +1,18 @@
+const { PrismaClient } = require('@prisma/client');
+const prisma = new PrismaClient();
+
+async function main() {
+  const result = await prisma.subscriptionPlan.deleteMany({
+    where: {
+      name: { in: ['测试套餐', '测试版', 'Test Plan', 'TestPackage'] }
+    }
+  });
+  console.log('Deleted count:', result.count);
+
+  const remaining = await prisma.subscriptionPlan.findMany({
+    orderBy: { sortOrder: 'asc' }
+  });
+  console.log('Remaining plans:', remaining.map(p => ({ id: p.id, name: p.name, level: p.level, priceMonthly: p.priceMonthly })));
+}
+
+main().catch(console.error).finally(() => prisma.$disconnect());

+ 1 - 0
server/_final_url.txt

@@ -0,0 +1 @@
+stdout is not a tty

+ 1 - 0
server/_newkeys.txt

@@ -0,0 +1 @@
+stdout is not a tty

+ 1 - 0
server/_priv.txt

@@ -0,0 +1 @@
+MIIEowIBAAKCAQEAvC51A7qwQ/wN6uBFaXL8bvnnd0ozrJo/xtI7mloyCwucMQd78GOkJaQ5EpOF7Ux8bUj9T2f8287IP+t4GNn7jUuIrxySr63mfYZcgCQlfuq5iP3mjsMWelZB/doPYvEZ3XTSOgRG6/iJG16czfinU+VuwcJZydyPhDr47wImY7pNhF5hAC/DY4lFAh7bw4LPtm8snuKK9VKP4dDHN0SOOUoJAtguIEK8VRjgJR8HyrTgs6XCOobQIeIc2TWC6pf2rZyxl+rbGgfWCsiq/n2lYKSK9i+oTlLGnpHnCdHE188GJy4qS1ASrOyvi6rEKIJUdWt3V485TttVbTmRIyTL2wIDAQABAoIBAEDYg3MjCG2M9GDNIq4pNQqq7BPDypW+W57QQEC6twbuW8bbbN6T/3MHKbGYi2yP0Eq6fau4yj4WfhPkuPBbYV85uaKaJoQKWWRFAigYDKG1EfU+hQ4J9Pt4MOpgk6/FJGQXRwA+DBBxhxGa407o0gRLigSTy+wPKJdo2PJHm+H+L8gkJnt78vh592+bExcFQQe37Zdwl0oII7HWVabaur7G/KsJ3bWZzu+lYZYU7fzSte7tOSGPvwpi9pS+pGOD/vhv8AR8rUq06yUNJIThUZnQ5NRBVvfyaIMNjPHw3JAoH32a/HbriFuCb+l+/npTQ4J1rFykWRnxq2FXAN0VM70CgYEA7e5mtxQc+5JKO0hQBI8XjzGDlk1MQFFE0lu9ZhEdQIfuIV+7TseWpM/zBtSNsxyhxfOlSsko58wTDwTyShW3jmIL7cU8nQlskepi39qqW+EK8O0EgxDkUE5k6hNhYD4OPM1oWiOlC4RPanqwFo/VngnoCREpC4//soNY011y0O0CgYEAynjgBwPhDpTVUnjPE5PiaCHV1gs8T9BvDJW8U7hBUs+Q59LrtltY5hAb1MZyk4LN0VahA3mtYwDJB4qIWHwXSKKi2I0Zj2N5TOOvnGE/7cM+9Dzv0rrRMlg+/UB2ohuisbgDLCmMwitGXwJEOuu0vAXgF0OhfuZ2PBqw1wb4nucCgYALGh6s2EFp86+HCJ7DN7IPum27a6vtLF2buzAhPRcyVaVk5USmLmX3FsTuQqjB/MoHpJDH5o8EEd+pyb9Mearw9946oANuCGyKendWnqtoUh3ilAyr2PPuU1oeGf1nMj4T1DnUSul+mhPikgr5YojDMQ8krSpPIc76k2pnomqjGQKBgDXW7KOQ6YCqHQugKlhnY7YTATy4oiILJ+xtyLVucphlr3+lu54zLjGHKQ9scb099CLtaUV2Tp/b7pj9woHdBYgAD1OMObbCWdgTV3g69/fuikgCCDe+C7DafG11fdZfiOS/LMDKj652OXE8Ng6A7P9uhIdLKZ+IAvq6kjmrleMTAoGBAIM/cVTjyUhQcQv6k84aIJCkbRtp6wAi6DXmemTCx71PiYXkqCy/tpcI9x+gKX6HLn2Fm03yxrKxoqQvEAE02jKKjvJJa9NS7+zuk0YHzMZr5y1WBOqYR4pH2WfYcr2B4l00R83SUjFWrESLeePd73ZmmBDL166cyC/8D0pylHPE

+ 9 - 0
server/_pub.txt

@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvC51A7qwQ/wN6uBFaXL8
+bvnnd0ozrJo/xtI7mloyCwucMQd78GOkJaQ5EpOF7Ux8bUj9T2f8287IP+t4GNn7
+jUuIrxySr63mfYZcgCQlfuq5iP3mjsMWelZB/doPYvEZ3XTSOgRG6/iJG16czfin
+U+VuwcJZydyPhDr47wImY7pNhF5hAC/DY4lFAh7bw4LPtm8snuKK9VKP4dDHN0SO
+OUoJAtguIEK8VRjgJR8HyrTgs6XCOobQIeIc2TWC6pf2rZyxl+rbGgfWCsiq/n2l
+YKSK9i+oTlLGnpHnCdHE188GJy4qS1ASrOyvi6rEKIJUdWt3V485TttVbTmRIyTL
+2wIDAQAB
+-----END PUBLIC KEY-----

+ 32 - 0
server/_test.cjs

@@ -0,0 +1,32 @@
+require('dotenv').config();
+const { AlipaySdk } = require('alipay-sdk');
+
+const rawKey = process.env.ALIPAY_PRIVATE_KEY;
+const rawPub = process.env.ALIPAY_PUBLIC_KEY;
+
+// 使用 PKCS#8 正确包装
+const privateKey = `-----BEGIN PRIVATE KEY-----\n${rawKey}\n-----END PRIVATE KEY-----`;
+const alipayPublicKey = rawPub.includes('BEGIN') ? rawPub : `-----BEGIN PUBLIC KEY-----\n${rawPub}\n-----END PUBLIC KEY-----`;
+
+const sdk = new AlipaySdk({
+  appId: '2021006154659461',
+  privateKey,
+  keyType: 'PKCS8',
+  alipayPublicKey,
+  gateway: 'https://openapi.alipay.com/gateway.do',
+  signType: 'RSA2',
+});
+
+// GET方式 - 全部参数在URL中
+const url = sdk.pageExecute('alipay.trade.wap.pay', 'GET', {
+  bizContent: {
+    out_trade_no: 'D' + Date.now(),
+    total_amount: '0.01',
+    subject: 'Direct',
+    product_code: 'QUICK_WAP_WAY',
+  },
+  returnUrl: 'https://book.rrbrr.com/#/pages/payment-result/index',
+  notifyUrl: 'https://book.rrbrr.com/api/payment/alipay/notify',
+});
+
+console.log(String(url));

+ 0 - 62
server/_test_alipay_sdk.js

@@ -1,62 +0,0 @@
-// 测试 Alipay SDK 修复后是否能正确签名
-async function test() {
-  const { AlipaySdk } = await import('alipay-sdk');
-
-  const rawKey = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC798Z+2//v0KPswSiczFy/oGv7M99FCgQ4nh4i/F8OtWDs37+Ts/5HD0Jo4wxgrlKHLtUmsEDH2hXn2/08wj0V2cDNgUe2IrOOrpmaiVnBczjilmheUZDtnG26I38WyE3viZ7zQ5ZfNTcVxRH1gbpSC512922Vi9lFRJycIlptvKzsov11RtE29NDoKcPxTo6gMa94hLEVsDRoJroGBlGjw27Pbynwdgcjb4URacUO4T8ZFTnuYZeTyZHqyJF41WUIwRfzRSrlFUb7oRfF4LC23eXELATtpL+28Y5k4CyA6uIOPKJ02CsmCg+VjYxvV/JEw8slm/IUJiLAfjoy8vf3AgMBAAECggEAL6OyuUBE86DrivvBsUvSTfb4pygUdEj/nOHmPXO9G9s4f5CN23+I+LWTXUX+Qxqra/ugpjeRk5Vga+1Dl3ybQsh3px4W8MCGxajW4WXiS7dKOZNpTE5b7vzwinaopTuLFSTKFlmQ6Pxxw6C41oJUmKq89APt0DMrk/S5xJCoXps86xp6zbXjlPm8HtLwuL0OP0WfgnwfXm8kOkaCswS4NXrz83DcR/tUrsdNmcOUmXXuXx+hwu/x0hgMOnSZYlrea/el6deM/giU+JPbM9BMrM8IeMW0MkLSBTikSsav+hetosxuPUrCebcK4QSUrTJ+mwqWNvFFeJuM/P6HbvfTQQKBgQD8GQlwpaQBRe2VXoapjB4ShL6ml+vVNwsjhR0WiwJHavGRS/hKIe/YLRqgfpHUR0/bUOFFxpkRApBT0LevL74c1HriXzHmGhC7DLmYQD+MZmTEbHNbEgVUsJeB04Cn+teWIsKYe80YYbvpwNvsX/Eu5FutOoZCNLrR5SAR7iZNZwKBgQC+4J39gO35W1Kc91tpX5UsmuGPdO6F89BFqvydkcYAYsEi3P92lrTXiplhNnGBDe7KoM8M7gWrnjvTr9S3ZLylKK7cgKn1VUYIqdAqyikiI7hVCfKZZeMa4TKZ0Os0PnGf6PqX3Rm/3yFZpXqD48QqVq2zLePWQGDXdd6BVFDW8QKBgEpBda5gm/ZkCp88wJ2gUBtUTnPzu9uJ4xYLV44UVA+FSLJrli7/4A3k+zrbDUlh1/vZzwEmhl+kSa7mg7Ruc6k6TRfwwlnsw5DVJrXZ9t38KyJQb4obvMY9XeM30WWpVWc7xeX0UW0lHJy12hG+EOSnDvnviyxlK8I3o0Kky1NxAoGAFsyVknYX9rTlxUH2dTupgc5uKsh4xR8+PVQTIG8NC3bAQv3KtcuChkxTzbzHCfc0Epfqo0y6+KjJ1Jsn+U/bCI83c7calec9mkNOTg4fo6hImD+77n6xBcDoXl8J+Q+MWuFlLUaHkNeCEEcnvyExiS0ZSlGs6SZ/arz1etUrYsECgYEA3D2gaibcyTAYrJ/mk5pu2M6g+Ga7A4TSwUCAwUIN0pYVCgFLvl6Faii5n9AzHRd9/H5CQKl2pqEw44tBYPNAyv8zGivyUAO2uDWpQYm7enZjJ/4eSKAJgoF2tGQEjOyORQub6i78P4vnW0yQLpfR7O73JGF9C+PohmfyDpSkbXY=';
-  const pubKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAg2H/H1UoYUUSr3hdtgrMQqoFvHpWaz9nSOC9+rM2Csci9iRWW/K9mk4Vfawh9qwaRAv4OPB82arS7NSPUQirUraeGw3JbZ/HUhBY0blkES9GznhQzeht/IES2WccNC+nCprcXcvaLCqEx0gPTb1ujhKDZOkoc9H2JZCyPOiOsMlKdFJjk7jlR/Vn5aA+M9TaK8hOwCz02VCg7d/NfXZFwdg+88k9e4GLbdHI0MnmQ2Mg7DmuSM0TjSk6oPh3OlePMIPcvO/Wt/k2x6Fl2aVvH3FabtqZzNRtseKiTSzKCiNb7mJktkSsoE7lySXsUEP4X+hlesjNyIGRgf/yTCDlNwIDAQAB';
-
-  // 使用 PKCS#8 头(修复后的方式)
-  const privateKey = '-----BEGIN PRIVATE KEY-----\n' + rawKey + '\n-----END PRIVATE KEY-----';
-  const alipayPublicKey = '-----BEGIN PUBLIC KEY-----\n' + pubKey + '\n-----END PUBLIC KEY-----';
-
-  console.log('=== 测试1: 修复后 SDK 初始化 ===');
-  const sdk = new AlipaySdk({
-    appId: '2021006154659461',
-    privateKey,
-    keyType: 'PKCS8',
-    alipayPublicKey,
-    gateway: 'https://openapi.alipay.com/gateway.do',
-    signType: 'RSA2',
-  });
-  console.log('✅ SDK 初始化成功\n');
-
-  console.log('=== 测试2: pageExecute 生成支付表单 ===');
-  try {
-    const form = sdk.pageExecute('alipay.trade.wap.pay', 'POST', {
-      bizContent: {
-        out_trade_no: 'TEST' + Date.now(),
-        total_amount: '0.01',
-        subject: 'AI有声书订阅-测试',
-        product_code: 'QUICK_WAP_WAY',
-      },
-      returnUrl: 'https://book.rrbrr.com/#/pages/payment-result/index',
-      notifyUrl: 'https://book.rrbrr.com/api/payment/alipay/notify',
-    });
-
-    const actionMatch = String(form).match(/action="([^"]+)"/);
-    const paymentUrl = actionMatch ? actionMatch[1] : '未能提取action';
-    console.log('✅ pageExecute 调用成功');
-    console.log('支付网关地址:', paymentUrl);
-    console.log('表单前200字符:', String(form).substring(0, 200));
-  } catch (e) {
-    console.error('❌ pageExecute 失败:', e.message);
-  }
-
-  console.log('\n=== 测试3: 错误格式对比 (PKCS#1 包装) ===');
-  try {
-    const wrongKey = '-----BEGIN RSA PRIVATE KEY-----\n' + rawKey + '\n-----END RSA PRIVATE KEY-----';
-    const sdkWrong = new AlipaySdk({
-      appId: '2021006154659461',
-      privateKey: wrongKey,
-      keyType: 'PKCS1',
-      alipayPublicKey,
-      gateway: 'https://openapi.alipay.com/gateway.do',
-    });
-    // 能初始化但签名会是错的
-    console.log('SDK 未报错(但签名会是错的,支付宝会返回 invalid-signature)');
-  } catch (e) {
-    console.log('SDK 报错:', e.message);
-  }
-}
-
-test().catch(e => console.error('脚本异常:', e));

+ 1 - 0
server/_url.txt

@@ -0,0 +1 @@
+https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&app_id=2021006154659461&charset=utf-8&version=1.0&sign_type=RSA2&timestamp=2026-05-28+01%3A43%3A13&sign=oTmZlkbpd6dduME0j7qJ5N7JxSUZ%2B3UopEx%2BH7mPsA84%2Fnep9yEQgwoLY5O%2BqqOUdg5iR0rHH%2FdznxoeGM%2BeHvUclhrqaEKHxFJG7o3HzxW8K6d5JmyFggH1h8g1I1IJHAuuSeP6cy7Hxas9OS9NgrQ9bLATKdI5FUbrkZIiWuzpljHebO%2Fhoamdj0PWKnojni%2FhKunx3xC4PWguM92w%2BOT%2F0WX34G%2FM9PQRL49GpUfUNU7WGO%2FtUH7s3KVWKspvioxJcRpdE3o%2BSXAeA53BZAYWKOA8ORBhm%2BZYuIrTJGsjkqw8HBPh9JnTFiK3EUroEizVpppgYyhJSTCCADPsGg%3D%3D&alipay_sdk=alipay-sdk-nodejs-4.0.0&biz_content=%7B%22out_trade_no%22%3A%22T1779903793469%22%2C%22total_amount%22%3A%220.01%22%2C%22subject%22%3A%22x%22%2C%22product_code%22%3A%22QUICK_WAP_WAY%22%7D

+ 1 - 0
server/cert/alipay/密钥20260525204200/应用公钥RSA2048.txt

@@ -0,0 +1 @@
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fGftv/79Cj7MEonMxcv6Br+zPfRQoEOJ4eIvxfDrVg7N+/k7P+Rw9CaOMMYK5Shy7VJrBAx9oV59v9PMI9FdnAzYFHtiKzjq6ZmolZwXM44pZoXlGQ7ZxtuiN/FshN74me80OWXzU3FcUR9YG6UguddvdtlYvZRUScnCJabbys7KL9dUbRNvTQ6CnD8U6OoDGveISxFbA0aCa6BgZRo8Nuz28p8HYHI2+FEWnFDuE/GRU57mGXk8mR6siReNVlCMEX80Uq5RVG+6EXxeCwtt3lxCwE7aS/tvGOZOAsgOriDjyidNgrJgoPlY2Mb1fyRMPLJZvyFCYiwH46MvL39wIDAQAB

+ 1 - 0
server/cert/alipay/密钥20260525204200/应用私钥RSA2048-敏感数据,请妥善保管.txt

@@ -0,0 +1 @@
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC798Z+2//v0KPswSiczFy/oGv7M99FCgQ4nh4i/F8OtWDs37+Ts/5HD0Jo4wxgrlKHLtUmsEDH2hXn2/08wj0V2cDNgUe2IrOOrpmaiVnBczjilmheUZDtnG26I38WyE3viZ7zQ5ZfNTcVxRH1gbpSC512922Vi9lFRJycIlptvKzsov11RtE29NDoKcPxTo6gMa94hLEVsDRoJroGBlGjw27Pbynwdgcjb4URacUO4T8ZFTnuYZeTyZHqyJF41WUIwRfzRSrlFUb7oRfF4LC23eXELATtpL+28Y5k4CyA6uIOPKJ02CsmCg+VjYxvV/JEw8slm/IUJiLAfjoy8vf3AgMBAAECggEAL6OyuUBE86DrivvBsUvSTfb4pygUdEj/nOHmPXO9G9s4f5CN23+I+LWTXUX+Qxqra/ugpjeRk5Vga+1Dl3ybQsh3px4W8MCGxajW4WXiS7dKOZNpTE5b7vzwinaopTuLFSTKFlmQ6Pxxw6C41oJUmKq89APt0DMrk/S5xJCoXps86xp6zbXjlPm8HtLwuL0OP0WfgnwfXm8kOkaCswS4NXrz83DcR/tUrsdNmcOUmXXuXx+hwu/x0hgMOnSZYlrea/el6deM/giU+JPbM9BMrM8IeMW0MkLSBTikSsav+hetosxuPUrCebcK4QSUrTJ+mwqWNvFFeJuM/P6HbvfTQQKBgQD8GQlwpaQBRe2VXoapjB4ShL6ml+vVNwsjhR0WiwJHavGRS/hKIe/YLRqgfpHUR0/bUOFFxpkRApBT0LevL74c1HriXzHmGhC7DLmYQD+MZmTEbHNbEgVUsJeB04Cn+teWIsKYe80YYbvpwNvsX/Eu5FutOoZCNLrR5SAR7iZNZwKBgQC+4J39gO35W1Kc91tpX5UsmuGPdO6F89BFqvydkcYAYsEi3P92lrTXiplhNnGBDe7KoM8M7gWrnjvTr9S3ZLylKK7cgKn1VUYIqdAqyikiI7hVCfKZZeMa4TKZ0Os0PnGf6PqX3Rm/3yFZpXqD48QqVq2zLePWQGDXdd6BVFDW8QKBgEpBda5gm/ZkCp88wJ2gUBtUTnPzu9uJ4xYLV44UVA+FSLJrli7/4A3k+zrbDUlh1/vZzwEmhl+kSa7mg7Ruc6k6TRfwwlnsw5DVJrXZ9t38KyJQb4obvMY9XeM30WWpVWc7xeX0UW0lHJy12hG+EOSnDvnviyxlK8I3o0Kky1NxAoGAFsyVknYX9rTlxUH2dTupgc5uKsh4xR8+PVQTIG8NC3bAQv3KtcuChkxTzbzHCfc0Epfqo0y6+KjJ1Jsn+U/bCI83c7calec9mkNOTg4fo6hImD+77n6xBcDoXl8J+Q+MWuFlLUaHkNeCEEcnvyExiS0ZSlGs6SZ/arz1etUrYsECgYEA3D2gaibcyTAYrJ/mk5pu2M6g+Ga7A4TSwUCAwUIN0pYVCgFLvl6Faii5n9AzHRd9/H5CQKl2pqEw44tBYPNAyv8zGivyUAO2uDWpQYm7enZjJ/4eSKAJgoF2tGQEjOyORQub6i78P4vnW0yQLpfR7O73JGF9C+PohmfyDpSkbXY=

+ 58 - 0
server/diag.cjs

@@ -0,0 +1,58 @@
+require('dotenv').config();
+const { AlipaySdk } = require('alipay-sdk');
+
+const sdk = new AlipaySdk({
+  appId: process.env.ALIPAY_APP_ID,
+  privateKey: process.env.ALIPAY_PRIVATE_KEY,
+  alipayPublicKey: process.env.ALIPAY_PUBLIC_KEY,
+  signType: 'RSA2',
+});
+
+// 用 exec 直接调API(不走浏览器跳转),能看到服务器返回的原始错误
+async function test() {
+  try {
+    console.log('[1] 测试预下单 alipay.trade.precreate...\n');
+    const r1 = await sdk.exec('alipay.trade.precreate', {
+      bizContent: {
+        out_trade_no: 'DIAG' + Date.now(),
+        total_amount: '0.01',
+        subject: '诊断测试',
+      },
+    });
+    console.log('precreate 结果:', JSON.stringify(r1, null, 2).substring(0, 500));
+  } catch(e) {
+    console.log('precreate 错误:', e.message);
+  }
+
+  try {
+    console.log('\n[2] 测试查询 alipay.trade.query...\n');
+    const r2 = await sdk.exec('alipay.trade.query', {
+      bizContent: {
+        out_trade_no: 'DIAG' + Date.now(),
+      },
+    });
+    console.log('query 结果:', JSON.stringify(r2, null, 2).substring(0, 500));
+  } catch(e) {
+    console.log('query 错误:', e.message);
+  }
+
+  // 测试 pageExec 用 GET 方式
+  try {
+    console.log('\n[3] 测试 pageExec GET...\n');
+    const u = sdk.pageExec('alipay.trade.wap.pay', {
+      bizContent: {
+        out_trade_no: 'PGET' + Date.now(),
+        total_amount: '0.01',
+        subject: 'pageExec测试',
+        product_code: 'QUICK_WAP_WAY',
+      },
+      returnUrl: 'https://book.rrbrr.com/#/pages/payment-result/index',
+      notifyUrl: 'https://book.rrbrr.com/api/payment/alipay/notify',
+    });
+    console.log('pageExec 结果(前300):', String(u).substring(0, 300));
+  } catch(e) {
+    console.log('pageExec 错误:', e.message);
+  }
+}
+
+test();

+ 24 - 0
server/genkeys.js

@@ -0,0 +1,24 @@
+const crypto = require('crypto');
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+  modulusLength: 2048,
+  publicKeyEncoding: { type: 'spki', format: 'pem' },
+  privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
+});
+
+const privB64 = privateKey
+  .replace('-----BEGIN RSA PRIVATE KEY-----\n', '')
+  .replace('\n-----END RSA PRIVATE KEY-----', '')
+  .replace(/\n/g, '');
+
+const fs = require('fs');
+fs.writeFileSync('_priv.txt', privB64);
+fs.writeFileSync('_pub.txt', publicKey);
+
+// Verify
+const privObj = crypto.createPrivateKey(privateKey);
+const pubObj = crypto.createPublicKey(privObj);
+const pubOut = pubObj.export({ type: 'spki', format: 'pem' });
+console.log('Generated and verified.');
+console.log('Private base64 length:', privB64.length);
+console.log('Public PEM:');
+console.log(pubOut);

Fișier diff suprimat deoarece este prea mare
+ 0 - 104834
server/logs/requests.json


+ 3 - 0
server/src/app.ts

@@ -100,6 +100,9 @@ app.use(koaBody({
   },
 }));
 
+// 请求日志(记录 body 参数,必须在 koaBody 之后注册)
+app.use(requestLogger);
+
 // 静态文件服务 - 音频文件
 app.use(mount('/uploads', serve(config.upload.dir)));
 

+ 164 - 4
server/src/modules/payment/payment.controller.ts

@@ -127,13 +127,31 @@ router.get('/alipay/return', async (ctx: Context) => {
   }
 });
 
-// 微信支付回调
+// 微信支付回调(V3 加密格式)
 router.post('/wechat/notify', async (ctx: Context) => {
   try {
     const body = ctx.request.body as any;
     console.log('[WeChat Notify] 收到回调:', JSON.stringify(body));
 
-    const { out_trade_no, transaction_id, trade_state } = body;
+    // WeChat V3 回调是加密的,需要先解密 resource.ciphertext
+    const eventType = body.event_type;
+    const decrypted = PaymentService.decryptWechatNotify(body);
+
+    if (!decrypted) {
+      console.error('[WeChat Notify] 回调解密失败,返回 500');
+      ctx.status = 500;
+      ctx.body = { code: 'FAIL', message: '解密失败' };
+      return;
+    }
+
+    const { out_trade_no, transaction_id, trade_state } = decrypted;
+
+    if (!out_trade_no) {
+      console.error('[WeChat Notify] 解密后缺少 out_trade_no:', JSON.stringify(decrypted));
+      ctx.status = 400;
+      ctx.body = { code: 'FAIL', message: '缺少订单号' };
+      return;
+    }
 
     if (trade_state === 'SUCCESS') {
       await PaymentService.handlePaymentCallback(out_trade_no, transaction_id, 'success');
@@ -191,12 +209,12 @@ router.get('/orders', authMiddleware, async (ctx: Context) => {
   };
 });
 
-// 获取订单详情
+// 获取订单详情(含主动同步微信支付状态)
 router.get('/orders/:orderNo', authMiddleware, async (ctx: Context) => {
   const userId = safeParseInt(ctx.state.user.userId);
   const { orderNo } = ctx.params;
 
-  const result = await PaymentService.getOrderDetail(orderNo, userId);
+  const result = await PaymentService.getOrderDetailWithSync(orderNo, userId);
 
   ctx.body = {
     code: 0,
@@ -255,4 +273,146 @@ router.post('/alipay/qrcode', authMiddleware, async (ctx: Context) => {
   };
 });
 
+// ==================== 微信 JSAPI 支付(公众号内支付) ====================
+
+// 生成微信 OAuth 授权链接(前端跳转到微信获取 code)
+router.get('/wechat/oauth-url', async (ctx: Context) => {
+  const { redirect } = ctx.query as { redirect?: string };
+  const appId = process.env.WECHAT_APP_ID;
+
+  if (!appId) {
+    ctx.body = { code: -1, message: '未配置微信公众号', data: null };
+    return;
+  }
+
+  // 构造 OAuth 授权链接
+  // scope=snsapi_base 静默授权,不需要用户确认,但只能获取 openid
+  const baseUrl = redirect || (process.env.BASE_URL || 'https://book.rrbrr.com');
+  const redirectUri = encodeURIComponent(baseUrl);
+  const state = Math.random().toString(36).substring(2, 10);
+  const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=${state}#wechat_redirect`;
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: { oauthUrl, state }
+  };
+});
+
+// 通过 OAuth code 获取 openid
+router.post('/wechat/openid', async (ctx: Context) => {
+  const { code } = ctx.request.body as { code: string };
+
+  if (!code) {
+    ctx.body = { code: -1, message: '授权码不能为空', data: null };
+    return;
+  }
+
+  const openid = await PaymentService.getWechatOpenid(code);
+
+  if (!openid) {
+    ctx.body = { code: -1, message: '获取用户信息失败', data: null };
+    return;
+  }
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: { openid }
+  };
+});
+
+// 获取微信 JS-SDK 配置(用于 wx.config)
+router.post('/wechat/jsapi-config', async (ctx: Context) => {
+  const { url } = ctx.request.body as { url: string };
+
+  if (!url) {
+    ctx.body = { code: -1, message: '请提供页面URL', data: null };
+    return;
+  }
+
+  const config = await PaymentService.getWechatJsapiConfig(url);
+
+  if (!config) {
+    ctx.body = { code: -1, message: 'JS-SDK配置获取失败', data: null };
+    return;
+  }
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: {
+      debug: false,
+      appId: config.appId,
+      timestamp: config.timestamp,
+      nonceStr: config.nonceStr,
+      signature: config.signature,
+      jsApiList: ['chooseWXPay'] // 只需要支付接口
+    }
+  };
+});
+
+// 创建微信 JSAPI 支付订单(公众号内支付)
+router.post('/wechat/jsapi', authMiddleware, async (ctx: Context) => {
+  const userId = safeParseInt(ctx.state.user.userId);
+  const { planId, period = 'monthly', openid } = ctx.request.body as {
+    planId: number;
+    period?: 'monthly' | 'yearly';
+    openid: string;
+  };
+
+  if (!planId) {
+    throw new BadRequestError('请选择套餐');
+  }
+
+  if (!openid) {
+    throw new BadRequestError('缺少用户标识(openid),请在微信中打开');
+  }
+
+  // 获取套餐信息
+  const plan = await prisma.subscriptionPlan.findUnique({
+    where: { id: planId }
+  });
+
+  if (!plan) {
+    throw new BadRequestError('套餐不存在');
+  }
+
+  // 生成订单
+  const orderNo = PaymentService.generateOrderNo();
+  const amount = period === 'yearly' ? Number(plan.priceYearly) : Number(plan.priceMonthly);
+
+  // 创建订单记录
+  await prisma.order.create({
+    data: {
+      userId,
+      orderNo,
+      planId,
+      productType: period,
+      amount,
+      status: 'pending',
+      paymentMethod: 'wechat',
+    }
+  });
+
+  // 调用微信 JSAPI 支付
+  const payParams = await PaymentService.generateWechatJsapiPayment(
+    orderNo,
+    amount,
+    plan.name,
+    openid
+  );
+
+  ctx.body = {
+    code: 0,
+    message: '订单创建成功',
+    data: {
+      orderNo,
+      amount,
+      planName: plan.name,
+      payParams // 用于 wx.chooseWXPay 的参数
+    }
+  };
+});
+
 export default router;

+ 288 - 49
server/src/modules/payment/payment.service.ts

@@ -1,11 +1,19 @@
 import { prisma } from '../../models';
 import crypto from 'crypto';
+import https from 'https';
 
 // 懒加载支付 SDK(避免 Node.js ESM 兼容性问题)
 let AlipaySdk: any = null;
 let WeChatPay: any = null;
 let sdkInitialized = false;
 
+// ==================== 微信 JSAPI 支付相关缓存 ====================
+let cachedAccessToken: string | null = null;
+let accessTokenExpireAt: number = 0;
+
+let cachedJsapiTicket: string | null = null;
+let jsapiTicketExpireAt: number = 0;
+
 // 初始化支付 SDK
 async function initPaymentSdks() {
   if (sdkInitialized) return;
@@ -45,59 +53,25 @@ function getAlipaySdk(): any | null {
     return null;
   }
   const appId = process.env.ALIPAY_APP_ID || '';
-  const rawPrivateKey = process.env.ALIPAY_PRIVATE_KEY || '';
-  const rawPublicKey = process.env.ALIPAY_PUBLIC_KEY || '';
-
-  // 自动检测并正确包装密钥格式
-  let privateKey: string;
-  let keyType: 'PKCS1' | 'PKCS8' = 'PKCS1';
-
-  if (rawPrivateKey.includes('BEGIN PRIVATE KEY')) {
-    // 已有 PKCS#8 头
-    privateKey = rawPrivateKey;
-    keyType = 'PKCS8';
-  } else if (rawPrivateKey.includes('BEGIN RSA PRIVATE KEY')) {
-    // 已有 PKCS#1 头
-    privateKey = rawPrivateKey;
-    keyType = 'PKCS1';
-  } else {
-    // 没有 PEM 头,通过 DER 判断格式
-    // PKCS#8: MIIEvQ... 开头,SEQUENCE { INTEGER 0, AlgorithmIdentifier, OCTET STRING }
-    // PKCS#1: MIIEvA... 开头,SEQUENCE { INTEGER 0, INTEGER n, INTEGER e, ... }
-    const derBuf = Buffer.from(rawPrivateKey, 'base64');
-    if (derBuf[4] === 0x02 && derBuf[5] === 0x01 && derBuf[6] === 0x00) {
-      // 第5字节是 version=0,说明是 PKCS#8 PrivateKeyInfo
-      privateKey = `-----BEGIN PRIVATE KEY-----\n${rawPrivateKey}\n-----END PRIVATE KEY-----`;
-      keyType = 'PKCS8';
-    } else {
-      // 否则按 PKCS#1 处理
-      privateKey = `-----BEGIN RSA PRIVATE KEY-----\n${rawPrivateKey}\n-----END RSA PRIVATE KEY-----`;
-      keyType = 'PKCS1';
-    }
-  }
-
-  const alipayPublicKey = rawPublicKey.includes('BEGIN') ? rawPublicKey
-    : `-----BEGIN PUBLIC KEY-----\n${rawPublicKey}\n-----END PUBLIC KEY-----`;
+  const privateKey = process.env.ALIPAY_PRIVATE_KEY || '';
+  const alipayPublicKey = process.env.ALIPAY_PUBLIC_KEY || '';
 
   if (!appId || !privateKey || !alipayPublicKey) {
     console.warn('[Alipay] 未配置支付宝参数', { appId: !!appId, hasPrivateKey: !!privateKey, hasPublicKey: !!alipayPublicKey });
     return null;
   }
 
-  console.log('[Alipay] 初始化 SDK', { 
-    appId, 
-    keyType,
-    privateKeyStart: privateKey.substring(0, 50),
-    alipayPublicKeyStart: alipayPublicKey.substring(0, 50)
-  });
-
   if (!alipaySdk) {
+    // keyType 必须指定为 PKCS8,因为 ALIPAY_PRIVATE_KEY 是 PKCS#8 格式(以 MIIEvQIBADAN 开头)
+    // 如果不指定 keyType,SDK 默认按 PKCS1 处理,会把 PEM 头写为 RSA PRIVATE KEY
+    // 导致 Node.js crypto 模块解析 PKCS8 数据时出错,从而签名失败
     alipaySdk = new AlipaySdk({
       appId,
       privateKey,
-      keyType,
       alipayPublicKey,
       gateway: 'https://openapi.alipay.com/gateway.do',
+      signType: 'RSA2',
+      keyType: 'PKCS8',
     });
   }
 
@@ -251,25 +225,22 @@ async function generateAlipayPayment(
   }
 
   // 使用手机网站支付 API(WAP支付)
+  // sdkExecute 返回完整查询字符串(含 biz_content + sign),拼上网关地址即可
+  // pageExec 生成的 POST 表单把 biz_content 放在隐藏 input 里,提取 action URL 会丢失它
   try {
     const notifyUrl = process.env.ALIPAY_NOTIFY_URL || '';
-    // pageExecute(method, httpMethod, bizParams)
-    // httpMethod: 'POST' 返回HTML表单, 'GET' 返回支付链接
-    const htmlForm = sdk.pageExecute('alipay.trade.wap.pay', 'POST', {
+    const queryString = sdk.sdkExec('alipay.trade.wap.pay', {
       bizContent: {
         out_trade_no: orderNo,
         total_amount: String(amount),
         subject: subject,
         product_code: 'QUICK_WAP_WAY',
-        quit_url: finalReturnUrl,
       },
       returnUrl: finalReturnUrl,
-      notifyUrl,  // 异步通知地址,用于接收支付结果
+      notifyUrl,
     });
 
-    // 从 HTML 表单中提取 action URL
-    const actionMatch = String(htmlForm).match(/action="([^"]+)"/);
-    const paymentUrl = actionMatch ? actionMatch[1] : '';
+    const paymentUrl = `https://openapi.alipay.com/gateway.do?${queryString}`;
 
     return {
       paymentUrl,
@@ -609,5 +580,273 @@ export async function queryWechatOrder(outTradeNo: string) {
   }
 }
 
+// 解密 WeChat V3 回调通知
+export function decryptWechatNotify(body: any): any | null {
+  const wechat = getWechatPay();
+  if (!wechat) return null;
+
+  const { resource } = body || {};
+  if (!resource || !resource.ciphertext) {
+    console.error('[WeChatPay] 回调格式无效,缺少 resource.ciphertext');
+    return null;
+  }
+
+  try {
+    const decrypted = wechat.decipher_gcm(
+      resource.ciphertext,
+      resource.associated_data || '',
+      resource.nonce,
+      wechat.key  // APIv3 key
+    );
+    console.log('[WeChatPay] 回调解密成功:', JSON.stringify(decrypted));
+    return decrypted;
+  } catch (e: any) {
+    console.error('[WeChatPay] 回调解密失败:', e.message);
+    return null;
+  }
+}
+
+// 查询订单并同步微信支付状态(主动查询 + 自动更新)
+export async function getOrderDetailWithSync(orderNo: string, userId: number) {
+  const order = await prisma.order.findFirst({
+    where: { orderNo, userId },
+    include: { plan: true }
+  });
+
+  if (!order) {
+    throw new Error('订单不存在');
+  }
+
+  // 如果订单是 pending 状态且为微信支付,主动查询微信确认状态
+  if (order.status === 'pending' && order.paymentMethod === 'wechat') {
+    console.log('[Payment] 订单 pending,主动查询微信支付状态:', orderNo);
+    const wechatResult = await queryWechatOrder(orderNo);
+
+    if (wechatResult?.data?.trade_state === 'SUCCESS') {
+      console.log('[Payment] 微信支付已成功,自动更新订单状态:', orderNo);
+      const transactionId = wechatResult.data.transaction_id || '';
+      await handlePaymentCallback(orderNo, transactionId, 'success');
+      // 重新查询更新后的订单
+      const updatedOrder = await prisma.order.findFirst({
+        where: { orderNo, userId },
+        include: { plan: true }
+      });
+      if (updatedOrder) {
+        return {
+          ...updatedOrder,
+          amount: Number(updatedOrder.amount),
+          planName: updatedOrder.plan?.name || ''
+        };
+      }
+    }
+  }
+
+  return {
+    ...order,
+    amount: Number(order.amount),
+    planName: order.plan?.name || ''
+  };
+}
+
+// ==================== 微信 JSAPI 支付(公众号支付) ====================
+
+// Node.js https 简单 GET 请求
+function httpsGet(url: string): Promise<any> {
+  return new Promise((resolve, reject) => {
+    https.get(url, (res) => {
+      let data = '';
+      res.on('data', (chunk) => data += chunk);
+      res.on('end', () => {
+        try {
+          resolve(JSON.parse(data));
+        } catch (e) {
+          resolve(data);
+        }
+      });
+    }).on('error', reject);
+  });
+}
+
+// 获取微信公众号 access_token(带缓存)
+export async function getWechatAccessToken(): Promise<string | null> {
+  const now = Date.now();
+  if (cachedAccessToken && now < accessTokenExpireAt) {
+    return cachedAccessToken;
+  }
+
+  const appId = process.env.WECHAT_APP_ID;
+  const appSecret = process.env.WECHAT_APP_SECRET;
+
+  if (!appId || !appSecret) {
+    console.warn('[WeChat JSAPI] 未配置 WECHAT_APP_ID 或 WECHAT_APP_SECRET');
+    return null;
+  }
+
+  try {
+    const data = await httpsGet(
+      `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`
+    );
+    if (data.errcode) {
+      console.error('[WeChat JSAPI] 获取 access_token 失败:', data);
+      return null;
+    }
+    cachedAccessToken = data.access_token;
+    // 提前 5 分钟过期
+    accessTokenExpireAt = now + (data.expires_in - 300) * 1000;
+    console.log('[WeChat JSAPI] access_token 刷新成功, 过期时间:', new Date(accessTokenExpireAt).toISOString());
+    return cachedAccessToken;
+  } catch (e) {
+    console.error('[WeChat JSAPI] 获取 access_token 异常:', e);
+    return null;
+  }
+}
+
+// 获取微信 JS-SDK ticket(带缓存)
+export async function getWechatJsapiTicket(): Promise<string | null> {
+  const now = Date.now();
+  if (cachedJsapiTicket && now < jsapiTicketExpireAt) {
+    return cachedJsapiTicket;
+  }
+
+  const accessToken = await getWechatAccessToken();
+  if (!accessToken) return null;
+
+  try {
+    const data = await httpsGet(
+      `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${accessToken}&type=jsapi`
+    );
+    if (data.errcode !== 0) {
+      console.error('[WeChat JSAPI] 获取 jsapi_ticket 失败:', data);
+      return null;
+    }
+    cachedJsapiTicket = data.ticket;
+    // 提前 5 分钟过期
+    jsapiTicketExpireAt = now + (data.expires_in - 300) * 1000;
+    console.log('[WeChat JSAPI] jsapi_ticket 刷新成功');
+    return cachedJsapiTicket;
+  } catch (e) {
+    console.error('[WeChat JSAPI] 获取 jsapi_ticket 异常:', e);
+    return null;
+  }
+}
+
+// 生成微信 JS-SDK 签名配置
+export async function getWechatJsapiConfig(url: string): Promise<{
+  appId: string;
+  timestamp: string;
+  nonceStr: string;
+  signature: string;
+} | null> {
+  const ticket = await getWechatJsapiTicket();
+  if (!ticket) return null;
+
+  const nonceStr = crypto.randomBytes(16).toString('hex');
+  const timestamp = Math.floor(Date.now() / 1000).toString();
+  const signStr = `jsapi_ticket=${ticket}&noncestr=${nonceStr}&timestamp=${timestamp}&url=${url}`;
+
+  const signature = crypto.createHash('sha1').update(signStr).digest('hex');
+
+  return {
+    appId: process.env.WECHAT_APP_ID || '',
+    timestamp,
+    nonceStr,
+    signature,
+  };
+}
+
+// 通过 OAuth code 获取用户 openid
+export async function getWechatOpenid(code: string): Promise<string | null> {
+  const appId = process.env.WECHAT_APP_ID;
+  const appSecret = process.env.WECHAT_APP_SECRET;
+
+  if (!appId || !appSecret) {
+    console.warn('[WeChat OAuth] 未配置 WECHAT_APP_ID 或 WECHAT_APP_SECRET');
+    return null;
+  }
+
+  try {
+    const data = await httpsGet(
+      `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${code}&grant_type=authorization_code`
+    );
+    if (data.errcode || !data.openid) {
+      console.error('[WeChat OAuth] 获取 openid 失败:', data);
+      return null;
+    }
+    console.log('[WeChat OAuth] 获取 openid 成功');
+    return data.openid;
+  } catch (e) {
+    console.error('[WeChat OAuth] 获取 openid 异常:', e);
+    return null;
+  }
+}
+
+// 生成微信 JSAPI 支付(公众号内支付)
+// 注意:SDK的transactions_jsapi已自动处理签名,result.data直接可用于wx.chooseWXPay
+export async function generateWechatJsapiPayment(
+  orderNo: string,
+  amount: number,
+  subject: string,
+  openid: string
+): Promise<{
+  appId: string;
+  timeStamp: string;
+  nonceStr: string;
+  package: string;
+  signType: string;
+  paySign: string;
+} | null> {
+  const wechat = getWechatPay();
+
+  if (!wechat) {
+    console.warn('[WeChat JSAPI] 未配置微信支付');
+    return null;
+  }
+
+  try {
+    console.log('[WeChat JSAPI] 开始创建 JSAPI 支付订单:', { orderNo, amount, subject, openid });
+
+    // SDK 的 transactions_jsapi 会自动:
+    // 1. 添加 appid 和 mchid
+    // 2. 调用 /v3/pay/transactions/jsapi
+    // 3. 对 prepay_id 进行 RSA 签名
+    // 4. 返回可直接传给 wx.chooseWXPay 的参数
+    const result: any = await wechat.transactions_jsapi({
+      description: subject,
+      out_trade_no: orderNo,
+      amount: {
+        total: Math.round(amount * 100), // 转换为分
+        currency: 'CNY',
+      },
+      payer: {
+        openid,
+      },
+      notify_url: process.env.WECHAT_NOTIFY_URL || `${process.env.BASE_URL || 'https://your-domain.com'}/api/payment/wechat/notify`,
+    });
+
+    console.log('[WeChat JSAPI] 支付接口返回 status:', result.status);
+
+    // 检查返回状态
+    if (result.status !== 200 && result.status !== '200') {
+      const errorMsg = JSON.stringify(result);
+      console.error('[WeChat JSAPI] 支付失败:', errorMsg);
+      throw new Error(`微信支付接口返回错误: ${errorMsg}`);
+    }
+
+    // result.data 已经包含完整的 wx.chooseWXPay 参数:
+    // { appId, timeStamp, nonceStr, package, signType, paySign }
+    if (!result.data || !result.data.package) {
+      console.error('[WeChat JSAPI] 返回数据异常:', JSON.stringify(result));
+      throw new Error('微信支付返回数据异常,未获取到支付参数');
+    }
+
+    console.log('[WeChat JSAPI] 调起支付参数生成成功');
+    return result.data;
+  } catch (error: any) {
+    const detail = error.message || JSON.stringify(error);
+    console.error('[WeChat JSAPI] JSAPI支付创建失败:', detail);
+    throw new Error(`微信支付暂不可用(${detail}),请稍后重试或使用其他支付方式`);
+  }
+}
+
 // 初始化 SDK
 initPaymentSdks();

+ 23 - 11
server/src/modules/subscription/subscription.service.ts

@@ -225,8 +225,8 @@ export const DEFAULT_PLANS = [
   {
     name: '入门版',
     level: 1,
-    priceMonthly: 10,
-    priceYearly: 100,
+    priceMonthly: 0.01,
+    priceYearly: 0.1,
     description: '适合轻度使用',
     features: JSON.stringify([
       '每月300分钟音频',
@@ -335,23 +335,35 @@ export const DEFAULT_PLANS = [
   }
 ];
 
-// 初始化套餐数据
+// 初始化套餐数据(不存在时创建,存在时更新价格)
 export async function initializePlans() {
-  const existingPlans = await prisma.subscriptionPlan.count();
-  
-  if (existingPlans === 0) {
-    console.log('初始化订阅套餐数据...');
-    for (const plan of DEFAULT_PLANS) {
+  console.log('同步订阅套餐数据...');
+  for (const plan of DEFAULT_PLANS) {
+    const existing = await prisma.subscriptionPlan.findFirst({
+      where: { level: plan.level }
+    });
+    if (existing) {
+      // 更新现有套餐价格
+      await prisma.subscriptionPlan.update({
+        where: { id: existing.id },
+        data: { priceMonthly: plan.priceMonthly, priceYearly: plan.priceYearly }
+      });
+    } else {
       await prisma.subscriptionPlan.create({ data: plan });
     }
-    console.log('订阅套餐初始化完成');
   }
+  console.log('订阅套餐同步完成');
 }
 
-// 获取所有套餐列表
+// 获取所有套餐列表(过滤测试套餐)
 export async function getPlans() {
   const plans = await prisma.subscriptionPlan.findMany({
-    where: { isActive: true },
+    where: {
+      isActive: true,
+      name: {
+        notIn: ['测试套餐', '测试版', 'Test Plan', 'TestPackage']
+      }
+    },
     orderBy: { sortOrder: 'asc' }
   });
 

+ 155 - 21
server/src/services/log.service.ts

@@ -38,21 +38,32 @@ export interface ErrorAnalysis {
   solutions: string[];
 }
 
+// 日志配置
+const LOG_CONFIG = {
+  maxLogs: 5000,              // 内存最多保留条数
+  maxFileSize: 20 * 1024 * 1024, // 单文件最大 20MB
+  maxRetentionDays: 7,        // 保留最近 7 天日志
+  saveDebounceMs: 5000,       // 存档防抖间隔(5秒内多次写入合并为一次)
+};
+
 // 日志服务类
 export class LogService {
   private logs: RequestLog[] = [];
-  private maxLogs: number = 10000;
   private logFilePath: string;
+  private logDir: string;
   private errorPatterns: Map<string, RegExp> = new Map();
   private errorSolutions: Map<string, string[]> = new Map();
+  private saveTimer: ReturnType<typeof setTimeout> | null = null;
+  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
+  private lastCleanupDate: string = '';
 
   constructor() {
     this.logFilePath = path.join(process.cwd(), 'logs', 'requests.json');
+    this.logDir = path.dirname(this.logFilePath);
 
     // 确保日志目录存在
-    const logDir = path.dirname(this.logFilePath);
-    if (!fs.existsSync(logDir)) {
-      fs.mkdirSync(logDir, { recursive: true });
+    if (!fs.existsSync(this.logDir)) {
+      fs.mkdirSync(this.logDir, { recursive: true });
     }
 
     // 初始化错误模式和解决方案
@@ -60,6 +71,24 @@ export class LogService {
 
     // 从文件加载历史日志
     this.loadLogs();
+
+    // 启动定时清理(每小时检查一次)
+    this.startPeriodicCleanup();
+  }
+
+  // 启动定时清理
+  private startPeriodicCleanup(): void {
+    this.cleanupTimer = setInterval(() => {
+      this.cleanupByRetention();
+    }, 60 * 60 * 1000); // 每小时
+  }
+
+  // 停止定时清理(优雅关闭时调用)
+  stopCleanup(): void {
+    if (this.cleanupTimer) {
+      clearInterval(this.cleanupTimer);
+      this.cleanupTimer = null;
+    }
   }
 
   // 初始化常见错误模式
@@ -109,35 +138,120 @@ export class LogService {
     ]);
   }
 
-  // 加载历史日志
+  // 加载历史日志(只加载最近 maxLogs 条,过滤过期数据)
   private loadLogs(): void {
     try {
       if (fs.existsSync(this.logFilePath)) {
+        const stat = fs.statSync(this.logFilePath);
+        // 文件太大(>50MB)直接归档重建,避免阻塞启动
+        if (stat.size > 50 * 1024 * 1024) {
+          console.log(`⚠️  日志文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),归档后重建`);
+          this.archiveCurrentFile();
+          return;
+        }
+
         const data = fs.readFileSync(this.logFilePath, 'utf-8');
         const logs = JSON.parse(data);
-        this.logs = logs.map((log: any) => ({
-          ...log,
-          timestamp: new Date(log.timestamp)
-        }));
-        console.log(`📋 已加载 ${this.logs.length} 条历史日志`);
+        const cutoff = new Date(Date.now() - LOG_CONFIG.maxRetentionDays * 24 * 60 * 60 * 1000);
+
+        this.logs = logs
+          .map((log: any) => ({ ...log, timestamp: new Date(log.timestamp) }))
+          .filter((log: RequestLog) => log.timestamp >= cutoff)
+          .slice(-LOG_CONFIG.maxLogs);
+
+        const expired = logs.length - this.logs.length;
+        const skipped = logs.length > LOG_CONFIG.maxLogs ? logs.length - LOG_CONFIG.maxLogs - (expired > 0 ? 0 : 0) : 0;
+
+        console.log(`📋 已加载 ${this.logs.length} 条日志${expired > 0 ? ` (过滤 ${expired} 条过期)` : ''}${skipped > 0 ? ` (截断 ${skipped} 条)` : ''}`);
       }
     } catch (error) {
       console.error('加载日志文件失败:', error);
     }
   }
 
-  // 保存日志到文件
+  // 保存日志到文件(compact JSON + 防抖 + 大小检查)
   private saveLogs(): void {
     try {
-      // 只保存最近的日志
-      const logsToSave = this.logs.slice(-this.maxLogs);
-      fs.writeFileSync(this.logFilePath, JSON.stringify(logsToSave, null, 2));
+      // 限制数量
+      if (this.logs.length > LOG_CONFIG.maxLogs) {
+        this.logs = this.logs.slice(-LOG_CONFIG.maxLogs);
+      }
+
+      // compact JSON(不缩进,节省空间)
+      const json = JSON.stringify(this.logs);
+      fs.writeFileSync(this.logFilePath, json);
+
+      // 大小超限时归档
+      const stat = fs.statSync(this.logFilePath);
+      if (stat.size > LOG_CONFIG.maxFileSize) {
+        console.log(`📦 日志文件超过 ${LOG_CONFIG.maxFileSize / 1024 / 1024}MB,开始归档...`);
+        this.archiveCurrentFile();
+        // 归档后只保留最近一半
+        const keepCount = Math.floor(this.logs.length / 2);
+        this.logs = this.logs.slice(-keepCount);
+        fs.writeFileSync(this.logFilePath, JSON.stringify(this.logs));
+      }
     } catch (error) {
       console.error('保存日志文件失败:', error);
     }
   }
 
-  // 记录请求日志
+  // 按时间清理过期日志
+  private cleanupByRetention(): void {
+    const today = new Date().toISOString().slice(0, 10);
+    if (today === this.lastCleanupDate) return; // 今天已经清理过
+    this.lastCleanupDate = today;
+
+    const cutoff = new Date(Date.now() - LOG_CONFIG.maxRetentionDays * 24 * 60 * 60 * 1000);
+    const before = this.logs.length;
+    this.logs = this.logs.filter(log => log.timestamp >= cutoff);
+    const removed = before - this.logs.length;
+
+    if (removed > 0) {
+      console.log(`🧹 自动清理 ${removed} 条过期日志 (超过 ${LOG_CONFIG.maxRetentionDays} 天)`);
+      this.saveLogs();
+    }
+
+    // 清理过期归档文件
+    this.cleanupArchiveFiles();
+  }
+
+  // 清理过期归档文件
+  private cleanupArchiveFiles(): void {
+    try {
+      const cutoff = new Date(Date.now() - LOG_CONFIG.maxRetentionDays * 24 * 60 * 60 * 1000);
+      const files = fs.readdirSync(this.logDir);
+      for (const file of files) {
+        const match = file.match(/^requests-(\d{4}-\d{2}-\d{2})\.json$/);
+        if (match) {
+          const fileDate = new Date(match[1]);
+          if (fileDate < cutoff) {
+            fs.unlinkSync(path.join(this.logDir, file));
+            console.log(`🗑️  删除过期归档: ${file}`);
+          }
+        }
+      }
+    } catch (error) {
+      // 忽略清理错误
+    }
+  }
+
+  // 归档当前日志文件
+  private archiveCurrentFile(): void {
+    try {
+      if (fs.existsSync(this.logFilePath)) {
+        const date = new Date().toISOString().slice(0, 10);
+        const archiveName = `requests-${date}.json`;
+        const archivePath = path.join(this.logDir, archiveName);
+        fs.renameSync(this.logFilePath, archivePath);
+        console.log(`📦 日志已归档: ${archiveName}`);
+      }
+    } catch (error) {
+      console.error('日志归档失败:', error);
+    }
+  }
+
+  // 记录请求日志(带防抖保存)
   logRequest(log: RequestLog): void {
     log.timestamp = new Date();
 
@@ -145,15 +259,19 @@ export class LogService {
     this.logs.push(log);
 
     // 超过最大数量时移除旧日志
-    if (this.logs.length > this.maxLogs) {
-      this.logs = this.logs.slice(-this.maxLogs);
+    if (this.logs.length > LOG_CONFIG.maxLogs) {
+      this.logs = this.logs.slice(-LOG_CONFIG.maxLogs);
     }
 
-    // 异步保存到文件
-    setTimeout(() => this.saveLogs(), 100);
+    // 防抖保存(5秒内的多次写入合并)
+    if (this.saveTimer) clearTimeout(this.saveTimer);
+    this.saveTimer = setTimeout(() => {
+      this.saveLogs();
+      this.saveTimer = null;
+    }, LOG_CONFIG.saveDebounceMs);
 
     // 控制台输出
-    const levelEmoji = {
+    const levelEmoji: Record<string, string> = {
       [LogLevel.DEBUG]: '🔍',
       [LogLevel.INFO]: '📝',
       [LogLevel.WARN]: '⚠️',
@@ -321,13 +439,21 @@ export class LogService {
     warnings: number;
     averageResponseTime: number;
     topPaths: Array<{ path: string; count: number }>;
+    fileSize: string;
+    retentionDays: number;
+    maxLogs: number;
+    maxFileSize: string;
   } {
     const stats = {
       total: this.logs.length,
       errors: this.logs.filter(l => l.level === LogLevel.ERROR).length,
       warnings: this.logs.filter(l => l.level === LogLevel.WARN).length,
       averageResponseTime: 0,
-      topPaths: [] as Array<{ path: string; count: number }>
+      topPaths: [] as Array<{ path: string; count: number }>,
+      fileSize: '0 MB',
+      retentionDays: LOG_CONFIG.maxRetentionDays,
+      maxLogs: LOG_CONFIG.maxLogs,
+      maxFileSize: `${LOG_CONFIG.maxFileSize / 1024 / 1024} MB`,
     };
 
     if (this.logs.length > 0) {
@@ -346,6 +472,14 @@ export class LogService {
       .sort((a, b) => b.count - a.count)
       .slice(0, 10);
 
+    // 文件大小
+    try {
+      if (fs.existsSync(this.logFilePath)) {
+        const s = fs.statSync(this.logFilePath);
+        stats.fileSize = `${(s.size / 1024 / 1024).toFixed(2)} MB`;
+      }
+    } catch { /* ignore */ }
+
     return stats;
   }
 }

+ 71 - 1
server/src/services/requestLogger.ts

@@ -1,20 +1,90 @@
 import { Context, Next } from 'koa';
 import { logService, LogLevel, RequestLog } from './log.service';
 
+// 敏感字段过滤(密码、token等不记录明文)
+const SENSITIVE_KEYS = ['password', 'token', 'secret', 'authorization', 'cookie', 'accessToken', 'refreshToken'];
+
+// 只保留关键 headers,减少日志体积
+const HEADER_WHITELIST = [
+  'host', 'content-type', 'content-length', 'origin', 'referer',
+  'x-forwarded-for', 'x-real-ip', 'accept', 'authorization',
+];
+
+function filterHeaders(headers: Record<string, string>): Record<string, string> {
+  const filtered: Record<string, string> = {};
+  for (const key of HEADER_WHITELIST) {
+    const val = headers[key];
+    if (val !== undefined) {
+      // authorization 脱敏只保留前缀
+      if (key === 'authorization' && val.startsWith('Bearer ')) {
+        filtered[key] = `Bearer ${val.slice(7, 17)}...`;
+      } else {
+        filtered[key] = val;
+      }
+    }
+  }
+  return filtered;
+}
+
+function sanitizeBody(body: any): any {
+  if (!body) return body;
+  if (typeof body !== 'object') return body;
+  // 处理文件上传:只记录文件名和大小,不记录文件内容
+  if (body.files) {
+    return {
+      ...body,
+      files: Object.fromEntries(
+        Object.entries(body.files).map(([key, file]: [string, any]) => [
+          key,
+          Array.isArray(file)
+            ? file.map(f => ({ name: f?.originalFilename || f?.name, size: f?.size, mimetype: f?.mimetype }))
+            : { name: file?.originalFilename || file?.name, size: file?.size, mimetype: file?.mimetype }
+        ])
+      ),
+    };
+  }
+  // 过滤敏感字段
+  const sanitized: any = Array.isArray(body) ? [] : {};
+  for (const [key, value] of Object.entries(body)) {
+    const lowerKey = key.toLowerCase();
+    if (SENSITIVE_KEYS.some(k => lowerKey.includes(k.toLowerCase()))) {
+      sanitized[key] = '***REDACTED***';
+    } else if (typeof value === 'object' && value !== null && !Buffer.isBuffer(value)) {
+      sanitized[key] = sanitizeBody(value);
+    } else if (Buffer.isBuffer(value)) {
+      sanitized[key] = `<Buffer ${value.length} bytes>`;
+    } else {
+      sanitized[key] = value;
+    }
+  }
+  return sanitized;
+}
+
 // 请求日志中间件
 export async function requestLogger(ctx: Context, next: Next): Promise<void> {
   const startTime = Date.now();
   const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
 
+  // 捕获请求体(此中间件必须在 koaBody 之后注册才能拿到 body)
+  let requestBody: any = undefined;
+  try {
+    if (ctx.request.body) {
+      requestBody = sanitizeBody(ctx.request.body);
+    }
+  } catch {
+    // body 解析失败时忽略
+  }
+
   // 请求开始前记录
   const requestLog: Partial<RequestLog> = {
     requestId,
     method: ctx.method,
     path: ctx.path,
     query: ctx.querystring,
-    headers: ctx.headers as Record<string, string>,
+    headers: filterHeaders(ctx.headers as Record<string, string>),
     ip: ctx.ip,
     userAgent: ctx.get('user-agent') || '',
+    body: requestBody,
   };
 
   try {

BIN
token-packs.png


BIN
vip-page.png


BIN
wechat-fixed.png


BIN
wechat-test.png


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff