解决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: except Exception as e:
print(f"获取系统资源失败: {e}") print(f"获取系统资源失败: {e}")
return create_api_response(code="500", message=f"获取系统资源失败: {str(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 import hashlib
from typing import Union from typing import Union
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@ -20,18 +19,18 @@ def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest() return hashlib.sha256(password.encode()).hexdigest()
@router.post("/auth/login") @router.post("/auth/login")
def login(request: LoginRequest): def login(request_body: LoginRequest, request: Request):
with get_db_connection() as connection: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
query = "SELECT user_id, username, caption, email, password_hash, role_id FROM users WHERE username = %s" 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() user = cursor.fetchone()
if not user: if not user:
return create_api_response(code="401", message="用户名或密码错误") 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: if user['password_hash'] != hashed_input:
return create_api_response(code="401", message="用户名或密码错误") return create_api_response(code="401", message="用户名或密码错误")
@ -44,6 +43,29 @@ def login(request: LoginRequest):
} }
token = jwt_service.create_access_token(token_data) 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( login_response_data = LoginResponse(
user_id=user['user_id'], user_id=user['user_id'],
username=user['username'], username=user['username'],

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_transcription_service import AsyncTranscriptionService
from app.services.async_meeting_service import async_meeting_service from app.services.async_meeting_service import async_meeting_service
from app.services.audio_service import handle_audio_upload 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 app.core.response import create_api_response
from typing import List, Optional from typing import List, Optional
from datetime import datetime 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) return create_api_response(code="200", message="获取会议详情成功", data=meeting_data)
@router.get("/meetings/{meeting_id}/transcript") @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: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) 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: except Exception as e:
return create_api_response(code="500", message=f"保存文件失败: {str(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_path = '/' + str(relative_path)
file_name = audio_file.filename file_name = audio_file.filename
file_size = audio_file.size file_size = audio_file.size
@ -529,7 +539,8 @@ async def upload_audio(
current_user=current_user, current_user=current_user,
auto_summarize=auto_summarize_bool, auto_summarize=auto_summarize_bool,
background_tasks=background_tasks, 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") @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: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) 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,)) 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() extension = os.path.splitext(file_name)[1].lower()
mime_types = { mime_types = {
'.mp3': 'audio/mpeg', '.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4', '.m4a': 'audio/mp4', # 标准 MIME typeSafari 兼容
'.wav': 'audio/wav', '.wav': 'audio/wav',
'.mpeg': 'audio/mpeg', '.mpeg': 'audio/mpeg',
'.mp4': 'audio/mp4' '.mp4': 'audio/mp4',
'.webm': 'audio/webm'
} }
content_type = mime_types.get(extension, 'audio/mpeg') 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}", "Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": str(content_length), "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: else:
@ -682,7 +697,9 @@ async def stream_audio_file(
headers={ headers={
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": str(file_size), "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 email: Optional[str] = None
role_id: Optional[int] = 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): class AttendeeInfo(BaseModel):
user_id: int user_id: int
caption: str caption: str

View File

@ -24,12 +24,13 @@ def handle_audio_upload(
current_user: dict, current_user: dict,
auto_summarize: bool = True, auto_summarize: bool = True,
background_tasks: BackgroundTasks = None, background_tasks: BackgroundTasks = None,
prompt_id: int = None prompt_id: int = None,
duration: int = 0
) -> dict: ) -> dict:
""" """
处理已保存的完整音频文件 处理已保存的完整音频文件
职责 职责:
1. 权限检查 1. 权限检查
2. 检查已有文件和转录记录 2. 检查已有文件和转录记录
3. 更新数据库audio_files 3. 更新数据库audio_files
@ -45,6 +46,7 @@ def handle_audio_upload(
auto_summarize: 是否自动生成总结默认True auto_summarize: 是否自动生成总结默认True
background_tasks: FastAPI 后台任务对象 background_tasks: FastAPI 后台任务对象
prompt_id: 提示词模版ID可选如果不指定则使用默认模版 prompt_id: 提示词模版ID可选如果不指定则使用默认模版
duration: 音频时长
Returns: Returns:
dict: { dict: {
@ -120,13 +122,13 @@ def handle_audio_upload(
if replaced_existing: if replaced_existing:
cursor.execute( 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', '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, meeting_id) (file_name, file_path, file_size, duration, meeting_id)
) )
else: else:
cursor.execute( cursor.execute(
'INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) VALUES (%s, %s, %s, %s, NOW())', '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) (meeting_id, file_name, file_path, file_size, duration)
) )
connection.commit() 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 # APK Parsing
pyaxmlparser 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;