imetting_client/README.md

1460 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 (
<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 二维码组件
```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 (
<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 复用现有接口
#### 用户认证
```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 (
<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 响应式布局实现
```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 && (
<div className="loading-overlay">
<div className="spinner"></div>
<p>正在上传录音...</p>
</div>
)}
```
### 7.3 错误提示
```javascript
// components/Toast.jsx
const Toast = ({ message, type = 'info' }) => (
<div className={`toast toast-${type}`}>
{message}
</div>
);
// 使用
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": <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. 完成上传并合并
```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