解决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:
|
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)}")
|
||||||
|
|
|
||||||
|
|
@ -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,21 +19,21 @@ 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="用户名或密码错误")
|
||||||
|
|
||||||
# 创建JWT token
|
# 创建JWT token
|
||||||
token_data = {
|
token_data = {
|
||||||
"user_id": user['user_id'],
|
"user_id": user['user_id'],
|
||||||
|
|
@ -43,7 +42,30 @@ def login(request: LoginRequest):
|
||||||
"role_id": user['role_id']
|
"role_id": user['role_id']
|
||||||
}
|
}
|
||||||
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'],
|
||||||
|
|
@ -52,10 +74,10 @@ def login(request: LoginRequest):
|
||||||
token=token,
|
token=token,
|
||||||
role_id=user['role_id']
|
role_id=user['role_id']
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_api_response(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="登录成功",
|
message="登录成功",
|
||||||
data=login_response_data.dict()
|
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_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 type,Safari 兼容
|
||||||
'.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"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
# APK Parsing
|
||||||
pyaxmlparser
|
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