解决safari播放问题

main
mula.liu 2025-12-26 16:58:36 +08:00
parent 9085086d88
commit a2a8a48fad
13 changed files with 330 additions and 27 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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子查询获取

BIN
app.zip

Binary file not shown.

View File

@ -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)}")

View File

@ -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()
)

View File

@ -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 typeSafari 兼容
'.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"
}
)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -21,3 +21,6 @@ psutil
# APK Parsing
pyaxmlparser
# Audio Processing
mutagen==1.47.0

BIN
sql/.DS_Store vendored 100644

Binary file not shown.

View File

@ -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;

View File

@ -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;