imetting_client/README.md

32 KiB
Raw Blame History

iMeeting Web Client - 技术设计文档

一、项目概述

1.1 项目定位

iMeeting Web Client 是一个基于 React 的纯前端轻量级会议录音客户端,专注于快速录音和会议管理功能。

1.2 核心特性

  • 🎤 一键录音:点击即录,实时流式上传
  • 📚 会议列表:快速查看历史会议,生成分享二维码
  • 📱 响应式设计:支持桌面端、平板、移动端
  • 🚀 纯前端实现无需额外服务器直接调用现有API

二、界面设计分析

2.1 主界面布局(参考 design.png

采用 2x2 网格卡片布局:

┌─────────────┬─────────────┐
│   麦克风    │     AI      │
│  (橙色)     │   (灰色)    │
│  [激活]     │  [预留]     │
├─────────────┼─────────────┤
│   大脑      │   知识库    │
│  (灰色)     │  (橙色)     │
│  [预留]     │  [激活]     │
└─────────────┴─────────────┘

功能状态:

  • 麦克风(左上,橙色):一键录音功能 - 已激活
  • AI右上灰色AI分析功能 - 预留
  • 大脑(左下,灰色):智能功能 - 预留
  • 知识库(右下,橙色):会议列表 - 已激活

2.2 页面结构

/                    - 主页4个功能卡片
├── /record          - 录音页面(点击麦克风进入)
└── /meetings        - 会议列表页(点击知识库进入)
    └── /:id         - 会议详情(简介+二维码)

2.3 响应式断点

/* 移动端 */
@media (max-width: 768px) {
  /* 单列布局,卡片堆叠 */
}

/* 平板 */
@media (min-width: 768px) and (max-width: 1024px) {
  /* 2x2布局卡片适中 */
}

/* 桌面 */
@media (min-width: 1024px) {
  /* 2x2布局卡片标准 */
}

三、技术架构

3.1 技术栈选型

核心框架

  • React 18UI框架
  • React Router v6:路由管理
  • Vite:构建工具(快速、轻量)

样式方案

  • CSS Modules:模块化样式
  • 响应式布局CSS Grid + Flexbox

状态管理

  • React Context API:全局状态(用户、录音状态)
  • useState/useReducer:局部状态

核心库

{
  "react": "^18.2.0",
  "react-router-dom": "^6.20.0",
  "axios": "^1.6.0",
  "qrcode.react": "^3.1.0",
  "lucide-react": "^0.292.0"
}

3.2 项目结构

client/
├── public/
│   └── index.html
├── src/
│   ├── components/           # 公共组件
│   │   ├── Layout/
│   │   │   └── MainLayout.jsx
│   │   ├── FeatureCard/      # 功能卡片
│   │   │   ├── FeatureCard.jsx
│   │   │   └── FeatureCard.module.css
│   │   ├── RecordButton/     # 录音按钮
│   │   └── QRCodeModal/      # 二维码弹窗
│   ├── pages/
│   │   ├── Home/             # 主页2x2卡片
│   │   │   ├── Home.jsx
│   │   │   └── Home.module.css
│   │   ├── Record/           # 录音页
│   │   │   ├── Record.jsx
│   │   │   └── Record.module.css
│   │   └── Meetings/         # 会议列表
│   │       ├── MeetingList.jsx
│   │       ├── MeetingDetail.jsx
│   │       └── Meetings.module.css
│   ├── hooks/                # 自定义Hooks
│   │   ├── useAudioRecorder.js
│   │   ├── useStreamUpload.js
│   │   └── useAuth.js
│   ├── services/             # API服务
│   │   ├── api.js            # Axios封装
│   │   ├── auth.js
│   │   ├── meeting.js
│   │   └── upload.js
│   ├── utils/
│   │   ├── audio.js          # 音频工具
│   │   └── format.js
│   ├── styles/
│   │   └── global.css
│   ├── App.jsx
│   └── main.jsx
├── package.json
├── vite.config.js
└── README.md

四、核心功能实现

4.1 一键录音功能

4.1.1 录音流程

用户点击 → 请求麦克风权限 → 开始录音 →
分片收集 → 实时上传 → 停止录音 →
创建会议记录 → 跳转到会议详情

4.1.2 技术实现

1使用 MediaRecorder API

// hooks/useAudioRecorder.js
import { useState, useRef, useCallback } from 'react';

export const useAudioRecorder = () => {
  const [isRecording, setIsRecording] = useState(false);
  const [duration, setDuration] = useState(0);
  const mediaRecorderRef = useRef(null);
  const streamRef = useRef(null);
  const chunksRef = useRef([]);

  const startRecording = useCallback(async () => {
    try {
      // 请求麦克风权限
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true,    // 回声消除
          noiseSuppression: true,     // 降噪
          sampleRate: 44100           // 采样率
        }
      });

      streamRef.current = stream;

      // 选择合适的MIME类型
      const mimeType = getSupportedMimeType();

      const mediaRecorder = new MediaRecorder(stream, {
        mimeType,
        audioBitsPerSecond: 128000
      });

      // 每秒收集一次数据(用于流式上传)
      mediaRecorder.start(1000);

      mediaRecorder.ondataavailable = (event) => {
        if (event.data.size > 0) {
          chunksRef.current.push(event.data);
          // 触发流式上传
          onChunkAvailable(event.data);
        }
      };

      mediaRecorderRef.current = mediaRecorder;
      setIsRecording(true);

    } catch (error) {
      handleRecordingError(error);
    }
  }, []);

  const stopRecording = useCallback(() => {
    if (mediaRecorderRef.current) {
      mediaRecorderRef.current.stop();
      streamRef.current?.getTracks().forEach(track => track.stop());
      setIsRecording(false);
    }
  }, []);

  return { isRecording, duration, startRecording, stopRecording };
};

// 获取浏览器支持的MIME类型
const getSupportedMimeType = () => {
  const types = [
    'audio/webm;codecs=opus',
    'audio/webm',
    'audio/ogg;codecs=opus',
    'audio/mp4'
  ];
  return types.find(type => MediaRecorder.isTypeSupported(type)) || '';
};

2流式上传实现

方案一:分片上传(推荐)

优点:

  • HTTP协议简单稳定
  • 可断点续传
  • 兼容现有后端

实现:

// hooks/useStreamUpload.js
import { useState, useCallback } from 'react';
import { uploadAudioChunk, completeUpload } from '../services/upload';

export const useStreamUpload = () => {
  const [uploadProgress, setUploadProgress] = useState(0);
  const [sessionId, setSessionId] = useState(null);
  const chunkIndexRef = useRef(0);

  const startSession = useCallback(async (meetingId) => {
    // 初始化上传会话
    const session = await initUploadSession(meetingId);
    setSessionId(session.session_id);
    return session;
  }, []);

  const uploadChunk = useCallback(async (chunk) => {
    if (!sessionId) return;

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('chunk_index', chunkIndexRef.current);
    formData.append('session_id', sessionId);

    try {
      await uploadAudioChunk(formData);
      chunkIndexRef.current++;
      setUploadProgress(prev => prev + 1);
    } catch (error) {
      console.error('Upload chunk failed:', error);
      // 可以实现重试逻辑
    }
  }, [sessionId]);

  const finishUpload = useCallback(async (meetingId) => {
    if (!sessionId) return;

    return await completeUpload({
      session_id: sessionId,
      meeting_id: meetingId,
      total_chunks: chunkIndexRef.current
    });
  }, [sessionId]);

  return { startSession, uploadChunk, finishUpload, uploadProgress };
};

后端需要新增的接口:

// 需要后端提供这些接口
POST /api/audio/upload-init      // 初始化上传会话
POST /api/audio/upload-chunk     // 上传分片
POST /api/audio/upload-complete  // 完成上传,合并分片
方案二:简化方案(先录完再传)

如果后端暂时不支持分片,可以先实现简化版本:

const stopAndUpload = useCallback(async () => {
  mediaRecorder.stop();

  // 等待所有数据收集完毕
  mediaRecorder.onstop = async () => {
    const audioBlob = new Blob(chunksRef.current, { type: mimeType });

    // 一次性上传完整音频
    const formData = new FormData();
    formData.append('audio_file', audioBlob, 'recording.webm');
    formData.append('meeting_id', meetingId);

    await uploadAudio(formData);
  };
}, []);

推荐:先实现方案二(简单快速),后续优化为方案一

4.1.3 录音界面设计

┌─────────────────────────────────┐
│          正在录音...            │
│                                 │
│     ⏺  [动画波形]              │
│                                 │
│         00:03:24                │
│                                 │
│    ━━━━━━━━━━━━━━━━━━━━       │
│                                 │
│  [暂停]       [停止并保存]      │
└─────────────────────────────────┘

关键元素:

  • 录音状态指示(红点闪烁)
  • 实时时长显示
  • 简单的音量可视化(可选)
  • 暂停/继续按钮
  • 停止按钮

错误处理:

const handleRecordingError = (error) => {
  switch(error.name) {
    case 'NotAllowedError':
      showError('需要麦克风权限,请在浏览器设置中允许');
      break;
    case 'NotFoundError':
      showError('未检测到麦克风设备');
      break;
    case 'NotReadableError':
      showError('麦克风被其他应用占用');
      break;
    default:
      showError('录音失败,请重试');
  }
};

4.2 会议列表功能

4.2.1 列表页实现

// pages/Meetings/MeetingList.jsx
import { useState, useEffect } from 'react';
import { getMeetings } from '../../services/meeting';
import QRCodeModal from '../../components/QRCodeModal';

const MeetingList = () => {
  const [meetings, setMeetings] = useState([]);
  const [selectedMeeting, setSelectedMeeting] = useState(null);
  const [showQR, setShowQR] = useState(false);

  useEffect(() => {
    loadMeetings();
  }, []);

  const loadMeetings = async () => {
    const response = await getMeetings({
      user_id: currentUser.user_id,
      page: 1,
      page_size: 50
    });
    setMeetings(response.data.meetings);
  };

  const handleShowQR = (meeting) => {
    setSelectedMeeting(meeting);
    setShowQR(true);
  };

  return (
    <div className="meeting-list">
      {meetings.map(meeting => (
        <div key={meeting.meeting_id} className="meeting-item">
          <h3>{meeting.title}</h3>
          <p className="time">{formatDateTime(meeting.meeting_time)}</p>
          <p className="summary">{meeting.summary?.substring(0, 100)}...</p>
          <button onClick={() => handleShowQR(meeting)}>
            查看二维码
          </button>
        </div>
      ))}

      {showQR && (
        <QRCodeModal
          meeting={selectedMeeting}
          onClose={() => setShowQR(false)}
        />
      )}
    </div>
  );
};

4.2.2 二维码组件

// components/QRCodeModal/QRCodeModal.jsx
import { QRCodeSVG } from 'qrcode.react';

const QRCodeModal = ({ meeting, onClose }) => {
  const qrUrl = `${window.location.origin}/meetings/preview/${meeting.meeting_id}`;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={e => e.stopPropagation()}>
        <h2>{meeting.title}</h2>

        <div className="qr-code-container">
          <QRCodeSVG
            value={qrUrl}
            size={256}
            level="H"
            includeMargin={true}
          />
        </div>

        <p className="hint">扫描二维码查看会议详情</p>

        <div className="meeting-info">
          <p><strong>会议时间</strong>{formatDateTime(meeting.meeting_time)}</p>
          <p><strong>创建人</strong>{meeting.creator_username}</p>
          <p><strong>参会人数</strong>{meeting.attendees?.length || 0}</p>
        </div>

        <button onClick={onClose}>关闭</button>
      </div>
    </div>
  );
};

二维码跳转:

  • URL: /meetings/preview/{meetingId}
  • 这个页面已经在后端实现(无需登录即可查看)

五、API接口清单

5.1 复用现有接口

用户认证

POST /api/auth/login           // 登录
GET  /api/auth/me              // 获取当前用户信息

会议管理

POST   /api/meetings           // 创建会议
GET    /api/meetings           // 获取会议列表
  ?user_id=xxx&page=1&page_size=20
GET    /api/meetings/{id}      // 获取会议详情
DELETE /api/meetings/{id}      // 删除会议

POST   /api/meetings/upload-audio  // 上传音频(完整文件)
  - meeting_id
  - audio_file
  - auto_summarize (false)

5.2 需要新增的接口(分片上传,可选)

POST /api/audio/upload-init
// 初始化分片上传
Request: {
  meeting_id: number,
  estimated_duration: number,  // 预计时长(秒)
  mime_type: string
}
Response: {
  session_id: string
}

POST /api/audio/upload-chunk
// 上传音频分片
Request: FormData {
  session_id: string,
  chunk_index: number,
  chunk: Blob
}
Response: {
  success: boolean
}

POST /api/audio/upload-complete
// 完成上传
Request: {
  session_id: string,
  meeting_id: number,
  total_chunks: number
}
Response: {
  file_path: string,
  task_id: string  // 转录任务ID
}

六、关键技术实现

6.1 音频格式兼容性

浏览器 推荐格式 备选格式
Chrome webm(opus) webm
Safari mp4(aac) -
Firefox webm(opus) ogg
Edge webm(opus) webm

检测代码:

const getSupportedMimeType = () => {
  const types = [
    'audio/webm;codecs=opus',  // Chrome, Firefox, Edge
    'audio/mp4',               // Safari
    'audio/webm',              // 通用webm
    'audio/ogg;codecs=opus'    // Firefox fallback
  ];

  for (const type of types) {
    if (MediaRecorder.isTypeSupported(type)) {
      console.log('Using MIME type:', type);
      return type;
    }
  }

  throw new Error('No supported audio MIME type found');
};

6.2 权限管理

// utils/permissions.js
export const requestMicrophonePermission = async () => {
  try {
    // 尝试获取麦克风流
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

    // 立即停止流(仅用于权限检查)
    stream.getTracks().forEach(track => track.stop());

    return { granted: true };
  } catch (error) {
    if (error.name === 'NotAllowedError') {
      return {
        granted: false,
        error: '用户拒绝了麦克风权限'
      };
    } else if (error.name === 'NotFoundError') {
      return {
        granted: false,
        error: '未找到麦克风设备'
      };
    } else {
      return {
        granted: false,
        error: '获取麦克风权限失败'
      };
    }
  }
};

6.3 状态管理Context

// context/AppContext.jsx
import { createContext, useContext, useState } from 'react';

const AppContext = createContext();

export const AppProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [isRecording, setIsRecording] = useState(false);

  const value = {
    user,
    setUser,
    isRecording,
    setIsRecording
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
};

export const useApp = () => {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useApp must be used within AppProvider');
  }
  return context;
};

6.4 响应式布局实现

/* pages/Home/Home.module.css */
.home {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 20px;
  max-width: 600px;
  width: 100%;
}

/* 移动端 */
@media (max-width: 768px) {
  .grid {
    grid-template-columns: 1fr;
    max-width: 400px;
  }
}

/* 功能卡片 */
.card {
  aspect-ratio: 1;
  border-radius: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
}

.card:hover {
  transform: translateY(-5px);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}

.card.active {
  background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
  color: white;
}

.card.inactive {
  background: #E0E0E0;
  color: #999;
  cursor: not-allowed;
}

.card.inactive:hover {
  transform: none;
}

七、用户体验优化

7.1 录音反馈

// 视觉反馈:录音按钮呼吸动画
@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.05); }
}

.recording {
  animation: pulse 1.5s ease-in-out infinite;
}

// 红点闪烁
@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

.recording-indicator {
  animation: blink 1s infinite;
}

7.2 加载状态

const [loading, setLoading] = useState(false);

// 上传时显示进度
{loading && (
  <div className="loading-overlay">
    <div className="spinner"></div>
    <p>正在上传录音...</p>
  </div>
)}

7.3 错误提示

// components/Toast.jsx
const Toast = ({ message, type = 'info' }) => (
  <div className={`toast toast-${type}`}>
    {message}
  </div>
);

// 使用
showToast('录音已保存', 'success');
showToast('上传失败,请重试', 'error');

八、安全性考虑

8.1 Token管理

// services/api.js
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL
});

// 请求拦截器添加Token
api.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器处理401
api.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default api;

8.2 数据加密

  • HTTPS传输生产环境必须
  • 录音数据不在本地长期存储
  • Token使用JWT设置合理过期时间

九、部署方案

9.1 构建配置

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3002,
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true
      }
    }
  },
  build: {
    outDir: 'dist',
    sourcemap: false,
    minify: 'terser'
  }
});

9.2 环境变量

# .env.development
VITE_API_BASE_URL=http://localhost:8000

# .env.production
VITE_API_BASE_URL=https://api.yourdomain.com

9.3 部署方式

方案1独立部署推荐

npm run build
# 将 dist/ 部署到 Nginx/CDN

Nginx配置

server {
  listen 80;
  server_name client.yourdomain.com;

  root /var/www/client/dist;
  index index.html;

  location / {
    try_files $uri $uri/ /index.html;
  }

  location /api {
    proxy_pass http://backend:8000;
  }
}

方案2集成到现有frontend

frontend/
├── src/              # 原有管理端
└── client/           # 新客户端
    └── build后集成

十、后端接口规划

10.1 用户认证接口

1. 用户登录

POST /api/auth/login

Request:
{
  "username": "string",
  "password": "string"
}

Response:
{
  "code": "200",
  "message": "登录成功",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "user": {
      "user_id": 1,
      "username": "testuser",
      "caption": "测试用户"
    }
  }
}

Error Response:
{
  "code": "401",
  "message": "用户名或密码错误",
  "data": null
}

2. 获取当前用户信息

GET /api/auth/me

Headers:
{
  "Authorization": "Bearer {token}"
}

Response:
{
  "code": "200",
  "message": "获取成功",
  "data": {
    "user_id": 1,
    "username": "testuser",
    "caption": "测试用户",
    "created_at": "2025-01-20T10:00:00"
  }
}

10.2 会议管理接口

1. 创建会议

POST /api/meetings

Headers:
{
  "Authorization": "Bearer {token}"
}

Request:
{
  "title": "临时会议",  // 可选,默认"临时会议-时间戳"
  "meeting_time": "2025-01-25T14:30:00",  // 可选,默认当前时间
  "attendee_ids": [],  // 可选参会人员ID数组
  "tags": ""  // 可选,标签
}

Response:
{
  "code": "200",
  "message": "会议创建成功",
  "data": {
    "meeting_id": 123,
    "title": "临时会议-20250125143000",
    "meeting_time": "2025-01-25T14:30:00",
    "user_id": 1,
    "created_at": "2025-01-25T14:30:00"
  }
}

说明
- Web客户端录音前先调用此接口创建会议记录
- 录音完成后使用meeting_id上传音频

2. 获取会议列表

GET /api/meetings

Headers:
{
  "Authorization": "Bearer {token}"
}

Query Parameters:
{
  "user_id": 1,              // 必填当前用户ID
  "page": 1,                 // 可选页码默认1
  "page_size": 20,           // 可选每页数量默认20
  "filter_type": "created"   // 可选:筛选类型 (created/attended/all)默认created
}

Response:
{
  "code": "200",
  "message": "获取会议列表成功",
  "data": {
    "meetings": [
      {
        "meeting_id": 123,
        "title": "产品需求讨论会",
        "meeting_time": "2025-01-25T14:30:00",
        "summary": "会议讨论了新版本的产品需求...",
        "created_at": "2025-01-25T14:25:00",
        "audio_file_path": "/uploads/audio/123/xxx.webm",
        "creator_id": 1,
        "creator_username": "张三",
        "attendees": [
          { "user_id": 2, "caption": "李四" },
          { "user_id": 3, "caption": "王五" }
        ],
        "tags": [
          { "id": 1, "name": "产品", "color": "#FF5733" }
        ]
      }
    ],
    "total": 45,
    "page": 1,
    "page_size": 20,
    "total_pages": 3,
    "has_more": true
  }
}

10.3 流式音频上传接口(新增)

1. 初始化上传会话

POST /api/audio/stream/init

Headers:
{
  "Authorization": "Bearer {token}",
  "Content-Type": "application/json"
}

Request:
{
  "meeting_id": 123,
  "mime_type": "audio/webm;codecs=opus",  // 前端录音格式
  "estimated_duration": 300  // 预计时长(秒),可选
}

Response:
{
  "code": "200",
  "message": "上传会话初始化成功",
  "data": {
    "session_id": "sess_1737804123456_abc123",
    "chunk_size": 1024000,  // 建议分片大小字节1MB
    "max_chunks": 1000      // 最大分片数量
  }
}

说明
- 后端创建临时目录存储分片
- session_id用于后续上传分片
- 返回建议的分片大小

2. 上传音频分片

POST /api/audio/stream/chunk

Headers:
{
  "Authorization": "Bearer {token}",
  "Content-Type": "multipart/form-data"
}

Request: FormData
{
  "session_id": "sess_1737804123456_abc123",
  "chunk_index": 0,        // 分片序号从0开始
  "chunk": <Blob>,         // 音频分片数据
  "is_last": false         // 是否为最后一片
}

Response:
{
  "code": "200",
  "message": "分片上传成功",
  "data": {
    "session_id": "sess_1737804123456_abc123",
    "chunk_index": 0,
    "received": true,
    "total_received": 1  // 已接收的分片总数
  }
}

Error Response (需要重传):
{
  "code": "500",
  "message": "分片上传失败",
  "data": {
    "session_id": "sess_1737804123456_abc123",
    "chunk_index": 0,
    "should_retry": true
  }
}

说明
- 前端每1秒收集一次音频数据MediaRecorder.start(1000)
- 实时上传每个分片不等待录音结束
- 支持失败重传
- chunk_index连续递增

3. 完成上传并合并

POST /api/audio/stream/complete

Headers:
{
  "Authorization": "Bearer {token}",
  "Content-Type": "application/json"
}

Request:
{
  "session_id": "sess_1737804123456_abc123",
  "meeting_id": 123,
  "total_chunks": 180,     // 总分片数
  "mime_type": "audio/webm;codecs=opus",
  "auto_transcribe": true  // 是否自动启动转录
}

Response:
{
  "code": "200",
  "message": "音频上传完成",
  "data": {
    "meeting_id": 123,
    "file_path": "/uploads/audio/123/abc123.webm",
    "file_size": 184320000,  // 字节
    "duration": 180,         // 秒
    "task_id": "task_transcription_123",  // 转录任务ID如果auto_transcribe=true
    "task_status": "pending"
  }
}

Error Response (合并失败):
{
  "code": "500",
  "message": "音频合并失败:部分分片丢失",
  "data": {
    "missing_chunks": [5, 12, 34],  // 缺失的分片序号
    "should_retry": true
  }
}

说明
- 后端按序合并所有分片
- 验证分片完整性
- 保存最终音频文件
- 可选自动启动转录任务
- 清理临时分片文件

4. 取消上传会话

DELETE /api/audio/stream/cancel

Headers:
{
  "Authorization": "Bearer {token}",
  "Content-Type": "application/json"
}

Request:
{
  "session_id": "sess_1737804123456_abc123"
}

Response:
{
  "code": "200",
  "message": "上传会话已取消",
  "data": {
    "session_id": "sess_1737804123456_abc123",
    "cleaned": true
  }
}

说明
- 用户中途停止录音或取消上传时调用
- 后端清理临时分片文件
- 释放会话资源

10.4 接口调用流程

完整录音上传流程

1. 用户点击录音按钮
   ↓
2. 前端:创建会议
   POST /api/meetings
   → 获得 meeting_id
   ↓
3. 前端:初始化上传会话
   POST /api/audio/stream/init
   → 获得 session_id
   ↓
4. 前端开始录音MediaRecorder
   ↓
5. 每1秒收集音频数据
   ↓
6. 实时上传分片
   POST /api/audio/stream/chunk
   (chunk_index: 0, 1, 2, ...)
   ↓
7. 用户点击停止
   ↓
8. 前端:完成上传
   POST /api/audio/stream/complete
   (is_last=true, total_chunks=N)
   ↓
9. 后端:合并分片 → 启动转录
   ↓
10. 前端:跳转到会议详情页
    /meetings/{meeting_id}

错误恢复流程

分片上传失败
   ↓
前端重试该分片最多3次
   ↓
   成功 → 继续下一分片
   ↓
   失败 → 提示用户,取消上传
          DELETE /api/audio/stream/cancel

10.5 后端实现要点

1. 分片存储策略

# 后端临时目录结构
/tmp/audio_uploads/
  └── sess_1737804123456_abc123/
      ├── chunk_0000.webm
      ├── chunk_0001.webm
      ├── chunk_0002.webm
      └── metadata.json

# metadata.json
{
  "session_id": "sess_1737804123456_abc123",
  "meeting_id": 123,
  "mime_type": "audio/webm;codecs=opus",
  "total_chunks": null,  # 初始为null完成时填入
  "received_chunks": [0, 1, 2, ...],
  "created_at": "2025-01-25T14:30:00",
  "expires_at": "2025-01-25T15:30:00"  # 1小时后过期
}

2. 分片合并逻辑

def merge_audio_chunks(session_id, total_chunks):
    """合并音频分片"""
    session_dir = f"/tmp/audio_uploads/{session_id}"

    # 1. 验证分片完整性
    missing = []
    for i in range(total_chunks):
        if not os.path.exists(f"{session_dir}/chunk_{i:04d}.webm"):
            missing.append(i)

    if missing:
        raise ValueError(f"Missing chunks: {missing}")

    # 2. 按序合并
    output_path = f"/app/uploads/audio/{meeting_id}/{uuid.uuid4()}.webm"
    with open(output_path, 'wb') as outfile:
        for i in range(total_chunks):
            chunk_path = f"{session_dir}/chunk_{i:04d}.webm"
            with open(chunk_path, 'rb') as infile:
                outfile.write(infile.read())

    # 3. 清理临时文件
    shutil.rmtree(session_dir)

    return output_path

3. 会话过期清理

# 定时任务清理1小时前的过期会话
@scheduler.scheduled_job('interval', hours=1)
def cleanup_expired_sessions():
    """清理过期的上传会话"""
    now = datetime.now()
    upload_dir = "/tmp/audio_uploads"

    for session_dir in os.listdir(upload_dir):
        metadata_path = f"{upload_dir}/{session_dir}/metadata.json"
        if os.path.exists(metadata_path):
            with open(metadata_path) as f:
                metadata = json.load(f)

            expires_at = datetime.fromisoformat(metadata['expires_at'])
            if now > expires_at:
                shutil.rmtree(f"{upload_dir}/{session_dir}")
                print(f"Cleaned up expired session: {session_dir}")

4. 音频格式处理

# 支持的音频格式
SUPPORTED_MIME_TYPES = {
    'audio/webm;codecs=opus': '.webm',
    'audio/webm': '.webm',
    'audio/ogg;codecs=opus': '.ogg',
    'audio/mp4': '.m4a'
}

def validate_mime_type(mime_type):
    """验证MIME类型"""
    if mime_type not in SUPPORTED_MIME_TYPES:
        raise ValueError(f"Unsupported MIME type: {mime_type}")
    return SUPPORTED_MIME_TYPES[mime_type]

10.6 性能优化建议

1. 并发控制

# 限制每个用户的并发上传会话数
MAX_CONCURRENT_SESSIONS_PER_USER = 2

# 使用Redis记录用户的活跃会话
redis.sadd(f"user:{user_id}:sessions", session_id)
if redis.scard(f"user:{user_id}:sessions") > MAX_CONCURRENT_SESSIONS_PER_USER:
    raise TooManyConcurrentSessions()

2. 分片上传队列

# 使用Celery异步处理分片保存
@celery.task
def save_audio_chunk(session_id, chunk_index, chunk_data):
    """异步保存音频分片"""
    session_dir = f"/tmp/audio_uploads/{session_id}"
    chunk_path = f"{session_dir}/chunk_{chunk_index:04d}.webm"

    with open(chunk_path, 'wb') as f:
        f.write(chunk_data)

    # 更新metadata
    update_session_metadata(session_id, chunk_index)

3. 带宽优化

# 配置Nginx限速可选
location /api/audio/stream/chunk {
    limit_req zone=upload_rate burst=5;
    limit_rate 2m;  # 限制每个连接2MB/s
}

10.7 安全性措施

1. 文件大小限制

MAX_CHUNK_SIZE = 2 * 1024 * 1024  # 2MB per chunk
MAX_TOTAL_SIZE = 500 * 1024 * 1024  # 500MB total
MAX_DURATION = 3600  # 1 hour max recording

@app.post("/api/audio/stream/chunk")
async def upload_chunk(
    chunk: UploadFile = File(...),
    session_id: str = Form(...),
    chunk_index: int = Form(...)
):
    # 验证分片大小
    if chunk.size > MAX_CHUNK_SIZE:
        raise HTTPException(400, "Chunk too large")

    # 验证总大小
    session_total = get_session_total_size(session_id)
    if session_total + chunk.size > MAX_TOTAL_SIZE:
        raise HTTPException(400, "Total size exceeds limit")

2. 防止目录遍历攻击

def validate_session_id(session_id: str):
    """验证session_id格式防止路径注入"""
    if not re.match(r'^sess_\d+_[a-zA-Z0-9]+$', session_id):
        raise ValueError("Invalid session_id format")
    return session_id

3. 权限验证

@app.post("/api/audio/stream/complete")
async def complete_upload(
    request: CompleteUploadRequest,
    current_user: dict = Depends(get_current_user)
):
    # 验证会议所有权
    meeting = get_meeting(request.meeting_id)
    if meeting.user_id != current_user['user_id']:
        raise HTTPException(403, "Permission denied")

10.8 监控与日志

关键指标

# 记录以下指标
- 上传会话数总数活跃数
- 分片上传成功率
- 平均上传速度
- 合并耗时
- 失败原因统计

# 日志格式
{
  "event": "chunk_upload",
  "session_id": "sess_xxx",
  "chunk_index": 10,
  "chunk_size": 1024000,
  "upload_time_ms": 234,
  "user_id": 1,
  "timestamp": "2025-01-25T14:30:00"
}

十一、快速开始

# 1. 安装依赖
npm install

# 2. 启动开发服务器
npm run dev

# 3. 构建生产版本
npm run build

# 4. 预览生产构建
npm run preview

文档版本v1.0 最后更新2025-01-25