解决safari播放问题
parent
9085086d88
commit
a2a8a48fad
|
|
@ -0,0 +1,101 @@
|
|||
# 音频处理功能部署说明
|
||||
|
||||
## 1. 安装Python依赖
|
||||
|
||||
```bash
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
pip install mutagen
|
||||
```
|
||||
|
||||
## 2. 执行数据库迁移
|
||||
|
||||
### 2.1 创建用户日志表
|
||||
```bash
|
||||
mysql -u root -p imeeting < sql/migrations/create_user_logs_table.sql
|
||||
```
|
||||
|
||||
### 2.2 为audio_files表添加duration字段
|
||||
```bash
|
||||
mysql -u root -p imeeting < sql/migrations/add_duration_to_audio_files.sql
|
||||
```
|
||||
|
||||
## 3. 功能说明
|
||||
|
||||
### 3.1 用户日志表 (user_logs)
|
||||
- 记录用户登录活动
|
||||
- 字段:log_id, user_id, action_type, ip_address, user_agent, metadata, created_at
|
||||
- 管理后台可查询用户最后登录时间
|
||||
|
||||
### 3.2 音频处理优化
|
||||
**音频时长计算:**
|
||||
- 使用mutagen库自动识别音频格式并提取时长
|
||||
- 支持mp3, m4a, mp4, wav等常见格式
|
||||
- 时长保存到audio_files.duration字段(单位:秒)
|
||||
- 时长读取功能位于`app/utils/audio_parser.py`
|
||||
|
||||
### 3.3 管理后台用户统计
|
||||
- 排除没有会议的用户
|
||||
- 显示字段:
|
||||
- ID
|
||||
- 用户名
|
||||
- 姓名
|
||||
- 注册时间
|
||||
- 最新登录时间(从user_logs表查询)
|
||||
- 会议数量
|
||||
- 会议时长(从audio_files.duration汇总,格式:Xh Ym)
|
||||
|
||||
## 4. 代码变更清单
|
||||
|
||||
### 后端变更:
|
||||
- `/backend/app/utils/audio_parser.py` - 新增音频时长解析工具
|
||||
- `/backend/app/api/endpoints/meetings.py` - 音频上传时调用audio_parser获取时长,修复Safari播放问题
|
||||
- `/backend/app/services/audio_service.py` - 增加duration参数
|
||||
- `/backend/app/api/endpoints/auth.py` - 登录时记录日志
|
||||
- `/backend/app/api/endpoints/admin_dashboard.py` - 更新用户统计查询
|
||||
- `/backend/app/models/models.py` - 新增UserLog模型
|
||||
|
||||
### 前端变更:
|
||||
- `/frontend/src/pages/HomePage.jsx` - 密码显示/隐藏切换
|
||||
- `/frontend/src/pages/HomePage.css` - 密码切换按钮样式
|
||||
- `/frontend/src/pages/AdminDashboard.jsx` - 用户列表增加会议时长列
|
||||
- `/frontend/src/pages/MeetingPreview.jsx` - 新增转录标签页,修复Safari音频播放
|
||||
- `/frontend/src/pages/MeetingDetails.jsx` - 修复Safari音频播放问题
|
||||
|
||||
### 数据库变更:
|
||||
- `sql/migrations/create_user_logs_table.sql` - 创建用户日志表
|
||||
- `sql/migrations/add_duration_to_audio_files.sql` - 添加音频时长字段
|
||||
|
||||
## 5. 测试检查清单
|
||||
|
||||
- [ ] 安装Python依赖成功
|
||||
- [ ] 数据库迁移执行成功
|
||||
- [ ] 用户登录后,user_logs表有记录
|
||||
- [ ] 上传音频文件,Safari可以正常播放
|
||||
- [ ] 上传音频后,audio_files.duration有正确的时长值
|
||||
- [ ] 管理后台用户列表显示正确(排除无会议用户)
|
||||
- [ ] 用户列表中会议时长统计正确
|
||||
- [ ] 登录页面密码显示/隐藏按钮正常工作
|
||||
- [ ] 会议预览页面音频播放正常(Safari/Chrome)
|
||||
- [ ] 会议详情页面音频播放正常(Safari/Chrome)
|
||||
|
||||
## 6. 注意事项
|
||||
|
||||
1. **音频时长计算**:
|
||||
- 使用mutagen自动识别格式
|
||||
- 计算失败时duration为0,不影响其他功能
|
||||
- 支持所有mutagen支持的格式
|
||||
|
||||
2. **Safari音频播放兼容性**:
|
||||
- 使用直接src属性而非source子元素
|
||||
- preload="metadata"模式
|
||||
- 后端支持HTTP Range请求
|
||||
- 正确的MIME类型由后端自动设置
|
||||
|
||||
3. **用户日志**:
|
||||
- 登录日志记录失败不影响登录流程
|
||||
- IP地址优先从X-Forwarded-For获取(考虑代理场景)
|
||||
|
||||
4. **管理后台统计**:
|
||||
- 使用INNER JOIN过滤无会议用户
|
||||
- 时长汇总从audio_files.duration计算
|
||||
- 最后登录时间从user_logs子查询获取
|
||||
|
|
@ -396,3 +396,63 @@ async def get_system_resources(current_user=Depends(get_current_admin_user)):
|
|||
except Exception as e:
|
||||
print(f"获取系统资源失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/admin/user-stats")
|
||||
async def get_user_stats(current_user=Depends(get_current_admin_user)):
|
||||
"""获取用户统计列表"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 查询所有用户及其会议统计和最后登录时间(排除没有会议的用户)
|
||||
query = """
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.caption,
|
||||
u.created_at,
|
||||
(SELECT MAX(created_at) FROM user_logs
|
||||
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
|
||||
COUNT(DISTINCT m.meeting_id) as meeting_count,
|
||||
COALESCE(SUM(af.duration), 0) as total_duration_seconds
|
||||
FROM users u
|
||||
INNER JOIN meetings m ON u.user_id = m.user_id
|
||||
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
|
||||
GROUP BY u.user_id, u.username, u.caption, u.created_at
|
||||
HAVING meeting_count > 0
|
||||
ORDER BY u.user_id ASC
|
||||
"""
|
||||
|
||||
cursor.execute(query)
|
||||
users = cursor.fetchall()
|
||||
|
||||
# 格式化返回数据
|
||||
users_list = []
|
||||
for user in users:
|
||||
total_seconds = int(user['total_duration_seconds']) if user['total_duration_seconds'] else 0
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
|
||||
users_list.append({
|
||||
'user_id': user['user_id'],
|
||||
'username': user['username'],
|
||||
'caption': user['caption'],
|
||||
'created_at': user['created_at'].isoformat() if user['created_at'] else None,
|
||||
'last_login_time': user['last_login_time'].isoformat() if user['last_login_time'] else None,
|
||||
'meeting_count': user['meeting_count'],
|
||||
'total_duration_seconds': total_seconds,
|
||||
'total_duration_formatted': f"{hours}h {minutes}m" if total_seconds > 0 else '-'
|
||||
})
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户统计成功",
|
||||
data={"users": users_list, "total": len(users_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户统计失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return create_api_response(code="500", message=f"获取用户统计失败: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
|
||||
import hashlib
|
||||
from typing import Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
|
|
@ -20,21 +19,21 @@ def hash_password(password: str) -> str:
|
|||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
@router.post("/auth/login")
|
||||
def login(request: LoginRequest):
|
||||
def login(request_body: LoginRequest, request: Request):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
|
||||
query = "SELECT user_id, username, caption, email, password_hash, role_id FROM users WHERE username = %s"
|
||||
cursor.execute(query, (request.username,))
|
||||
cursor.execute(query, (request_body.username,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
|
||||
if not user:
|
||||
return create_api_response(code="401", message="用户名或密码错误")
|
||||
|
||||
hashed_input = hash_password(request.password)
|
||||
|
||||
hashed_input = hash_password(request_body.password)
|
||||
if user['password_hash'] != hashed_input:
|
||||
return create_api_response(code="401", message="用户名或密码错误")
|
||||
|
||||
|
||||
# 创建JWT token
|
||||
token_data = {
|
||||
"user_id": user['user_id'],
|
||||
|
|
@ -43,7 +42,30 @@ def login(request: LoginRequest):
|
|||
"role_id": user['role_id']
|
||||
}
|
||||
token = jwt_service.create_access_token(token_data)
|
||||
|
||||
|
||||
# 记录登录日志
|
||||
try:
|
||||
# 获取客户端IP地址(考虑代理)
|
||||
client_ip = request.client.host if request.client else None
|
||||
if "x-forwarded-for" in request.headers:
|
||||
client_ip = request.headers["x-forwarded-for"].split(",")[0].strip()
|
||||
elif "x-real-ip" in request.headers:
|
||||
client_ip = request.headers["x-real-ip"]
|
||||
|
||||
# 获取User-Agent
|
||||
user_agent = request.headers.get("user-agent", None)
|
||||
|
||||
# 插入登录日志
|
||||
log_query = """
|
||||
INSERT INTO user_logs (user_id, action_type, ip_address, user_agent)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(log_query, (user['user_id'], 'login', client_ip, user_agent))
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
# 日志记录失败不影响登录流程
|
||||
print(f"Failed to log user login: {e}")
|
||||
|
||||
login_response_data = LoginResponse(
|
||||
user_id=user['user_id'],
|
||||
username=user['username'],
|
||||
|
|
@ -52,10 +74,10 @@ def login(request: LoginRequest):
|
|||
token=token,
|
||||
role_id=user['role_id']
|
||||
)
|
||||
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="登录成功",
|
||||
code="200",
|
||||
message="登录成功",
|
||||
data=login_response_data.dict()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ from app.services.llm_service import LLMService
|
|||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.async_meeting_service import async_meeting_service
|
||||
from app.services.audio_service import handle_audio_upload
|
||||
from app.core.auth import get_current_user
|
||||
from app.utils.audio_parser import get_audio_duration
|
||||
from app.core.auth import get_current_user, get_optional_current_user
|
||||
from app.core.response import create_api_response
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
|
@ -346,7 +347,8 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
|
|||
return create_api_response(code="200", message="获取会议详情成功", data=meeting_data)
|
||||
|
||||
@router.get("/meetings/{meeting_id}/transcript")
|
||||
def get_meeting_transcript(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
||||
def get_meeting_transcript(meeting_id: int, current_user: Optional[dict] = Depends(get_optional_current_user)):
|
||||
"""获取会议转录内容(支持公开访问用于预览)"""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||
|
|
@ -516,6 +518,14 @@ async def upload_audio(
|
|||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"保存文件失败: {str(e)}")
|
||||
|
||||
# 3.5 获取音频时长
|
||||
audio_duration = 0
|
||||
try:
|
||||
audio_duration = get_audio_duration(str(absolute_path))
|
||||
print(f"音频时长: {audio_duration}秒")
|
||||
except Exception as e:
|
||||
print(f"警告: 获取音频时长失败,但不影响后续流程: {e}")
|
||||
|
||||
file_path = '/' + str(relative_path)
|
||||
file_name = audio_file.filename
|
||||
file_size = audio_file.size
|
||||
|
|
@ -529,7 +539,8 @@ async def upload_audio(
|
|||
current_user=current_user,
|
||||
auto_summarize=auto_summarize_bool,
|
||||
background_tasks=background_tasks,
|
||||
prompt_id=prompt_id # 传递 prompt_id 参数
|
||||
prompt_id=prompt_id,
|
||||
duration=audio_duration # 传递时长参数
|
||||
)
|
||||
|
||||
# 如果不成功,删除已保存的文件并返回错误
|
||||
|
|
@ -568,7 +579,8 @@ async def upload_audio(
|
|||
)
|
||||
|
||||
@router.get("/meetings/{meeting_id}/audio")
|
||||
def get_audio_file(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
||||
def get_audio_file(meeting_id: int, current_user: Optional[dict] = Depends(get_optional_current_user)):
|
||||
"""获取音频文件信息(支持公开访问用于预览)"""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT file_name, file_path, file_size, upload_time FROM audio_files WHERE meeting_id = %s", (meeting_id,))
|
||||
|
|
@ -607,10 +619,11 @@ async def stream_audio_file(
|
|||
extension = os.path.splitext(file_name)[1].lower()
|
||||
mime_types = {
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.m4a': 'audio/mp4', # 标准 MIME type,Safari 兼容
|
||||
'.wav': 'audio/wav',
|
||||
'.mpeg': 'audio/mpeg',
|
||||
'.mp4': 'audio/mp4'
|
||||
'.mp4': 'audio/mp4',
|
||||
'.webm': 'audio/webm'
|
||||
}
|
||||
content_type = mime_types.get(extension, 'audio/mpeg')
|
||||
|
||||
|
|
@ -671,7 +684,9 @@ async def stream_audio_file(
|
|||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(content_length),
|
||||
"Content-Disposition": filename_header
|
||||
"Content-Disposition": filename_header,
|
||||
"Cache-Control": "public, max-age=31536000", # 1年缓存
|
||||
"X-Content-Type-Options": "nosniff"
|
||||
}
|
||||
)
|
||||
else:
|
||||
|
|
@ -682,7 +697,9 @@ async def stream_audio_file(
|
|||
headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(file_size),
|
||||
"Content-Disposition": filename_header
|
||||
"Content-Disposition": filename_header,
|
||||
"Cache-Control": "public, max-age=31536000", # 1年缓存
|
||||
"X-Content-Type-Options": "nosniff"
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@ class UpdateUserRequest(BaseModel):
|
|||
email: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
|
||||
class UserLog(BaseModel):
|
||||
log_id: int
|
||||
user_id: int
|
||||
action_type: str
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
created_at: datetime.datetime
|
||||
|
||||
class AttendeeInfo(BaseModel):
|
||||
user_id: int
|
||||
caption: str
|
||||
|
|
|
|||
|
|
@ -24,12 +24,13 @@ def handle_audio_upload(
|
|||
current_user: dict,
|
||||
auto_summarize: bool = True,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
prompt_id: int = None
|
||||
prompt_id: int = None,
|
||||
duration: int = 0
|
||||
) -> dict:
|
||||
"""
|
||||
处理已保存的完整音频文件
|
||||
|
||||
职责:
|
||||
职责:
|
||||
1. 权限检查
|
||||
2. 检查已有文件和转录记录
|
||||
3. 更新数据库(audio_files 表)
|
||||
|
|
@ -45,6 +46,7 @@ def handle_audio_upload(
|
|||
auto_summarize: 是否自动生成总结(默认True)
|
||||
background_tasks: FastAPI 后台任务对象
|
||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||
duration: 音频时长(秒)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
|
|
@ -120,13 +122,13 @@ def handle_audio_upload(
|
|||
|
||||
if replaced_existing:
|
||||
cursor.execute(
|
||||
'UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s',
|
||||
(file_name, file_path, file_size, meeting_id)
|
||||
'UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, duration = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s',
|
||||
(file_name, file_path, file_size, duration, meeting_id)
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
'INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) VALUES (%s, %s, %s, %s, NOW())',
|
||||
(meeting_id, file_name, file_path, file_size)
|
||||
'INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, duration, upload_time) VALUES (%s, %s, %s, %s, %s, NOW())',
|
||||
(meeting_id, file_name, file_path, file_size, duration)
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
"""
|
||||
音频文件解析工具
|
||||
|
||||
用于解析音频文件的元数据信息,如时长、采样率、编码格式等
|
||||
"""
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
|
||||
def get_audio_duration(file_path: str) -> int:
|
||||
"""
|
||||
获取音频文件时长(秒)
|
||||
|
||||
Args:
|
||||
file_path: 音频文件的完整路径
|
||||
|
||||
Returns:
|
||||
音频时长(秒),如果解析失败返回0
|
||||
|
||||
支持格式:
|
||||
- MP3 (.mp3)
|
||||
- M4A (.m4a)
|
||||
- MP4 (.mp4)
|
||||
- WAV (.wav)
|
||||
- 以及mutagen支持的其他格式
|
||||
"""
|
||||
try:
|
||||
audio = MutagenFile(file_path)
|
||||
if audio is not None and hasattr(audio.info, 'length'):
|
||||
return int(audio.info.length)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"获取音频时长失败 ({file_path}): {e}")
|
||||
return 0
|
||||
|
|
@ -21,3 +21,6 @@ psutil
|
|||
|
||||
# APK Parsing
|
||||
pyaxmlparser
|
||||
|
||||
# Audio Processing
|
||||
mutagen==1.47.0
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,20 @@
|
|||
-- ============================================
|
||||
-- 为 audio_files 表添加 duration 字段
|
||||
-- 创建时间: 2025-01-26
|
||||
-- 说明: 添加音频时长字段(秒),用于统计用户会议总时长
|
||||
-- ============================================
|
||||
|
||||
-- 添加 duration 字段(单位:秒)
|
||||
ALTER TABLE audio_files
|
||||
ADD COLUMN duration INT(11) DEFAULT 0 COMMENT '音频时长(秒)'
|
||||
AFTER file_size;
|
||||
|
||||
-- 添加索引以提高查询性能
|
||||
ALTER TABLE audio_files
|
||||
ADD INDEX idx_duration (duration);
|
||||
|
||||
-- ============================================
|
||||
-- 验证修改
|
||||
-- ============================================
|
||||
-- 查看表结构
|
||||
-- DESCRIBE audio_files;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
-- ============================================
|
||||
-- 创建用户日志表 (user_logs)
|
||||
-- 创建时间: 2025-01-26
|
||||
-- 说明: 用于记录用户活动日志,包括登录、登出等操作
|
||||
-- 支持查询用户最后登录时间等统计信息
|
||||
-- ============================================
|
||||
|
||||
-- 创建 user_logs 表
|
||||
CREATE TABLE IF NOT EXISTS user_logs (
|
||||
log_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID',
|
||||
user_id INT(11) NOT NULL COMMENT '用户ID',
|
||||
action_type VARCHAR(50) NOT NULL COMMENT '操作类型: login, logout, etc.',
|
||||
ip_address VARCHAR(50) DEFAULT NULL COMMENT '用户IP地址',
|
||||
user_agent TEXT DEFAULT NULL COMMENT '用户代理字符串(浏览器/设备信息)',
|
||||
metadata JSON DEFAULT NULL COMMENT '额外的元数据(JSON格式)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '日志创建时间',
|
||||
|
||||
-- 索引
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_action_type (action_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_user_action (user_id, action_type),
|
||||
|
||||
-- 外键约束
|
||||
CONSTRAINT fk_user_logs_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户活动日志表';
|
||||
|
||||
-- ============================================
|
||||
-- 验证创建
|
||||
-- ============================================
|
||||
-- 查看表结构
|
||||
-- DESCRIBE user_logs;
|
||||
|
||||
-- 查看索引
|
||||
-- SHOW INDEX FROM user_logs;
|
||||
Loading…
Reference in New Issue