diff --git a/.DS_Store b/.DS_Store index 39928e3..ec39ca3 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/DEPLOYMENT_AUDIO_FEATURES.md b/DEPLOYMENT_AUDIO_FEATURES.md new file mode 100644 index 0000000..037da92 --- /dev/null +++ b/DEPLOYMENT_AUDIO_FEATURES.md @@ -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子查询获取 diff --git a/app.zip b/app.zip deleted file mode 100644 index 92ba9aa..0000000 Binary files a/app.zip and /dev/null differ diff --git a/app/api/endpoints/admin_dashboard.py b/app/api/endpoints/admin_dashboard.py index 79dc4ed..a0129f4 100644 --- a/app/api/endpoints/admin_dashboard.py +++ b/app/api/endpoints/admin_dashboard.py @@ -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)}") diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py index 710d433..82355db 100644 --- a/app/api/endpoints/auth.py +++ b/app/api/endpoints/auth.py @@ -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() ) diff --git a/app/api/endpoints/meetings.py b/app/api/endpoints/meetings.py index 0ddff02..9df41bf 100644 --- a/app/api/endpoints/meetings.py +++ b/app/api/endpoints/meetings.py @@ -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" } ) diff --git a/app/models/models.py b/app/models/models.py index 1a6ae9a..b0a61e2 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -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 diff --git a/app/services/audio_service.py b/app/services/audio_service.py index 5142699..778c338 100644 --- a/app/services/audio_service.py +++ b/app/services/audio_service.py @@ -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() diff --git a/app/utils/audio_parser.py b/app/utils/audio_parser.py new file mode 100644 index 0000000..442d21f --- /dev/null +++ b/app/utils/audio_parser.py @@ -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 diff --git a/requirements-prod.txt b/requirements-prod.txt index 475ac80..3d94fd4 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -21,3 +21,6 @@ psutil # APK Parsing pyaxmlparser + +# Audio Processing +mutagen==1.47.0 diff --git a/sql/.DS_Store b/sql/.DS_Store new file mode 100644 index 0000000..2e46cac Binary files /dev/null and b/sql/.DS_Store differ diff --git a/sql/migrations/add_duration_to_audio_files.sql b/sql/migrations/add_duration_to_audio_files.sql new file mode 100644 index 0000000..b90af46 --- /dev/null +++ b/sql/migrations/add_duration_to_audio_files.sql @@ -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; diff --git a/sql/migrations/create_user_logs_table.sql b/sql/migrations/create_user_logs_table.sql new file mode 100644 index 0000000..bf06b1a --- /dev/null +++ b/sql/migrations/create_user_logs_table.sql @@ -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;