1460 lines
32 KiB
Markdown
1460 lines
32 KiB
Markdown
|
|
# 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
|