# 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 响应式断点
```css
/* 移动端 */
@media (max-width: 768px) {
/* 单列布局,卡片堆叠 */
}
/* 平板 */
@media (min-width: 768px) and (max-width: 1024px) {
/* 2x2布局,卡片适中 */
}
/* 桌面 */
@media (min-width: 1024px) {
/* 2x2布局,卡片标准 */
}
```
---
## 三、技术架构
### 3.1 技术栈选型
#### 核心框架
- **React 18**:UI框架
- **React Router v6**:路由管理
- **Vite**:构建工具(快速、轻量)
#### 样式方案
- **CSS Modules**:模块化样式
- **响应式布局**:CSS Grid + Flexbox
#### 状态管理
- **React Context API**:全局状态(用户、录音状态)
- **useState/useReducer**:局部状态
#### 核心库
```json
{
"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**
```javascript
// 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协议,简单稳定
- ✅ 可断点续传
- ✅ 兼容现有后端
**实现:**
```javascript
// 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 };
};
```
**后端需要新增的接口:**
```javascript
// 需要后端提供这些接口
POST /api/audio/upload-init // 初始化上传会话
POST /api/audio/upload-chunk // 上传分片
POST /api/audio/upload-complete // 完成上传,合并分片
```
##### 方案二:简化方案(先录完再传)
如果后端暂时不支持分片,可以先实现简化版本:
```javascript
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 │
│ │
│ ━━━━━━━━━━━━━━━━━━━━ │
│ │
│ [暂停] [停止并保存] │
└─────────────────────────────────┘
```
**关键元素:**
- 录音状态指示(红点闪烁)
- 实时时长显示
- 简单的音量可视化(可选)
- 暂停/继续按钮
- 停止按钮
**错误处理:**
```javascript
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 列表页实现
```javascript
// 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 (
{meetings.map(meeting => (
{meeting.title}
{formatDateTime(meeting.meeting_time)}
{meeting.summary?.substring(0, 100)}...
))}
{showQR && (
setShowQR(false)}
/>
)}
);
};
```
#### 4.2.2 二维码组件
```javascript
// components/QRCodeModal/QRCodeModal.jsx
import { QRCodeSVG } from 'qrcode.react';
const QRCodeModal = ({ meeting, onClose }) => {
const qrUrl = `${window.location.origin}/meetings/preview/${meeting.meeting_id}`;
return (
e.stopPropagation()}>
{meeting.title}
扫描二维码查看会议详情
会议时间:{formatDateTime(meeting.meeting_time)}
创建人:{meeting.creator_username}
参会人数:{meeting.attendees?.length || 0}
);
};
```
**二维码跳转:**
- URL: `/meetings/preview/{meetingId}`
- 这个页面已经在后端实现(无需登录即可查看)
---
## 五、API接口清单
### 5.1 复用现有接口
#### 用户认证
```javascript
POST /api/auth/login // 登录
GET /api/auth/me // 获取当前用户信息
```
#### 会议管理
```javascript
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 需要新增的接口(分片上传,可选)
```javascript
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 |
**检测代码:**
```javascript
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 权限管理
```javascript
// 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)
```javascript
// 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 (
{children}
);
};
export const useApp = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
};
```
### 6.4 响应式布局实现
```css
/* 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 录音反馈
```javascript
// 视觉反馈:录音按钮呼吸动画
@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 加载状态
```javascript
const [loading, setLoading] = useState(false);
// 上传时显示进度
{loading && (
)}
```
### 7.3 错误提示
```javascript
// components/Toast.jsx
const Toast = ({ message, type = 'info' }) => (
{message}
);
// 使用
showToast('录音已保存', 'success');
showToast('上传失败,请重试', 'error');
```
---
## 八、安全性考虑
### 8.1 Token管理
```javascript
// 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 构建配置
```javascript
// 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 环境变量
```bash
# .env.development
VITE_API_BASE_URL=http://localhost:8000
# .env.production
VITE_API_BASE_URL=https://api.yourdomain.com
```
### 9.3 部署方式
**方案1:独立部署(推荐)**
```bash
npm run build
# 将 dist/ 部署到 Nginx/CDN
```
**Nginx配置:**
```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. 用户登录
```javascript
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. 获取当前用户信息
```javascript
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. 创建会议
```javascript
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. 获取会议列表
```javascript
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. 初始化上传会话
```javascript
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. 上传音频分片
```javascript
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": , // 音频分片数据
"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. 完成上传并合并
```javascript
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. 取消上传会话
```javascript
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. 分片存储策略
```python
# 后端临时目录结构
/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. 分片合并逻辑
```python
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. 会话过期清理
```python
# 定时任务:清理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. 音频格式处理
```python
# 支持的音频格式
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. 并发控制
```python
# 限制每个用户的并发上传会话数
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. 分片上传队列
```python
# 使用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. 带宽优化
```python
# 配置Nginx限速(可选)
location /api/audio/stream/chunk {
limit_req zone=upload_rate burst=5;
limit_rate 2m; # 限制每个连接2MB/s
}
```
---
### 10.7 安全性措施
#### 1. 文件大小限制
```python
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. 防止目录遍历攻击
```python
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. 权限验证
```python
@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 监控与日志
#### 关键指标
```python
# 记录以下指标
- 上传会话数(总数、活跃数)
- 分片上传成功率
- 平均上传速度
- 合并耗时
- 失败原因统计
# 日志格式
{
"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"
}
```
---
## 十一、快速开始
```bash
# 1. 安装依赖
npm install
# 2. 启动开发服务器
npm run dev
# 3. 构建生产版本
npm run build
# 4. 预览生产构建
npm run preview
```
---
**文档版本**:v1.0
**最后更新**:2025-01-25