diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index d579d95..493e656 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -1,391 +1,52 @@ from fastapi import APIRouter, Depends, Query + from app.core.auth import get_current_admin_user, get_current_user -from app.core.response import create_api_response -from app.core.database import get_db_connection from app.models.models import ( - MenuInfo, - MenuListResponse, - RolePermissionInfo, - UpdateRolePermissionsRequest, - RoleInfo, CreateMenuRequest, - UpdateMenuRequest, CreateRoleRequest, + UpdateMenuRequest, + UpdateRolePermissionsRequest, UpdateRoleRequest, ) -from typing import List -import time +import app.services.admin_service as admin_service + router = APIRouter() -_USER_MENU_CACHE_TTL_SECONDS = 120 -_USER_MENU_CACHE_VERSION = "menu-rules-v4" -_user_menu_cache_by_role = {} -def _get_cached_user_menus(role_id: int): - cached = _user_menu_cache_by_role.get(role_id) - if not cached: - return None - if cached.get("version") != _USER_MENU_CACHE_VERSION: - _user_menu_cache_by_role.pop(role_id, None) - return None - if time.time() > cached["expires_at"]: - _user_menu_cache_by_role.pop(role_id, None) - return None - return cached["menus"] - - -def _set_cached_user_menus(role_id: int, menus): - _user_menu_cache_by_role[role_id] = { - "version": _USER_MENU_CACHE_VERSION, - "menus": menus, - "expires_at": time.time() + _USER_MENU_CACHE_TTL_SECONDS, - } - - -def _invalidate_user_menu_cache(role_id: int | None = None): - if role_id is None: - _user_menu_cache_by_role.clear() - return - _user_menu_cache_by_role.pop(role_id, None) - - -def _build_menu_index(menus): - menu_by_id = {} - children_by_parent = {} - for menu in menus: - menu_id = menu["menu_id"] - menu_by_id[menu_id] = menu - parent_id = menu.get("parent_id") - if parent_id is not None: - children_by_parent.setdefault(parent_id, []).append(menu_id) - return menu_by_id, children_by_parent - - -def _get_descendants(menu_id, children_by_parent): - result = set() - stack = [menu_id] - while stack: - current = stack.pop() - for child_id in children_by_parent.get(current, []): - if child_id in result: - continue - result.add(child_id) - stack.append(child_id) - return result - - -def _normalize_permission_menu_ids(raw_menu_ids, all_menus): - """ - 对权限菜单ID做归一化: - 1. 选中父节点 => 自动包含全部子孙节点 - 2. 选中子节点 => 自动包含全部祖先节点 - """ - menu_by_id, children_by_parent = _build_menu_index(all_menus) - selected = {menu_id for menu_id in raw_menu_ids if menu_id in menu_by_id} - - expanded = set(selected) - - # 父 -> 子孙 - for menu_id in list(expanded): - expanded.update(_get_descendants(menu_id, children_by_parent)) - - # 子 -> 祖先 - for menu_id in list(expanded): - cursor = menu_by_id[menu_id].get("parent_id") - while cursor is not None and cursor in menu_by_id: - if cursor in expanded: - break - expanded.add(cursor) - cursor = menu_by_id[cursor].get("parent_id") - - return sorted(expanded) - -# ========== 菜单权限管理接口 ========== - @router.get("/admin/menus") async def get_all_menus(current_user=Depends(get_current_admin_user)): - """ - 获取所有菜单列表 - 只有管理员才能访问 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - query = """ - SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, - parent_id, sort_order, is_active, description, created_at, updated_at - FROM sys_menus - ORDER BY - COALESCE(parent_id, menu_id) ASC, - CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END ASC, - sort_order ASC, - menu_id ASC - """ - cursor.execute(query) - menus = cursor.fetchall() - - menu_list = [MenuInfo(**menu) for menu in menus] - - return create_api_response( - code="200", - message="获取菜单列表成功", - data=MenuListResponse(menus=menu_list, total=len(menu_list)) - ) - except Exception as e: - return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}") + return admin_service.get_all_menus() @router.post("/admin/menus") async def create_menu(request: CreateMenuRequest, current_user=Depends(get_current_admin_user)): - """ - 创建菜单 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_code = %s", (request.menu_code,)) - if cursor.fetchone(): - return create_api_response(code="400", message="菜单编码已存在") - - if request.parent_id is not None: - cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,)) - if not cursor.fetchone(): - return create_api_response(code="400", message="父菜单不存在") - - cursor.execute( - """ - INSERT INTO sys_menus (menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - request.menu_code, - request.menu_name, - request.menu_icon, - request.menu_url, - request.menu_type, - request.parent_id, - request.sort_order, - 1 if request.is_active else 0, - request.description, - ), - ) - menu_id = cursor.lastrowid - connection.commit() - _invalidate_user_menu_cache() - - cursor.execute( - """ - SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, - parent_id, sort_order, is_active, description, created_at, updated_at - FROM sys_menus - WHERE menu_id = %s - """, - (menu_id,), - ) - created = cursor.fetchone() - return create_api_response(code="200", message="创建菜单成功", data=created) - except Exception as e: - return create_api_response(code="500", message=f"创建菜单失败: {str(e)}") + return admin_service.create_menu(request) @router.put("/admin/menus/{menu_id}") async def update_menu(menu_id: int, request: UpdateMenuRequest, current_user=Depends(get_current_admin_user)): - """ - 更新菜单 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - cursor.execute("SELECT * FROM sys_menus WHERE menu_id = %s", (menu_id,)) - current = cursor.fetchone() - if not current: - return create_api_response(code="404", message="菜单不存在") - - updates = {} - for field in [ - "menu_code", - "menu_name", - "menu_icon", - "menu_url", - "menu_type", - "sort_order", - "description", - ]: - value = getattr(request, field) - if value is not None: - updates[field] = value - - if request.is_active is not None: - updates["is_active"] = 1 if request.is_active else 0 - - fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set())) - - # parent_id 允许设为 null,且不允许设为自己 - if request.parent_id == menu_id: - return create_api_response(code="400", message="父菜单不能为自身") - if request.parent_id is not None: - cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,)) - if not cursor.fetchone(): - return create_api_response(code="400", message="父菜单不存在") - - # 防止形成环:父菜单不能是当前菜单的子孙 - cursor.execute("SELECT menu_id, parent_id FROM sys_menus") - all_menus = cursor.fetchall() - _, children_by_parent = _build_menu_index(all_menus) - descendants = _get_descendants(menu_id, children_by_parent) - if request.parent_id in descendants: - return create_api_response(code="400", message="父菜单不能设置为当前菜单的子孙菜单") - - if request.parent_id is not None or (request.parent_id is None and "parent_id" in fields_set): - updates["parent_id"] = request.parent_id - - if "menu_code" in updates: - cursor.execute( - "SELECT menu_id FROM sys_menus WHERE menu_code = %s AND menu_id != %s", - (updates["menu_code"], menu_id), - ) - if cursor.fetchone(): - return create_api_response(code="400", message="菜单编码已存在") - - if not updates: - return create_api_response(code="200", message="没有变更内容", data=current) - - set_sql = ", ".join([f"{k} = %s" for k in updates.keys()]) - values = list(updates.values()) + [menu_id] - cursor.execute(f"UPDATE sys_menus SET {set_sql} WHERE menu_id = %s", tuple(values)) - connection.commit() - _invalidate_user_menu_cache() - - cursor.execute( - """ - SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, - parent_id, sort_order, is_active, description, created_at, updated_at - FROM sys_menus - WHERE menu_id = %s - """, - (menu_id,), - ) - updated = cursor.fetchone() - return create_api_response(code="200", message="更新菜单成功", data=updated) - except Exception as e: - return create_api_response(code="500", message=f"更新菜单失败: {str(e)}") + return admin_service.update_menu(menu_id, request) @router.delete("/admin/menus/{menu_id}") async def delete_menu(menu_id: int, current_user=Depends(get_current_admin_user)): - """ - 删除菜单(有子菜单时不允许删除) - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) + return admin_service.delete_menu(menu_id) - cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (menu_id,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="菜单不存在") - - cursor.execute("SELECT COUNT(*) AS cnt FROM sys_menus WHERE parent_id = %s", (menu_id,)) - child_count = cursor.fetchone()["cnt"] - if child_count > 0: - return create_api_response(code="400", message="请先删除子菜单") - - cursor.execute("DELETE FROM sys_role_menu_permissions WHERE menu_id = %s", (menu_id,)) - cursor.execute("DELETE FROM sys_menus WHERE menu_id = %s", (menu_id,)) - connection.commit() - _invalidate_user_menu_cache() - return create_api_response(code="200", message="删除菜单成功") - except Exception as e: - return create_api_response(code="500", message=f"删除菜单失败: {str(e)}") @router.get("/admin/roles") async def get_all_roles(current_user=Depends(get_current_admin_user)): - """ - 获取所有角色列表及其权限统计 - 只有管理员才能访问 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 查询所有角色及其权限数量 - query = """ - SELECT r.role_id, r.role_name, r.created_at, - COUNT(rmp.menu_id) as menu_count - FROM sys_roles r - LEFT JOIN sys_role_menu_permissions rmp ON r.role_id = rmp.role_id - GROUP BY r.role_id - ORDER BY r.role_id ASC - """ - cursor.execute(query) - roles = cursor.fetchall() - - return create_api_response( - code="200", - message="获取角色列表成功", - data={"roles": roles, "total": len(roles)} - ) - except Exception as e: - return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}") + return admin_service.get_all_roles() @router.post("/admin/roles") async def create_role(request: CreateRoleRequest, current_user=Depends(get_current_admin_user)): - """ - 创建角色 - """ - try: - role_name = request.role_name.strip() - if not role_name: - return create_api_response(code="400", message="角色名称不能为空") - - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s", (role_name,)) - if cursor.fetchone(): - return create_api_response(code="400", message="角色名称已存在") - - cursor.execute("INSERT INTO sys_roles (role_name) VALUES (%s)", (role_name,)) - role_id = cursor.lastrowid - connection.commit() - - cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,)) - role = cursor.fetchone() - return create_api_response(code="200", message="创建角色成功", data=role) - except Exception as e: - return create_api_response(code="500", message=f"创建角色失败: {str(e)}") + return admin_service.create_role(request) @router.put("/admin/roles/{role_id}") async def update_role(role_id: int, request: UpdateRoleRequest, current_user=Depends(get_current_admin_user)): - """ - 更新角色 - """ - try: - role_name = request.role_name.strip() - if not role_name: - return create_api_response(code="400", message="角色名称不能为空") - - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="角色不存在") - - cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s AND role_id != %s", (role_name, role_id)) - if cursor.fetchone(): - return create_api_response(code="400", message="角色名称已存在") - - cursor.execute("UPDATE sys_roles SET role_name = %s WHERE role_id = %s", (role_name, role_id)) - connection.commit() - - cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,)) - role = cursor.fetchone() - return create_api_response(code="200", message="更新角色成功", data=role) - except Exception as e: - return create_api_response(code="500", message=f"更新角色失败: {str(e)}") + return admin_service.update_role(role_id, request) @router.get("/admin/roles/{role_id}/users") @@ -395,253 +56,28 @@ async def get_role_users( size: int = Query(10, ge=1, le=100), current_user=Depends(get_current_admin_user), ): - """ - 获取角色下用户列表 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,)) - role = cursor.fetchone() - if not role: - return create_api_response(code="404", message="角色不存在") - - cursor.execute( - """ - SELECT COUNT(*) AS total - FROM sys_users - WHERE role_id = %s - """, - (role_id,), - ) - total = cursor.fetchone()["total"] - offset = (page - 1) * size - - cursor.execute( - """ - SELECT user_id, username, caption, email, avatar_url, role_id, created_at - FROM sys_users - WHERE role_id = %s - ORDER BY user_id ASC - LIMIT %s OFFSET %s - """, - (role_id, size, offset), - ) - users = cursor.fetchall() - return create_api_response( - code="200", - message="获取角色用户成功", - data={ - "role_id": role_id, - "role_name": role["role_name"], - "users": users, - "total": total, - "page": page, - "size": size, - }, - ) - except Exception as e: - return create_api_response(code="500", message=f"获取角色用户失败: {str(e)}") + return admin_service.get_role_users(role_id, page, size) @router.get("/admin/roles/permissions/all") async def get_all_role_permissions(current_user=Depends(get_current_admin_user)): - """ - 批量获取所有角色权限(用于减少N次请求) - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute( - """ - SELECT rmp.role_id, rmp.menu_id - FROM sys_role_menu_permissions rmp - JOIN sys_menus m ON m.menu_id = rmp.menu_id - WHERE m.is_active = 1 - ORDER BY rmp.role_id ASC, rmp.menu_id ASC - """ - ) - rows = cursor.fetchall() + return admin_service.get_all_role_permissions() - result = {} - for row in rows: - result.setdefault(row["role_id"], []).append(row["menu_id"]) - return create_api_response(code="200", message="获取角色权限成功", data={"permissions": result}) - except Exception as e: - return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}") @router.get("/admin/roles/{role_id}/permissions") async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)): - """ - 获取指定角色的菜单权限 - 只有管理员才能访问 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) + return admin_service.get_role_permissions(role_id) - # 检查角色是否存在 - cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,)) - role = cursor.fetchone() - if not role: - return create_api_response(code="404", message="角色不存在") - - # 查询该角色的所有菜单权限 - query = """ - SELECT rmp.menu_id - FROM sys_role_menu_permissions rmp - JOIN sys_menus m ON m.menu_id = rmp.menu_id - WHERE rmp.role_id = %s - AND m.is_active = 1 - """ - cursor.execute(query, (role_id,)) - permissions = cursor.fetchall() - - menu_ids = [p['menu_id'] for p in permissions] - - return create_api_response( - code="200", - message="获取角色权限成功", - data=RolePermissionInfo( - role_id=role['role_id'], - role_name=role['role_name'], - menu_ids=menu_ids - ) - ) - except Exception as e: - return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}") @router.put("/admin/roles/{role_id}/permissions") async def update_role_permissions( role_id: int, request: UpdateRolePermissionsRequest, - current_user=Depends(get_current_admin_user) + current_user=Depends(get_current_admin_user), ): - """ - 更新指定角色的菜单权限 - 只有管理员才能访问 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) + return admin_service.update_role_permissions(role_id, request) - # 检查角色是否存在 - cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="角色不存在") - - cursor.execute( - """ - SELECT menu_id, parent_id - FROM sys_menus - WHERE is_active = 1 - """ - ) - all_menus = cursor.fetchall() - menu_id_set = {menu["menu_id"] for menu in all_menus} - - # 验证所有menu_id是否有效 - invalid_menu_ids = [menu_id for menu_id in request.menu_ids if menu_id not in menu_id_set] - if invalid_menu_ids: - return create_api_response(code="400", message="包含无效的菜单ID") - - normalized_menu_ids = _normalize_permission_menu_ids(request.menu_ids, all_menus) - - # 删除该角色的所有现有权限 - cursor.execute("DELETE FROM sys_role_menu_permissions WHERE role_id = %s", (role_id,)) - - # 插入新的权限 - if normalized_menu_ids: - insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids] - cursor.executemany( - "INSERT INTO sys_role_menu_permissions (role_id, menu_id) VALUES (%s, %s)", - insert_values - ) - - connection.commit() - _invalidate_user_menu_cache(role_id) - - return create_api_response( - code="200", - message="更新角色权限成功", - data={"role_id": role_id, "menu_count": len(normalized_menu_ids)} - ) - except Exception as e: - return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}") @router.get("/menus/user") async def get_user_menus(current_user=Depends(get_current_user)): - """ - 获取当前用户可访问的菜单列表(用于渲染下拉菜单) - 所有登录用户都可以访问 - """ - try: - role_id = current_user["role_id"] - cached_menus = _get_cached_user_menus(role_id) - if cached_menus is not None: - return create_api_response( - code="200", - message="获取用户菜单成功", - data={"menus": cached_menus} - ) - - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 根据用户的role_id查询可访问的菜单 - query = """ - SELECT m.menu_id, m.menu_code, m.menu_name, m.menu_icon, - m.menu_url, m.menu_type, m.parent_id, m.sort_order - FROM sys_menus m - JOIN sys_role_menu_permissions rmp ON m.menu_id = rmp.menu_id - WHERE rmp.role_id = %s - AND m.is_active = 1 - AND (m.is_visible = 1 OR m.is_visible IS NULL OR m.menu_code IN ('dashboard', 'desktop')) - ORDER BY - COALESCE(m.parent_id, m.menu_id) ASC, - CASE WHEN m.parent_id IS NULL THEN 0 ELSE 1 END ASC, - m.sort_order ASC, - m.menu_id ASC - """ - cursor.execute(query, (role_id,)) - menus = cursor.fetchall() - - # 仅在缺失父菜单时补查,减少不必要的SQL - current_menu_ids = {menu["menu_id"] for menu in menus} - missing_parent_ids = { - menu["parent_id"] for menu in menus - if menu.get("parent_id") is not None and menu["parent_id"] not in current_menu_ids - } - - if missing_parent_ids: - format_strings = ",".join(["%s"] * len(missing_parent_ids)) - cursor.execute( - f""" - SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order - FROM sys_menus - WHERE is_active = 1 AND menu_id IN ({format_strings}) - """, - tuple(missing_parent_ids), - ) - parent_rows = cursor.fetchall() - menus.extend(parent_rows) - current_menu_ids.update(row["menu_id"] for row in parent_rows) - - menus = sorted( - {menu["menu_id"]: menu for menu in menus}.values(), - key=lambda m: ( - m["parent_id"] if m["parent_id"] is not None else m["menu_id"], - 0 if m["parent_id"] is None else 1, - m["sort_order"], - m["menu_id"], - ), - ) - _set_cached_user_menus(role_id, menus) - - return create_api_response( - code="200", - message="获取用户菜单成功", - data={"menus": menus} - ) - except Exception as e: - return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}") + return admin_service.get_user_menus(current_user) diff --git a/backend/app/api/endpoints/admin_dashboard.py b/backend/app/api/endpoints/admin_dashboard.py index 2317619..3130e62 100644 --- a/backend/app/api/endpoints/admin_dashboard.py +++ b/backend/app/api/endpoints/admin_dashboard.py @@ -1,281 +1,25 @@ from fastapi import APIRouter, Depends, Query + from app.core.auth import get_current_admin_user -from app.core.response import create_api_response -from app.core.database import get_db_connection -from app.services.jwt_service import jwt_service -from app.core.config import AUDIO_DIR, REDIS_CONFIG -from datetime import datetime -from typing import Dict, List -import os -import redis +import app.services.admin_dashboard_service as admin_dashboard_service + router = APIRouter() -# Redis 客户端 -redis_client = redis.Redis(**REDIS_CONFIG) - -# 常量定义 -AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.mpeg', '.mp4', '.webm') -BYTES_TO_GB = 1024 ** 3 - - -def _build_status_condition(status: str) -> str: - """构建任务状态查询条件""" - if status == 'running': - return "AND (t.status = 'pending' OR t.status = 'processing')" - elif status == 'completed': - return "AND t.status = 'completed'" - elif status == 'failed': - return "AND t.status = 'failed'" - return "" - - -def _get_task_stats_query() -> str: - """获取任务统计的 SQL 查询""" - return """ - SELECT - COUNT(*) as total, - SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, - SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed - """ - - -def _get_online_user_count(redis_client) -> int: - """从 Redis 获取在线用户数""" - try: - token_keys = redis_client.keys("token:*") - user_ids = set() - for key in token_keys: - if isinstance(key, bytes): - key = key.decode("utf-8", errors="ignore") - parts = key.split(':') - if len(parts) >= 2: - user_ids.add(parts[1]) - return len(user_ids) - except Exception as e: - print(f"获取在线用户数失败: {e}") - return 0 - - -def _table_exists(cursor, table_name: str) -> bool: - cursor.execute( - """ - SELECT COUNT(*) AS cnt - FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = %s - """, - (table_name,), - ) - return (cursor.fetchone() or {}).get("cnt", 0) > 0 - - -def _calculate_audio_storage() -> Dict[str, float]: - """计算音频文件存储统计""" - audio_files_count = 0 - audio_total_size = 0 - - try: - if os.path.exists(AUDIO_DIR): - for root, _, files in os.walk(AUDIO_DIR): - for file in files: - file_extension = os.path.splitext(file)[1].lower() - if file_extension in AUDIO_FILE_EXTENSIONS: - audio_files_count += 1 - file_path = os.path.join(root, file) - try: - audio_total_size += os.path.getsize(file_path) - except OSError: - continue - except Exception as e: - print(f"统计音频文件失败: {e}") - - return { - "audio_file_count": audio_files_count, - "audio_files_count": audio_files_count, - "audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2) - } - @router.get("/admin/dashboard/stats") async def get_dashboard_stats(current_user=Depends(get_current_admin_user)): - """获取管理员 Dashboard 统计数据""" - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 1. 用户统计 - today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - total_users = 0 - today_new_users = 0 - - if _table_exists(cursor, "sys_users"): - cursor.execute("SELECT COUNT(*) as total FROM sys_users") - total_users = (cursor.fetchone() or {}).get("total", 0) - - cursor.execute( - "SELECT COUNT(*) as count FROM sys_users WHERE created_at >= %s", - (today_start,), - ) - today_new_users = (cursor.fetchone() or {}).get("count", 0) - - online_users = _get_online_user_count(redis_client) - - # 2. 会议统计 - total_meetings = 0 - today_new_meetings = 0 - if _table_exists(cursor, "meetings"): - cursor.execute("SELECT COUNT(*) as total FROM meetings") - total_meetings = (cursor.fetchone() or {}).get("total", 0) - - cursor.execute( - "SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s", - (today_start,), - ) - today_new_meetings = (cursor.fetchone() or {}).get("count", 0) - - # 3. 任务统计 - task_stats_query = _get_task_stats_query() - - # 转录任务 - if _table_exists(cursor, "transcript_tasks"): - cursor.execute(f"{task_stats_query} FROM transcript_tasks") - transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} - else: - transcription_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} - - # 总结任务 - if _table_exists(cursor, "llm_tasks"): - cursor.execute(f"{task_stats_query} FROM llm_tasks") - summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} - else: - summary_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} - - # 知识库任务 - if _table_exists(cursor, "knowledge_base_tasks"): - cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks") - kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} - else: - kb_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} - - # 4. 音频存储统计 - storage_stats = _calculate_audio_storage() - - # 组装返回数据 - stats = { - "users": { - "total": total_users, - "today_new": today_new_users, - "online": online_users - }, - "meetings": { - "total": total_meetings, - "today_new": today_new_meetings - }, - "tasks": { - "transcription": { - "total": transcription_stats['total'] or 0, - "running": transcription_stats['running'] or 0, - "completed": transcription_stats['completed'] or 0, - "failed": transcription_stats['failed'] or 0 - }, - "summary": { - "total": summary_stats['total'] or 0, - "running": summary_stats['running'] or 0, - "completed": summary_stats['completed'] or 0, - "failed": summary_stats['failed'] or 0 - }, - "knowledge_base": { - "total": kb_stats['total'] or 0, - "running": kb_stats['running'] or 0, - "completed": kb_stats['completed'] or 0, - "failed": kb_stats['failed'] or 0 - } - }, - "storage": storage_stats - } - - return create_api_response(code="200", message="获取统计数据成功", data=stats) - - except Exception as e: - print(f"获取Dashboard统计数据失败: {e}") - return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}") + return await admin_dashboard_service.get_dashboard_stats(current_user) @router.get("/admin/online-users") async def get_online_users(current_user=Depends(get_current_admin_user)): - """获取在线用户列表""" - try: - token_keys = redis_client.keys("token:*") - - # 提取用户ID并去重 - user_tokens = {} - for key in token_keys: - if isinstance(key, bytes): - key = key.decode("utf-8", errors="ignore") - parts = key.split(':') - if len(parts) >= 3: - user_id = int(parts[1]) - token = parts[2] - if user_id not in user_tokens: - user_tokens[user_id] = [] - user_tokens[user_id].append({'token': token, 'key': key}) - - # 查询用户信息 - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - online_users_list = [] - for user_id, tokens in user_tokens.items(): - cursor.execute( - "SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s", - (user_id,) - ) - user = cursor.fetchone() - if user: - ttl_seconds = redis_client.ttl(tokens[0]['key']) - online_users_list.append({ - **user, - 'token_count': len(tokens), - 'ttl_seconds': ttl_seconds, - 'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0 - }) - - # 按用户ID排序 - online_users_list.sort(key=lambda x: x['user_id']) - - return create_api_response( - code="200", - message="获取在线用户列表成功", - data={"users": online_users_list, "total": len(online_users_list)} - ) - - except Exception as e: - print(f"获取在线用户列表失败: {e}") - return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}") + return await admin_dashboard_service.get_online_users(current_user) @router.post("/admin/kick-user/{user_id}") async def kick_user(user_id: int, current_user=Depends(get_current_admin_user)): - """踢出用户(撤销该用户的所有 token)""" - try: - revoked_count = jwt_service.revoke_all_user_tokens(user_id) - - if revoked_count > 0: - return create_api_response( - code="200", - message=f"已踢出用户,撤销了 {revoked_count} 个 token", - data={"user_id": user_id, "revoked_count": revoked_count} - ) - else: - return create_api_response( - code="404", - message="该用户当前不在线或未找到 token" - ) - - except Exception as e: - print(f"踢出用户失败: {e}") - return create_api_response(code="500", message=f"踢出用户失败: {str(e)}") + return await admin_dashboard_service.kick_user(user_id, current_user) @router.get("/admin/tasks/monitor") @@ -285,207 +29,14 @@ async def monitor_tasks( limit: int = Query(20, ge=1, le=100, description="返回数量限制"), current_user=Depends(get_current_admin_user) ): - """监控任务进度""" - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - tasks = [] - status_condition = _build_status_condition(status) - - # 转录任务 - if task_type in ['all', 'transcription']: - query = f""" - SELECT - t.task_id, - 'transcription' as task_type, - t.meeting_id, - m.title as meeting_title, - t.status, - t.progress, - t.error_message, - t.created_at, - t.completed_at, - u.username as creator_name - FROM transcript_tasks t - LEFT JOIN meetings m ON t.meeting_id = m.meeting_id - LEFT JOIN sys_users u ON m.user_id = u.user_id - WHERE 1=1 {status_condition} - ORDER BY t.created_at DESC - LIMIT %s - """ - cursor.execute(query, (limit,)) - tasks.extend(cursor.fetchall()) - - # 总结任务 - if task_type in ['all', 'summary']: - query = f""" - SELECT - t.task_id, - 'summary' as task_type, - t.meeting_id, - m.title as meeting_title, - t.status, - t.progress, - t.error_message, - t.created_at, - t.completed_at, - u.username as creator_name - FROM llm_tasks t - LEFT JOIN meetings m ON t.meeting_id = m.meeting_id - LEFT JOIN sys_users u ON m.user_id = u.user_id - WHERE 1=1 {status_condition} - ORDER BY t.created_at DESC - LIMIT %s - """ - cursor.execute(query, (limit,)) - tasks.extend(cursor.fetchall()) - - # 知识库任务 - if task_type in ['all', 'knowledge_base']: - query = f""" - SELECT - t.task_id, - 'knowledge_base' as task_type, - t.kb_id as meeting_id, - k.title as meeting_title, - t.status, - t.progress, - t.error_message, - t.created_at, - t.updated_at, - u.username as creator_name - FROM knowledge_base_tasks t - LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id - LEFT JOIN sys_users u ON k.creator_id = u.user_id - WHERE 1=1 {status_condition} - ORDER BY t.created_at DESC - LIMIT %s - """ - cursor.execute(query, (limit,)) - tasks.extend(cursor.fetchall()) - - # 按创建时间排序并限制返回数量 - tasks.sort(key=lambda x: x['created_at'], reverse=True) - tasks = tasks[:limit] - - return create_api_response( - code="200", - message="获取任务监控数据成功", - data={"tasks": tasks, "total": len(tasks)} - ) - - except Exception as e: - print(f"获取任务监控数据失败: {e}") - import traceback - traceback.print_exc() - return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}") + return await admin_dashboard_service.monitor_tasks(task_type, status, limit, current_user) @router.get("/admin/system/resources") async def get_system_resources(current_user=Depends(get_current_admin_user)): - """获取服务器资源使用情况""" - try: - import psutil - - # CPU 使用率 - cpu_percent = psutil.cpu_percent(interval=1) - cpu_count = psutil.cpu_count() - - # 内存使用情况 - memory = psutil.virtual_memory() - memory_total_gb = round(memory.total / BYTES_TO_GB, 2) - memory_used_gb = round(memory.used / BYTES_TO_GB, 2) - - # 磁盘使用情况 - disk = psutil.disk_usage('/') - disk_total_gb = round(disk.total / BYTES_TO_GB, 2) - disk_used_gb = round(disk.used / BYTES_TO_GB, 2) - - resources = { - "cpu": { - "percent": cpu_percent, - "count": cpu_count - }, - "memory": { - "total_gb": memory_total_gb, - "used_gb": memory_used_gb, - "percent": memory.percent - }, - "disk": { - "total_gb": disk_total_gb, - "used_gb": disk_used_gb, - "percent": disk.percent - }, - "timestamp": datetime.now().isoformat() - } - - return create_api_response(code="200", message="获取系统资源成功", data=resources) - - except ImportError: - return create_api_response( - code="500", - message="psutil 库未安装,请运行: pip install psutil" - ) - except Exception as e: - print(f"获取系统资源失败: {e}") - return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}") + return await admin_dashboard_service.get_system_resources(current_user) @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 sys_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)}") + return await admin_dashboard_service.get_user_stats(current_user) diff --git a/backend/app/api/endpoints/admin_settings.py b/backend/app/api/endpoints/admin_settings.py index 03a9ee4..89065e3 100644 --- a/backend/app/api/endpoints/admin_settings.py +++ b/backend/app/api/endpoints/admin_settings.py @@ -1,215 +1,13 @@ -import json from typing import Any from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from app.core.auth import get_current_admin_user -from app.core.database import get_db_connection -from app.core.response import create_api_response -from app.services.async_transcription_service import AsyncTranscriptionService -from app.services.llm_service import LLMService -from app.services.system_config_service import SystemConfigService +import app.services.admin_settings_service as admin_settings_service + router = APIRouter() -llm_service = LLMService() -transcription_service = AsyncTranscriptionService() - - -def _parse_json_object(value: Any) -> dict[str, Any]: - if value is None: - return {} - if isinstance(value, dict): - return dict(value) - if isinstance(value, str): - value = value.strip() - if not value: - return {} - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except json.JSONDecodeError: - return {} - return {} - - -def _normalize_string_list(value: Any) -> list[str] | None: - if value is None: - return None - if isinstance(value, list): - values = [str(item).strip() for item in value if str(item).strip()] - return values or None - if isinstance(value, str): - values = [item.strip() for item in value.split(",") if item.strip()] - return values or None - return None - - -def _normalize_int_list(value: Any) -> list[int] | None: - if value is None: - return None - if isinstance(value, list): - items = value - elif isinstance(value, str): - items = [item.strip() for item in value.split(",") if item.strip()] - else: - return None - - normalized = [] - for item in items: - try: - normalized.append(int(item)) - except (TypeError, ValueError): - continue - return normalized or None - - -def _clean_extra_config(config: dict[str, Any]) -> dict[str, Any]: - cleaned: dict[str, Any] = {} - for key, value in (config or {}).items(): - if value is None: - continue - if isinstance(value, str): - stripped = value.strip() - if stripped: - cleaned[key] = stripped - continue - if isinstance(value, list): - normalized_list = [] - for item in value: - if item is None: - continue - if isinstance(item, str): - stripped = item.strip() - if stripped: - normalized_list.append(stripped) - else: - normalized_list.append(item) - if normalized_list: - cleaned[key] = normalized_list - continue - cleaned[key] = value - return cleaned - - -def _merge_audio_extra_config( - request: "AudioModelUpsertRequest", - vocabulary_id: str | None = None, -) -> dict[str, Any]: - extra_config = _parse_json_object(request.extra_config) - - if request.audio_scene == "asr": - legacy_config = { - "model": request.asr_model_name, - "speaker_count": request.asr_speaker_count, - "language_hints": request.asr_language_hints, - "disfluency_removal_enabled": request.asr_disfluency_removal_enabled, - "diarization_enabled": request.asr_diarization_enabled, - } - else: - legacy_config = { - "model": request.model_name, - "template_text": request.vp_template_text, - "duration_seconds": request.vp_duration_seconds, - "sample_rate": request.vp_sample_rate, - "channels": request.vp_channels, - "max_size_bytes": request.vp_max_size_bytes, - } - - merged = {**legacy_config, **extra_config} - - language_hints = _normalize_string_list(merged.get("language_hints")) - if language_hints is not None: - merged["language_hints"] = language_hints - - channel_id = _normalize_int_list(merged.get("channel_id")) - if channel_id is not None: - merged["channel_id"] = channel_id - - resolved_vocabulary_id = vocabulary_id or merged.get("vocabulary_id") or request.asr_vocabulary_id - if request.audio_scene == "asr" and resolved_vocabulary_id: - merged["vocabulary_id"] = resolved_vocabulary_id - - return _clean_extra_config(merged) - - -def _extract_legacy_audio_columns(audio_scene: str, extra_config: dict[str, Any]) -> dict[str, Any]: - extra_config = _parse_json_object(extra_config) - columns = { - "asr_model_name": None, - "asr_vocabulary_id": None, - "asr_speaker_count": None, - "asr_language_hints": None, - "asr_disfluency_removal_enabled": None, - "asr_diarization_enabled": None, - "vp_template_text": None, - "vp_duration_seconds": None, - "vp_sample_rate": None, - "vp_channels": None, - "vp_max_size_bytes": None, - } - - if audio_scene == "asr": - language_hints = extra_config.get("language_hints") - if isinstance(language_hints, list): - language_hints = ",".join(str(item).strip() for item in language_hints if str(item).strip()) - columns.update( - { - "asr_model_name": extra_config.get("model"), - "asr_vocabulary_id": extra_config.get("vocabulary_id"), - "asr_speaker_count": extra_config.get("speaker_count"), - "asr_language_hints": language_hints, - "asr_disfluency_removal_enabled": 1 if extra_config.get("disfluency_removal_enabled") is True else 0 if extra_config.get("disfluency_removal_enabled") is False else None, - "asr_diarization_enabled": 1 if extra_config.get("diarization_enabled") is True else 0 if extra_config.get("diarization_enabled") is False else None, - } - ) - else: - columns.update( - { - "vp_template_text": extra_config.get("template_text"), - "vp_duration_seconds": extra_config.get("duration_seconds"), - "vp_sample_rate": extra_config.get("sample_rate"), - "vp_channels": extra_config.get("channels"), - "vp_max_size_bytes": extra_config.get("max_size_bytes"), - } - ) - - return columns - - -def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]: - extra_config = _parse_json_object(row.get("extra_config")) - - if row.get("audio_scene") == "asr": - if extra_config.get("model") is None and row.get("asr_model_name") is not None: - extra_config["model"] = row["asr_model_name"] - if extra_config.get("vocabulary_id") is None and row.get("asr_vocabulary_id") is not None: - extra_config["vocabulary_id"] = row["asr_vocabulary_id"] - if extra_config.get("speaker_count") is None and row.get("asr_speaker_count") is not None: - extra_config["speaker_count"] = row["asr_speaker_count"] - if extra_config.get("language_hints") is None and row.get("asr_language_hints"): - extra_config["language_hints"] = _normalize_string_list(row["asr_language_hints"]) - if extra_config.get("disfluency_removal_enabled") is None and row.get("asr_disfluency_removal_enabled") is not None: - extra_config["disfluency_removal_enabled"] = bool(row["asr_disfluency_removal_enabled"]) - if extra_config.get("diarization_enabled") is None and row.get("asr_diarization_enabled") is not None: - extra_config["diarization_enabled"] = bool(row["asr_diarization_enabled"]) - else: - if extra_config.get("model") is None and row.get("model_name"): - extra_config["model"] = row["model_name"] - if extra_config.get("template_text") is None and row.get("vp_template_text") is not None: - extra_config["template_text"] = row["vp_template_text"] - if extra_config.get("duration_seconds") is None and row.get("vp_duration_seconds") is not None: - extra_config["duration_seconds"] = row["vp_duration_seconds"] - if extra_config.get("sample_rate") is None and row.get("vp_sample_rate") is not None: - extra_config["sample_rate"] = row["vp_sample_rate"] - if extra_config.get("channels") is None and row.get("vp_channels") is not None: - extra_config["channels"] = row["vp_channels"] - if extra_config.get("max_size_bytes") is None and row.get("vp_max_size_bytes") is not None: - extra_config["max_size_bytes"] = row["vp_max_size_bytes"] - - row["extra_config"] = extra_config - row["service_model_name"] = extra_config.get("model") - return row class ParameterUpsertRequest(BaseModel): @@ -242,7 +40,7 @@ class LLMModelUpsertRequest(BaseModel): class AudioModelUpsertRequest(BaseModel): model_code: str model_name: str - audio_scene: str # asr / voiceprint + audio_scene: str provider: str | None = None endpoint_url: str | None = None api_key: str | None = None @@ -278,89 +76,17 @@ async def list_parameters( keyword: str | None = Query(None), current_user=Depends(get_current_admin_user), ): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - query = """ - SELECT param_id, param_key, param_name, param_value, value_type, category, - description, is_active, created_at, updated_at - FROM sys_system_parameters - WHERE 1=1 - """ - params = [] - if category: - query += " AND category = %s" - params.append(category) - if keyword: - like_pattern = f"%{keyword}%" - query += " AND (param_key LIKE %s OR param_name LIKE %s)" - params.extend([like_pattern, like_pattern]) - - query += " ORDER BY category ASC, param_key ASC" - cursor.execute(query, tuple(params)) - rows = cursor.fetchall() - return create_api_response( - code="200", - message="获取参数列表成功", - data={"items": rows, "total": len(rows)}, - ) - except Exception as e: - return create_api_response(code="500", message=f"获取参数列表失败: {str(e)}") + return admin_settings_service.list_parameters(category, keyword) @router.get("/admin/parameters/{param_key}") async def get_parameter(param_key: str, current_user=Depends(get_current_admin_user)): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - SELECT param_id, param_key, param_name, param_value, value_type, category, - description, is_active, created_at, updated_at - FROM sys_system_parameters - WHERE param_key = %s - LIMIT 1 - """, - (param_key,), - ) - row = cursor.fetchone() - if not row: - return create_api_response(code="404", message="参数不存在") - return create_api_response(code="200", message="获取参数成功", data=row) - except Exception as e: - return create_api_response(code="500", message=f"获取参数失败: {str(e)}") + return admin_settings_service.get_parameter(param_key) @router.post("/admin/parameters") async def create_parameter(request: ParameterUpsertRequest, current_user=Depends(get_current_admin_user)): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (request.param_key,)) - if cursor.fetchone(): - return create_api_response(code="400", message="参数键已存在") - - cursor.execute( - """ - INSERT INTO sys_system_parameters - (param_key, param_name, param_value, value_type, category, description, is_active) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, - ( - request.param_key, - request.param_name, - request.param_value, - request.value_type, - request.category, - request.description, - 1 if request.is_active else 0, - ), - ) - conn.commit() - SystemConfigService.invalidate_cache() - return create_api_response(code="200", message="创建参数成功") - except Exception as e: - return create_api_response(code="500", message=f"创建参数失败: {str(e)}") + return admin_settings_service.create_parameter(request) @router.put("/admin/parameters/{param_key}") @@ -369,131 +95,22 @@ async def update_parameter( request: ParameterUpsertRequest, current_user=Depends(get_current_admin_user), ): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,)) - existed = cursor.fetchone() - if not existed: - return create_api_response(code="404", message="参数不存在") - - new_key = request.param_key or param_key - if new_key != param_key: - cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (new_key,)) - if cursor.fetchone(): - return create_api_response(code="400", message="新的参数键已存在") - - cursor.execute( - """ - UPDATE sys_system_parameters - SET param_key = %s, param_name = %s, param_value = %s, value_type = %s, - category = %s, description = %s, is_active = %s - WHERE param_key = %s - """, - ( - new_key, - request.param_name, - request.param_value, - request.value_type, - request.category, - request.description, - 1 if request.is_active else 0, - param_key, - ), - ) - conn.commit() - SystemConfigService.invalidate_cache() - return create_api_response(code="200", message="更新参数成功") - except Exception as e: - return create_api_response(code="500", message=f"更新参数失败: {str(e)}") + return admin_settings_service.update_parameter(param_key, request) @router.delete("/admin/parameters/{param_key}") async def delete_parameter(param_key: str, current_user=Depends(get_current_admin_user)): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,)) - existed = cursor.fetchone() - if not existed: - return create_api_response(code="404", message="参数不存在") - - cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,)) - conn.commit() - SystemConfigService.invalidate_cache() - return create_api_response(code="200", message="删除参数成功") - except Exception as e: - return create_api_response(code="500", message=f"删除参数失败: {str(e)}") + return admin_settings_service.delete_parameter(param_key) @router.get("/admin/model-configs/llm") async def list_llm_model_configs(current_user=Depends(get_current_admin_user)): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - SELECT config_id, model_code, model_name, provider, endpoint_url, api_key, - llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, - llm_system_prompt, description, is_active, is_default, created_at, updated_at - FROM llm_model_config - ORDER BY model_code ASC - """ - ) - rows = cursor.fetchall() - return create_api_response( - code="200", - message="获取LLM模型配置成功", - data={"items": rows, "total": len(rows)}, - ) - except Exception as e: - return create_api_response(code="500", message=f"获取LLM模型配置失败: {str(e)}") + return admin_settings_service.list_llm_model_configs() @router.post("/admin/model-configs/llm") async def create_llm_model_config(request: LLMModelUpsertRequest, current_user=Depends(get_current_admin_user)): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,)) - if cursor.fetchone(): - return create_api_response(code="400", message="模型编码已存在") - - cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config") - total_row = cursor.fetchone() or {"total": 0} - is_default = bool(request.is_default) or total_row["total"] == 0 - if is_default: - cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE is_default = 1") - - cursor.execute( - """ - INSERT INTO llm_model_config - (model_code, model_name, provider, endpoint_url, api_key, llm_model_name, - llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt, - description, is_active, is_default) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - request.model_code, - request.model_name, - request.provider, - request.endpoint_url, - request.api_key, - request.llm_model_name, - request.llm_timeout, - request.llm_temperature, - request.llm_top_p, - request.llm_max_tokens, - request.llm_system_prompt, - request.description, - 1 if request.is_active else 0, - 1 if is_default else 0, - ), - ) - conn.commit() - return create_api_response(code="200", message="创建LLM模型配置成功") - except Exception as e: - return create_api_response(code="500", message=f"创建LLM模型配置失败: {str(e)}") + return admin_settings_service.create_llm_model_config(request) @router.put("/admin/model-configs/llm/{model_code}") @@ -502,54 +119,7 @@ async def update_llm_model_config( request: LLMModelUpsertRequest, current_user=Depends(get_current_admin_user), ): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,)) - existed = cursor.fetchone() - if not existed: - return create_api_response(code="404", message="模型配置不存在") - - new_model_code = request.model_code or model_code - if new_model_code != model_code: - cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (new_model_code,)) - duplicate_row = cursor.fetchone() - if duplicate_row and duplicate_row["config_id"] != existed["config_id"]: - return create_api_response(code="400", message="新的模型编码已存在") - - if request.is_default: - cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1", (model_code,)) - - cursor.execute( - """ - UPDATE llm_model_config - SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s, - llm_model_name = %s, llm_timeout = %s, llm_temperature = %s, llm_top_p = %s, - llm_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s - WHERE model_code = %s - """, - ( - new_model_code, - request.model_name, - request.provider, - request.endpoint_url, - request.api_key, - request.llm_model_name, - request.llm_timeout, - request.llm_temperature, - request.llm_top_p, - request.llm_max_tokens, - request.llm_system_prompt, - request.description, - 1 if request.is_active else 0, - 1 if request.is_default else 0, - model_code, - ), - ) - conn.commit() - return create_api_response(code="200", message="更新LLM模型配置成功") - except Exception as e: - return create_api_response(code="500", message=f"更新LLM模型配置失败: {str(e)}") + return admin_settings_service.update_llm_model_config(model_code, request) @router.get("/admin/model-configs/audio") @@ -557,97 +127,12 @@ async def list_audio_model_configs( scene: str = Query("all"), current_user=Depends(get_current_admin_user), ): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - sql = """ - SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key, - a.asr_model_name, a.asr_vocabulary_id, a.hot_word_group_id, a.asr_speaker_count, a.asr_language_hints, - a.asr_disfluency_removal_enabled, a.asr_diarization_enabled, - a.vp_template_text, a.vp_duration_seconds, a.vp_sample_rate, a.vp_channels, a.vp_max_size_bytes, - a.extra_config, a.description, a.is_active, a.is_default, a.created_at, a.updated_at, - g.name AS hot_word_group_name, g.vocabulary_id AS hot_word_group_vocab_id - FROM audio_model_config a - LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id - """ - params = [] - if scene in ("asr", "voiceprint"): - sql += " WHERE a.audio_scene = %s" - params.append(scene) - sql += " ORDER BY a.audio_scene ASC, a.model_code ASC" - cursor.execute(sql, tuple(params)) - rows = [_normalize_audio_row(row) for row in cursor.fetchall()] - return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)}) - except Exception as e: - return create_api_response(code="500", message=f"获取音频模型配置失败: {str(e)}") + return admin_settings_service.list_audio_model_configs(scene) @router.post("/admin/model-configs/audio") async def create_audio_model_config(request: AudioModelUpsertRequest, current_user=Depends(get_current_admin_user)): - try: - if request.audio_scene not in ("asr", "voiceprint"): - return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (request.model_code,)) - if cursor.fetchone(): - return create_api_response(code="400", message="模型编码已存在") - - cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE audio_scene = %s", (request.audio_scene,)) - total_row = cursor.fetchone() or {"total": 0} - is_default = bool(request.is_default) or total_row["total"] == 0 - if is_default: - cursor.execute("UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1", (request.audio_scene,)) - - # 如果指定了热词组,从组中获取 vocabulary_id - asr_vocabulary_id = request.asr_vocabulary_id - if request.hot_word_group_id: - cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,)) - group_row = cursor.fetchone() - if group_row and group_row.get("vocabulary_id"): - asr_vocabulary_id = group_row["vocabulary_id"] - extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) - legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config) - - cursor.execute( - """ - INSERT INTO audio_model_config - (model_code, model_name, audio_scene, provider, endpoint_url, api_key, - asr_model_name, asr_vocabulary_id, hot_word_group_id, asr_speaker_count, asr_language_hints, - asr_disfluency_removal_enabled, asr_diarization_enabled, - vp_template_text, vp_duration_seconds, vp_sample_rate, vp_channels, vp_max_size_bytes, - extra_config, description, is_active, is_default) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - request.model_code, - request.model_name, - request.audio_scene, - request.provider, - request.endpoint_url, - request.api_key, - legacy_columns["asr_model_name"], - legacy_columns["asr_vocabulary_id"], - request.hot_word_group_id, - legacy_columns["asr_speaker_count"], - legacy_columns["asr_language_hints"], - legacy_columns["asr_disfluency_removal_enabled"], - legacy_columns["asr_diarization_enabled"], - legacy_columns["vp_template_text"], - legacy_columns["vp_duration_seconds"], - legacy_columns["vp_sample_rate"], - legacy_columns["vp_channels"], - legacy_columns["vp_max_size_bytes"], - json.dumps(extra_config, ensure_ascii=False), - request.description, - 1 if request.is_active else 0, - 1 if is_default else 0, - ), - ) - conn.commit() - return create_api_response(code="200", message="创建音频模型配置成功") - except Exception as e: - return create_api_response(code="500", message=f"创建音频模型配置失败: {str(e)}") + return admin_settings_service.create_audio_model_config(request) @router.put("/admin/model-configs/audio/{model_code}") @@ -656,196 +141,34 @@ async def update_audio_model_config( request: AudioModelUpsertRequest, current_user=Depends(get_current_admin_user), ): - try: - if request.audio_scene not in ("asr", "voiceprint"): - return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,)) - existed = cursor.fetchone() - if not existed: - return create_api_response(code="404", message="模型配置不存在") - - new_model_code = request.model_code or model_code - if new_model_code != model_code: - cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (new_model_code,)) - duplicate_row = cursor.fetchone() - if duplicate_row and duplicate_row["config_id"] != existed["config_id"]: - return create_api_response(code="400", message="新的模型编码已存在") - - if request.is_default: - cursor.execute( - "UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1", - (request.audio_scene, model_code), - ) - - # 如果指定了热词组,从组中获取 vocabulary_id - asr_vocabulary_id = request.asr_vocabulary_id - if request.hot_word_group_id: - cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,)) - group_row = cursor.fetchone() - if group_row and group_row.get("vocabulary_id"): - asr_vocabulary_id = group_row["vocabulary_id"] - extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) - legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config) - - cursor.execute( - """ - UPDATE audio_model_config - SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s, - asr_model_name = %s, asr_vocabulary_id = %s, hot_word_group_id = %s, asr_speaker_count = %s, asr_language_hints = %s, - asr_disfluency_removal_enabled = %s, asr_diarization_enabled = %s, - vp_template_text = %s, vp_duration_seconds = %s, vp_sample_rate = %s, vp_channels = %s, vp_max_size_bytes = %s, - extra_config = %s, description = %s, is_active = %s, is_default = %s - WHERE model_code = %s - """, - ( - new_model_code, - request.model_name, - request.audio_scene, - request.provider, - request.endpoint_url, - request.api_key, - legacy_columns["asr_model_name"], - legacy_columns["asr_vocabulary_id"], - request.hot_word_group_id, - legacy_columns["asr_speaker_count"], - legacy_columns["asr_language_hints"], - legacy_columns["asr_disfluency_removal_enabled"], - legacy_columns["asr_diarization_enabled"], - legacy_columns["vp_template_text"], - legacy_columns["vp_duration_seconds"], - legacy_columns["vp_sample_rate"], - legacy_columns["vp_channels"], - legacy_columns["vp_max_size_bytes"], - json.dumps(extra_config, ensure_ascii=False), - request.description, - 1 if request.is_active else 0, - 1 if request.is_default else 0, - model_code, - ), - ) - conn.commit() - return create_api_response(code="200", message="更新音频模型配置成功") - except Exception as e: - return create_api_response(code="500", message=f"更新音频模型配置失败: {str(e)}") + return admin_settings_service.update_audio_model_config(model_code, request) @router.delete("/admin/model-configs/llm/{model_code}") async def delete_llm_model_config(model_code: str, current_user=Depends(get_current_admin_user)): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="模型配置不存在") - - cursor.execute("DELETE FROM llm_model_config WHERE model_code = %s", (model_code,)) - conn.commit() - return create_api_response(code="200", message="删除LLM模型配置成功") - except Exception as e: - return create_api_response(code="500", message=f"删除LLM模型配置失败: {str(e)}") + return admin_settings_service.delete_llm_model_config(model_code) @router.delete("/admin/model-configs/audio/{model_code}") async def delete_audio_model_config(model_code: str, current_user=Depends(get_current_admin_user)): - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="模型配置不存在") - - cursor.execute("DELETE FROM audio_model_config WHERE model_code = %s", (model_code,)) - conn.commit() - return create_api_response(code="200", message="删除音频模型配置成功") - except Exception as e: - return create_api_response(code="500", message=f"删除音频模型配置失败: {str(e)}") + return admin_settings_service.delete_audio_model_config(model_code) @router.post("/admin/model-configs/llm/test") async def test_llm_model_config(request: LLMModelTestRequest, current_user=Depends(get_current_admin_user)): - try: - payload = request.model_dump() if hasattr(request, "model_dump") else request.dict() - result = llm_service.test_model(payload, prompt=request.test_prompt) - return create_api_response(code="200", message="LLM模型测试成功", data=result) - except Exception as e: - return create_api_response(code="500", message=f"LLM模型测试失败: {str(e)}") + return admin_settings_service.test_llm_model_config(request) @router.post("/admin/model-configs/audio/test") async def test_audio_model_config(request: AudioModelTestRequest, current_user=Depends(get_current_admin_user)): - try: - if request.audio_scene != "asr": - return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试") - - vocabulary_id = request.asr_vocabulary_id - if request.hot_word_group_id: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,)) - group_row = cursor.fetchone() - cursor.close() - if group_row and group_row.get("vocabulary_id"): - vocabulary_id = group_row["vocabulary_id"] - - extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id) - runtime_config = { - "provider": request.provider, - "endpoint_url": request.endpoint_url, - "api_key": request.api_key, - "audio_scene": request.audio_scene, - "hot_word_group_id": request.hot_word_group_id, - **extra_config, - } - result = transcription_service.test_asr_model(runtime_config, test_file_url=request.test_file_url) - return create_api_response(code="200", message="音频模型测试任务已提交", data=result) - except Exception as e: - return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}") + return admin_settings_service.test_audio_model_config(request) @router.get("/system-config/public") async def get_public_system_config(): - try: - return create_api_response( - code="200", - message="获取公开配置成功", - data=SystemConfigService.get_public_configs() - ) - except Exception as e: - return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}") + return admin_settings_service.get_public_system_config() @router.get("/admin/system-config") async def get_system_config_compat(current_user=Depends(get_current_admin_user)): - """兼容旧前端的系统配置接口,数据来源为 sys_system_parameters。""" - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - SELECT param_key, param_value - FROM sys_system_parameters - WHERE is_active = 1 - """ - ) - rows = cursor.fetchall() - data = {row["param_key"]: row["param_value"] for row in rows} - - # 兼容旧字段 - if "max_audio_size" in data: - try: - data["MAX_FILE_SIZE"] = int(data["max_audio_size"]) * 1024 * 1024 - except Exception: - data["MAX_FILE_SIZE"] = 100 * 1024 * 1024 - if "max_image_size" in data: - try: - data["MAX_IMAGE_SIZE"] = int(data["max_image_size"]) * 1024 * 1024 - except Exception: - data["MAX_IMAGE_SIZE"] = 10 * 1024 * 1024 - else: - data.setdefault("MAX_IMAGE_SIZE", 10 * 1024 * 1024) - - return create_api_response(code="200", message="获取系统配置成功", data=data) - except Exception as e: - return create_api_response(code="500", message=f"获取系统配置失败: {str(e)}") + return admin_settings_service.get_system_config_compat() diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index 7c80b3e..8c16e70 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -1,296 +1,24 @@ -from fastapi import APIRouter, UploadFile, File, Form, Depends, BackgroundTasks, Request, Header -from fastapi.responses import StreamingResponse, Response -from app.models.models import Meeting, TranscriptSegment, TranscriptionTaskStatus, CreateMeetingRequest, UpdateMeetingRequest, SpeakerTagUpdateRequest, BatchSpeakerTagUpdateRequest, BatchTranscriptUpdateRequest, Tag -from app.core.database import get_db_connection -from app.core.config import BASE_DIR, AUDIO_DIR, MARKDOWN_DIR, ALLOWED_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS -import app.core.config as config_module -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.services.system_config_service import SystemConfigService -from app.utils.audio_parser import get_audio_duration +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Header, UploadFile + from app.core.auth import get_current_user, get_optional_current_user -from app.core.response import create_api_response -from typing import Any, Dict, List, Optional -from datetime import datetime -from pydantic import BaseModel -from urllib.parse import quote -from collections import defaultdict -import os -import uuid -import shutil -import mimetypes +from app.models.models import ( + BatchSpeakerTagUpdateRequest, + BatchTranscriptUpdateRequest, + CreateMeetingRequest, + SpeakerTagUpdateRequest, + UpdateMeetingRequest, +) +import app.services.meeting_service as meeting_service + router = APIRouter() -llm_service = LLMService() -transcription_service = AsyncTranscriptionService() +GenerateSummaryRequest = meeting_service.GenerateSummaryRequest +AccessPasswordRequest = meeting_service.AccessPasswordRequest +VerifyPasswordRequest = meeting_service.VerifyPasswordRequest -class GenerateSummaryRequest(BaseModel): - user_prompt: Optional[str] = "" - prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版 - model_code: Optional[str] = None # LLM模型编码,如果不指定则使用默认模型 - - -def _split_tag_names(tag_string: Optional[str]) -> List[str]: - if not tag_string: - return [] - return [name.strip() for name in str(tag_string).split(',') if name and name.strip()] - - -def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: - """ - 处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签 - """ - tag_names = _split_tag_names(tag_string) - if not tag_names: - return [] - - # 如果提供了 creator_id,则创建不存在的标签 - if creator_id: - insert_ignore_query = "INSERT IGNORE INTO tags (name, creator_id) VALUES (%s, %s)" - cursor.executemany(insert_ignore_query, [(name, creator_id) for name in tag_names]) - - # 查询所有标签信息 - format_strings = ', '.join(['%s'] * len(tag_names)) - cursor.execute(f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", tuple(tag_names)) - tags_data = cursor.fetchall() - return [Tag(**tag) for tag in tags_data] - -def _sync_attendees(cursor, meeting_id: int, attendee_ids: Optional[List[int]]) -> None: - attendee_id_list = [] - if attendee_ids: - attendee_id_list = list(dict.fromkeys(int(user_id) for user_id in attendee_ids if user_id is not None)) - - cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) - - if attendee_id_list: - cursor.executemany( - 'INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', - [(meeting_id, user_id) for user_id in attendee_id_list] - ) - - -def _serialize_task_record(task_record: Optional[Dict[str, Any]], meeting_id: int) -> Optional[Dict[str, Any]]: - if not task_record: - return None - - created_at = task_record.get('created_at') - completed_at = task_record.get('completed_at') - - return { - 'task_id': task_record.get('task_id'), - 'status': task_record.get('status', 'pending') or 'pending', - 'progress': int(task_record.get('progress') or 0), - 'meeting_id': meeting_id, - 'created_at': created_at.isoformat() if hasattr(created_at, 'isoformat') else created_at, - 'completed_at': completed_at.isoformat() if hasattr(completed_at, 'isoformat') else completed_at, - 'error_message': task_record.get('error_message') - } - - -def _build_meeting_overall_status( - transcription_status: Optional[Dict[str, Any]] = None, - llm_status: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: - trans_data = { - "status": transcription_status.get('status', 'pending') if transcription_status else 'pending', - "progress": transcription_status.get('progress', 0) if transcription_status else 0, - "task_id": transcription_status.get('task_id') if transcription_status else None, - "error_message": transcription_status.get('error_message') if transcription_status else None, - "created_at": transcription_status.get('created_at') if transcription_status else None - } - - llm_data = { - "status": llm_status.get('status', 'pending') if llm_status else 'pending', - "progress": llm_status.get('progress', 0) if llm_status else 0, - "task_id": llm_status.get('task_id') if llm_status else None, - "error_message": llm_status.get('error_message') if llm_status else None, - "created_at": llm_status.get('created_at') if llm_status else None - } - - trans_status_val = trans_data["status"] - llm_status_val = llm_data["status"] - - if trans_status_val == 'failed': - overall_status = "failed" - current_stage = "transcription" - overall_progress = 0 - elif llm_status_val == 'failed': - overall_status = "failed" - current_stage = "llm" - overall_progress = 50 - elif trans_status_val == 'completed' and llm_status_val == 'completed': - overall_status = "completed" - current_stage = "completed" - overall_progress = 100 - elif trans_status_val == 'completed': - if llm_status_val in ['pending', 'processing']: - overall_status = "summarizing" - current_stage = "llm" - overall_progress = 50 + int(llm_data["progress"] * 0.5) - else: - overall_status = "summarizing" - current_stage = "llm" - overall_progress = 50 - else: - if trans_status_val in ['pending', 'processing']: - overall_status = "transcribing" - current_stage = "transcription" - overall_progress = int(trans_data["progress"] * 0.5) - else: - overall_status = "pending" - current_stage = "transcription" - overall_progress = 0 - - return { - "overall_status": overall_status, - "overall_progress": overall_progress, - "current_stage": current_stage, - "transcription": trans_data, - "llm": llm_data - } - - -def _load_attendees_map(cursor, meeting_ids: List[int]) -> Dict[int, List[Dict[str, Any]]]: - if not meeting_ids: - return {} - - format_strings = ', '.join(['%s'] * len(meeting_ids)) - query = f''' - SELECT a.meeting_id, u.user_id, u.caption - FROM attendees a - JOIN sys_users u ON a.user_id = u.user_id - WHERE a.meeting_id IN ({format_strings}) - ORDER BY a.meeting_id ASC, a.attendee_id ASC - ''' - cursor.execute(query, tuple(meeting_ids)) - - attendees_map: Dict[int, List[Dict[str, Any]]] = defaultdict(list) - for row in cursor.fetchall(): - attendees_map[row['meeting_id']].append({ - 'user_id': row['user_id'], - 'caption': row['caption'] - }) - - return dict(attendees_map) - - -def _load_tags_map(cursor, meetings: List[Dict[str, Any]]) -> Dict[int, List[Tag]]: - meeting_tag_names: Dict[int, List[str]] = {} - all_tag_names: List[str] = [] - - for meeting in meetings: - tag_names = _split_tag_names(meeting.get('tags')) - meeting_tag_names[meeting['meeting_id']] = tag_names - all_tag_names.extend(tag_names) - - if not all_tag_names: - return {} - - unique_tag_names = list(dict.fromkeys(all_tag_names)) - format_strings = ', '.join(['%s'] * len(unique_tag_names)) - cursor.execute( - f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", - tuple(unique_tag_names) - ) - name_to_tag = {row['name']: Tag(**row) for row in cursor.fetchall()} - - return { - meeting_id: [name_to_tag[name] for name in tag_names if name in name_to_tag] - for meeting_id, tag_names in meeting_tag_names.items() - } - - -def _load_latest_task_map(cursor, meeting_ids: List[int], task_type: str) -> Dict[int, Dict[str, Any]]: - if not meeting_ids: - return {} - - table_name = 'transcript_tasks' if task_type == 'transcription' else 'llm_tasks' - format_strings = ', '.join(['%s'] * len(meeting_ids)) - query = f''' - SELECT meeting_id, task_id, status, progress, created_at, completed_at, error_message - FROM {table_name} - WHERE meeting_id IN ({format_strings}) - ORDER BY meeting_id ASC, created_at DESC, task_id DESC - ''' - cursor.execute(query, tuple(meeting_ids)) - - latest_task_map: Dict[int, Dict[str, Any]] = {} - for row in cursor.fetchall(): - meeting_id = row['meeting_id'] - if meeting_id not in latest_task_map: - latest_task_map[meeting_id] = _serialize_task_record(row, meeting_id) - - return latest_task_map - - -def _load_latest_task_record(cursor, meeting_id: int, task_type: str) -> Optional[Dict[str, Any]]: - table_name = 'transcript_tasks' if task_type == 'transcription' else 'llm_tasks' - query = f''' - SELECT task_id, status, progress, created_at, completed_at, error_message - FROM {table_name} - WHERE meeting_id = %s - ORDER BY created_at DESC, task_id DESC - LIMIT 1 - ''' - cursor.execute(query, (meeting_id,)) - return _serialize_task_record(cursor.fetchone(), meeting_id) - - -def _load_overall_status_map(cursor, meeting_ids: List[int]) -> Dict[int, Dict[str, Any]]: - if not meeting_ids: - return {} - - transcription_map = _load_latest_task_map(cursor, meeting_ids, 'transcription') - llm_map = _load_latest_task_map(cursor, meeting_ids, 'llm') - - return { - meeting_id: _build_meeting_overall_status( - transcription_map.get(meeting_id), - llm_map.get(meeting_id) - ) - for meeting_id in meeting_ids - } - - -def _build_task_status_model(task_record: Optional[Dict[str, Any]]) -> Optional[TranscriptionTaskStatus]: - if not task_record: - return None - - return TranscriptionTaskStatus( - task_id=task_record.get('task_id'), - status=task_record.get('status', 'pending') or 'pending', - progress=int(task_record.get('progress') or 0), - message=task_record.get('message') - ) - -def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int): - cursor.execute("SELECT meeting_id, user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return None, create_api_response(code="404", message="Meeting not found") - if meeting['user_id'] != current_user_id: - return None, create_api_response(code="403", message="仅会议创建人可执行此操作") - return meeting, None - -def _get_meeting_overall_status(meeting_id: int) -> dict: - """ - 获取会议的整体进度状态(包含转译和LLM两个阶段) - - Returns: - dict: { - "overall_status": "pending" | "transcribing" | "summarizing" | "completed" | "failed", - "overall_progress": 0-100, - "current_stage": "transcription" | "llm" | "completed", - "transcription": {status, progress, task_id, error_message, created_at}, - "llm": {status, progress, task_id, error_message, created_at} - } - """ - transcription_status = transcription_service.get_meeting_transcription_status(meeting_id) - llm_status = async_meeting_service.get_meeting_llm_status(meeting_id) - return _build_meeting_overall_status(transcription_status, llm_status) @router.get("/meetings") def get_meetings( @@ -302,643 +30,85 @@ def get_meetings( tags: Optional[str] = None, filter_type: str = "all" ): - # 使用配置的默认页面大小 - if page_size is None: - page_size = SystemConfigService.get_page_size(default=10) + return meeting_service.get_meetings(current_user, user_id, page, page_size, search, tags, filter_type) - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 构建WHERE子句 - where_conditions = [] - params = [] - - # 用户过滤 - if user_id: - # 需要联表查询参与者 - has_attendees_join = True - else: - has_attendees_join = False - - # 按类型过滤 (created/attended/all) - if user_id: - if filter_type == "created": - where_conditions.append("m.user_id = %s") - params.append(user_id) - elif filter_type == "attended": - where_conditions.append("m.user_id != %s AND a.user_id = %s") - params.extend([user_id, user_id]) - has_attendees_join = True - else: # all - where_conditions.append("(m.user_id = %s OR a.user_id = %s)") - params.extend([user_id, user_id]) - has_attendees_join = True - - # 搜索关键词过滤 - if search and search.strip(): - search_pattern = f"%{search.strip()}%" - where_conditions.append("(m.title LIKE %s OR u.caption LIKE %s)") - params.extend([search_pattern, search_pattern]) - - # 标签过滤 - if tags and tags.strip(): - tag_list = [t.strip() for t in tags.split(',') if t.strip()] - if tag_list: - # 使用JSON_CONTAINS或LIKE查询 - tag_conditions = [] - for tag in tag_list: - tag_conditions.append("m.tags LIKE %s") - params.append(f"%{tag}%") - where_conditions.append(f"({' OR '.join(tag_conditions)})") - - # 构建基础查询 - base_query = ''' - SELECT m.meeting_id, m.title, m.meeting_time, - CASE - WHEN m.summary IS NULL THEN NULL - ELSE LEFT(m.summary, 240) - END as summary, - m.created_at, m.tags, m.access_password, - m.user_id as creator_id, u.caption as creator_username, MAX(af.file_path) as audio_file_path - FROM meetings m - JOIN sys_users u ON m.user_id = u.user_id - LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id - ''' - - if has_attendees_join: - base_query += " LEFT JOIN attendees a ON m.meeting_id = a.meeting_id" - - # 添加WHERE子句 - if where_conditions: - base_query += f" WHERE {' AND '.join(where_conditions)}" - - # 获取总数 - 需要在添加 GROUP BY 之前 - count_base = base_query # 保存一份不含GROUP BY的查询 - if has_attendees_join: - # 如果有联表,使用子查询计数 - count_query = f"SELECT COUNT(DISTINCT m.meeting_id) as total {count_base[count_base.find('FROM'):]}" - else: - # 没有联表,直接计数 - count_query = f"SELECT COUNT(*) as total {count_base[count_base.find('FROM'):]}" - - cursor.execute(count_query, params) - total = cursor.fetchone()['total'] - - # 添加GROUP BY(因为使用了MAX聚合函数,总是需要GROUP BY) - base_query += " GROUP BY m.meeting_id" - - # 计算分页 - total_pages = (total + page_size - 1) // page_size - has_more = page < total_pages - offset = (page - 1) * page_size - - # 添加排序和分页 - query = f"{base_query} ORDER BY m.meeting_time DESC, m.created_at DESC LIMIT %s OFFSET %s" - params.extend([page_size, offset]) - - cursor.execute(query, params) - meetings = cursor.fetchall() - - meeting_ids = [meeting['meeting_id'] for meeting in meetings] - attendees_map = _load_attendees_map(cursor, meeting_ids) - tags_map = _load_tags_map(cursor, meetings) - status_map = _load_overall_status_map(cursor, meeting_ids) - - meeting_list = [] - for meeting in meetings: - attendees = attendees_map.get(meeting['meeting_id'], []) - tags_list = tags_map.get(meeting['meeting_id'], []) - progress_info = status_map.get(meeting['meeting_id']) or _build_meeting_overall_status() - meeting_list.append(Meeting( - meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], - summary=meeting['summary'], created_at=meeting['created_at'], audio_file_path=meeting['audio_file_path'], - attendees=attendees, attendee_ids=[row['user_id'] for row in attendees], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list, - access_password=meeting.get('access_password'), - overall_status=progress_info.get('overall_status'), - overall_progress=progress_info.get('overall_progress'), - current_stage=progress_info.get('current_stage'), - )) - - return create_api_response(code="200", message="获取会议列表成功", data={ - "meetings": meeting_list, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - "has_more": has_more - }) @router.get("/meetings/stats") def get_meetings_stats( current_user: dict = Depends(get_current_user), user_id: Optional[int] = None ): - """ - 获取会议统计数据:全部会议、我创建的会议、我参加的会议数量 - """ - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) + return meeting_service.get_meetings_stats(current_user, user_id) - if not user_id: - return create_api_response(code="400", message="user_id is required") - - # 获取全部会议数量(创建的 + 参加的) - all_query = ''' - SELECT COUNT(DISTINCT m.meeting_id) as count - FROM meetings m - LEFT JOIN attendees a ON m.meeting_id = a.meeting_id - WHERE m.user_id = %s OR a.user_id = %s - ''' - cursor.execute(all_query, (user_id, user_id)) - all_count = cursor.fetchone()['count'] - - # 获取我创建的会议数量 - created_query = ''' - SELECT COUNT(*) as count - FROM meetings m - WHERE m.user_id = %s - ''' - cursor.execute(created_query, (user_id,)) - created_count = cursor.fetchone()['count'] - - # 获取我参加的会议数量(不包括我创建的) - attended_query = ''' - SELECT COUNT(DISTINCT a.meeting_id) as count - FROM attendees a - JOIN meetings m ON a.meeting_id = m.meeting_id - WHERE a.user_id = %s AND m.user_id != %s - ''' - cursor.execute(attended_query, (user_id, user_id)) - attended_count = cursor.fetchone()['count'] - - return create_api_response(code="200", message="获取会议统计成功", data={ - "all_meetings": all_count, - "created_meetings": created_count, - "attended_meetings": attended_count - }) @router.get("/meetings/{meeting_id}") def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_current_user)): - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - query = ''' - SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, - m.user_id as creator_id, u.caption as creator_username, m.prompt_id, - af.file_path as audio_file_path, af.duration as audio_duration, - p.name as prompt_name, m.access_password - FROM meetings m - JOIN sys_users u ON m.user_id = u.user_id - LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id - LEFT JOIN prompts p ON m.prompt_id = p.id - WHERE m.meeting_id = %s - ''' - cursor.execute(query, (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return create_api_response(code="404", message="Meeting not found") + return meeting_service.get_meeting_details(meeting_id, current_user) - attendees_data = _load_attendees_map(cursor, [meeting_id]).get(meeting_id, []) - attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - tags = _load_tags_map(cursor, [meeting]).get(meeting_id, []) - transcription_task = transcription_service.get_meeting_transcription_status(meeting_id) - llm_task = async_meeting_service.get_meeting_llm_status(meeting_id) - overall_status = _build_meeting_overall_status(transcription_task, llm_task) - cursor.close() - - meeting_data = Meeting( - meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], - summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, - attendee_ids=[row['user_id'] for row in attendees_data], - creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags, - prompt_id=meeting.get('prompt_id'), - prompt_name=meeting.get('prompt_name'), - overall_status=overall_status.get('overall_status'), - overall_progress=overall_status.get('overall_progress'), - current_stage=overall_status.get('current_stage'), - access_password=meeting.get('access_password') - ) - # 只有路径长度大于5(排除空串或占位符)才认为有录音 - if meeting.get('audio_file_path') and len(meeting['audio_file_path']) > 5: - meeting_data.audio_file_path = meeting['audio_file_path'] - meeting_data.audio_duration = meeting['audio_duration'] - - meeting_data.transcription_status = _build_task_status_model(transcription_task) - meeting_data.llm_status = _build_task_status_model(llm_task) - - 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: 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,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="Meeting not found") - transcript_query = ''' - SELECT segment_id, meeting_id, speaker_id, speaker_tag, start_time_ms, end_time_ms, text_content - FROM transcript_segments WHERE meeting_id = %s ORDER BY start_time_ms ASC - ''' - cursor.execute(transcript_query, (meeting_id,)) - segments = cursor.fetchall() - transcript_segments = [TranscriptSegment( - segment_id=s['segment_id'], meeting_id=s['meeting_id'], speaker_id=s['speaker_id'], - speaker_tag=s['speaker_tag'] if s['speaker_tag'] else f"发言人 {s['speaker_id']}", - start_time_ms=s['start_time_ms'], end_time_ms=s['end_time_ms'], text_content=s['text_content'] - ) for s in segments] - return create_api_response(code="200", message="获取转录内容成功", data=transcript_segments) + return meeting_service.get_meeting_transcript(meeting_id, current_user) + @router.post("/meetings") def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = Depends(get_current_user)): - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - # 使用 _process_tags 来处理标签创建 - if meeting_request.tags: - _process_tags(cursor, meeting_request.tags, current_user['user_id']) - meeting_query = ''' - INSERT INTO meetings (user_id, title, meeting_time, summary, tags, prompt_id, created_at) - VALUES (%s, %s, %s, %s, %s, %s, %s) - ''' - cursor.execute( - meeting_query, - ( - current_user['user_id'], - meeting_request.title, - meeting_request.meeting_time, - None, - meeting_request.tags, - meeting_request.prompt_id or 0, - datetime.now().isoformat(), - ), - ) - meeting_id = cursor.lastrowid - _sync_attendees(cursor, meeting_id, meeting_request.attendee_ids) - connection.commit() - return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id}) + return meeting_service.create_meeting(meeting_request, current_user) + @router.put("/meetings/{meeting_id}") def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict = Depends(get_current_user)): - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return create_api_response(code="404", message="Meeting not found") - if meeting['user_id'] != current_user['user_id']: - return create_api_response(code="403", message="Permission denied") - # 使用 _process_tags 来处理标签创建 - if meeting_request.tags: - _process_tags(cursor, meeting_request.tags, current_user['user_id']) - update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s, prompt_id = %s WHERE meeting_id = %s' - cursor.execute( - update_query, - ( - meeting_request.title, - meeting_request.meeting_time, - meeting_request.summary, - meeting_request.tags, - meeting_request.prompt_id if meeting_request.prompt_id is not None else meeting['prompt_id'], - meeting_id, - ), - ) - if meeting_request.attendee_ids is not None: - _sync_attendees(cursor, meeting_id, meeting_request.attendee_ids) - connection.commit() - # 同步导出总结MD文件 - if meeting_request.summary: - async_meeting_service._export_summary_md(meeting_id, meeting_request.summary) - return create_api_response(code="200", message="Meeting updated successfully") + return meeting_service.update_meeting(meeting_id, meeting_request, current_user) + @router.delete("/meetings/{meeting_id}") def delete_meeting(meeting_id: int, current_user: dict = Depends(get_current_user)): - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return create_api_response(code="404", message="Meeting not found") - if meeting['user_id'] != current_user['user_id']: - return create_api_response(code="403", message="Permission denied") - cursor.execute("DELETE FROM transcript_segments WHERE meeting_id = %s", (meeting_id,)) - cursor.execute("DELETE FROM audio_files WHERE meeting_id = %s", (meeting_id,)) - cursor.execute("DELETE FROM attachments WHERE meeting_id = %s", (meeting_id,)) - cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) - cursor.execute("DELETE FROM meetings WHERE meeting_id = %s", (meeting_id,)) - connection.commit() - return create_api_response(code="200", message="Meeting deleted successfully") + return meeting_service.delete_meeting(meeting_id, current_user) + @router.get("/meetings/{meeting_id}/edit") def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_current_user)): - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - query = ''' - SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, - m.user_id as creator_id, u.caption as creator_username, m.prompt_id, af.file_path as audio_file_path, - m.access_password - FROM meetings m JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id - WHERE m.meeting_id = %s - ''' - cursor.execute(query, (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return create_api_response(code="404", message="Meeting not found") - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' - cursor.execute(attendees_query, (meeting['meeting_id'],)) - attendees_data = cursor.fetchall() - attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - tags = _process_tags(cursor, meeting.get('tags')) - cursor.close() - meeting_data = Meeting( - meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], - summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, - attendee_ids=[row['user_id'] for row in attendees_data], - creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags, - prompt_id=meeting.get('prompt_id'), - access_password=meeting.get('access_password') - ) - if meeting.get('audio_file_path'): - meeting_data.audio_file_path = meeting['audio_file_path'] - try: - transcription_status_data = transcription_service.get_meeting_transcription_status(meeting_id) - if transcription_status_data: - meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data) - except Exception as e: - print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}") - return create_api_response(code="200", message="获取会议编辑信息成功", data=meeting_data) + return meeting_service.get_meeting_for_edit(meeting_id, current_user) + @router.post("/meetings/upload-audio") async def upload_audio( audio_file: UploadFile = File(...), meeting_id: int = Form(...), auto_summarize: str = Form("true"), - prompt_id: Optional[int] = Form(None), # 可选的提示词模版ID - model_code: Optional[str] = Form(None), # 可选的总结模型编码 + prompt_id: Optional[int] = Form(None), + model_code: Optional[str] = Form(None), background_tasks: BackgroundTasks = None, current_user: dict = Depends(get_current_user) ): - """ - 音频文件上传接口 - - 上传音频文件并启动转录任务,可选择是否自动生成总结 - - Args: - audio_file: 音频文件 - meeting_id: 会议ID - auto_summarize: 是否自动生成总结("true"/"false",默认"true") - prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版) - model_code: 总结模型编码(可选,如果不指定则使用默认模型) - background_tasks: FastAPI后台任务 - current_user: 当前登录用户 - - Returns: - HTTP 200: 处理成功,返回任务ID - HTTP 400/403/404/500: 各种错误情况 - """ - auto_summarize_bool = auto_summarize.lower() in ("true", "1", "yes") - - model_code = model_code.strip() if model_code else None - - # 0. 如果没有传入 prompt_id,优先使用会议已配置模版,否则回退默认模版 - if prompt_id is None: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting_row = cursor.fetchone() - if meeting_row and meeting_row.get('prompt_id') and int(meeting_row['prompt_id']) > 0: - prompt_id = int(meeting_row['prompt_id']) - else: - cursor = connection.cursor() - cursor.execute( - "SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1" - ) - prompt_row = cursor.fetchone() - prompt_id = prompt_row[0] if prompt_row else None - - # 1. 文件类型验证 - file_extension = os.path.splitext(audio_file.filename)[1].lower() - if file_extension not in ALLOWED_EXTENSIONS: - return create_api_response( - code="400", - message=f"不支持的文件类型。支持的类型: {', '.join(ALLOWED_EXTENSIONS)}" - ) - - # 2. 文件大小验证 - max_file_size = SystemConfigService.get_max_audio_size(default=100) * 1024 * 1024 # MB转字节 - if audio_file.size > max_file_size: - return create_api_response( - code="400", - message=f"文件大小超过 {max_file_size // (1024 * 1024)}MB 限制" - ) - - # 3. 保存音频文件到磁盘 - meeting_dir = AUDIO_DIR / str(meeting_id) - meeting_dir.mkdir(exist_ok=True) - unique_filename = f"{uuid.uuid4()}{file_extension}" - absolute_path = meeting_dir / unique_filename - relative_path = absolute_path.relative_to(BASE_DIR) - - try: - with open(absolute_path, "wb") as buffer: - shutil.copyfileobj(audio_file.file, buffer) - 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 - - # 4. 调用 audio_service 处理文件(权限检查、数据库更新、启动转录) - result = handle_audio_upload( - file_path=file_path, - file_name=file_name, - file_size=file_size, - meeting_id=meeting_id, - current_user=current_user, - auto_summarize=auto_summarize_bool, - background_tasks=background_tasks, - prompt_id=prompt_id, - model_code=model_code, - duration=audio_duration # 传递时长参数 + return await meeting_service.upload_audio( + audio_file, + meeting_id, + auto_summarize, + prompt_id, + model_code, + background_tasks, + current_user, ) - # 如果不成功,删除已保存的文件并返回错误 - if not result["success"]: - if absolute_path.exists(): - try: - os.remove(absolute_path) - print(f"Deleted file due to processing error: {absolute_path}") - except Exception as e: - print(f"Warning: Failed to delete file {absolute_path}: {e}") - return result["response"] - - # 5. 返回成功响应 - transcription_task_id = result["transcription_task_id"] - message_suffix = "" - if transcription_task_id: - if auto_summarize_bool: - message_suffix = ",正在进行转录和总结" - else: - message_suffix = ",正在进行转录" - - return create_api_response( - code="200", - message="Audio file uploaded successfully" + - (" and replaced existing file" if result["replaced_existing"] else "") + - message_suffix, - data={ - "file_name": result["file_info"]["file_name"], - "file_path": result["file_info"]["file_path"], - "task_id": transcription_task_id, - "transcription_started": transcription_task_id is not None, - "auto_summarize": auto_summarize_bool, - "model_code": model_code, - "replaced_existing": result["replaced_existing"], - "previous_transcription_cleared": result["replaced_existing"] and result["has_transcription"] - } - ) @router.get("/meetings/{meeting_id}/audio") 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,)) - audio_file = cursor.fetchone() - if not audio_file: - return create_api_response(code="404", message="Audio file not found for this meeting") - return create_api_response(code="200", message="Audio file found", data=audio_file) + return meeting_service.get_audio_file(meeting_id, current_user) + @router.get("/meetings/{meeting_id}/audio/stream") async def stream_audio_file( meeting_id: int, range: Optional[str] = Header(None, alias="Range") ): - """ - 音频文件流式传输端点,支持HTTP Range请求(Safari浏览器必需) - 无需登录认证,用于前端audio标签直接访问 - """ - # 获取音频文件信息 - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT file_name, file_path, file_size FROM audio_files WHERE meeting_id = %s", (meeting_id,)) - audio_file = cursor.fetchone() - if not audio_file: - return Response(content="Audio file not found", status_code=404) + return await meeting_service.stream_audio_file(meeting_id, range) - # 构建完整文件路径 - file_path = BASE_DIR / audio_file['file_path'].lstrip('/') - if not file_path.exists(): - return Response(content="Audio file not found on disk", status_code=404) - - # 总是使用实际文件大小(不依赖数据库记录,防止文件被优化后大小不匹配) - file_size = os.path.getsize(file_path) - file_name = audio_file['file_name'] - - # 根据文件扩展名确定MIME类型 - extension = os.path.splitext(file_name)[1].lower() - mime_types = { - '.mp3': 'audio/mpeg', - '.m4a': 'audio/mp4', # 标准 MIME type,Safari 兼容 - '.wav': 'audio/wav', - '.mpeg': 'audio/mpeg', - '.mp4': 'audio/mp4', - '.webm': 'audio/webm' - } - content_type = mime_types.get(extension, 'audio/mpeg') - - # 处理Range请求 - start = 0 - end = file_size - 1 - - if range: - # 解析Range头: "bytes=start-end" 或 "bytes=start-" - try: - range_spec = range.replace("bytes=", "") - if "-" in range_spec: - parts = range_spec.split("-") - if parts[0]: - start = int(parts[0]) - if parts[1]: - end = int(parts[1]) - except (ValueError, IndexError): - pass - - # 确保范围有效 - if start >= file_size: - return Response( - content="Range Not Satisfiable", - status_code=416, - headers={"Content-Range": f"bytes */{file_size}"} - ) - - end = min(end, file_size - 1) - content_length = end - start + 1 - - # 对所有文件名统一使用RFC 5987标准的URL编码格式 - # 这样可以正确处理中文、特殊字符等所有情况 - encoded_filename = quote(file_name) - filename_header = f"inline; filename*=UTF-8''{encoded_filename}" - - # 生成器函数用于流式读取文件 - def iter_file(): - with open(file_path, 'rb') as f: - f.seek(start) - remaining = content_length - chunk_size = 64 * 1024 # 64KB chunks - while remaining > 0: - read_size = min(chunk_size, remaining) - data = f.read(read_size) - if not data: - break - remaining -= len(data) - yield data - - # 根据是否有Range请求返回不同的响应 - if range: - return StreamingResponse( - iter_file(), - status_code=206, # Partial Content - media_type=content_type, - headers={ - "Content-Range": f"bytes {start}-{end}/{file_size}", - "Accept-Ranges": "bytes", - "Content-Length": str(content_length), - "Content-Disposition": filename_header, - "Cache-Control": "public, max-age=31536000", # 1年缓存 - "X-Content-Type-Options": "nosniff" - } - ) - else: - return StreamingResponse( - iter_file(), - status_code=200, - media_type=content_type, - headers={ - "Accept-Ranges": "bytes", - "Content-Length": str(file_size), - "Content-Disposition": filename_header, - "Cache-Control": "public, max-age=31536000", # 1年缓存 - "X-Content-Type-Options": "nosniff" - } - ) @router.get("/meetings/{meeting_id}/transcription/status") def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depends(get_current_user)): - try: - status_info = transcription_service.get_meeting_transcription_status(meeting_id) - if not status_info: - return create_api_response(code="404", message="No transcription task found for this meeting") - return create_api_response(code="200", message="Transcription status retrieved", data=status_info) - except Exception as e: - return create_api_response(code="500", message=f"Failed to get meeting transcription status: {str(e)}") + return meeting_service.get_meeting_transcription_status(meeting_id, current_user) + @router.post("/meetings/{meeting_id}/transcription/start") def start_meeting_transcription( @@ -946,214 +116,67 @@ def start_meeting_transcription( background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user) ): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT meeting_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return create_api_response(code="404", message="Meeting not found") - cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,)) - audio_file = cursor.fetchone() - if not audio_file: - return create_api_response(code="400", message="No audio file found for this meeting") - existing_status = transcription_service.get_meeting_transcription_status(meeting_id) - if existing_status and existing_status['status'] in ['pending', 'processing']: - return create_api_response(code="409", message="Transcription task already exists", data={ - "task_id": existing_status['task_id'], "status": existing_status['status'] - }) - task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path']) - async_meeting_service.enqueue_transcription_monitor( - meeting_id, - task_id, - meeting.get('prompt_id') if meeting.get('prompt_id') not in (None, 0) else None, - None - ) - return create_api_response(code="200", message="Transcription task started successfully", data={ - "task_id": task_id, "meeting_id": meeting_id - }) - except Exception as e: - return create_api_response(code="500", message=f"Failed to start transcription: {str(e)}") + return meeting_service.start_meeting_transcription(meeting_id, background_tasks, current_user) + @router.post("/meetings/{meeting_id}/upload-image") async def upload_image(meeting_id: int, image_file: UploadFile = File(...), current_user: dict = Depends(get_current_user)): - file_extension = os.path.splitext(image_file.filename)[1].lower() - if file_extension not in ALLOWED_IMAGE_EXTENSIONS: - return create_api_response(code="400", message=f"Unsupported image type. Allowed types: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}") - max_image_size = getattr(config_module, 'MAX_IMAGE_SIZE', 10 * 1024 * 1024) - if image_file.size > max_image_size: - return create_api_response(code="400", message=f"Image size exceeds {max_image_size // (1024 * 1024)}MB limit") - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return create_api_response(code="404", message="Meeting not found") - if meeting['user_id'] != current_user['user_id']: - return create_api_response(code="403", message="Permission denied") - meeting_dir = MARKDOWN_DIR / str(meeting_id) - meeting_dir.mkdir(exist_ok=True) - unique_filename = f"{uuid.uuid4()}{file_extension}" - absolute_path = meeting_dir / unique_filename - relative_path = absolute_path.relative_to(BASE_DIR) - try: - with open(absolute_path, "wb") as buffer: - shutil.copyfileobj(image_file.file, buffer) - except Exception as e: - return create_api_response(code="500", message=f"Failed to save image: {str(e)}") - return create_api_response(code="200", message="Image uploaded successfully", data={ - "file_name": image_file.filename, "file_path": '/'+ str(relative_path) - }) + return await meeting_service.upload_image(meeting_id, image_file, current_user) + @router.put("/meetings/{meeting_id}/speaker-tags") def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) - if error_response: - return error_response - update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' - cursor.execute(update_query, (request.new_tag, meeting_id, request.speaker_id)) - if cursor.rowcount == 0: - return create_api_response(code="404", message="No segments found for this speaker") - connection.commit() - return create_api_response(code="200", message="Speaker tag updated successfully", data={'updated_count': cursor.rowcount}) - except Exception as e: - return create_api_response(code="500", message=f"Failed to update speaker tag: {str(e)}") + return meeting_service.update_speaker_tag(meeting_id, request, current_user) + @router.put("/meetings/{meeting_id}/speaker-tags/batch") -def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) - if error_response: - return error_response - total_updated = 0 - for update_item in request.updates: - update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' - cursor.execute(update_query, (update_item.new_tag, meeting_id, update_item.speaker_id)) - total_updated += cursor.rowcount - connection.commit() - return create_api_response(code="200", message="Speaker tags updated successfully", data={'total_updated': total_updated}) - except Exception as e: - return create_api_response(code="500", message=f"Failed to batch update speaker tags: {str(e)}") +def batch_update_speaker_tags( + meeting_id: int, + request: BatchSpeakerTagUpdateRequest, + current_user: dict = Depends(get_current_user) +): + return meeting_service.batch_update_speaker_tags(meeting_id, request, current_user) + @router.put("/meetings/{meeting_id}/transcript/batch") -def batch_update_transcript(meeting_id: int, request: BatchTranscriptUpdateRequest, current_user: dict = Depends(get_current_user)): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) - if error_response: - return error_response - total_updated = 0 - for update_item in request.updates: - cursor.execute("SELECT segment_id FROM transcript_segments WHERE segment_id = %s AND meeting_id = %s", (update_item.segment_id, meeting_id)) - if not cursor.fetchone(): - continue - update_query = 'UPDATE transcript_segments SET text_content = %s WHERE segment_id = %s AND meeting_id = %s' - cursor.execute(update_query, (update_item.text_content, update_item.segment_id, meeting_id)) - total_updated += cursor.rowcount - connection.commit() - return create_api_response(code="200", message="Transcript updated successfully", data={'total_updated': total_updated}) - except Exception as e: - return create_api_response(code="500", message=f"Failed to update transcript: {str(e)}") +def batch_update_transcript( + meeting_id: int, + request: BatchTranscriptUpdateRequest, + current_user: dict = Depends(get_current_user) +): + return meeting_service.batch_update_transcript(meeting_id, request, current_user) + @router.get("/meetings/{meeting_id}/summaries") def get_meeting_summaries(meeting_id: int, current_user: dict = Depends(get_current_user)): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="Meeting not found") - summaries = llm_service.get_meeting_summaries(meeting_id) - return create_api_response(code="200", message="Summaries retrieved successfully", data={"summaries": summaries}) - except Exception as e: - return create_api_response(code="500", message=f"Failed to get summaries: {str(e)}") + return meeting_service.get_meeting_summaries(meeting_id, current_user) + @router.get("/meetings/{meeting_id}/summaries/{summary_id}") def get_summary_detail(meeting_id: int, summary_id: int, current_user: dict = Depends(get_current_user)): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - query = "SELECT id, summary_content, user_prompt, created_at FROM meeting_summaries WHERE id = %s AND meeting_id = %s" - cursor.execute(query, (summary_id, meeting_id)) - summary = cursor.fetchone() - if not summary: - return create_api_response(code="404", message="Summary not found") - return create_api_response(code="200", message="Summary detail retrieved", data=summary) - except Exception as e: - return create_api_response(code="500", message=f"Failed to get summary detail: {str(e)}") + return meeting_service.get_summary_detail(meeting_id, summary_id, current_user) + @router.post("/meetings/{meeting_id}/generate-summary-async") -def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) - if error_response: - return error_response - transcription_status = transcription_service.get_meeting_transcription_status(meeting_id) - if transcription_status and transcription_status.get('status') in ['pending', 'processing']: - return create_api_response(code="409", message="转录进行中,暂不允许重新总结", data={ - "task_id": transcription_status.get('task_id'), - "status": transcription_status.get('status') - }) - llm_status = async_meeting_service.get_meeting_llm_status(meeting_id) - if llm_status and llm_status.get('status') in ['pending', 'processing']: - return create_api_response(code="409", message="总结任务已存在", data={ - "task_id": llm_status.get('task_id'), - "status": llm_status.get('status') - }) - # 传递 prompt_id 和 model_code 参数给服务层 - task_id, created = async_meeting_service.enqueue_summary_generation( - meeting_id, - request.user_prompt, - request.prompt_id, - request.model_code, - ) - if not created: - return create_api_response(code="409", message="总结任务已存在", data={ - "task_id": task_id, - "status": "pending" - }) - return create_api_response(code="200", message="Summary generation task has been accepted.", data={ - "task_id": task_id, "status": "pending", "meeting_id": meeting_id - }) - except Exception as e: - return create_api_response(code="500", message=f"Failed to start summary generation: {str(e)}") +def generate_meeting_summary_async( + meeting_id: int, + request: GenerateSummaryRequest, + background_tasks: BackgroundTasks, + current_user: dict = Depends(get_current_user) +): + return meeting_service.generate_meeting_summary_async(meeting_id, request, background_tasks, current_user) + @router.get("/meetings/{meeting_id}/llm-tasks") def get_meeting_llm_tasks(meeting_id: int, current_user: dict = Depends(get_current_user)): - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - if not cursor.fetchone(): - return create_api_response(code="404", message="Meeting not found") - tasks = async_meeting_service.get_meeting_llm_tasks(meeting_id) - return create_api_response(code="200", message="LLM tasks retrieved successfully", data={ - "tasks": tasks, "total": len(tasks) - }) - except Exception as e: - return create_api_response(code="500", message=f"Failed to get LLM tasks: {str(e)}") + return meeting_service.get_meeting_llm_tasks(meeting_id, current_user) + @router.get("/llm-models/active") def list_active_llm_models(current_user: dict = Depends(get_current_user)): - """获取所有激活的LLM模型列表(供普通用户选择)""" - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - cursor.execute( - "SELECT model_code, model_name, provider, is_default FROM llm_model_config WHERE is_active = 1 ORDER BY is_default DESC, model_code ASC" - ) - models = cursor.fetchall() - return create_api_response(code="200", message="获取模型列表成功", data=models) - except Exception as e: - return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}") + return meeting_service.list_active_llm_models(current_user) + + @router.get("/meetings/{meeting_id}/navigation") def get_meeting_navigation( meeting_id: int, @@ -1163,261 +186,20 @@ def get_meeting_navigation( search: Optional[str] = None, tags: Optional[str] = None ): - """ - 获取当前会议在列表中的上一条和下一条 + return meeting_service.get_meeting_navigation( + meeting_id, + current_user, + user_id, + filter_type, + search, + tags, + ) - Query params: - - user_id: 当前用户ID - - filter_type: 筛选类型 ('all', 'created', 'attended') - - search: 搜索关键词 (可选) - - tags: 标签列表,逗号分隔 (可选) - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 构建WHERE子句 - 与get_meetings保持一致 - where_conditions = [] - params = [] - has_attendees_join = False - - # 按类型过滤 - if user_id: - if filter_type == "created": - where_conditions.append("m.user_id = %s") - params.append(user_id) - elif filter_type == "attended": - where_conditions.append("m.user_id != %s AND a.user_id = %s") - params.extend([user_id, user_id]) - has_attendees_join = True - else: # all - where_conditions.append("(m.user_id = %s OR a.user_id = %s)") - params.extend([user_id, user_id]) - has_attendees_join = True - - # 搜索关键词过滤 - if search and search.strip(): - search_pattern = f"%{search.strip()}%" - where_conditions.append("(m.title LIKE %s OR u.caption LIKE %s)") - params.extend([search_pattern, search_pattern]) - - # 标签过滤 - if tags and tags.strip(): - tag_list = [t.strip() for t in tags.split(',') if t.strip()] - if tag_list: - tag_conditions = [] - for tag in tag_list: - tag_conditions.append("m.tags LIKE %s") - params.append(f"%{tag}%") - where_conditions.append(f"({' OR '.join(tag_conditions)})") - - # 构建查询 - 只获取meeting_id,按meeting_time降序排序 - query = ''' - SELECT m.meeting_id - FROM meetings m - JOIN sys_users u ON m.user_id = u.user_id - ''' - - if has_attendees_join: - query += " LEFT JOIN attendees a ON m.meeting_id = a.meeting_id" - - if where_conditions: - query += f" WHERE {' AND '.join(where_conditions)}" - - if has_attendees_join: - query += " GROUP BY m.meeting_id" - - query += " ORDER BY m.meeting_time DESC, m.created_at DESC" - - cursor.execute(query, params) - all_meetings = cursor.fetchall() - all_meeting_ids = [m['meeting_id'] for m in all_meetings] - - # 找到当前会议在列表中的位置 - try: - current_index = all_meeting_ids.index(meeting_id) - except ValueError: - return create_api_response(code="200", message="当前会议不在筛选结果中", data={ - 'prev_meeting_id': None, - 'next_meeting_id': None, - 'current_index': None, - 'total_count': len(all_meeting_ids) - }) - - # 计算上一条和下一条 - prev_meeting_id = all_meeting_ids[current_index - 1] if current_index > 0 else None - next_meeting_id = all_meeting_ids[current_index + 1] if current_index < len(all_meeting_ids) - 1 else None - - return create_api_response(code="200", message="获取导航信息成功", data={ - 'prev_meeting_id': prev_meeting_id, - 'next_meeting_id': next_meeting_id, - 'current_index': current_index, - 'total_count': len(all_meeting_ids) - }) - - except Exception as e: - return create_api_response(code="500", message=f"获取导航信息失败: {str(e)}") @router.get("/meetings/{meeting_id}/preview-data") def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None): - """ - 获取会议预览数据(无需登录认证) - 用于二维码扫描后的预览页面 + return meeting_service.get_meeting_preview_data(meeting_id, password) - 返回状态码说明: - - 200: 会议已完成(summary已生成) - - 400: 会议处理中(转译或总结阶段) - - 503: 处理失败(转译或总结失败) - - 504: 数据异常(流程完成但summary未生成) - - 404: 会议不存在 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 检查会议是否存在,并获取基本信息 - query = ''' - SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id, m.tags, - m.user_id as creator_id, u.caption as creator_username, - p.name as prompt_name, m.access_password - FROM meetings m - JOIN sys_users u ON m.user_id = u.user_id - LEFT JOIN prompts p ON m.prompt_id = p.id - WHERE m.meeting_id = %s - ''' - cursor.execute(query, (meeting_id,)) - meeting = cursor.fetchone() - - if not meeting: - return create_api_response(code="404", message="会议不存在") - - stored_password = (meeting.get('access_password') or '').strip() - provided_password = (password or '').strip() - - if stored_password: - if not provided_password: - return create_api_response( - code="401", - message="此会议受密码保护", - data={ - "meeting_id": meeting_id, - "title": meeting['title'], - "requires_password": True - } - ) - - if provided_password != stored_password: - return create_api_response( - code="401", - message="密码错误", - data={ - "meeting_id": meeting_id, - "title": meeting['title'], - "requires_password": True - } - ) - - # 获取整体进度状态(两阶段) - progress_info = _get_meeting_overall_status(meeting_id) - overall_status = progress_info["overall_status"] - - # 根据整体状态返回不同响应 - - # 情况1: 任一阶段失败 → 返回503 - if overall_status == "failed": - failed_stage = progress_info["current_stage"] - error_info = progress_info["transcription"] if failed_stage == "transcription" else progress_info["llm"] - error_message = error_info["error_message"] or "处理失败" - - stage_name = "转译" if failed_stage == "transcription" else "总结" - return create_api_response( - code="503", - message=f"会议{stage_name}失败: {error_message}", - data={ - "meeting_id": meeting_id, - "title": meeting['title'], - "processing_status": progress_info - } - ) - - # 情况2: 处理中(转译或总结阶段)→ 返回400 - if overall_status in ["pending", "transcribing", "summarizing"]: - stage_descriptions = { - "pending": "等待开始", - "transcribing": "正在转译音频", - "summarizing": "正在生成总结" - } - return create_api_response( - code="400", - message=f"会议正在处理中: {stage_descriptions[overall_status]}", - data={ - "meeting_id": meeting_id, - "title": meeting['title'], - "processing_status": progress_info - } - ) - - # 情况3: 全部完成但Summary缺失 → 返回504 - if overall_status == "completed" and not meeting['summary']: - return create_api_response( - code="504", - message="处理已完成,AI总结尚未同步,请稍后重试", - data={ - "meeting_id": meeting_id, - "title": meeting['title'], - "processing_status": progress_info - } - ) - - # 情况4: 全部完成 → 返回200,提供完整预览数据 - if overall_status == "completed" and meeting['summary']: - # 获取参会人员信息 - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' - cursor.execute(attendees_query, (meeting_id,)) - attendees_data = cursor.fetchall() - attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - cursor.execute( - ''' - SELECT COUNT(DISTINCT speaker_id) AS participant_count - FROM transcript_segments - WHERE meeting_id = %s AND speaker_id IS NOT NULL - ''', - (meeting_id,) - ) - speaker_count_row = cursor.fetchone() or {} - participant_count = speaker_count_row.get('participant_count') or len(attendees) - tags = _process_tags(cursor, meeting.get('tags')) - - # 组装返回数据 - preview_data = { - "meeting_id": meeting['meeting_id'], - "title": meeting['title'], - "meeting_time": meeting['meeting_time'], - "summary": meeting['summary'], - "creator_username": meeting['creator_username'], - "prompt_id": meeting['prompt_id'], - "prompt_name": meeting['prompt_name'], - "attendees": attendees, - "attendees_count": participant_count, - "tags": tags, - "has_password": bool(meeting.get('access_password')), - "processing_status": progress_info # 附带进度信息供调试 - } - - return create_api_response(code="200", message="获取会议预览数据成功", data=preview_data) - - except Exception as e: - return create_api_response(code="500", message=f"Failed to get meeting preview data: {str(e)}") - -# 访问密码管理相关API - -class AccessPasswordRequest(BaseModel): - password: Optional[str] = None # None表示关闭密码 - -class VerifyPasswordRequest(BaseModel): - password: str @router.put("/meetings/{meeting_id}/access-password") def update_meeting_access_password( @@ -1425,118 +207,9 @@ def update_meeting_access_password( request: AccessPasswordRequest, current_user: dict = Depends(get_current_user) ): - """ - 设置或关闭会议访问密码(仅创建人可操作) + return meeting_service.update_meeting_access_password(meeting_id, request, current_user) - Args: - meeting_id: 会议ID - request.password: 密码字符串(None表示关闭密码) - current_user: 当前登录用户 - - Returns: - API响应,包含操作结果 - """ - try: - normalized_password = None - if request.password is not None: - normalized_password = request.password.strip() - if not normalized_password: - return create_api_response(code="400", message="访问密码不能为空") - - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 检查会议是否存在且当前用户是创建人 - cursor.execute( - "SELECT meeting_id, user_id FROM meetings WHERE meeting_id = %s", - (meeting_id,) - ) - meeting = cursor.fetchone() - - if not meeting: - return create_api_response(code="404", message="会议不存在") - - if meeting['user_id'] != current_user['user_id']: - return create_api_response(code="403", message="仅创建人可以设置访问密码") - - # 更新访问密码 - cursor.execute( - "UPDATE meetings SET access_password = %s WHERE meeting_id = %s", - (normalized_password, meeting_id) - ) - connection.commit() - - if normalized_password: - return create_api_response( - code="200", - message="访问密码已设置", - data={"password": normalized_password} - ) - else: - return create_api_response( - code="200", - message="访问密码已关闭", - data={"password": None} - ) - - except Exception as e: - return create_api_response( - code="500", - message=f"设置访问密码失败: {str(e)}" - ) @router.post("/meetings/{meeting_id}/verify-password") def verify_meeting_password(meeting_id: int, request: VerifyPasswordRequest): - """ - 验证会议访问密码(无需登录认证) - - Args: - meeting_id: 会议ID - request.password: 要验证的密码 - - Returns: - API响应,包含验证结果 - """ - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 获取会议的访问密码 - cursor.execute( - "SELECT access_password FROM meetings WHERE meeting_id = %s", - (meeting_id,) - ) - meeting = cursor.fetchone() - - if not meeting: - return create_api_response(code="404", message="会议不存在") - - # 验证密码 - stored_password = meeting.get('access_password') - - if not stored_password: - # 没有设置密码,直接通过 - return create_api_response( - code="200", - message="该会议未设置访问密码", - data={"verified": True} - ) - - if request.password == stored_password: - return create_api_response( - code="200", - message="密码验证成功", - data={"verified": True} - ) - else: - return create_api_response( - code="200", - message="密码错误", - data={"verified": False} - ) - - except Exception as e: - return create_api_response( - code="500", - message=f"验证密码失败: {str(e)}" - ) + return meeting_service.verify_meeting_password(meeting_id, request) diff --git a/backend/app/app_factory.py b/backend/app/app_factory.py new file mode 100644 index 0000000..ddcaf6e --- /dev/null +++ b/backend/app/app_factory.py @@ -0,0 +1,90 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.staticfiles import StaticFiles + +from app.api.endpoints import ( + admin, + admin_dashboard, + admin_settings, + audio, + auth, + client_downloads, + dict_data, + external_apps, + hot_words, + knowledge_base, + meetings, + prompts, + tags, + tasks, + terminals, + users, + voiceprint, +) +from app.core.config import UPLOAD_DIR +from app.core.middleware import TerminalCheckMiddleware + + +def create_app() -> FastAPI: + app = FastAPI( + title="iMeeting API", + description="iMeeting API说明", + version="1.1.0", + docs_url=None, + redoc_url=None, + ) + + app.add_middleware(TerminalCheckMiddleware) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + if UPLOAD_DIR.exists(): + app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") + + app.include_router(auth.router, prefix="/api", tags=["Authentication"]) + app.include_router(users.router, prefix="/api", tags=["Users"]) + app.include_router(meetings.router, prefix="/api", tags=["Meetings"]) + app.include_router(tags.router, prefix="/api", tags=["Tags"]) + app.include_router(admin.router, prefix="/api", tags=["Admin"]) + app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"]) + app.include_router(admin_settings.router, prefix="/api", tags=["AdminSettings"]) + app.include_router(tasks.router, prefix="/api", tags=["Tasks"]) + app.include_router(prompts.router, prefix="/api", tags=["Prompts"]) + app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"]) + app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"]) + app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"]) + app.include_router(dict_data.router, prefix="/api", tags=["DictData"]) + app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"]) + app.include_router(audio.router, prefix="/api", tags=["Audio"]) + app.include_router(hot_words.router, prefix="/api", tags=["HotWords"]) + app.include_router(terminals.router, prefix="/api", tags=["Terminals"]) + + @app.get("/docs", include_in_schema=False) + async def custom_swagger_ui_html(): + return get_swagger_ui_html( + openapi_url=app.openapi_url, + title=app.title + " - Swagger UI", + oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, + swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js", + swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css", + ) + + @app.get("/") + def read_root(): + return {"message": "Welcome to iMeeting API"} + + @app.get("/health") + def health_check(): + return { + "status": "healthy", + "service": "iMeeting API", + "version": "1.1.0", + } + + return app diff --git a/backend/app/main.py b/backend/app/main.py index c1f0ad5..52ed3cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,110 +1,22 @@ import sys -import os from pathlib import Path # 添加项目根目录到 Python 路径 -# 无论从哪里运行,都能正确找到 app 模块 current_file = Path(__file__).resolve() -project_root = current_file.parent.parent # backend/ +project_root = current_file.parent.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) import uvicorn -from fastapi import FastAPI, Request, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.openapi.docs import get_swagger_ui_html -from app.core.middleware import TerminalCheckMiddleware -from app.api.endpoints import ( - auth, - users, - meetings, - tags, - admin, - admin_dashboard, - admin_settings, - tasks, - prompts, - knowledge_base, - client_downloads, - voiceprint, - audio, - dict_data, - hot_words, - external_apps, - terminals, -) -from app.core.config import UPLOAD_DIR, API_CONFIG -app = FastAPI( - title="iMeeting API", - description="iMeeting API说明", - version="1.1.0", - docs_url=None, # 禁用默认docs,使用自定义CDN - redoc_url=None -) +from app.app_factory import create_app +from app.core.config import API_CONFIG -# 添加终端检查中间件 (在CORS之前添加,以便位于CORS内部) -app.add_middleware(TerminalCheckMiddleware) -# 添加CORS中间件 -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +app = create_app() -# 静态文件服务 - 提供音频文件下载 -if UPLOAD_DIR.exists(): - app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") - -# 包含API路由 -app.include_router(auth.router, prefix="/api", tags=["Authentication"]) -app.include_router(users.router, prefix="/api", tags=["Users"]) -app.include_router(meetings.router, prefix="/api", tags=["Meetings"]) -app.include_router(tags.router, prefix="/api", tags=["Tags"]) -app.include_router(admin.router, prefix="/api", tags=["Admin"]) -app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"]) -app.include_router(admin_settings.router, prefix="/api", tags=["AdminSettings"]) -app.include_router(tasks.router, prefix="/api", tags=["Tasks"]) -app.include_router(prompts.router, prefix="/api", tags=["Prompts"]) -app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"]) -app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"]) -app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"]) -app.include_router(dict_data.router, prefix="/api", tags=["DictData"]) -app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"]) -app.include_router(audio.router, prefix="/api", tags=["Audio"]) -app.include_router(hot_words.router, prefix="/api", tags=["HotWords"]) -app.include_router(terminals.router, prefix="/api", tags=["Terminals"]) - -@app.get("/docs", include_in_schema=False) -async def custom_swagger_ui_html(): - """自定义Swagger UI,使用国内可访问的CDN""" - return get_swagger_ui_html( - openapi_url=app.openapi_url, - title=app.title + " - Swagger UI", - oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, - swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js", - swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css", - ) - -@app.get("/") -def read_root(): - return {"message": "Welcome to iMeeting API"} - -@app.get("/health") -def health_check(): - """健康检查端点""" - return { - "status": "healthy", - "service": "iMeeting API", - "version": "1.1.0" - } if __name__ == "__main__": - # 简单的uvicorn配置,避免参数冲突 uvicorn.run( "app.main:app", host=API_CONFIG['host'], diff --git a/backend/app/services/admin_dashboard_service.py b/backend/app/services/admin_dashboard_service.py new file mode 100644 index 0000000..40e8029 --- /dev/null +++ b/backend/app/services/admin_dashboard_service.py @@ -0,0 +1,482 @@ +from app.core.response import create_api_response +from app.core.database import get_db_connection +from app.services.jwt_service import jwt_service +from app.core.config import AUDIO_DIR, REDIS_CONFIG +from datetime import datetime +from typing import Dict, List +import os +import redis + + +# Redis 客户端 +redis_client = redis.Redis(**REDIS_CONFIG) + +# 常量定义 +AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.mpeg', '.mp4', '.webm') +BYTES_TO_GB = 1024 ** 3 + + +def _build_status_condition(status: str) -> str: + """构建任务状态查询条件""" + if status == 'running': + return "AND (t.status = 'pending' OR t.status = 'processing')" + elif status == 'completed': + return "AND t.status = 'completed'" + elif status == 'failed': + return "AND t.status = 'failed'" + return "" + + +def _get_task_stats_query() -> str: + """获取任务统计的 SQL 查询""" + return """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed + """ + + +def _get_online_user_count(redis_client) -> int: + """从 Redis 获取在线用户数""" + try: + token_keys = redis_client.keys("token:*") + user_ids = set() + for key in token_keys: + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") + parts = key.split(':') + if len(parts) >= 2: + user_ids.add(parts[1]) + return len(user_ids) + except Exception as e: + print(f"获取在线用户数失败: {e}") + return 0 + + +def _table_exists(cursor, table_name: str) -> bool: + cursor.execute( + """ + SELECT COUNT(*) AS cnt + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = %s + """, + (table_name,), + ) + return (cursor.fetchone() or {}).get("cnt", 0) > 0 + + +def _calculate_audio_storage() -> Dict[str, float]: + """计算音频文件存储统计""" + audio_files_count = 0 + audio_total_size = 0 + + try: + if os.path.exists(AUDIO_DIR): + for root, _, files in os.walk(AUDIO_DIR): + for file in files: + file_extension = os.path.splitext(file)[1].lower() + if file_extension in AUDIO_FILE_EXTENSIONS: + audio_files_count += 1 + file_path = os.path.join(root, file) + try: + audio_total_size += os.path.getsize(file_path) + except OSError: + continue + except Exception as e: + print(f"统计音频文件失败: {e}") + + return { + "audio_file_count": audio_files_count, + "audio_files_count": audio_files_count, + "audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2) + } + + +async def get_dashboard_stats(current_user=None): + """获取管理员 Dashboard 统计数据""" + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 1. 用户统计 + today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + total_users = 0 + today_new_users = 0 + + if _table_exists(cursor, "sys_users"): + cursor.execute("SELECT COUNT(*) as total FROM sys_users") + total_users = (cursor.fetchone() or {}).get("total", 0) + + cursor.execute( + "SELECT COUNT(*) as count FROM sys_users WHERE created_at >= %s", + (today_start,), + ) + today_new_users = (cursor.fetchone() or {}).get("count", 0) + + online_users = _get_online_user_count(redis_client) + + # 2. 会议统计 + total_meetings = 0 + today_new_meetings = 0 + if _table_exists(cursor, "meetings"): + cursor.execute("SELECT COUNT(*) as total FROM meetings") + total_meetings = (cursor.fetchone() or {}).get("total", 0) + + cursor.execute( + "SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s", + (today_start,), + ) + today_new_meetings = (cursor.fetchone() or {}).get("count", 0) + + # 3. 任务统计 + task_stats_query = _get_task_stats_query() + + # 转录任务 + if _table_exists(cursor, "transcript_tasks"): + cursor.execute(f"{task_stats_query} FROM transcript_tasks") + transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + else: + transcription_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + + # 总结任务 + if _table_exists(cursor, "llm_tasks"): + cursor.execute(f"{task_stats_query} FROM llm_tasks") + summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + else: + summary_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + + # 知识库任务 + if _table_exists(cursor, "knowledge_base_tasks"): + cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks") + kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + else: + kb_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0} + + # 4. 音频存储统计 + storage_stats = _calculate_audio_storage() + + # 组装返回数据 + stats = { + "users": { + "total": total_users, + "today_new": today_new_users, + "online": online_users + }, + "meetings": { + "total": total_meetings, + "today_new": today_new_meetings + }, + "tasks": { + "transcription": { + "total": transcription_stats['total'] or 0, + "running": transcription_stats['running'] or 0, + "completed": transcription_stats['completed'] or 0, + "failed": transcription_stats['failed'] or 0 + }, + "summary": { + "total": summary_stats['total'] or 0, + "running": summary_stats['running'] or 0, + "completed": summary_stats['completed'] or 0, + "failed": summary_stats['failed'] or 0 + }, + "knowledge_base": { + "total": kb_stats['total'] or 0, + "running": kb_stats['running'] or 0, + "completed": kb_stats['completed'] or 0, + "failed": kb_stats['failed'] or 0 + } + }, + "storage": storage_stats + } + + return create_api_response(code="200", message="获取统计数据成功", data=stats) + + except Exception as e: + print(f"获取Dashboard统计数据失败: {e}") + return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}") + + +async def get_online_users(current_user=None): + """获取在线用户列表""" + try: + token_keys = redis_client.keys("token:*") + + # 提取用户ID并去重 + user_tokens = {} + for key in token_keys: + if isinstance(key, bytes): + key = key.decode("utf-8", errors="ignore") + parts = key.split(':') + if len(parts) >= 3: + user_id = int(parts[1]) + token = parts[2] + if user_id not in user_tokens: + user_tokens[user_id] = [] + user_tokens[user_id].append({'token': token, 'key': key}) + + # 查询用户信息 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + online_users_list = [] + for user_id, tokens in user_tokens.items(): + cursor.execute( + "SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s", + (user_id,) + ) + user = cursor.fetchone() + if user: + ttl_seconds = redis_client.ttl(tokens[0]['key']) + online_users_list.append({ + **user, + 'token_count': len(tokens), + 'ttl_seconds': ttl_seconds, + 'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0 + }) + + # 按用户ID排序 + online_users_list.sort(key=lambda x: x['user_id']) + + return create_api_response( + code="200", + message="获取在线用户列表成功", + data={"users": online_users_list, "total": len(online_users_list)} + ) + + except Exception as e: + print(f"获取在线用户列表失败: {e}") + return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}") + + +async def kick_user(user_id: int, current_user=None): + """踢出用户(撤销该用户的所有 token)""" + try: + revoked_count = jwt_service.revoke_all_user_tokens(user_id) + + if revoked_count > 0: + return create_api_response( + code="200", + message=f"已踢出用户,撤销了 {revoked_count} 个 token", + data={"user_id": user_id, "revoked_count": revoked_count} + ) + else: + return create_api_response( + code="404", + message="该用户当前不在线或未找到 token" + ) + + except Exception as e: + print(f"踢出用户失败: {e}") + return create_api_response(code="500", message=f"踢出用户失败: {str(e)}") + + +async def monitor_tasks( + task_type: str = 'all', + status: str = 'all', + limit: int = 20, + current_user=None +): + """监控任务进度""" + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + tasks = [] + status_condition = _build_status_condition(status) + + # 转录任务 + if task_type in ['all', 'transcription']: + query = f""" + SELECT + t.task_id, + 'transcription' as task_type, + t.meeting_id, + m.title as meeting_title, + t.status, + t.progress, + t.error_message, + t.created_at, + t.completed_at, + u.username as creator_name + FROM transcript_tasks t + LEFT JOIN meetings m ON t.meeting_id = m.meeting_id + LEFT JOIN sys_users u ON m.user_id = u.user_id + WHERE 1=1 {status_condition} + ORDER BY t.created_at DESC + LIMIT %s + """ + cursor.execute(query, (limit,)) + tasks.extend(cursor.fetchall()) + + # 总结任务 + if task_type in ['all', 'summary']: + query = f""" + SELECT + t.task_id, + 'summary' as task_type, + t.meeting_id, + m.title as meeting_title, + t.status, + t.progress, + t.error_message, + t.created_at, + t.completed_at, + u.username as creator_name + FROM llm_tasks t + LEFT JOIN meetings m ON t.meeting_id = m.meeting_id + LEFT JOIN sys_users u ON m.user_id = u.user_id + WHERE 1=1 {status_condition} + ORDER BY t.created_at DESC + LIMIT %s + """ + cursor.execute(query, (limit,)) + tasks.extend(cursor.fetchall()) + + # 知识库任务 + if task_type in ['all', 'knowledge_base']: + query = f""" + SELECT + t.task_id, + 'knowledge_base' as task_type, + t.kb_id as meeting_id, + k.title as meeting_title, + t.status, + t.progress, + t.error_message, + t.created_at, + t.updated_at, + u.username as creator_name + FROM knowledge_base_tasks t + LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id + LEFT JOIN sys_users u ON k.creator_id = u.user_id + WHERE 1=1 {status_condition} + ORDER BY t.created_at DESC + LIMIT %s + """ + cursor.execute(query, (limit,)) + tasks.extend(cursor.fetchall()) + + # 按创建时间排序并限制返回数量 + tasks.sort(key=lambda x: x['created_at'], reverse=True) + tasks = tasks[:limit] + + return create_api_response( + code="200", + message="获取任务监控数据成功", + data={"tasks": tasks, "total": len(tasks)} + ) + + except Exception as e: + print(f"获取任务监控数据失败: {e}") + import traceback + traceback.print_exc() + return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}") + + +async def get_system_resources(current_user=None): + """获取服务器资源使用情况""" + try: + import psutil + + # CPU 使用率 + cpu_percent = psutil.cpu_percent(interval=1) + cpu_count = psutil.cpu_count() + + # 内存使用情况 + memory = psutil.virtual_memory() + memory_total_gb = round(memory.total / BYTES_TO_GB, 2) + memory_used_gb = round(memory.used / BYTES_TO_GB, 2) + + # 磁盘使用情况 + disk = psutil.disk_usage('/') + disk_total_gb = round(disk.total / BYTES_TO_GB, 2) + disk_used_gb = round(disk.used / BYTES_TO_GB, 2) + + resources = { + "cpu": { + "percent": cpu_percent, + "count": cpu_count + }, + "memory": { + "total_gb": memory_total_gb, + "used_gb": memory_used_gb, + "percent": memory.percent + }, + "disk": { + "total_gb": disk_total_gb, + "used_gb": disk_used_gb, + "percent": disk.percent + }, + "timestamp": datetime.now().isoformat() + } + + return create_api_response(code="200", message="获取系统资源成功", data=resources) + + except ImportError: + return create_api_response( + code="500", + message="psutil 库未安装,请运行: pip install psutil" + ) + except Exception as e: + print(f"获取系统资源失败: {e}") + return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}") + + +async def get_user_stats(current_user=None): + """获取用户统计列表""" + 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 sys_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/backend/app/services/admin_service.py b/backend/app/services/admin_service.py new file mode 100644 index 0000000..e6520e7 --- /dev/null +++ b/backend/app/services/admin_service.py @@ -0,0 +1,550 @@ +import time +from typing import Any + +from app.core.database import get_db_connection +from app.core.response import create_api_response +from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo + + +_USER_MENU_CACHE_TTL_SECONDS = 120 +_USER_MENU_CACHE_VERSION = "menu-rules-v4" +_user_menu_cache_by_role: dict[int, dict[str, Any]] = {} + + +def _get_cached_user_menus(role_id: int): + cached = _user_menu_cache_by_role.get(role_id) + if not cached: + return None + if cached.get("version") != _USER_MENU_CACHE_VERSION: + _user_menu_cache_by_role.pop(role_id, None) + return None + if time.time() > cached["expires_at"]: + _user_menu_cache_by_role.pop(role_id, None) + return None + return cached["menus"] + + +def _set_cached_user_menus(role_id: int, menus): + _user_menu_cache_by_role[role_id] = { + "version": _USER_MENU_CACHE_VERSION, + "menus": menus, + "expires_at": time.time() + _USER_MENU_CACHE_TTL_SECONDS, + } + + +def _invalidate_user_menu_cache(role_id: int | None = None): + if role_id is None: + _user_menu_cache_by_role.clear() + return + _user_menu_cache_by_role.pop(role_id, None) + + +def _build_menu_index(menus): + menu_by_id = {} + children_by_parent = {} + for menu in menus: + menu_id = menu["menu_id"] + menu_by_id[menu_id] = menu + parent_id = menu.get("parent_id") + if parent_id is not None: + children_by_parent.setdefault(parent_id, []).append(menu_id) + return menu_by_id, children_by_parent + + +def _get_descendants(menu_id, children_by_parent): + result = set() + stack = [menu_id] + while stack: + current = stack.pop() + for child_id in children_by_parent.get(current, []): + if child_id in result: + continue + result.add(child_id) + stack.append(child_id) + return result + + +def _normalize_permission_menu_ids(raw_menu_ids, all_menus): + menu_by_id, children_by_parent = _build_menu_index(all_menus) + selected = {menu_id for menu_id in raw_menu_ids if menu_id in menu_by_id} + + expanded = set(selected) + + for menu_id in list(expanded): + expanded.update(_get_descendants(menu_id, children_by_parent)) + + for menu_id in list(expanded): + cursor = menu_by_id[menu_id].get("parent_id") + while cursor is not None and cursor in menu_by_id: + if cursor in expanded: + break + expanded.add(cursor) + cursor = menu_by_id[cursor].get("parent_id") + + return sorted(expanded) + + +def get_all_menus(): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + query = """ + SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, + parent_id, sort_order, is_active, description, created_at, updated_at + FROM sys_menus + ORDER BY + COALESCE(parent_id, menu_id) ASC, + CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END ASC, + sort_order ASC, + menu_id ASC + """ + cursor.execute(query) + menus = cursor.fetchall() + menu_list = [MenuInfo(**menu) for menu in menus] + return create_api_response( + code="200", + message="获取菜单列表成功", + data=MenuListResponse(menus=menu_list, total=len(menu_list)), + ) + except Exception as e: + return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}") + + +def create_menu(request): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_code = %s", (request.menu_code,)) + if cursor.fetchone(): + return create_api_response(code="400", message="菜单编码已存在") + + if request.parent_id is not None: + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,)) + if not cursor.fetchone(): + return create_api_response(code="400", message="父菜单不存在") + + cursor.execute( + """ + INSERT INTO sys_menus (menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + request.menu_code, + request.menu_name, + request.menu_icon, + request.menu_url, + request.menu_type, + request.parent_id, + request.sort_order, + 1 if request.is_active else 0, + request.description, + ), + ) + menu_id = cursor.lastrowid + connection.commit() + _invalidate_user_menu_cache() + + cursor.execute( + """ + SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, + parent_id, sort_order, is_active, description, created_at, updated_at + FROM sys_menus + WHERE menu_id = %s + """, + (menu_id,), + ) + created = cursor.fetchone() + return create_api_response(code="200", message="创建菜单成功", data=created) + except Exception as e: + return create_api_response(code="500", message=f"创建菜单失败: {str(e)}") + + +def update_menu(menu_id: int, request): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + cursor.execute("SELECT * FROM sys_menus WHERE menu_id = %s", (menu_id,)) + current = cursor.fetchone() + if not current: + return create_api_response(code="404", message="菜单不存在") + + updates = {} + for field in [ + "menu_code", + "menu_name", + "menu_icon", + "menu_url", + "menu_type", + "sort_order", + "description", + ]: + value = getattr(request, field) + if value is not None: + updates[field] = value + + if request.is_active is not None: + updates["is_active"] = 1 if request.is_active else 0 + + fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set())) + + if request.parent_id == menu_id: + return create_api_response(code="400", message="父菜单不能为自身") + if request.parent_id is not None: + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,)) + if not cursor.fetchone(): + return create_api_response(code="400", message="父菜单不存在") + + cursor.execute("SELECT menu_id, parent_id FROM sys_menus") + all_menus = cursor.fetchall() + _, children_by_parent = _build_menu_index(all_menus) + descendants = _get_descendants(menu_id, children_by_parent) + if request.parent_id in descendants: + return create_api_response(code="400", message="父菜单不能设置为当前菜单的子孙菜单") + + if request.parent_id is not None or (request.parent_id is None and "parent_id" in fields_set): + updates["parent_id"] = request.parent_id + + if "menu_code" in updates: + cursor.execute( + "SELECT menu_id FROM sys_menus WHERE menu_code = %s AND menu_id != %s", + (updates["menu_code"], menu_id), + ) + if cursor.fetchone(): + return create_api_response(code="400", message="菜单编码已存在") + + if not updates: + return create_api_response(code="200", message="没有变更内容", data=current) + + set_sql = ", ".join([f"{key} = %s" for key in updates.keys()]) + values = list(updates.values()) + [menu_id] + cursor.execute(f"UPDATE sys_menus SET {set_sql} WHERE menu_id = %s", tuple(values)) + connection.commit() + _invalidate_user_menu_cache() + + cursor.execute( + """ + SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, + parent_id, sort_order, is_active, description, created_at, updated_at + FROM sys_menus + WHERE menu_id = %s + """, + (menu_id,), + ) + updated = cursor.fetchone() + return create_api_response(code="200", message="更新菜单成功", data=updated) + except Exception as e: + return create_api_response(code="500", message=f"更新菜单失败: {str(e)}") + + +def delete_menu(menu_id: int): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (menu_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="菜单不存在") + + cursor.execute("SELECT COUNT(*) AS cnt FROM sys_menus WHERE parent_id = %s", (menu_id,)) + child_count = cursor.fetchone()["cnt"] + if child_count > 0: + return create_api_response(code="400", message="请先删除子菜单") + + cursor.execute("DELETE FROM sys_role_menu_permissions WHERE menu_id = %s", (menu_id,)) + cursor.execute("DELETE FROM sys_menus WHERE menu_id = %s", (menu_id,)) + connection.commit() + _invalidate_user_menu_cache() + return create_api_response(code="200", message="删除菜单成功") + except Exception as e: + return create_api_response(code="500", message=f"删除菜单失败: {str(e)}") + + +def get_all_roles(): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + query = """ + SELECT r.role_id, r.role_name, r.created_at, + COUNT(rmp.menu_id) as menu_count + FROM sys_roles r + LEFT JOIN sys_role_menu_permissions rmp ON r.role_id = rmp.role_id + GROUP BY r.role_id + ORDER BY r.role_id ASC + """ + cursor.execute(query) + roles = cursor.fetchall() + + return create_api_response( + code="200", + message="获取角色列表成功", + data={"roles": roles, "total": len(roles)}, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}") + + +def create_role(request): + try: + role_name = request.role_name.strip() + if not role_name: + return create_api_response(code="400", message="角色名称不能为空") + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s", (role_name,)) + if cursor.fetchone(): + return create_api_response(code="400", message="角色名称已存在") + + cursor.execute("INSERT INTO sys_roles (role_name) VALUES (%s)", (role_name,)) + role_id = cursor.lastrowid + connection.commit() + + cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,)) + role = cursor.fetchone() + return create_api_response(code="200", message="创建角色成功", data=role) + except Exception as e: + return create_api_response(code="500", message=f"创建角色失败: {str(e)}") + + +def update_role(role_id: int, request): + try: + role_name = request.role_name.strip() + if not role_name: + return create_api_response(code="400", message="角色名称不能为空") + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="角色不存在") + + cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s AND role_id != %s", (role_name, role_id)) + if cursor.fetchone(): + return create_api_response(code="400", message="角色名称已存在") + + cursor.execute("UPDATE sys_roles SET role_name = %s WHERE role_id = %s", (role_name, role_id)) + connection.commit() + + cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,)) + role = cursor.fetchone() + return create_api_response(code="200", message="更新角色成功", data=role) + except Exception as e: + return create_api_response(code="500", message=f"更新角色失败: {str(e)}") + + +def get_role_users(role_id: int, page: int, size: int): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,)) + role = cursor.fetchone() + if not role: + return create_api_response(code="404", message="角色不存在") + + cursor.execute( + """ + SELECT COUNT(*) AS total + FROM sys_users + WHERE role_id = %s + """, + (role_id,), + ) + total = cursor.fetchone()["total"] + offset = (page - 1) * size + + cursor.execute( + """ + SELECT user_id, username, caption, email, avatar_url, role_id, created_at + FROM sys_users + WHERE role_id = %s + ORDER BY user_id ASC + LIMIT %s OFFSET %s + """, + (role_id, size, offset), + ) + users = cursor.fetchall() + return create_api_response( + code="200", + message="获取角色用户成功", + data={ + "role_id": role_id, + "role_name": role["role_name"], + "users": users, + "total": total, + "page": page, + "size": size, + }, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取角色用户失败: {str(e)}") + + +def get_all_role_permissions(): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + """ + SELECT rmp.role_id, rmp.menu_id + FROM sys_role_menu_permissions rmp + JOIN sys_menus m ON m.menu_id = rmp.menu_id + WHERE m.is_active = 1 + ORDER BY rmp.role_id ASC, rmp.menu_id ASC + """ + ) + rows = cursor.fetchall() + + result = {} + for row in rows: + result.setdefault(row["role_id"], []).append(row["menu_id"]) + return create_api_response(code="200", message="获取角色权限成功", data={"permissions": result}) + except Exception as e: + return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}") + + +def get_role_permissions(role_id: int): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,)) + role = cursor.fetchone() + if not role: + return create_api_response(code="404", message="角色不存在") + + query = """ + SELECT rmp.menu_id + FROM sys_role_menu_permissions rmp + JOIN sys_menus m ON m.menu_id = rmp.menu_id + WHERE rmp.role_id = %s + AND m.is_active = 1 + """ + cursor.execute(query, (role_id,)) + permissions = cursor.fetchall() + menu_ids = [permission["menu_id"] for permission in permissions] + + return create_api_response( + code="200", + message="获取角色权限成功", + data=RolePermissionInfo( + role_id=role["role_id"], + role_name=role["role_name"], + menu_ids=menu_ids, + ), + ) + except Exception as e: + return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}") + + +def update_role_permissions(role_id: int, request): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="角色不存在") + + cursor.execute( + """ + SELECT menu_id, parent_id + FROM sys_menus + WHERE is_active = 1 + """ + ) + all_menus = cursor.fetchall() + menu_id_set = {menu["menu_id"] for menu in all_menus} + + invalid_menu_ids = [menu_id for menu_id in request.menu_ids if menu_id not in menu_id_set] + if invalid_menu_ids: + return create_api_response(code="400", message="包含无效的菜单ID") + + normalized_menu_ids = _normalize_permission_menu_ids(request.menu_ids, all_menus) + + cursor.execute("DELETE FROM sys_role_menu_permissions WHERE role_id = %s", (role_id,)) + if normalized_menu_ids: + insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids] + cursor.executemany( + "INSERT INTO sys_role_menu_permissions (role_id, menu_id) VALUES (%s, %s)", + insert_values, + ) + + connection.commit() + _invalidate_user_menu_cache(role_id) + return create_api_response( + code="200", + message="更新角色权限成功", + data={"role_id": role_id, "menu_count": len(normalized_menu_ids)}, + ) + except Exception as e: + return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}") + + +def get_user_menus(current_user: dict): + try: + role_id = current_user["role_id"] + cached_menus = _get_cached_user_menus(role_id) + if cached_menus is not None: + return create_api_response( + code="200", + message="获取用户菜单成功", + data={"menus": cached_menus}, + ) + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + query = """ + SELECT m.menu_id, m.menu_code, m.menu_name, m.menu_icon, + m.menu_url, m.menu_type, m.parent_id, m.sort_order + FROM sys_menus m + JOIN sys_role_menu_permissions rmp ON m.menu_id = rmp.menu_id + WHERE rmp.role_id = %s + AND m.is_active = 1 + AND (m.is_visible = 1 OR m.is_visible IS NULL OR m.menu_code IN ('dashboard', 'desktop')) + ORDER BY + COALESCE(m.parent_id, m.menu_id) ASC, + CASE WHEN m.parent_id IS NULL THEN 0 ELSE 1 END ASC, + m.sort_order ASC, + m.menu_id ASC + """ + cursor.execute(query, (role_id,)) + menus = cursor.fetchall() + + current_menu_ids = {menu["menu_id"] for menu in menus} + missing_parent_ids = { + menu["parent_id"] + for menu in menus + if menu.get("parent_id") is not None and menu["parent_id"] not in current_menu_ids + } + + if missing_parent_ids: + format_strings = ",".join(["%s"] * len(missing_parent_ids)) + cursor.execute( + f""" + SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order + FROM sys_menus + WHERE is_active = 1 AND menu_id IN ({format_strings}) + """, + tuple(missing_parent_ids), + ) + parent_rows = cursor.fetchall() + menus.extend(parent_rows) + + menus = sorted( + {menu["menu_id"]: menu for menu in menus}.values(), + key=lambda menu: ( + menu["parent_id"] if menu["parent_id"] is not None else menu["menu_id"], + 0 if menu["parent_id"] is None else 1, + menu["sort_order"], + menu["menu_id"], + ), + ) + _set_cached_user_menus(role_id, menus) + + return create_api_response( + code="200", + message="获取用户菜单成功", + data={"menus": menus}, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}") diff --git a/backend/app/services/admin_settings_service.py b/backend/app/services/admin_settings_service.py new file mode 100644 index 0000000..9e6bc28 --- /dev/null +++ b/backend/app/services/admin_settings_service.py @@ -0,0 +1,744 @@ +import json +from typing import Any + +from app.core.database import get_db_connection +from app.core.response import create_api_response +from app.services.async_transcription_service import AsyncTranscriptionService +from app.services.llm_service import LLMService +from app.services.system_config_service import SystemConfigService + + +llm_service = LLMService() +transcription_service = AsyncTranscriptionService() + + +def _parse_json_object(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + value = value.strip() + if not value: + return {} + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + +def _normalize_string_list(value: Any) -> list[str] | None: + if value is None: + return None + if isinstance(value, list): + values = [str(item).strip() for item in value if str(item).strip()] + return values or None + if isinstance(value, str): + values = [item.strip() for item in value.split(",") if item.strip()] + return values or None + return None + + +def _normalize_int_list(value: Any) -> list[int] | None: + if value is None: + return None + if isinstance(value, list): + items = value + elif isinstance(value, str): + items = [item.strip() for item in value.split(",") if item.strip()] + else: + return None + + normalized = [] + for item in items: + try: + normalized.append(int(item)) + except (TypeError, ValueError): + continue + return normalized or None + + +def _clean_extra_config(config: dict[str, Any]) -> dict[str, Any]: + cleaned: dict[str, Any] = {} + for key, value in (config or {}).items(): + if value is None: + continue + if isinstance(value, str): + stripped = value.strip() + if stripped: + cleaned[key] = stripped + continue + if isinstance(value, list): + normalized_list = [] + for item in value: + if item is None: + continue + if isinstance(item, str): + stripped = item.strip() + if stripped: + normalized_list.append(stripped) + else: + normalized_list.append(item) + if normalized_list: + cleaned[key] = normalized_list + continue + cleaned[key] = value + return cleaned + + +def _merge_audio_extra_config(request, vocabulary_id: str | None = None) -> dict[str, Any]: + extra_config = _parse_json_object(request.extra_config) + + if request.audio_scene == "asr": + legacy_config = { + "model": request.asr_model_name, + "speaker_count": request.asr_speaker_count, + "language_hints": request.asr_language_hints, + "disfluency_removal_enabled": request.asr_disfluency_removal_enabled, + "diarization_enabled": request.asr_diarization_enabled, + } + else: + legacy_config = { + "model": request.model_name, + "template_text": request.vp_template_text, + "duration_seconds": request.vp_duration_seconds, + "sample_rate": request.vp_sample_rate, + "channels": request.vp_channels, + "max_size_bytes": request.vp_max_size_bytes, + } + + merged = {**legacy_config, **extra_config} + + language_hints = _normalize_string_list(merged.get("language_hints")) + if language_hints is not None: + merged["language_hints"] = language_hints + + channel_id = _normalize_int_list(merged.get("channel_id")) + if channel_id is not None: + merged["channel_id"] = channel_id + + resolved_vocabulary_id = vocabulary_id or merged.get("vocabulary_id") or request.asr_vocabulary_id + if request.audio_scene == "asr" and resolved_vocabulary_id: + merged["vocabulary_id"] = resolved_vocabulary_id + + return _clean_extra_config(merged) + + +def _extract_legacy_audio_columns(audio_scene: str, extra_config: dict[str, Any]) -> dict[str, Any]: + extra_config = _parse_json_object(extra_config) + columns = { + "asr_model_name": None, + "asr_vocabulary_id": None, + "asr_speaker_count": None, + "asr_language_hints": None, + "asr_disfluency_removal_enabled": None, + "asr_diarization_enabled": None, + "vp_template_text": None, + "vp_duration_seconds": None, + "vp_sample_rate": None, + "vp_channels": None, + "vp_max_size_bytes": None, + } + + if audio_scene == "asr": + language_hints = extra_config.get("language_hints") + if isinstance(language_hints, list): + language_hints = ",".join(str(item).strip() for item in language_hints if str(item).strip()) + columns.update( + { + "asr_model_name": extra_config.get("model"), + "asr_vocabulary_id": extra_config.get("vocabulary_id"), + "asr_speaker_count": extra_config.get("speaker_count"), + "asr_language_hints": language_hints, + "asr_disfluency_removal_enabled": 1 if extra_config.get("disfluency_removal_enabled") is True else 0 if extra_config.get("disfluency_removal_enabled") is False else None, + "asr_diarization_enabled": 1 if extra_config.get("diarization_enabled") is True else 0 if extra_config.get("diarization_enabled") is False else None, + } + ) + else: + columns.update( + { + "vp_template_text": extra_config.get("template_text"), + "vp_duration_seconds": extra_config.get("duration_seconds"), + "vp_sample_rate": extra_config.get("sample_rate"), + "vp_channels": extra_config.get("channels"), + "vp_max_size_bytes": extra_config.get("max_size_bytes"), + } + ) + + return columns + + +def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]: + extra_config = _parse_json_object(row.get("extra_config")) + + if row.get("audio_scene") == "asr": + if extra_config.get("model") is None and row.get("asr_model_name") is not None: + extra_config["model"] = row["asr_model_name"] + if extra_config.get("vocabulary_id") is None and row.get("asr_vocabulary_id") is not None: + extra_config["vocabulary_id"] = row["asr_vocabulary_id"] + if extra_config.get("speaker_count") is None and row.get("asr_speaker_count") is not None: + extra_config["speaker_count"] = row["asr_speaker_count"] + if extra_config.get("language_hints") is None and row.get("asr_language_hints"): + extra_config["language_hints"] = _normalize_string_list(row["asr_language_hints"]) + if extra_config.get("disfluency_removal_enabled") is None and row.get("asr_disfluency_removal_enabled") is not None: + extra_config["disfluency_removal_enabled"] = bool(row["asr_disfluency_removal_enabled"]) + if extra_config.get("diarization_enabled") is None and row.get("asr_diarization_enabled") is not None: + extra_config["diarization_enabled"] = bool(row["asr_diarization_enabled"]) + else: + if extra_config.get("model") is None and row.get("model_name"): + extra_config["model"] = row["model_name"] + if extra_config.get("template_text") is None and row.get("vp_template_text") is not None: + extra_config["template_text"] = row["vp_template_text"] + if extra_config.get("duration_seconds") is None and row.get("vp_duration_seconds") is not None: + extra_config["duration_seconds"] = row["vp_duration_seconds"] + if extra_config.get("sample_rate") is None and row.get("vp_sample_rate") is not None: + extra_config["sample_rate"] = row["vp_sample_rate"] + if extra_config.get("channels") is None and row.get("vp_channels") is not None: + extra_config["channels"] = row["vp_channels"] + if extra_config.get("max_size_bytes") is None and row.get("vp_max_size_bytes") is not None: + extra_config["max_size_bytes"] = row["vp_max_size_bytes"] + + row["extra_config"] = extra_config + row["service_model_name"] = extra_config.get("model") + return row + + +def _resolve_hot_word_vocabulary_id(cursor, request) -> str | None: + vocabulary_id = request.asr_vocabulary_id + if request.hot_word_group_id: + cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,)) + group_row = cursor.fetchone() + if group_row and group_row.get("vocabulary_id"): + vocabulary_id = group_row["vocabulary_id"] + return vocabulary_id + + +def list_parameters(category: str | None = None, keyword: str | None = None): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + query = """ + SELECT param_id, param_key, param_name, param_value, value_type, category, + description, is_active, created_at, updated_at + FROM sys_system_parameters + WHERE 1=1 + """ + params = [] + if category: + query += " AND category = %s" + params.append(category) + if keyword: + like_pattern = f"%{keyword}%" + query += " AND (param_key LIKE %s OR param_name LIKE %s)" + params.extend([like_pattern, like_pattern]) + + query += " ORDER BY category ASC, param_key ASC" + cursor.execute(query, tuple(params)) + rows = cursor.fetchall() + return create_api_response( + code="200", + message="获取参数列表成功", + data={"items": rows, "total": len(rows)}, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取参数列表失败: {str(e)}") + + +def get_parameter(param_key: str): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT param_id, param_key, param_name, param_value, value_type, category, + description, is_active, created_at, updated_at + FROM sys_system_parameters + WHERE param_key = %s + LIMIT 1 + """, + (param_key,), + ) + row = cursor.fetchone() + if not row: + return create_api_response(code="404", message="参数不存在") + return create_api_response(code="200", message="获取参数成功", data=row) + except Exception as e: + return create_api_response(code="500", message=f"获取参数失败: {str(e)}") + + +def create_parameter(request): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (request.param_key,)) + if cursor.fetchone(): + return create_api_response(code="400", message="参数键已存在") + + cursor.execute( + """ + INSERT INTO sys_system_parameters + (param_key, param_name, param_value, value_type, category, description, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + request.param_key, + request.param_name, + request.param_value, + request.value_type, + request.category, + request.description, + 1 if request.is_active else 0, + ), + ) + conn.commit() + SystemConfigService.invalidate_cache() + return create_api_response(code="200", message="创建参数成功") + except Exception as e: + return create_api_response(code="500", message=f"创建参数失败: {str(e)}") + + +def update_parameter(param_key: str, request): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="参数不存在") + + new_key = request.param_key or param_key + if new_key != param_key: + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (new_key,)) + if cursor.fetchone(): + return create_api_response(code="400", message="新的参数键已存在") + + cursor.execute( + """ + UPDATE sys_system_parameters + SET param_key = %s, param_name = %s, param_value = %s, value_type = %s, + category = %s, description = %s, is_active = %s + WHERE param_key = %s + """, + ( + new_key, + request.param_name, + request.param_value, + request.value_type, + request.category, + request.description, + 1 if request.is_active else 0, + param_key, + ), + ) + conn.commit() + SystemConfigService.invalidate_cache() + return create_api_response(code="200", message="更新参数成功") + except Exception as e: + return create_api_response(code="500", message=f"更新参数失败: {str(e)}") + + +def delete_parameter(param_key: str): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="参数不存在") + + cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,)) + conn.commit() + SystemConfigService.invalidate_cache() + return create_api_response(code="200", message="删除参数成功") + except Exception as e: + return create_api_response(code="500", message=f"删除参数失败: {str(e)}") + + +def list_llm_model_configs(): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT config_id, model_code, model_name, provider, endpoint_url, api_key, + llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, + llm_system_prompt, description, is_active, is_default, created_at, updated_at + FROM llm_model_config + ORDER BY model_code ASC + """ + ) + rows = cursor.fetchall() + return create_api_response( + code="200", + message="获取LLM模型配置成功", + data={"items": rows, "total": len(rows)}, + ) + except Exception as e: + return create_api_response(code="500", message=f"获取LLM模型配置失败: {str(e)}") + + +def create_llm_model_config(request): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,)) + if cursor.fetchone(): + return create_api_response(code="400", message="模型编码已存在") + + cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config") + total_row = cursor.fetchone() or {"total": 0} + is_default = bool(request.is_default) or total_row["total"] == 0 + if is_default: + cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE is_default = 1") + + cursor.execute( + """ + INSERT INTO llm_model_config + (model_code, model_name, provider, endpoint_url, api_key, llm_model_name, + llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt, + description, is_active, is_default) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + request.model_code, + request.model_name, + request.provider, + request.endpoint_url, + request.api_key, + request.llm_model_name, + request.llm_timeout, + request.llm_temperature, + request.llm_top_p, + request.llm_max_tokens, + request.llm_system_prompt, + request.description, + 1 if request.is_active else 0, + 1 if is_default else 0, + ), + ) + conn.commit() + return create_api_response(code="200", message="创建LLM模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"创建LLM模型配置失败: {str(e)}") + + +def update_llm_model_config(model_code: str, request): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,)) + existed = cursor.fetchone() + if not existed: + return create_api_response(code="404", message="模型配置不存在") + + new_model_code = request.model_code or model_code + if new_model_code != model_code: + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (new_model_code,)) + duplicate_row = cursor.fetchone() + if duplicate_row and duplicate_row["config_id"] != existed["config_id"]: + return create_api_response(code="400", message="新的模型编码已存在") + + if request.is_default: + cursor.execute( + "UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1", + (model_code,), + ) + + cursor.execute( + """ + UPDATE llm_model_config + SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s, + llm_model_name = %s, llm_timeout = %s, llm_temperature = %s, llm_top_p = %s, + llm_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s + WHERE model_code = %s + """, + ( + new_model_code, + request.model_name, + request.provider, + request.endpoint_url, + request.api_key, + request.llm_model_name, + request.llm_timeout, + request.llm_temperature, + request.llm_top_p, + request.llm_max_tokens, + request.llm_system_prompt, + request.description, + 1 if request.is_active else 0, + 1 if request.is_default else 0, + model_code, + ), + ) + conn.commit() + return create_api_response(code="200", message="更新LLM模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"更新LLM模型配置失败: {str(e)}") + + +def list_audio_model_configs(scene: str = "all"): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + sql = """ + SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key, + a.asr_model_name, a.asr_vocabulary_id, a.hot_word_group_id, a.asr_speaker_count, a.asr_language_hints, + a.asr_disfluency_removal_enabled, a.asr_diarization_enabled, + a.vp_template_text, a.vp_duration_seconds, a.vp_sample_rate, a.vp_channels, a.vp_max_size_bytes, + a.extra_config, a.description, a.is_active, a.is_default, a.created_at, a.updated_at, + g.name AS hot_word_group_name, g.vocabulary_id AS hot_word_group_vocab_id + FROM audio_model_config a + LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id + """ + params = [] + if scene in ("asr", "voiceprint"): + sql += " WHERE a.audio_scene = %s" + params.append(scene) + sql += " ORDER BY a.audio_scene ASC, a.model_code ASC" + cursor.execute(sql, tuple(params)) + rows = [_normalize_audio_row(row) for row in cursor.fetchall()] + return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)}) + except Exception as e: + return create_api_response(code="500", message=f"获取音频模型配置失败: {str(e)}") + + +def create_audio_model_config(request): + try: + if request.audio_scene not in ("asr", "voiceprint"): + return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") + + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (request.model_code,)) + if cursor.fetchone(): + return create_api_response(code="400", message="模型编码已存在") + + cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE audio_scene = %s", (request.audio_scene,)) + total_row = cursor.fetchone() or {"total": 0} + is_default = bool(request.is_default) or total_row["total"] == 0 + if is_default: + cursor.execute( + "UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1", + (request.audio_scene,), + ) + + asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request) + extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) + legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config) + + cursor.execute( + """ + INSERT INTO audio_model_config + (model_code, model_name, audio_scene, provider, endpoint_url, api_key, + asr_model_name, asr_vocabulary_id, hot_word_group_id, asr_speaker_count, asr_language_hints, + asr_disfluency_removal_enabled, asr_diarization_enabled, + vp_template_text, vp_duration_seconds, vp_sample_rate, vp_channels, vp_max_size_bytes, + extra_config, description, is_active, is_default) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + request.model_code, + request.model_name, + request.audio_scene, + request.provider, + request.endpoint_url, + request.api_key, + legacy_columns["asr_model_name"], + legacy_columns["asr_vocabulary_id"], + request.hot_word_group_id, + legacy_columns["asr_speaker_count"], + legacy_columns["asr_language_hints"], + legacy_columns["asr_disfluency_removal_enabled"], + legacy_columns["asr_diarization_enabled"], + legacy_columns["vp_template_text"], + legacy_columns["vp_duration_seconds"], + legacy_columns["vp_sample_rate"], + legacy_columns["vp_channels"], + legacy_columns["vp_max_size_bytes"], + json.dumps(extra_config, ensure_ascii=False), + request.description, + 1 if request.is_active else 0, + 1 if is_default else 0, + ), + ) + conn.commit() + return create_api_response(code="200", message="创建音频模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"创建音频模型配置失败: {str(e)}") + + +def update_audio_model_config(model_code: str, request): + try: + if request.audio_scene not in ("asr", "voiceprint"): + return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint") + + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,)) + existed = cursor.fetchone() + if not existed: + return create_api_response(code="404", message="模型配置不存在") + + new_model_code = request.model_code or model_code + if new_model_code != model_code: + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (new_model_code,)) + duplicate_row = cursor.fetchone() + if duplicate_row and duplicate_row["config_id"] != existed["config_id"]: + return create_api_response(code="400", message="新的模型编码已存在") + + if request.is_default: + cursor.execute( + "UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1", + (request.audio_scene, model_code), + ) + + asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request) + extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id) + legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config) + + cursor.execute( + """ + UPDATE audio_model_config + SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s, + asr_model_name = %s, asr_vocabulary_id = %s, hot_word_group_id = %s, asr_speaker_count = %s, asr_language_hints = %s, + asr_disfluency_removal_enabled = %s, asr_diarization_enabled = %s, + vp_template_text = %s, vp_duration_seconds = %s, vp_sample_rate = %s, vp_channels = %s, vp_max_size_bytes = %s, + extra_config = %s, description = %s, is_active = %s, is_default = %s + WHERE model_code = %s + """, + ( + new_model_code, + request.model_name, + request.audio_scene, + request.provider, + request.endpoint_url, + request.api_key, + legacy_columns["asr_model_name"], + legacy_columns["asr_vocabulary_id"], + request.hot_word_group_id, + legacy_columns["asr_speaker_count"], + legacy_columns["asr_language_hints"], + legacy_columns["asr_disfluency_removal_enabled"], + legacy_columns["asr_diarization_enabled"], + legacy_columns["vp_template_text"], + legacy_columns["vp_duration_seconds"], + legacy_columns["vp_sample_rate"], + legacy_columns["vp_channels"], + legacy_columns["vp_max_size_bytes"], + json.dumps(extra_config, ensure_ascii=False), + request.description, + 1 if request.is_active else 0, + 1 if request.is_default else 0, + model_code, + ), + ) + conn.commit() + return create_api_response(code="200", message="更新音频模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"更新音频模型配置失败: {str(e)}") + + +def delete_llm_model_config(model_code: str): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="模型配置不存在") + + cursor.execute("DELETE FROM llm_model_config WHERE model_code = %s", (model_code,)) + conn.commit() + return create_api_response(code="200", message="删除LLM模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"删除LLM模型配置失败: {str(e)}") + + +def delete_audio_model_config(model_code: str): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="模型配置不存在") + + cursor.execute("DELETE FROM audio_model_config WHERE model_code = %s", (model_code,)) + conn.commit() + return create_api_response(code="200", message="删除音频模型配置成功") + except Exception as e: + return create_api_response(code="500", message=f"删除音频模型配置失败: {str(e)}") + + +def test_llm_model_config(request): + try: + payload = request.model_dump() if hasattr(request, "model_dump") else request.dict() + result = llm_service.test_model(payload, prompt=request.test_prompt) + return create_api_response(code="200", message="LLM模型测试成功", data=result) + except Exception as e: + return create_api_response(code="500", message=f"LLM模型测试失败: {str(e)}") + + +def test_audio_model_config(request): + try: + if request.audio_scene != "asr": + return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试") + + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request) + + extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id) + runtime_config = { + "provider": request.provider, + "endpoint_url": request.endpoint_url, + "api_key": request.api_key, + "audio_scene": request.audio_scene, + "hot_word_group_id": request.hot_word_group_id, + **extra_config, + } + result = transcription_service.test_asr_model(runtime_config, test_file_url=request.test_file_url) + return create_api_response(code="200", message="音频模型测试任务已提交", data=result) + except Exception as e: + return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}") + + +def get_public_system_config(): + try: + return create_api_response( + code="200", + message="获取公开配置成功", + data=SystemConfigService.get_public_configs(), + ) + except Exception as e: + return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}") + + +def get_system_config_compat(): + try: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT param_key, param_value + FROM sys_system_parameters + WHERE is_active = 1 + """ + ) + rows = cursor.fetchall() + data = {row["param_key"]: row["param_value"] for row in rows} + + if "max_audio_size" in data: + try: + data["MAX_FILE_SIZE"] = int(data["max_audio_size"]) * 1024 * 1024 + except Exception: + data["MAX_FILE_SIZE"] = 100 * 1024 * 1024 + if "max_image_size" in data: + try: + data["MAX_IMAGE_SIZE"] = int(data["max_image_size"]) * 1024 * 1024 + except Exception: + data["MAX_IMAGE_SIZE"] = 10 * 1024 * 1024 + else: + data.setdefault("MAX_IMAGE_SIZE", 10 * 1024 * 1024) + + return create_api_response(code="200", message="获取系统配置成功", data=data) + except Exception as e: + return create_api_response(code="500", message=f"获取系统配置失败: {str(e)}") diff --git a/backend/app/services/meeting_service.py b/backend/app/services/meeting_service.py new file mode 100644 index 0000000..1b1a4c8 --- /dev/null +++ b/backend/app/services/meeting_service.py @@ -0,0 +1,1515 @@ +from fastapi import APIRouter, UploadFile, File, Form, Depends, BackgroundTasks, Request, Header +from fastapi.responses import StreamingResponse, Response +from app.models.models import Meeting, TranscriptSegment, TranscriptionTaskStatus, CreateMeetingRequest, UpdateMeetingRequest, SpeakerTagUpdateRequest, BatchSpeakerTagUpdateRequest, BatchTranscriptUpdateRequest, Tag +from app.core.database import get_db_connection +from app.core.config import BASE_DIR, AUDIO_DIR, MARKDOWN_DIR, ALLOWED_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS +import app.core.config as config_module +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.services.system_config_service import SystemConfigService +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 Any, Dict, List, Optional +from datetime import datetime +from pydantic import BaseModel +from urllib.parse import quote +from collections import defaultdict +import os +import uuid +import shutil +import mimetypes + + +llm_service = LLMService() +transcription_service = AsyncTranscriptionService() + +class GenerateSummaryRequest(BaseModel): + user_prompt: Optional[str] = "" + prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版 + model_code: Optional[str] = None # LLM模型编码,如果不指定则使用默认模型 + + +def _split_tag_names(tag_string: Optional[str]) -> List[str]: + if not tag_string: + return [] + return [name.strip() for name in str(tag_string).split(',') if name and name.strip()] + + +def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: + """ + 处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签 + """ + tag_names = _split_tag_names(tag_string) + if not tag_names: + return [] + + # 如果提供了 creator_id,则创建不存在的标签 + if creator_id: + insert_ignore_query = "INSERT IGNORE INTO tags (name, creator_id) VALUES (%s, %s)" + cursor.executemany(insert_ignore_query, [(name, creator_id) for name in tag_names]) + + # 查询所有标签信息 + format_strings = ', '.join(['%s'] * len(tag_names)) + cursor.execute(f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", tuple(tag_names)) + tags_data = cursor.fetchall() + return [Tag(**tag) for tag in tags_data] + +def _sync_attendees(cursor, meeting_id: int, attendee_ids: Optional[List[int]]) -> None: + attendee_id_list = [] + if attendee_ids: + attendee_id_list = list(dict.fromkeys(int(user_id) for user_id in attendee_ids if user_id is not None)) + + cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) + + if attendee_id_list: + cursor.executemany( + 'INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', + [(meeting_id, user_id) for user_id in attendee_id_list] + ) + + +def _serialize_task_record(task_record: Optional[Dict[str, Any]], meeting_id: int) -> Optional[Dict[str, Any]]: + if not task_record: + return None + + created_at = task_record.get('created_at') + completed_at = task_record.get('completed_at') + + return { + 'task_id': task_record.get('task_id'), + 'status': task_record.get('status', 'pending') or 'pending', + 'progress': int(task_record.get('progress') or 0), + 'meeting_id': meeting_id, + 'created_at': created_at.isoformat() if hasattr(created_at, 'isoformat') else created_at, + 'completed_at': completed_at.isoformat() if hasattr(completed_at, 'isoformat') else completed_at, + 'error_message': task_record.get('error_message') + } + + +def _build_meeting_overall_status( + transcription_status: Optional[Dict[str, Any]] = None, + llm_status: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + trans_data = { + "status": transcription_status.get('status', 'pending') if transcription_status else 'pending', + "progress": transcription_status.get('progress', 0) if transcription_status else 0, + "task_id": transcription_status.get('task_id') if transcription_status else None, + "error_message": transcription_status.get('error_message') if transcription_status else None, + "created_at": transcription_status.get('created_at') if transcription_status else None + } + + llm_data = { + "status": llm_status.get('status', 'pending') if llm_status else 'pending', + "progress": llm_status.get('progress', 0) if llm_status else 0, + "task_id": llm_status.get('task_id') if llm_status else None, + "error_message": llm_status.get('error_message') if llm_status else None, + "created_at": llm_status.get('created_at') if llm_status else None + } + + trans_status_val = trans_data["status"] + llm_status_val = llm_data["status"] + + if trans_status_val == 'failed': + overall_status = "failed" + current_stage = "transcription" + overall_progress = 0 + elif llm_status_val == 'failed': + overall_status = "failed" + current_stage = "llm" + overall_progress = 50 + elif trans_status_val == 'completed' and llm_status_val == 'completed': + overall_status = "completed" + current_stage = "completed" + overall_progress = 100 + elif trans_status_val == 'completed': + if llm_status_val in ['pending', 'processing']: + overall_status = "summarizing" + current_stage = "llm" + overall_progress = 50 + int(llm_data["progress"] * 0.5) + else: + overall_status = "summarizing" + current_stage = "llm" + overall_progress = 50 + else: + if trans_status_val in ['pending', 'processing']: + overall_status = "transcribing" + current_stage = "transcription" + overall_progress = int(trans_data["progress"] * 0.5) + else: + overall_status = "pending" + current_stage = "transcription" + overall_progress = 0 + + return { + "overall_status": overall_status, + "overall_progress": overall_progress, + "current_stage": current_stage, + "transcription": trans_data, + "llm": llm_data + } + + +def _load_attendees_map(cursor, meeting_ids: List[int]) -> Dict[int, List[Dict[str, Any]]]: + if not meeting_ids: + return {} + + format_strings = ', '.join(['%s'] * len(meeting_ids)) + query = f''' + SELECT a.meeting_id, u.user_id, u.caption + FROM attendees a + JOIN sys_users u ON a.user_id = u.user_id + WHERE a.meeting_id IN ({format_strings}) + ORDER BY a.meeting_id ASC, a.attendee_id ASC + ''' + cursor.execute(query, tuple(meeting_ids)) + + attendees_map: Dict[int, List[Dict[str, Any]]] = defaultdict(list) + for row in cursor.fetchall(): + attendees_map[row['meeting_id']].append({ + 'user_id': row['user_id'], + 'caption': row['caption'] + }) + + return dict(attendees_map) + + +def _load_tags_map(cursor, meetings: List[Dict[str, Any]]) -> Dict[int, List[Tag]]: + meeting_tag_names: Dict[int, List[str]] = {} + all_tag_names: List[str] = [] + + for meeting in meetings: + tag_names = _split_tag_names(meeting.get('tags')) + meeting_tag_names[meeting['meeting_id']] = tag_names + all_tag_names.extend(tag_names) + + if not all_tag_names: + return {} + + unique_tag_names = list(dict.fromkeys(all_tag_names)) + format_strings = ', '.join(['%s'] * len(unique_tag_names)) + cursor.execute( + f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", + tuple(unique_tag_names) + ) + name_to_tag = {row['name']: Tag(**row) for row in cursor.fetchall()} + + return { + meeting_id: [name_to_tag[name] for name in tag_names if name in name_to_tag] + for meeting_id, tag_names in meeting_tag_names.items() + } + + +def _load_latest_task_map(cursor, meeting_ids: List[int], task_type: str) -> Dict[int, Dict[str, Any]]: + if not meeting_ids: + return {} + + table_name = 'transcript_tasks' if task_type == 'transcription' else 'llm_tasks' + format_strings = ', '.join(['%s'] * len(meeting_ids)) + query = f''' + SELECT meeting_id, task_id, status, progress, created_at, completed_at, error_message + FROM {table_name} + WHERE meeting_id IN ({format_strings}) + ORDER BY meeting_id ASC, created_at DESC, task_id DESC + ''' + cursor.execute(query, tuple(meeting_ids)) + + latest_task_map: Dict[int, Dict[str, Any]] = {} + for row in cursor.fetchall(): + meeting_id = row['meeting_id'] + if meeting_id not in latest_task_map: + latest_task_map[meeting_id] = _serialize_task_record(row, meeting_id) + + return latest_task_map + + +def _load_latest_task_record(cursor, meeting_id: int, task_type: str) -> Optional[Dict[str, Any]]: + table_name = 'transcript_tasks' if task_type == 'transcription' else 'llm_tasks' + query = f''' + SELECT task_id, status, progress, created_at, completed_at, error_message + FROM {table_name} + WHERE meeting_id = %s + ORDER BY created_at DESC, task_id DESC + LIMIT 1 + ''' + cursor.execute(query, (meeting_id,)) + return _serialize_task_record(cursor.fetchone(), meeting_id) + + +def _load_overall_status_map(cursor, meeting_ids: List[int]) -> Dict[int, Dict[str, Any]]: + if not meeting_ids: + return {} + + transcription_map = _load_latest_task_map(cursor, meeting_ids, 'transcription') + llm_map = _load_latest_task_map(cursor, meeting_ids, 'llm') + + return { + meeting_id: _build_meeting_overall_status( + transcription_map.get(meeting_id), + llm_map.get(meeting_id) + ) + for meeting_id in meeting_ids + } + + +def _build_task_status_model(task_record: Optional[Dict[str, Any]]) -> Optional[TranscriptionTaskStatus]: + if not task_record: + return None + + return TranscriptionTaskStatus( + task_id=task_record.get('task_id'), + status=task_record.get('status', 'pending') or 'pending', + progress=int(task_record.get('progress') or 0), + message=task_record.get('message') + ) + +def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int): + cursor.execute("SELECT meeting_id, user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return None, create_api_response(code="404", message="Meeting not found") + if meeting['user_id'] != current_user_id: + return None, create_api_response(code="403", message="仅会议创建人可执行此操作") + return meeting, None + +def _get_meeting_overall_status(meeting_id: int) -> dict: + """ + 获取会议的整体进度状态(包含转译和LLM两个阶段) + + Returns: + dict: { + "overall_status": "pending" | "transcribing" | "summarizing" | "completed" | "failed", + "overall_progress": 0-100, + "current_stage": "transcription" | "llm" | "completed", + "transcription": {status, progress, task_id, error_message, created_at}, + "llm": {status, progress, task_id, error_message, created_at} + } + """ + transcription_status = transcription_service.get_meeting_transcription_status(meeting_id) + llm_status = async_meeting_service.get_meeting_llm_status(meeting_id) + return _build_meeting_overall_status(transcription_status, llm_status) + +def get_meetings( + current_user: dict = Depends(get_current_user), + user_id: Optional[int] = None, + page: int = 1, + page_size: Optional[int] = None, + search: Optional[str] = None, + tags: Optional[str] = None, + filter_type: str = "all" +): + # 使用配置的默认页面大小 + if page_size is None: + page_size = SystemConfigService.get_page_size(default=10) + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 构建WHERE子句 + where_conditions = [] + params = [] + + # 用户过滤 + if user_id: + # 需要联表查询参与者 + has_attendees_join = True + else: + has_attendees_join = False + + # 按类型过滤 (created/attended/all) + if user_id: + if filter_type == "created": + where_conditions.append("m.user_id = %s") + params.append(user_id) + elif filter_type == "attended": + where_conditions.append("m.user_id != %s AND a.user_id = %s") + params.extend([user_id, user_id]) + has_attendees_join = True + else: # all + where_conditions.append("(m.user_id = %s OR a.user_id = %s)") + params.extend([user_id, user_id]) + has_attendees_join = True + + # 搜索关键词过滤 + if search and search.strip(): + search_pattern = f"%{search.strip()}%" + where_conditions.append("(m.title LIKE %s OR u.caption LIKE %s)") + params.extend([search_pattern, search_pattern]) + + # 标签过滤 + if tags and tags.strip(): + tag_list = [t.strip() for t in tags.split(',') if t.strip()] + if tag_list: + # 使用JSON_CONTAINS或LIKE查询 + tag_conditions = [] + for tag in tag_list: + tag_conditions.append("m.tags LIKE %s") + params.append(f"%{tag}%") + where_conditions.append(f"({' OR '.join(tag_conditions)})") + + # 构建基础查询 + base_query = ''' + SELECT m.meeting_id, m.title, m.meeting_time, + CASE + WHEN m.summary IS NULL THEN NULL + ELSE LEFT(m.summary, 240) + END as summary, + m.created_at, m.tags, m.access_password, + m.user_id as creator_id, u.caption as creator_username, MAX(af.file_path) as audio_file_path + FROM meetings m + JOIN sys_users u ON m.user_id = u.user_id + LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id + ''' + + if has_attendees_join: + base_query += " LEFT JOIN attendees a ON m.meeting_id = a.meeting_id" + + # 添加WHERE子句 + if where_conditions: + base_query += f" WHERE {' AND '.join(where_conditions)}" + + # 获取总数 - 需要在添加 GROUP BY 之前 + count_base = base_query # 保存一份不含GROUP BY的查询 + if has_attendees_join: + # 如果有联表,使用子查询计数 + count_query = f"SELECT COUNT(DISTINCT m.meeting_id) as total {count_base[count_base.find('FROM'):]}" + else: + # 没有联表,直接计数 + count_query = f"SELECT COUNT(*) as total {count_base[count_base.find('FROM'):]}" + + cursor.execute(count_query, params) + total = cursor.fetchone()['total'] + + # 添加GROUP BY(因为使用了MAX聚合函数,总是需要GROUP BY) + base_query += " GROUP BY m.meeting_id" + + # 计算分页 + total_pages = (total + page_size - 1) // page_size + has_more = page < total_pages + offset = (page - 1) * page_size + + # 添加排序和分页 + query = f"{base_query} ORDER BY m.meeting_time DESC, m.created_at DESC LIMIT %s OFFSET %s" + params.extend([page_size, offset]) + + cursor.execute(query, params) + meetings = cursor.fetchall() + + meeting_ids = [meeting['meeting_id'] for meeting in meetings] + attendees_map = _load_attendees_map(cursor, meeting_ids) + tags_map = _load_tags_map(cursor, meetings) + status_map = _load_overall_status_map(cursor, meeting_ids) + + meeting_list = [] + for meeting in meetings: + attendees = attendees_map.get(meeting['meeting_id'], []) + tags_list = tags_map.get(meeting['meeting_id'], []) + progress_info = status_map.get(meeting['meeting_id']) or _build_meeting_overall_status() + meeting_list.append(Meeting( + meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], + summary=meeting['summary'], created_at=meeting['created_at'], audio_file_path=meeting['audio_file_path'], + attendees=attendees, attendee_ids=[row['user_id'] for row in attendees], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list, + access_password=meeting.get('access_password'), + overall_status=progress_info.get('overall_status'), + overall_progress=progress_info.get('overall_progress'), + current_stage=progress_info.get('current_stage'), + )) + + return create_api_response(code="200", message="获取会议列表成功", data={ + "meetings": meeting_list, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "has_more": has_more + }) + +def get_meetings_stats( + current_user: dict = Depends(get_current_user), + user_id: Optional[int] = None +): + """ + 获取会议统计数据:全部会议、我创建的会议、我参加的会议数量 + """ + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + if not user_id: + return create_api_response(code="400", message="user_id is required") + + # 获取全部会议数量(创建的 + 参加的) + all_query = ''' + SELECT COUNT(DISTINCT m.meeting_id) as count + FROM meetings m + LEFT JOIN attendees a ON m.meeting_id = a.meeting_id + WHERE m.user_id = %s OR a.user_id = %s + ''' + cursor.execute(all_query, (user_id, user_id)) + all_count = cursor.fetchone()['count'] + + # 获取我创建的会议数量 + created_query = ''' + SELECT COUNT(*) as count + FROM meetings m + WHERE m.user_id = %s + ''' + cursor.execute(created_query, (user_id,)) + created_count = cursor.fetchone()['count'] + + # 获取我参加的会议数量(不包括我创建的) + attended_query = ''' + SELECT COUNT(DISTINCT a.meeting_id) as count + FROM attendees a + JOIN meetings m ON a.meeting_id = m.meeting_id + WHERE a.user_id = %s AND m.user_id != %s + ''' + cursor.execute(attended_query, (user_id, user_id)) + attended_count = cursor.fetchone()['count'] + + return create_api_response(code="200", message="获取会议统计成功", data={ + "all_meetings": all_count, + "created_meetings": created_count, + "attended_meetings": attended_count + }) + +def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_current_user)): + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + query = ''' + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, + m.user_id as creator_id, u.caption as creator_username, m.prompt_id, + af.file_path as audio_file_path, af.duration as audio_duration, + p.name as prompt_name, m.access_password + FROM meetings m + JOIN sys_users u ON m.user_id = u.user_id + LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id + LEFT JOIN prompts p ON m.prompt_id = p.id + WHERE m.meeting_id = %s + ''' + cursor.execute(query, (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return create_api_response(code="404", message="Meeting not found") + + attendees_data = _load_attendees_map(cursor, [meeting_id]).get(meeting_id, []) + attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] + tags = _load_tags_map(cursor, [meeting]).get(meeting_id, []) + transcription_task = transcription_service.get_meeting_transcription_status(meeting_id) + llm_task = async_meeting_service.get_meeting_llm_status(meeting_id) + overall_status = _build_meeting_overall_status(transcription_task, llm_task) + cursor.close() + + meeting_data = Meeting( + meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], + summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, + attendee_ids=[row['user_id'] for row in attendees_data], + creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags, + prompt_id=meeting.get('prompt_id'), + prompt_name=meeting.get('prompt_name'), + overall_status=overall_status.get('overall_status'), + overall_progress=overall_status.get('overall_progress'), + current_stage=overall_status.get('current_stage'), + access_password=meeting.get('access_password') + ) + # 只有路径长度大于5(排除空串或占位符)才认为有录音 + if meeting.get('audio_file_path') and len(meeting['audio_file_path']) > 5: + meeting_data.audio_file_path = meeting['audio_file_path'] + meeting_data.audio_duration = meeting['audio_duration'] + + meeting_data.transcription_status = _build_task_status_model(transcription_task) + meeting_data.llm_status = _build_task_status_model(llm_task) + + return create_api_response(code="200", message="获取会议详情成功", data=meeting_data) + +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,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="Meeting not found") + transcript_query = ''' + SELECT segment_id, meeting_id, speaker_id, speaker_tag, start_time_ms, end_time_ms, text_content + FROM transcript_segments WHERE meeting_id = %s ORDER BY start_time_ms ASC + ''' + cursor.execute(transcript_query, (meeting_id,)) + segments = cursor.fetchall() + transcript_segments = [TranscriptSegment( + segment_id=s['segment_id'], meeting_id=s['meeting_id'], speaker_id=s['speaker_id'], + speaker_tag=s['speaker_tag'] if s['speaker_tag'] else f"发言人 {s['speaker_id']}", + start_time_ms=s['start_time_ms'], end_time_ms=s['end_time_ms'], text_content=s['text_content'] + ) for s in segments] + return create_api_response(code="200", message="获取转录内容成功", data=transcript_segments) + +def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = Depends(get_current_user)): + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + # 使用 _process_tags 来处理标签创建 + if meeting_request.tags: + _process_tags(cursor, meeting_request.tags, current_user['user_id']) + meeting_query = ''' + INSERT INTO meetings (user_id, title, meeting_time, summary, tags, prompt_id, created_at) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ''' + cursor.execute( + meeting_query, + ( + current_user['user_id'], + meeting_request.title, + meeting_request.meeting_time, + None, + meeting_request.tags, + meeting_request.prompt_id or 0, + datetime.now().isoformat(), + ), + ) + meeting_id = cursor.lastrowid + _sync_attendees(cursor, meeting_id, meeting_request.attendee_ids) + connection.commit() + return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id}) + +def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict = Depends(get_current_user)): + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT user_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return create_api_response(code="404", message="Meeting not found") + if meeting['user_id'] != current_user['user_id']: + return create_api_response(code="403", message="Permission denied") + # 使用 _process_tags 来处理标签创建 + if meeting_request.tags: + _process_tags(cursor, meeting_request.tags, current_user['user_id']) + update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s, prompt_id = %s WHERE meeting_id = %s' + cursor.execute( + update_query, + ( + meeting_request.title, + meeting_request.meeting_time, + meeting_request.summary, + meeting_request.tags, + meeting_request.prompt_id if meeting_request.prompt_id is not None else meeting['prompt_id'], + meeting_id, + ), + ) + if meeting_request.attendee_ids is not None: + _sync_attendees(cursor, meeting_id, meeting_request.attendee_ids) + connection.commit() + # 同步导出总结MD文件 + if meeting_request.summary: + async_meeting_service._export_summary_md(meeting_id, meeting_request.summary) + return create_api_response(code="200", message="Meeting updated successfully") + +def delete_meeting(meeting_id: int, current_user: dict = Depends(get_current_user)): + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return create_api_response(code="404", message="Meeting not found") + if meeting['user_id'] != current_user['user_id']: + return create_api_response(code="403", message="Permission denied") + cursor.execute("DELETE FROM transcript_segments WHERE meeting_id = %s", (meeting_id,)) + cursor.execute("DELETE FROM audio_files WHERE meeting_id = %s", (meeting_id,)) + cursor.execute("DELETE FROM attachments WHERE meeting_id = %s", (meeting_id,)) + cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) + cursor.execute("DELETE FROM meetings WHERE meeting_id = %s", (meeting_id,)) + connection.commit() + return create_api_response(code="200", message="Meeting deleted successfully") + +def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_current_user)): + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + query = ''' + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, + m.user_id as creator_id, u.caption as creator_username, m.prompt_id, af.file_path as audio_file_path, + m.access_password + FROM meetings m JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id + WHERE m.meeting_id = %s + ''' + cursor.execute(query, (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return create_api_response(code="404", message="Meeting not found") + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' + cursor.execute(attendees_query, (meeting['meeting_id'],)) + attendees_data = cursor.fetchall() + attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] + tags = _process_tags(cursor, meeting.get('tags')) + cursor.close() + meeting_data = Meeting( + meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], + summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, + attendee_ids=[row['user_id'] for row in attendees_data], + creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags, + prompt_id=meeting.get('prompt_id'), + access_password=meeting.get('access_password') + ) + if meeting.get('audio_file_path'): + meeting_data.audio_file_path = meeting['audio_file_path'] + try: + transcription_status_data = transcription_service.get_meeting_transcription_status(meeting_id) + if transcription_status_data: + meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data) + except Exception as e: + print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}") + return create_api_response(code="200", message="获取会议编辑信息成功", data=meeting_data) + +async def upload_audio( + audio_file: UploadFile = File(...), + meeting_id: int = Form(...), + auto_summarize: str = Form("true"), + prompt_id: Optional[int] = Form(None), # 可选的提示词模版ID + model_code: Optional[str] = Form(None), # 可选的总结模型编码 + background_tasks: BackgroundTasks = None, + current_user: dict = Depends(get_current_user) +): + """ + 音频文件上传接口 + + 上传音频文件并启动转录任务,可选择是否自动生成总结 + + Args: + audio_file: 音频文件 + meeting_id: 会议ID + auto_summarize: 是否自动生成总结("true"/"false",默认"true") + prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版) + model_code: 总结模型编码(可选,如果不指定则使用默认模型) + background_tasks: FastAPI后台任务 + current_user: 当前登录用户 + + Returns: + HTTP 200: 处理成功,返回任务ID + HTTP 400/403/404/500: 各种错误情况 + """ + auto_summarize_bool = auto_summarize.lower() in ("true", "1", "yes") + + model_code = model_code.strip() if model_code else None + + # 0. 如果没有传入 prompt_id,优先使用会议已配置模版,否则回退默认模版 + if prompt_id is None: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting_row = cursor.fetchone() + if meeting_row and meeting_row.get('prompt_id') and int(meeting_row['prompt_id']) > 0: + prompt_id = int(meeting_row['prompt_id']) + else: + cursor = connection.cursor() + cursor.execute( + "SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1" + ) + prompt_row = cursor.fetchone() + prompt_id = prompt_row[0] if prompt_row else None + + # 1. 文件类型验证 + file_extension = os.path.splitext(audio_file.filename)[1].lower() + if file_extension not in ALLOWED_EXTENSIONS: + return create_api_response( + code="400", + message=f"不支持的文件类型。支持的类型: {', '.join(ALLOWED_EXTENSIONS)}" + ) + + # 2. 文件大小验证 + max_file_size = SystemConfigService.get_max_audio_size(default=100) * 1024 * 1024 # MB转字节 + if audio_file.size > max_file_size: + return create_api_response( + code="400", + message=f"文件大小超过 {max_file_size // (1024 * 1024)}MB 限制" + ) + + # 3. 保存音频文件到磁盘 + meeting_dir = AUDIO_DIR / str(meeting_id) + meeting_dir.mkdir(exist_ok=True) + unique_filename = f"{uuid.uuid4()}{file_extension}" + absolute_path = meeting_dir / unique_filename + relative_path = absolute_path.relative_to(BASE_DIR) + + try: + with open(absolute_path, "wb") as buffer: + shutil.copyfileobj(audio_file.file, buffer) + 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 + + # 4. 调用 audio_service 处理文件(权限检查、数据库更新、启动转录) + result = handle_audio_upload( + file_path=file_path, + file_name=file_name, + file_size=file_size, + meeting_id=meeting_id, + current_user=current_user, + auto_summarize=auto_summarize_bool, + background_tasks=background_tasks, + prompt_id=prompt_id, + model_code=model_code, + duration=audio_duration # 传递时长参数 + ) + + # 如果不成功,删除已保存的文件并返回错误 + if not result["success"]: + if absolute_path.exists(): + try: + os.remove(absolute_path) + print(f"Deleted file due to processing error: {absolute_path}") + except Exception as e: + print(f"Warning: Failed to delete file {absolute_path}: {e}") + return result["response"] + + # 5. 返回成功响应 + transcription_task_id = result["transcription_task_id"] + message_suffix = "" + if transcription_task_id: + if auto_summarize_bool: + message_suffix = ",正在进行转录和总结" + else: + message_suffix = ",正在进行转录" + + return create_api_response( + code="200", + message="Audio file uploaded successfully" + + (" and replaced existing file" if result["replaced_existing"] else "") + + message_suffix, + data={ + "file_name": result["file_info"]["file_name"], + "file_path": result["file_info"]["file_path"], + "task_id": transcription_task_id, + "transcription_started": transcription_task_id is not None, + "auto_summarize": auto_summarize_bool, + "model_code": model_code, + "replaced_existing": result["replaced_existing"], + "previous_transcription_cleared": result["replaced_existing"] and result["has_transcription"] + } + ) + +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,)) + audio_file = cursor.fetchone() + if not audio_file: + return create_api_response(code="404", message="Audio file not found for this meeting") + return create_api_response(code="200", message="Audio file found", data=audio_file) + +async def stream_audio_file( + meeting_id: int, + range: Optional[str] = Header(None, alias="Range") +): + """ + 音频文件流式传输端点,支持HTTP Range请求(Safari浏览器必需) + 无需登录认证,用于前端audio标签直接访问 + """ + # 获取音频文件信息 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT file_name, file_path, file_size FROM audio_files WHERE meeting_id = %s", (meeting_id,)) + audio_file = cursor.fetchone() + if not audio_file: + return Response(content="Audio file not found", status_code=404) + + # 构建完整文件路径 + file_path = BASE_DIR / audio_file['file_path'].lstrip('/') + if not file_path.exists(): + return Response(content="Audio file not found on disk", status_code=404) + + # 总是使用实际文件大小(不依赖数据库记录,防止文件被优化后大小不匹配) + file_size = os.path.getsize(file_path) + file_name = audio_file['file_name'] + + # 根据文件扩展名确定MIME类型 + extension = os.path.splitext(file_name)[1].lower() + mime_types = { + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', # 标准 MIME type,Safari 兼容 + '.wav': 'audio/wav', + '.mpeg': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.webm': 'audio/webm' + } + content_type = mime_types.get(extension, 'audio/mpeg') + + # 处理Range请求 + start = 0 + end = file_size - 1 + + if range: + # 解析Range头: "bytes=start-end" 或 "bytes=start-" + try: + range_spec = range.replace("bytes=", "") + if "-" in range_spec: + parts = range_spec.split("-") + if parts[0]: + start = int(parts[0]) + if parts[1]: + end = int(parts[1]) + except (ValueError, IndexError): + pass + + # 确保范围有效 + if start >= file_size: + return Response( + content="Range Not Satisfiable", + status_code=416, + headers={"Content-Range": f"bytes */{file_size}"} + ) + + end = min(end, file_size - 1) + content_length = end - start + 1 + + # 对所有文件名统一使用RFC 5987标准的URL编码格式 + # 这样可以正确处理中文、特殊字符等所有情况 + encoded_filename = quote(file_name) + filename_header = f"inline; filename*=UTF-8''{encoded_filename}" + + # 生成器函数用于流式读取文件 + def iter_file(): + with open(file_path, 'rb') as f: + f.seek(start) + remaining = content_length + chunk_size = 64 * 1024 # 64KB chunks + while remaining > 0: + read_size = min(chunk_size, remaining) + data = f.read(read_size) + if not data: + break + remaining -= len(data) + yield data + + # 根据是否有Range请求返回不同的响应 + if range: + return StreamingResponse( + iter_file(), + status_code=206, # Partial Content + media_type=content_type, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(content_length), + "Content-Disposition": filename_header, + "Cache-Control": "public, max-age=31536000", # 1年缓存 + "X-Content-Type-Options": "nosniff" + } + ) + else: + return StreamingResponse( + iter_file(), + status_code=200, + media_type=content_type, + headers={ + "Accept-Ranges": "bytes", + "Content-Length": str(file_size), + "Content-Disposition": filename_header, + "Cache-Control": "public, max-age=31536000", # 1年缓存 + "X-Content-Type-Options": "nosniff" + } + ) + +def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depends(get_current_user)): + try: + status_info = transcription_service.get_meeting_transcription_status(meeting_id) + if not status_info: + return create_api_response(code="404", message="No transcription task found for this meeting") + return create_api_response(code="200", message="Transcription status retrieved", data=status_info) + except Exception as e: + return create_api_response(code="500", message=f"Failed to get meeting transcription status: {str(e)}") + +def start_meeting_transcription( + meeting_id: int, + background_tasks: BackgroundTasks, + current_user: dict = Depends(get_current_user) +): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT meeting_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return create_api_response(code="404", message="Meeting not found") + cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,)) + audio_file = cursor.fetchone() + if not audio_file: + return create_api_response(code="400", message="No audio file found for this meeting") + existing_status = transcription_service.get_meeting_transcription_status(meeting_id) + if existing_status and existing_status['status'] in ['pending', 'processing']: + return create_api_response(code="409", message="Transcription task already exists", data={ + "task_id": existing_status['task_id'], "status": existing_status['status'] + }) + task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path']) + async_meeting_service.enqueue_transcription_monitor( + meeting_id, + task_id, + meeting.get('prompt_id') if meeting.get('prompt_id') not in (None, 0) else None, + None + ) + return create_api_response(code="200", message="Transcription task started successfully", data={ + "task_id": task_id, "meeting_id": meeting_id + }) + except Exception as e: + return create_api_response(code="500", message=f"Failed to start transcription: {str(e)}") + +async def upload_image(meeting_id: int, image_file: UploadFile = File(...), current_user: dict = Depends(get_current_user)): + file_extension = os.path.splitext(image_file.filename)[1].lower() + if file_extension not in ALLOWED_IMAGE_EXTENSIONS: + return create_api_response(code="400", message=f"Unsupported image type. Allowed types: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}") + max_image_size = getattr(config_module, 'MAX_IMAGE_SIZE', 10 * 1024 * 1024) + if image_file.size > max_image_size: + return create_api_response(code="400", message=f"Image size exceeds {max_image_size // (1024 * 1024)}MB limit") + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return create_api_response(code="404", message="Meeting not found") + if meeting['user_id'] != current_user['user_id']: + return create_api_response(code="403", message="Permission denied") + meeting_dir = MARKDOWN_DIR / str(meeting_id) + meeting_dir.mkdir(exist_ok=True) + unique_filename = f"{uuid.uuid4()}{file_extension}" + absolute_path = meeting_dir / unique_filename + relative_path = absolute_path.relative_to(BASE_DIR) + try: + with open(absolute_path, "wb") as buffer: + shutil.copyfileobj(image_file.file, buffer) + except Exception as e: + return create_api_response(code="500", message=f"Failed to save image: {str(e)}") + return create_api_response(code="200", message="Image uploaded successfully", data={ + "file_name": image_file.filename, "file_path": '/'+ str(relative_path) + }) + +def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response + update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' + cursor.execute(update_query, (request.new_tag, meeting_id, request.speaker_id)) + if cursor.rowcount == 0: + return create_api_response(code="404", message="No segments found for this speaker") + connection.commit() + return create_api_response(code="200", message="Speaker tag updated successfully", data={'updated_count': cursor.rowcount}) + except Exception as e: + return create_api_response(code="500", message=f"Failed to update speaker tag: {str(e)}") + +def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response + total_updated = 0 + for update_item in request.updates: + update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' + cursor.execute(update_query, (update_item.new_tag, meeting_id, update_item.speaker_id)) + total_updated += cursor.rowcount + connection.commit() + return create_api_response(code="200", message="Speaker tags updated successfully", data={'total_updated': total_updated}) + except Exception as e: + return create_api_response(code="500", message=f"Failed to batch update speaker tags: {str(e)}") + +def batch_update_transcript(meeting_id: int, request: BatchTranscriptUpdateRequest, current_user: dict = Depends(get_current_user)): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response + total_updated = 0 + for update_item in request.updates: + cursor.execute("SELECT segment_id FROM transcript_segments WHERE segment_id = %s AND meeting_id = %s", (update_item.segment_id, meeting_id)) + if not cursor.fetchone(): + continue + update_query = 'UPDATE transcript_segments SET text_content = %s WHERE segment_id = %s AND meeting_id = %s' + cursor.execute(update_query, (update_item.text_content, update_item.segment_id, meeting_id)) + total_updated += cursor.rowcount + connection.commit() + return create_api_response(code="200", message="Transcript updated successfully", data={'total_updated': total_updated}) + except Exception as e: + return create_api_response(code="500", message=f"Failed to update transcript: {str(e)}") + +def get_meeting_summaries(meeting_id: int, current_user: dict = Depends(get_current_user)): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="Meeting not found") + summaries = llm_service.get_meeting_summaries(meeting_id) + return create_api_response(code="200", message="Summaries retrieved successfully", data={"summaries": summaries}) + except Exception as e: + return create_api_response(code="500", message=f"Failed to get summaries: {str(e)}") + +def get_summary_detail(meeting_id: int, summary_id: int, current_user: dict = Depends(get_current_user)): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + query = "SELECT id, summary_content, user_prompt, created_at FROM meeting_summaries WHERE id = %s AND meeting_id = %s" + cursor.execute(query, (summary_id, meeting_id)) + summary = cursor.fetchone() + if not summary: + return create_api_response(code="404", message="Summary not found") + return create_api_response(code="200", message="Summary detail retrieved", data=summary) + except Exception as e: + return create_api_response(code="500", message=f"Failed to get summary detail: {str(e)}") + +def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + _, error_response = _verify_meeting_owner(cursor, meeting_id, current_user['user_id']) + if error_response: + return error_response + transcription_status = transcription_service.get_meeting_transcription_status(meeting_id) + if transcription_status and transcription_status.get('status') in ['pending', 'processing']: + return create_api_response(code="409", message="转录进行中,暂不允许重新总结", data={ + "task_id": transcription_status.get('task_id'), + "status": transcription_status.get('status') + }) + llm_status = async_meeting_service.get_meeting_llm_status(meeting_id) + if llm_status and llm_status.get('status') in ['pending', 'processing']: + return create_api_response(code="409", message="总结任务已存在", data={ + "task_id": llm_status.get('task_id'), + "status": llm_status.get('status') + }) + # 传递 prompt_id 和 model_code 参数给服务层 + task_id, created = async_meeting_service.enqueue_summary_generation( + meeting_id, + request.user_prompt, + request.prompt_id, + request.model_code, + ) + if not created: + return create_api_response(code="409", message="总结任务已存在", data={ + "task_id": task_id, + "status": "pending" + }) + return create_api_response(code="200", message="Summary generation task has been accepted.", data={ + "task_id": task_id, "status": "pending", "meeting_id": meeting_id + }) + except Exception as e: + return create_api_response(code="500", message=f"Failed to start summary generation: {str(e)}") + +def get_meeting_llm_tasks(meeting_id: int, current_user: dict = Depends(get_current_user)): + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="Meeting not found") + tasks = async_meeting_service.get_meeting_llm_tasks(meeting_id) + return create_api_response(code="200", message="LLM tasks retrieved successfully", data={ + "tasks": tasks, "total": len(tasks) + }) + except Exception as e: + return create_api_response(code="500", message=f"Failed to get LLM tasks: {str(e)}") + +def list_active_llm_models(current_user: dict = Depends(get_current_user)): + """获取所有激活的LLM模型列表(供普通用户选择)""" + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT model_code, model_name, provider, is_default FROM llm_model_config WHERE is_active = 1 ORDER BY is_default DESC, model_code ASC" + ) + models = cursor.fetchall() + return create_api_response(code="200", message="获取模型列表成功", data=models) + except Exception as e: + return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}") +def get_meeting_navigation( + meeting_id: int, + current_user: dict = Depends(get_current_user), + user_id: Optional[int] = None, + filter_type: str = "all", + search: Optional[str] = None, + tags: Optional[str] = None +): + """ + 获取当前会议在列表中的上一条和下一条 + + Query params: + - user_id: 当前用户ID + - filter_type: 筛选类型 ('all', 'created', 'attended') + - search: 搜索关键词 (可选) + - tags: 标签列表,逗号分隔 (可选) + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 构建WHERE子句 - 与get_meetings保持一致 + where_conditions = [] + params = [] + has_attendees_join = False + + # 按类型过滤 + if user_id: + if filter_type == "created": + where_conditions.append("m.user_id = %s") + params.append(user_id) + elif filter_type == "attended": + where_conditions.append("m.user_id != %s AND a.user_id = %s") + params.extend([user_id, user_id]) + has_attendees_join = True + else: # all + where_conditions.append("(m.user_id = %s OR a.user_id = %s)") + params.extend([user_id, user_id]) + has_attendees_join = True + + # 搜索关键词过滤 + if search and search.strip(): + search_pattern = f"%{search.strip()}%" + where_conditions.append("(m.title LIKE %s OR u.caption LIKE %s)") + params.extend([search_pattern, search_pattern]) + + # 标签过滤 + if tags and tags.strip(): + tag_list = [t.strip() for t in tags.split(',') if t.strip()] + if tag_list: + tag_conditions = [] + for tag in tag_list: + tag_conditions.append("m.tags LIKE %s") + params.append(f"%{tag}%") + where_conditions.append(f"({' OR '.join(tag_conditions)})") + + # 构建查询 - 只获取meeting_id,按meeting_time降序排序 + query = ''' + SELECT m.meeting_id + FROM meetings m + JOIN sys_users u ON m.user_id = u.user_id + ''' + + if has_attendees_join: + query += " LEFT JOIN attendees a ON m.meeting_id = a.meeting_id" + + if where_conditions: + query += f" WHERE {' AND '.join(where_conditions)}" + + if has_attendees_join: + query += " GROUP BY m.meeting_id" + + query += " ORDER BY m.meeting_time DESC, m.created_at DESC" + + cursor.execute(query, params) + all_meetings = cursor.fetchall() + all_meeting_ids = [m['meeting_id'] for m in all_meetings] + + # 找到当前会议在列表中的位置 + try: + current_index = all_meeting_ids.index(meeting_id) + except ValueError: + return create_api_response(code="200", message="当前会议不在筛选结果中", data={ + 'prev_meeting_id': None, + 'next_meeting_id': None, + 'current_index': None, + 'total_count': len(all_meeting_ids) + }) + + # 计算上一条和下一条 + prev_meeting_id = all_meeting_ids[current_index - 1] if current_index > 0 else None + next_meeting_id = all_meeting_ids[current_index + 1] if current_index < len(all_meeting_ids) - 1 else None + + return create_api_response(code="200", message="获取导航信息成功", data={ + 'prev_meeting_id': prev_meeting_id, + 'next_meeting_id': next_meeting_id, + 'current_index': current_index, + 'total_count': len(all_meeting_ids) + }) + + except Exception as e: + return create_api_response(code="500", message=f"获取导航信息失败: {str(e)}") + +def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None): + """ + 获取会议预览数据(无需登录认证) + 用于二维码扫描后的预览页面 + + 返回状态码说明: + - 200: 会议已完成(summary已生成) + - 400: 会议处理中(转译或总结阶段) + - 503: 处理失败(转译或总结失败) + - 504: 数据异常(流程完成但summary未生成) + - 404: 会议不存在 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 检查会议是否存在,并获取基本信息 + query = ''' + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id, m.tags, + m.user_id as creator_id, u.caption as creator_username, + p.name as prompt_name, m.access_password + FROM meetings m + JOIN sys_users u ON m.user_id = u.user_id + LEFT JOIN prompts p ON m.prompt_id = p.id + WHERE m.meeting_id = %s + ''' + cursor.execute(query, (meeting_id,)) + meeting = cursor.fetchone() + + if not meeting: + return create_api_response(code="404", message="会议不存在") + + stored_password = (meeting.get('access_password') or '').strip() + provided_password = (password or '').strip() + + if stored_password: + if not provided_password: + return create_api_response( + code="401", + message="此会议受密码保护", + data={ + "meeting_id": meeting_id, + "title": meeting['title'], + "requires_password": True + } + ) + + if provided_password != stored_password: + return create_api_response( + code="401", + message="密码错误", + data={ + "meeting_id": meeting_id, + "title": meeting['title'], + "requires_password": True + } + ) + + # 获取整体进度状态(两阶段) + progress_info = _get_meeting_overall_status(meeting_id) + overall_status = progress_info["overall_status"] + + # 根据整体状态返回不同响应 + + # 情况1: 任一阶段失败 → 返回503 + if overall_status == "failed": + failed_stage = progress_info["current_stage"] + error_info = progress_info["transcription"] if failed_stage == "transcription" else progress_info["llm"] + error_message = error_info["error_message"] or "处理失败" + + stage_name = "转译" if failed_stage == "transcription" else "总结" + return create_api_response( + code="503", + message=f"会议{stage_name}失败: {error_message}", + data={ + "meeting_id": meeting_id, + "title": meeting['title'], + "processing_status": progress_info + } + ) + + # 情况2: 处理中(转译或总结阶段)→ 返回400 + if overall_status in ["pending", "transcribing", "summarizing"]: + stage_descriptions = { + "pending": "等待开始", + "transcribing": "正在转译音频", + "summarizing": "正在生成总结" + } + return create_api_response( + code="400", + message=f"会议正在处理中: {stage_descriptions[overall_status]}", + data={ + "meeting_id": meeting_id, + "title": meeting['title'], + "processing_status": progress_info + } + ) + + # 情况3: 全部完成但Summary缺失 → 返回504 + if overall_status == "completed" and not meeting['summary']: + return create_api_response( + code="504", + message="处理已完成,AI总结尚未同步,请稍后重试", + data={ + "meeting_id": meeting_id, + "title": meeting['title'], + "processing_status": progress_info + } + ) + + # 情况4: 全部完成 → 返回200,提供完整预览数据 + if overall_status == "completed" and meeting['summary']: + # 获取参会人员信息 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' + cursor.execute(attendees_query, (meeting_id,)) + attendees_data = cursor.fetchall() + attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] + cursor.execute( + ''' + SELECT COUNT(DISTINCT speaker_id) AS participant_count + FROM transcript_segments + WHERE meeting_id = %s AND speaker_id IS NOT NULL + ''', + (meeting_id,) + ) + speaker_count_row = cursor.fetchone() or {} + participant_count = speaker_count_row.get('participant_count') or len(attendees) + tags = _process_tags(cursor, meeting.get('tags')) + + # 组装返回数据 + preview_data = { + "meeting_id": meeting['meeting_id'], + "title": meeting['title'], + "meeting_time": meeting['meeting_time'], + "summary": meeting['summary'], + "creator_username": meeting['creator_username'], + "prompt_id": meeting['prompt_id'], + "prompt_name": meeting['prompt_name'], + "attendees": attendees, + "attendees_count": participant_count, + "tags": tags, + "has_password": bool(meeting.get('access_password')), + "processing_status": progress_info # 附带进度信息供调试 + } + + return create_api_response(code="200", message="获取会议预览数据成功", data=preview_data) + + except Exception as e: + return create_api_response(code="500", message=f"Failed to get meeting preview data: {str(e)}") + +# 访问密码管理相关API + +class AccessPasswordRequest(BaseModel): + password: Optional[str] = None # None表示关闭密码 + +class VerifyPasswordRequest(BaseModel): + password: str + +def update_meeting_access_password( + meeting_id: int, + request: AccessPasswordRequest, + current_user: dict = Depends(get_current_user) +): + """ + 设置或关闭会议访问密码(仅创建人可操作) + + Args: + meeting_id: 会议ID + request.password: 密码字符串(None表示关闭密码) + current_user: 当前登录用户 + + Returns: + API响应,包含操作结果 + """ + try: + normalized_password = None + if request.password is not None: + normalized_password = request.password.strip() + if not normalized_password: + return create_api_response(code="400", message="访问密码不能为空") + + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 检查会议是否存在且当前用户是创建人 + cursor.execute( + "SELECT meeting_id, user_id FROM meetings WHERE meeting_id = %s", + (meeting_id,) + ) + meeting = cursor.fetchone() + + if not meeting: + return create_api_response(code="404", message="会议不存在") + + if meeting['user_id'] != current_user['user_id']: + return create_api_response(code="403", message="仅创建人可以设置访问密码") + + # 更新访问密码 + cursor.execute( + "UPDATE meetings SET access_password = %s WHERE meeting_id = %s", + (normalized_password, meeting_id) + ) + connection.commit() + + if normalized_password: + return create_api_response( + code="200", + message="访问密码已设置", + data={"password": normalized_password} + ) + else: + return create_api_response( + code="200", + message="访问密码已关闭", + data={"password": None} + ) + + except Exception as e: + return create_api_response( + code="500", + message=f"设置访问密码失败: {str(e)}" + ) + +def verify_meeting_password(meeting_id: int, request: VerifyPasswordRequest): + """ + 验证会议访问密码(无需登录认证) + + Args: + meeting_id: 会议ID + request.password: 要验证的密码 + + Returns: + API响应,包含验证结果 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 获取会议的访问密码 + cursor.execute( + "SELECT access_password FROM meetings WHERE meeting_id = %s", + (meeting_id,) + ) + meeting = cursor.fetchone() + + if not meeting: + return create_api_response(code="404", message="会议不存在") + + # 验证密码 + stored_password = meeting.get('access_password') + + if not stored_password: + # 没有设置密码,直接通过 + return create_api_response( + code="200", + message="该会议未设置访问密码", + data={"verified": True} + ) + + if request.password == stored_password: + return create_api_response( + code="200", + message="密码验证成功", + data={"verified": True} + ) + else: + return create_api_response( + code="200", + message="密码错误", + data={"verified": False} + ) + + except Exception as e: + return create_api_response( + code="500", + message=f"验证密码失败: {str(e)}" + ) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ab2a1c9..af7ba02 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,11 @@ import AccountSettings from './pages/AccountSettings'; import MeetingCenterPage from './pages/MeetingCenterPage'; import MainLayout from './components/MainLayout'; import menuService from './services/menuService'; +import { + clearAuthSession, + getStoredUser, + setStoredAuthPayload, +} from './services/authSessionService'; import configService from './utils/configService'; import './App.css'; import './styles/console-theme.css'; @@ -84,26 +89,8 @@ function App() { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); - // Load user from localStorage on app start useEffect(() => { - const savedAuth = localStorage.getItem('iMeetingUser'); - if (savedAuth && savedAuth !== "undefined" && savedAuth !== "null") { - try { - const authData = JSON.parse(savedAuth); - // 如果数据包含 user 字段,则提取 user 字段(适应新结构) - // 否则使用整个对象(兼容旧结构) - const userData = authData.user || authData; - - if (userData && typeof userData === 'object' && (userData.user_id || userData.id)) { - setUser(userData); - } else { - localStorage.removeItem('iMeetingUser'); - } - } catch (error) { - console.error('Error parsing saved user:', error); - localStorage.removeItem('iMeetingUser'); - } - } + setUser(getStoredUser()); setIsLoading(false); }, []); @@ -124,11 +111,7 @@ function App() { const handleLogin = (authData) => { if (authData) { menuService.clearCache(); - // 提取用户信息用于 UI 展示 - const userData = authData.user || authData; - setUser(userData); - // 存入完整 auth 数据(包含 token)供拦截器使用 - localStorage.setItem('iMeetingUser', JSON.stringify(authData)); + setUser(setStoredAuthPayload(authData)); } }; @@ -139,9 +122,8 @@ function App() { console.error('Logout API error:', error); } finally { setUser(null); - localStorage.removeItem('iMeetingUser'); menuService.clearCache(); - window.location.href = '/'; + clearAuthSession(); } }; diff --git a/frontend/src/components/ClientDownloads.jsx b/frontend/src/components/ClientDownloads.jsx index 27fdc02..d9a8df8 100644 --- a/frontend/src/components/ClientDownloads.jsx +++ b/frontend/src/components/ClientDownloads.jsx @@ -9,7 +9,7 @@ import { WindowsOutlined, RightOutlined } from '@ant-design/icons'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; const { Title, Text } = Typography; @@ -24,7 +24,7 @@ const ClientDownloads = () => { const fetchClients = async () => { try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.PUBLIC_LIST)); + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.PUBLIC_LIST)); setClients(response.data.clients || []); } catch (error) { console.error('获取下载列表失败:', error); diff --git a/frontend/src/components/MeetingFormDrawer.jsx b/frontend/src/components/MeetingFormDrawer.jsx index d3718be..74d94e5 100644 --- a/frontend/src/components/MeetingFormDrawer.jsx +++ b/frontend/src/components/MeetingFormDrawer.jsx @@ -1,160 +1,28 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Drawer, Form, Input, Button, DatePicker, Select, Space, App, Upload, Card, Progress, Typography } from 'antd'; +import React from 'react'; +import { Drawer, Form, Input, Button, DatePicker, Select, Space, Upload, Card, Progress, Typography } from 'antd'; import { SaveOutlined, UploadOutlined, DeleteOutlined, AudioOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import apiClient from '../utils/apiClient'; -import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import configService from '../utils/configService'; -import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService'; +import { AUDIO_UPLOAD_ACCEPT } from '../services/meetingAudioService'; +import useMeetingFormDrawer from '../hooks/useMeetingFormDrawer'; const { Text } = Typography; const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => { - const { message } = App.useApp(); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false); - const [users, setUsers] = useState([]); - const [prompts, setPrompts] = useState([]); - const [selectedAudioFile, setSelectedAudioFile] = useState(null); - const [audioUploading, setAudioUploading] = useState(false); - const [audioUploadProgress, setAudioUploadProgress] = useState(0); - const [audioUploadMessage, setAudioUploadMessage] = useState(''); - const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024); - - const isEdit = Boolean(meetingId); - - const fetchOptions = useCallback(async () => { - try { - const [uRes, pRes] = await Promise.all([ - apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)), - apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))), - ]); - setUsers(uRes.data.users || []); - setPrompts(pRes.data.prompts || []); - } catch { - message.error('加载会议表单选项失败'); - } - }, [message]); - - const loadAudioUploadConfig = useCallback(async () => { - try { - const nextMaxAudioSize = await configService.getMaxAudioSize(); - setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); - } catch { - setMaxAudioSize(100 * 1024 * 1024); - } - }, []); - - const fetchMeeting = useCallback(async () => { - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); - const meeting = res.data; - form.setFieldsValue({ - title: meeting.title, - meeting_time: dayjs(meeting.meeting_time), - attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [], - prompt_id: meeting.prompt_id, - tags: meeting.tags?.map((t) => t.name) || [], - }); - } catch { - message.error('加载会议数据失败'); - } - }, [form, meetingId, message]); - - useEffect(() => { - if (!open) return; - fetchOptions(); - loadAudioUploadConfig(); - if (isEdit) { - fetchMeeting(); - } else { - form.resetFields(); - form.setFieldsValue({ meeting_time: dayjs() }); - setSelectedAudioFile(null); - setAudioUploading(false); - setAudioUploadProgress(0); - setAudioUploadMessage(''); - } - }, [fetchMeeting, fetchOptions, form, isEdit, loadAudioUploadConfig, open]); - - const handleAudioBeforeUpload = (file) => { - const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); - if (validationMessage) { - message.warning(validationMessage); - return Upload.LIST_IGNORE; - } - - setSelectedAudioFile(file); - return false; - }; - - const clearSelectedAudio = () => { - setSelectedAudioFile(null); - setAudioUploadProgress(0); - setAudioUploadMessage(''); - }; - - const handleSubmit = async () => { - try { - const values = await form.validateFields(); - setLoading(true); - const payload = { - ...values, - meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'), - attendee_ids: values.attendee_ids || [], - tags: values.tags?.join(',') || '', - }; - - if (isEdit) { - await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload); - message.success('会议更新成功'); - } else { - const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); - if (res.code === '200') { - const newMeetingId = res.data.meeting_id; - if (selectedAudioFile) { - setAudioUploading(true); - setAudioUploadProgress(0); - setAudioUploadMessage('正在上传音频文件...'); - try { - await uploadMeetingAudio({ - meetingId: newMeetingId, - file: selectedAudioFile, - promptId: values.prompt_id, - onUploadProgress: (progressEvent) => { - if (progressEvent.total) { - setAudioUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total))); - } - setAudioUploadMessage('正在上传音频文件...'); - }, - }); - setAudioUploadProgress(100); - setAudioUploadMessage('上传完成,正在启动转录任务...'); - message.success('会议创建成功,音频已开始上传处理'); - } catch (uploadError) { - message.warning(uploadError?.response?.data?.message || uploadError?.response?.data?.detail || '会议已创建,但音频上传失败,请在详情页重试'); - } finally { - setAudioUploading(false); - } - } else { - message.success('会议创建成功'); - } - - onSuccess?.(res.data.meeting_id); - onClose(); - return; - } - } - onSuccess?.(); - onClose(); - } catch (error) { - if (!error?.errorFields) { - message.error(error?.response?.data?.message || error?.response?.data?.detail || '操作失败'); - } - } finally { - setLoading(false); - } - }; + const { + form, + isEdit, + loading, + users, + prompts, + selectedAudioFile, + audioUploading, + audioUploadProgress, + audioUploadMessage, + maxAudioSize, + handleAudioBeforeUpload, + clearSelectedAudio, + handleSubmit, + } = useMeetingFormDrawer({ open, onClose, onSuccess, meetingId }); return ( { } > -
+ diff --git a/frontend/src/components/TagCloud.jsx b/frontend/src/components/TagCloud.jsx index bf5988d..f2fd583 100644 --- a/frontend/src/components/TagCloud.jsx +++ b/frontend/src/components/TagCloud.jsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Tag, Space, Typography, Skeleton, Empty } from 'antd'; import { TagsOutlined } from '@ant-design/icons'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; const { CheckableTag } = Tag; @@ -19,7 +19,7 @@ const TagCloud = ({ const fetchAllTags = useCallback(async () => { try { setLoading(true); - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); const tags = response.data || []; setAllTags(limitTags ? tags.slice(0, 15) : tags); } catch (err) { diff --git a/frontend/src/hooks/useAdminDashboardPage.js b/frontend/src/hooks/useAdminDashboardPage.js new file mode 100644 index 0000000..81e7525 --- /dev/null +++ b/frontend/src/hooks/useAdminDashboardPage.js @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { App } from 'antd'; +import apiClient from '../utils/apiClient'; +import { buildApiUrl, API_ENDPOINTS } from '../config/api'; + +const AUTO_REFRESH_INTERVAL = 30; + +export default function useAdminDashboardPage() { + const { message, modal } = App.useApp(); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); + + const [stats, setStats] = useState(null); + const [onlineUsers, setOnlineUsers] = useState([]); + const [usersList, setUsersList] = useState([]); + const [tasks, setTasks] = useState([]); + const [resources, setResources] = useState(null); + const [loading, setLoading] = useState(true); + const [taskLoading, setTaskLoading] = useState(false); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + const [taskType, setTaskType] = useState('all'); + const [taskStatus, setTaskStatus] = useState('all'); + const [autoRefresh, setAutoRefresh] = useState(true); + const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL); + + const [showMeetingModal, setShowMeetingModal] = useState(false); + const [meetingDetails, setMeetingDetails] = useState(null); + const [meetingLoading, setMeetingLoading] = useState(false); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const fetchStats = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS)); + if (response.code === '200') { + setStats(response.data); + } + }, []); + + const fetchOnlineUsers = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS)); + if (response.code === '200') { + setOnlineUsers(response.data.users || []); + } + }, []); + + const fetchUsersList = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS)); + if (response.code === '200') { + setUsersList(response.data.users || []); + } + }, []); + + const fetchTasks = useCallback(async () => { + try { + setTaskLoading(true); + const params = new URLSearchParams(); + if (taskType !== 'all') params.append('task_type', taskType); + if (taskStatus !== 'all') params.append('status', taskStatus); + params.append('limit', '20'); + + const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`)); + if (response.code === '200') { + setTasks(response.data.tasks || []); + } + } finally { + setTaskLoading(false); + } + }, [taskStatus, taskType]); + + const fetchResources = useCallback(async () => { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES)); + if (response.code === '200') { + setResources(response.data); + } + }, []); + + const fetchAllData = useCallback(async ({ silent = false } = {}) => { + if (inFlightRef.current) { + return; + } + inFlightRef.current = true; + + try { + if (!silent && mountedRef.current) { + setLoading(true); + } + await Promise.all([fetchStats(), fetchOnlineUsers(), fetchUsersList(), fetchTasks(), fetchResources()]); + if (mountedRef.current) { + setLastUpdatedAt(new Date()); + setCountdown(AUTO_REFRESH_INTERVAL); + } + } catch (error) { + console.error('获取数据失败:', error); + if (mountedRef.current && !silent) { + message.error('加载数据失败,请稍后重试'); + } + } finally { + inFlightRef.current = false; + if (mountedRef.current && !silent) { + setLoading(false); + } + } + }, [fetchOnlineUsers, fetchResources, fetchStats, fetchTasks, fetchUsersList, message]); + + useEffect(() => { + fetchAllData(); + }, [fetchAllData]); + + useEffect(() => { + if (!autoRefresh || showMeetingModal) { + return undefined; + } + + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + fetchAllData({ silent: true }); + return AUTO_REFRESH_INTERVAL; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [autoRefresh, fetchAllData, showMeetingModal]); + + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + const handleKickUser = (user) => { + modal.confirm({ + title: '踢出用户', + content: `确定要踢出用户"${user.caption}"吗?该用户将被强制下线。`, + okText: '确定', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.KICK_USER(user.user_id))); + if (response.code === '200') { + message.success('用户已被踢出'); + fetchOnlineUsers(); + } + } catch { + message.error('踢出用户失败'); + } + }, + }); + }; + + const handleViewMeeting = async (meetingId) => { + setMeetingLoading(true); + setShowMeetingModal(true); + try { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); + if (response.code === '200') { + setMeetingDetails(response.data); + } + } catch { + message.error('获取会议详情失败'); + } finally { + setMeetingLoading(false); + } + }; + + const handleDownloadTranscript = async (meetingId) => { + try { + const response = await apiClient.get(buildApiUrl(`/api/meetings/${meetingId}/transcript`)); + if (response.code === '200') { + const dataStr = JSON.stringify(response.data, null, 2); + const blob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `transcript_${meetingId}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + } catch { + message.error('下载失败'); + } + }; + + const closeMeetingModal = () => { + setShowMeetingModal(false); + setMeetingDetails(null); + }; + + const taskCompletionRate = useMemo(() => { + const all = tasks.length || 1; + const completed = tasks.filter((item) => item.status === 'completed').length; + return Math.round((completed / all) * 100); + }, [tasks]); + + return { + stats, + onlineUsers, + usersList, + tasks, + resources, + loading, + taskLoading, + lastUpdatedAt, + taskType, + setTaskType, + taskStatus, + setTaskStatus, + autoRefresh, + setAutoRefresh, + countdown, + showMeetingModal, + meetingDetails, + meetingLoading, + fetchAllData, + fetchOnlineUsers, + handleKickUser, + handleViewMeeting, + handleDownloadTranscript, + closeMeetingModal, + taskCompletionRate, + }; +} diff --git a/frontend/src/hooks/useMeetingDetailsPage.js b/frontend/src/hooks/useMeetingDetailsPage.js new file mode 100644 index 0000000..8f656e7 --- /dev/null +++ b/frontend/src/hooks/useMeetingDetailsPage.js @@ -0,0 +1,979 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react'; +import { App } from 'antd'; +import { useNavigate, useParams } from 'react-router-dom'; +import apiClient from '../utils/apiClient'; +import configService from '../utils/configService'; +import { buildApiUrl, API_ENDPOINTS } from '../config/api'; +import { uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService'; + +const TRANSCRIPT_INITIAL_RENDER_COUNT = 80; +const TRANSCRIPT_RENDER_STEP = 120; + +const findTranscriptIndexByTime = (segments, timeMs) => { + let left = 0; + let right = segments.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const segment = segments[mid]; + + if (timeMs < segment.start_time_ms) { + right = mid - 1; + } else if (timeMs > segment.end_time_ms) { + left = mid + 1; + } else { + return mid; + } + } + + return -1; +}; + +const generateRandomPassword = (length = 4) => { + const charset = '0123456789'; + return Array.from({ length }, () => charset[Math.floor(Math.random() * charset.length)]).join(''); +}; + +const buildSpeakerState = (segments) => { + const speakerMap = new Map(); + + segments.forEach((segment) => { + if (segment?.speaker_id == null || speakerMap.has(segment.speaker_id)) { + return; + } + + speakerMap.set(segment.speaker_id, { + speaker_id: segment.speaker_id, + speaker_tag: segment.speaker_tag || `发言人 ${segment.speaker_id}`, + }); + }); + + const speakerList = Array.from(speakerMap.values()).sort((a, b) => a.speaker_id - b.speaker_id); + const editingSpeakers = {}; + speakerList.forEach((speaker) => { + editingSpeakers[speaker.speaker_id] = speaker.speaker_tag; + }); + + return { speakerList, editingSpeakers }; +}; + +export default function useMeetingDetailsPage({ user }) { + const { meeting_id: meetingId } = useParams(); + const navigate = useNavigate(); + const { message, modal } = App.useApp(); + + const [meeting, setMeeting] = useState(null); + const [loading, setLoading] = useState(true); + const [transcript, setTranscript] = useState([]); + const [transcriptLoading, setTranscriptLoading] = useState(false); + const [audioUrl, setAudioUrl] = useState(null); + + const [editingSpeakers, setEditingSpeakers] = useState({}); + const [speakerList, setSpeakerList] = useState([]); + + const [transcriptionStatus, setTranscriptionStatus] = useState(null); + const [transcriptionProgress, setTranscriptionProgress] = useState(0); + const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1); + + const [showSummaryDrawer, setShowSummaryDrawer] = useState(false); + const [summaryLoading, setSummaryLoading] = useState(false); + const [summaryResourcesLoading, setSummaryResourcesLoading] = useState(false); + const [userPrompt, setUserPrompt] = useState(''); + const [promptList, setPromptList] = useState([]); + const [selectedPromptId, setSelectedPromptId] = useState(null); + const [summaryTaskProgress, setSummaryTaskProgress] = useState(0); + const [summaryTaskMessage, setSummaryTaskMessage] = useState(''); + const [llmModels, setLlmModels] = useState([]); + const [selectedModelCode, setSelectedModelCode] = useState(null); + + const [showSpeakerDrawer, setShowSpeakerDrawer] = useState(false); + const [viewingPrompt, setViewingPrompt] = useState(null); + const [editDrawerOpen, setEditDrawerOpen] = useState(false); + const [showQRModal, setShowQRModal] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadStatusMessage, setUploadStatusMessage] = useState(''); + const [playbackRate, setPlaybackRate] = useState(1); + const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false); + const [accessPasswordDraft, setAccessPasswordDraft] = useState(''); + const [savingAccessPassword, setSavingAccessPassword] = useState(false); + const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024); + + const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false); + const [editingSegments, setEditingSegments] = useState({}); + + const [isEditingSummary, setIsEditingSummary] = useState(false); + const [editingSummaryContent, setEditingSummaryContent] = useState(''); + const [inlineSpeakerEdit, setInlineSpeakerEdit] = useState(null); + const [inlineSpeakerEditSegmentId, setInlineSpeakerEditSegmentId] = useState(null); + const [inlineSpeakerValue, setInlineSpeakerValue] = useState(''); + const [inlineSegmentEditId, setInlineSegmentEditId] = useState(null); + const [inlineSegmentValue, setInlineSegmentValue] = useState(''); + const [savingInlineEdit, setSavingInlineEdit] = useState(false); + const [transcriptVisibleCount, setTranscriptVisibleCount] = useState(TRANSCRIPT_INITIAL_RENDER_COUNT); + + const audioRef = useRef(null); + const transcriptRefs = useRef([]); + const statusCheckIntervalRef = useRef(null); + const summaryPollIntervalRef = useRef(null); + const summaryBootstrapTimeoutRef = useRef(null); + const activeSummaryTaskIdRef = useRef(null); + + const isMeetingOwner = user?.user_id === meeting?.creator_id; + const creatorName = meeting?.creator_username || '未知创建人'; + const hasUploadedAudio = Boolean(audioUrl); + const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status); + const isSummaryRunning = summaryLoading; + const displayUploadProgress = Math.max(0, Math.min(uploadProgress, 100)); + const displayTranscriptionProgress = Math.max(0, Math.min(transcriptionProgress, 100)); + const displaySummaryProgress = Math.max(0, Math.min(summaryTaskProgress, 100)); + const summaryDisabledReason = isUploading + ? '音频上传中,暂不允许重新总结' + : !hasUploadedAudio + ? '请先上传音频后再总结' + : isTranscriptionRunning + ? '转录进行中,完成后会自动总结' + : ''; + const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading; + + const clearSummaryBootstrapPolling = () => { + if (summaryBootstrapTimeoutRef.current) { + clearTimeout(summaryBootstrapTimeoutRef.current); + summaryBootstrapTimeoutRef.current = null; + } + }; + + const loadAudioUploadConfig = async () => { + try { + const nextMaxAudioSize = await configService.getMaxAudioSize(); + setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); + } catch { + setMaxAudioSize(100 * 1024 * 1024); + } + }; + + const fetchPromptList = async () => { + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); + setPromptList(res.data.prompts || []); + const defaultPrompt = res.data.prompts?.find((prompt) => prompt.is_default) || res.data.prompts?.[0]; + if (defaultPrompt) { + setSelectedPromptId(defaultPrompt.id); + } + } catch (error) { + console.debug('加载提示词列表失败:', error); + } + }; + + const fetchLlmModels = async () => { + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS)); + const models = Array.isArray(res.data) ? res.data : (res.data?.models || []); + setLlmModels(models); + const defaultModel = models.find((model) => model.is_default); + if (defaultModel) { + setSelectedModelCode(defaultModel.model_code); + } + } catch (error) { + console.debug('加载模型列表失败:', error); + } + }; + + const fetchSummaryResources = async () => { + setSummaryResourcesLoading(true); + try { + await Promise.allSettled([ + promptList.length > 0 ? Promise.resolve() : fetchPromptList(), + llmModels.length > 0 ? Promise.resolve() : fetchLlmModels(), + ]); + } finally { + setSummaryResourcesLoading(false); + } + }; + + const fetchTranscript = async () => { + setTranscriptLoading(true); + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meetingId))); + const segments = Array.isArray(res.data) ? res.data : []; + const speakerState = buildSpeakerState(segments); + + setTranscript(segments); + setSpeakerList(speakerState.speakerList); + setEditingSpeakers(speakerState.editingSpeakers); + } catch { + setTranscript([]); + setSpeakerList([]); + setEditingSpeakers({}); + } finally { + setTranscriptLoading(false); + } + }; + + const fetchMeetingDetails = async (options = {}) => { + const { showPageLoading = true } = options; + try { + if (showPageLoading) { + setLoading(true); + } + + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); + const meetingData = response.data; + + setMeeting(meetingData); + if (meetingData.prompt_id) { + setSelectedPromptId(meetingData.prompt_id); + } + setAccessPasswordEnabled(Boolean(meetingData.access_password)); + setAccessPasswordDraft(meetingData.access_password || ''); + + if (meetingData.transcription_status) { + const nextStatus = meetingData.transcription_status; + setTranscriptionStatus(nextStatus); + setTranscriptionProgress(nextStatus.progress || 0); + } else { + setTranscriptionStatus(null); + setTranscriptionProgress(0); + } + + if (meetingData.llm_status) { + const llmStatus = meetingData.llm_status; + clearSummaryBootstrapPolling(); + setSummaryTaskProgress(llmStatus.progress || 0); + setSummaryTaskMessage( + llmStatus.message + || (llmStatus.status === 'processing' + ? 'AI 正在分析会议内容...' + : llmStatus.status === 'pending' + ? 'AI 总结任务排队中...' + : '') + ); + if (!['pending', 'processing'].includes(llmStatus.status)) { + setSummaryLoading(false); + } + } else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) { + if (!activeSummaryTaskIdRef.current) { + setSummaryLoading(true); + setSummaryTaskProgress(0); + setSummaryTaskMessage('转录完成,正在启动 AI 分析...'); + } + } else { + clearSummaryBootstrapPolling(); + if (!activeSummaryTaskIdRef.current) { + setSummaryLoading(false); + setSummaryTaskProgress(0); + setSummaryTaskMessage(''); + } + } + + const hasAudioFile = Boolean(meetingData.audio_file_path && String(meetingData.audio_file_path).length > 5); + setAudioUrl(hasAudioFile ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meetingId)}/stream`) : null); + + return meetingData; + } catch { + message.error('加载会议详情失败'); + return null; + } finally { + if (showPageLoading) { + setLoading(false); + } + } + }; + + const startStatusPolling = (taskId) => { + if (statusCheckIntervalRef.current) { + clearInterval(statusCheckIntervalRef.current); + } + + const interval = setInterval(async () => { + try { + const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.TRANSCRIPTION_STATUS(taskId))); + const status = res.data; + setTranscriptionStatus(status); + setTranscriptionProgress(status.progress || 0); + setMeeting((prev) => (prev ? { ...prev, transcription_status: status } : prev)); + + if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) { + clearInterval(interval); + statusCheckIntervalRef.current = null; + + if (status.status === 'completed') { + await fetchTranscript(); + await fetchMeetingDetails({ showPageLoading: false }); + } + } + } catch { + clearInterval(interval); + statusCheckIntervalRef.current = null; + } + }, 3000); + + statusCheckIntervalRef.current = interval; + }; + + const startSummaryPolling = (taskId, options = {}) => { + const { closeDrawerOnComplete = false } = options; + if (!taskId) { + return; + } + if (summaryPollIntervalRef.current && activeSummaryTaskIdRef.current === taskId) { + return; + } + if (summaryPollIntervalRef.current) { + clearInterval(summaryPollIntervalRef.current); + } + + activeSummaryTaskIdRef.current = taskId; + setSummaryLoading(true); + + const poll = async () => { + try { + const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId))); + const status = statusRes.data; + setSummaryTaskProgress(status.progress || 0); + setSummaryTaskMessage(status.message || 'AI 正在分析会议内容...'); + setMeeting((prev) => (prev ? { ...prev, llm_status: status } : prev)); + + if (status.status === 'completed') { + clearInterval(interval); + summaryPollIntervalRef.current = null; + activeSummaryTaskIdRef.current = null; + setSummaryLoading(false); + + if (closeDrawerOnComplete) { + setShowSummaryDrawer(false); + } + + await fetchMeetingDetails({ showPageLoading: false }); + } else if (status.status === 'failed') { + clearInterval(interval); + summaryPollIntervalRef.current = null; + activeSummaryTaskIdRef.current = null; + setSummaryLoading(false); + message.error(status.error_message || '生成总结失败'); + } + } catch (error) { + clearInterval(interval); + summaryPollIntervalRef.current = null; + activeSummaryTaskIdRef.current = null; + setSummaryLoading(false); + message.error(error?.response?.data?.message || '获取总结状态失败'); + } + }; + + const interval = setInterval(poll, 3000); + summaryPollIntervalRef.current = interval; + poll(); + }; + + const scheduleSummaryBootstrapPolling = (attempt = 0) => { + if (summaryPollIntervalRef.current || activeSummaryTaskIdRef.current) { + return; + } + + clearSummaryBootstrapPolling(); + if (attempt >= 10) { + setSummaryLoading(false); + setSummaryTaskMessage(''); + return; + } + + summaryBootstrapTimeoutRef.current = setTimeout(async () => { + summaryBootstrapTimeoutRef.current = null; + + try { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); + const meetingData = response.data; + if (meetingData.llm_status?.task_id) { + startSummaryPolling(meetingData.llm_status.task_id); + return; + } + if (meetingData.llm_status || meetingData.summary) { + await fetchMeetingDetails({ showPageLoading: false }); + return; + } + } catch { + if (attempt >= 9) { + setSummaryLoading(false); + setSummaryTaskMessage(''); + return; + } + } + + scheduleSummaryBootstrapPolling(attempt + 1); + }, attempt === 0 ? 1200 : 2000); + }; + + const bootstrapMeetingPage = useEffectEvent(async () => { + const meetingData = await fetchMeetingDetails(); + await fetchTranscript(); + await loadAudioUploadConfig(); + + if (meetingData?.transcription_status?.task_id && ['pending', 'processing'].includes(meetingData.transcription_status.status)) { + startStatusPolling(meetingData.transcription_status.task_id); + } + + if (meetingData?.llm_status?.task_id && ['pending', 'processing'].includes(meetingData.llm_status.status)) { + startSummaryPolling(meetingData.llm_status.task_id); + } else if (meetingData?.transcription_status?.status === 'completed' && !meetingData.summary) { + scheduleSummaryBootstrapPolling(); + } + }); + + useEffect(() => { + bootstrapMeetingPage(); + + return () => { + if (statusCheckIntervalRef.current) { + clearInterval(statusCheckIntervalRef.current); + } + if (summaryPollIntervalRef.current) { + clearInterval(summaryPollIntervalRef.current); + } + if (summaryBootstrapTimeoutRef.current) { + clearTimeout(summaryBootstrapTimeoutRef.current); + } + }; + }, [bootstrapMeetingPage, meetingId]); + + const openSummaryResources = useEffectEvent(() => { + fetchSummaryResources(); + }); + + useEffect(() => { + if (!showSummaryDrawer) { + return; + } + if (promptList.length > 0 && llmModels.length > 0) { + return; + } + openSummaryResources(); + }, [llmModels.length, openSummaryResources, promptList.length, showSummaryDrawer]); + + useEffect(() => { + transcriptRefs.current = []; + setTranscriptVisibleCount(Math.min(transcript.length, TRANSCRIPT_INITIAL_RENDER_COUNT)); + }, [transcript]); + + useEffect(() => { + if (currentHighlightIndex < 0 || currentHighlightIndex < transcriptVisibleCount || transcriptVisibleCount >= transcript.length) { + return; + } + + setTranscriptVisibleCount((prev) => Math.min( + transcript.length, + Math.max(prev + TRANSCRIPT_RENDER_STEP, currentHighlightIndex + 20) + )); + }, [currentHighlightIndex, transcript.length, transcriptVisibleCount]); + + useEffect(() => { + if (currentHighlightIndex < 0) { + return; + } + + transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, [currentHighlightIndex, transcriptVisibleCount]); + + const validateAudioBeforeUpload = (file) => { + const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); + if (validationMessage) { + message.warning(validationMessage); + return validationMessage; + } + return null; + }; + + const handleUploadAudio = async (file) => { + const validationMessage = validateAudioBeforeUpload(file); + if (validationMessage) { + throw new Error(validationMessage); + } + + setIsUploading(true); + setUploadProgress(0); + setUploadStatusMessage('正在上传音频文件...'); + + try { + await uploadMeetingAudio({ + meetingId, + file, + promptId: meeting?.prompt_id, + modelCode: selectedModelCode, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total))); + } + setUploadStatusMessage('正在上传音频文件...'); + }, + }); + + setUploadProgress(100); + setUploadStatusMessage('上传完成,正在启动转录任务...'); + message.success('音频上传成功'); + setTranscript([]); + setSpeakerList([]); + setEditingSpeakers({}); + await fetchMeetingDetails({ showPageLoading: false }); + await fetchTranscript(); + } catch (error) { + message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败'); + throw error; + } finally { + setIsUploading(false); + setUploadProgress(0); + setUploadStatusMessage(''); + } + }; + + const handleUploadAudioRequest = async ({ file, onSuccess, onError }) => { + try { + await handleUploadAudio(file); + onSuccess?.({}, file); + } catch (error) { + onError?.(error); + } + }; + + const handleTimeUpdate = () => { + if (!audioRef.current) { + return; + } + + const timeMs = audioRef.current.currentTime * 1000; + const nextIndex = findTranscriptIndexByTime(transcript, timeMs); + + if (nextIndex !== -1 && nextIndex !== currentHighlightIndex) { + setCurrentHighlightIndex(nextIndex); + transcriptRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }; + + const handleTranscriptScroll = (event) => { + if (transcriptVisibleCount >= transcript.length) { + return; + } + + const { scrollTop, clientHeight, scrollHeight } = event.currentTarget; + if (scrollHeight - scrollTop - clientHeight > 240) { + return; + } + + setTranscriptVisibleCount((prev) => Math.min(transcript.length, prev + TRANSCRIPT_RENDER_STEP)); + }; + + const jumpToTime = (ms) => { + if (audioRef.current) { + audioRef.current.currentTime = ms / 1000; + audioRef.current.play(); + } + }; + + const saveAccessPassword = async () => { + const nextPassword = accessPasswordEnabled ? accessPasswordDraft.trim() : null; + if (accessPasswordEnabled && !nextPassword) { + message.warning('开启访问密码后,请先输入密码'); + return; + } + + setSavingAccessPassword(true); + try { + const res = await apiClient.put( + buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meetingId)), + { password: nextPassword } + ); + const savedPassword = res.data?.password || null; + setMeeting((prev) => (prev ? { ...prev, access_password: savedPassword } : prev)); + setAccessPasswordEnabled(Boolean(savedPassword)); + setAccessPasswordDraft(savedPassword || ''); + message.success(res.message || '访问密码已更新'); + } catch (error) { + message.error(error?.response?.data?.message || '访问密码更新失败'); + } finally { + setSavingAccessPassword(false); + } + }; + + const handleAccessPasswordSwitchChange = async (checked) => { + setAccessPasswordEnabled(checked); + + if (checked) { + const existingPassword = (meeting?.access_password || accessPasswordDraft || '').trim(); + setAccessPasswordDraft(existingPassword || generateRandomPassword()); + return; + } + + setAccessPasswordDraft(''); + setSavingAccessPassword(true); + try { + const res = await apiClient.put( + buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meetingId)), + { password: null } + ); + setMeeting((prev) => (prev ? { ...prev, access_password: null } : prev)); + message.success(res.message || '访问密码已关闭'); + } catch (error) { + setAccessPasswordEnabled(true); + setAccessPasswordDraft(meeting?.access_password || ''); + message.error(error?.response?.data?.message || '访问密码更新失败'); + } finally { + setSavingAccessPassword(false); + } + }; + + const copyAccessPassword = async () => { + if (!accessPasswordDraft) { + message.warning('当前没有可复制的访问密码'); + return; + } + await navigator.clipboard.writeText(accessPasswordDraft); + message.success('访问密码已复制'); + }; + + const openAudioUploadPicker = () => { + document.getElementById('audio-upload-input')?.click(); + }; + + const startInlineSpeakerEdit = (speakerId, currentTag, segmentId) => { + setInlineSpeakerEdit(speakerId); + setInlineSpeakerEditSegmentId(`speaker-${speakerId}-${segmentId}`); + setInlineSpeakerValue(currentTag || `发言人 ${speakerId}`); + }; + + const cancelInlineSpeakerEdit = () => { + setInlineSpeakerEdit(null); + setInlineSpeakerEditSegmentId(null); + setInlineSpeakerValue(''); + }; + + const saveInlineSpeakerEdit = async () => { + if (inlineSpeakerEdit == null) { + return; + } + + const nextTag = inlineSpeakerValue.trim(); + if (!nextTag) { + message.warning('发言人名称不能为空'); + return; + } + + setSavingInlineEdit(true); + try { + await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/speaker-tags/batch`), { + updates: [{ speaker_id: inlineSpeakerEdit, new_tag: nextTag }], + }); + + setTranscript((prev) => prev.map((item) => ( + item.speaker_id === inlineSpeakerEdit + ? { ...item, speaker_tag: nextTag } + : item + ))); + setSpeakerList((prev) => prev.map((item) => ( + item.speaker_id === inlineSpeakerEdit + ? { ...item, speaker_tag: nextTag } + : item + ))); + setEditingSpeakers((prev) => ({ ...prev, [inlineSpeakerEdit]: nextTag })); + message.success('发言人名称已更新'); + cancelInlineSpeakerEdit(); + } catch (error) { + message.error(error?.response?.data?.message || '更新发言人名称失败'); + } finally { + setSavingInlineEdit(false); + } + }; + + const startInlineSegmentEdit = (segment) => { + setInlineSegmentEditId(segment.segment_id); + setInlineSegmentValue(segment.text_content || ''); + }; + + const cancelInlineSegmentEdit = () => { + setInlineSegmentEditId(null); + setInlineSegmentValue(''); + }; + + const saveInlineSegmentEdit = async () => { + if (inlineSegmentEditId == null) { + return; + } + + setSavingInlineEdit(true); + try { + await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/transcript/batch`), { + updates: [{ segment_id: inlineSegmentEditId, text_content: inlineSegmentValue }], + }); + setTranscript((prev) => prev.map((item) => ( + item.segment_id === inlineSegmentEditId + ? { ...item, text_content: inlineSegmentValue } + : item + ))); + message.success('转录内容已更新'); + cancelInlineSegmentEdit(); + } catch (error) { + message.error(error?.response?.data?.message || '更新转录内容失败'); + } finally { + setSavingInlineEdit(false); + } + }; + + const changePlaybackRate = (nextRate) => { + setPlaybackRate(nextRate); + if (audioRef.current) { + audioRef.current.playbackRate = nextRate; + } + }; + + const handleStartTranscription = async () => { + try { + const res = await apiClient.post(buildApiUrl(`/api/meetings/${meetingId}/transcription/start`)); + if (res.data?.task_id) { + message.success('转录任务已启动'); + setTranscriptionStatus({ status: 'processing' }); + startStatusPolling(res.data.task_id); + } + } catch (error) { + message.error(error?.response?.data?.detail || '启动转录失败'); + } + }; + + const handleDeleteMeeting = () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可删除会议'); + return; + } + + modal.confirm({ + title: '删除会议', + content: '确定要删除此会议吗?此操作无法撤销。', + okText: '删除', + okType: 'danger', + onOk: async () => { + await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId))); + navigate('/dashboard'); + }, + }); + }; + + const generateSummary = async () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可重新总结'); + return; + } + if (isUploading) { + message.warning('音频上传中,暂不允许重新总结'); + return; + } + if (!hasUploadedAudio) { + message.warning('请先上传音频后再总结'); + return; + } + if (isTranscriptionRunning) { + message.warning('转录进行中,暂不允许重新总结'); + return; + } + + setSummaryLoading(true); + setSummaryTaskProgress(0); + try { + const res = await apiClient.post(buildApiUrl(`/api/meetings/${meetingId}/generate-summary-async`), { + user_prompt: userPrompt, + prompt_id: selectedPromptId, + model_code: selectedModelCode, + }); + startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true }); + } catch (error) { + message.error(error?.response?.data?.message || '生成总结失败'); + setSummaryLoading(false); + } + }; + + const openSummaryDrawer = () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可重新总结'); + return; + } + if (isUploading) { + message.warning('音频上传中,暂不允许重新总结'); + return; + } + if (!hasUploadedAudio) { + message.warning('请先上传音频后再总结'); + return; + } + if (isTranscriptionRunning) { + message.warning('转录进行中,完成后会自动总结'); + return; + } + setShowSummaryDrawer(true); + }; + + const downloadSummaryMd = () => { + if (!meeting?.summary) { + message.warning('暂无总结内容'); + return; + } + + const blob = new Blob([meeting.summary], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${meeting.title || 'summary'}_总结.md`; + anchor.click(); + URL.revokeObjectURL(url); + }; + + const saveTranscriptEdits = async () => { + try { + const updates = Object.values(editingSegments).map((segment) => ({ + segment_id: segment.segment_id, + text_content: segment.text_content, + })); + await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/transcript/batch`), { updates }); + message.success('转录内容已更新'); + setShowTranscriptEditDrawer(false); + await fetchTranscript(); + } catch (error) { + console.debug('批量更新转录失败:', error); + message.error('更新失败'); + } + }; + + const openSummaryEditDrawer = () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可编辑总结'); + return; + } + setEditingSummaryContent(meeting?.summary || ''); + setIsEditingSummary(true); + }; + + const saveSummaryContent = async () => { + if (!isMeetingOwner) { + message.warning('仅会议创建人可编辑总结'); + return; + } + + try { + await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), { + title: meeting.title, + meeting_time: meeting.meeting_time, + summary: editingSummaryContent, + tags: meeting.tags?.map((tag) => tag.name).join(',') || '', + }); + message.success('总结已保存'); + setMeeting((prev) => (prev ? { ...prev, summary: editingSummaryContent } : prev)); + setIsEditingSummary(false); + } catch { + message.error('保存失败'); + } + }; + + const saveSpeakerTags = async () => { + const updates = Object.entries(editingSpeakers).map(([id, tag]) => ({ + speaker_id: parseInt(id, 10), + new_tag: tag, + })); + + await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/speaker-tags/batch`), { updates }); + setShowSpeakerDrawer(false); + await fetchTranscript(); + message.success('更新成功'); + }; + + return { + meetingId, + meeting, + loading, + transcript, + transcriptLoading, + audioUrl, + editingSpeakers, + setEditingSpeakers, + speakerList, + transcriptionStatus, + currentHighlightIndex, + showSummaryDrawer, + setShowSummaryDrawer, + summaryLoading, + summaryResourcesLoading, + userPrompt, + setUserPrompt, + promptList, + selectedPromptId, + setSelectedPromptId, + summaryTaskProgress, + summaryTaskMessage, + llmModels, + selectedModelCode, + setSelectedModelCode, + showSpeakerDrawer, + setShowSpeakerDrawer, + viewingPrompt, + setViewingPrompt, + editDrawerOpen, + setEditDrawerOpen, + showQRModal, + setShowQRModal, + isUploading, + displayUploadProgress, + uploadStatusMessage, + playbackRate, + accessPasswordEnabled, + accessPasswordDraft, + setAccessPasswordDraft, + savingAccessPassword, + showTranscriptEditDrawer, + setShowTranscriptEditDrawer, + editingSegments, + setEditingSegments, + isEditingSummary, + setIsEditingSummary, + editingSummaryContent, + setEditingSummaryContent, + inlineSpeakerEdit, + inlineSpeakerEditSegmentId, + inlineSpeakerValue, + setInlineSpeakerValue, + inlineSegmentEditId, + inlineSegmentValue, + setInlineSegmentValue, + savingInlineEdit, + transcriptVisibleCount, + audioRef, + transcriptRefs, + isMeetingOwner, + creatorName, + isTranscriptionRunning, + isSummaryRunning, + displayTranscriptionProgress, + displaySummaryProgress, + summaryDisabledReason, + isSummaryActionDisabled, + validateAudioBeforeUpload, + handleUploadAudioRequest, + fetchMeetingDetails, + handleTimeUpdate, + handleTranscriptScroll, + jumpToTime, + saveAccessPassword, + handleAccessPasswordSwitchChange, + copyAccessPassword, + openAudioUploadPicker, + startInlineSpeakerEdit, + saveInlineSpeakerEdit, + cancelInlineSpeakerEdit, + startInlineSegmentEdit, + saveInlineSegmentEdit, + cancelInlineSegmentEdit, + changePlaybackRate, + handleStartTranscription, + handleDeleteMeeting, + generateSummary, + openSummaryDrawer, + downloadSummaryMd, + saveTranscriptEdits, + openSummaryEditDrawer, + saveSummaryContent, + saveSpeakerTags, + }; +} diff --git a/frontend/src/hooks/useMeetingFormDrawer.js b/frontend/src/hooks/useMeetingFormDrawer.js new file mode 100644 index 0000000..74f8faf --- /dev/null +++ b/frontend/src/hooks/useMeetingFormDrawer.js @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useState } from 'react'; +import { App, Form, Upload } from 'antd'; +import dayjs from 'dayjs'; +import apiClient from '../utils/apiClient'; +import { buildApiUrl, API_ENDPOINTS } from '../config/api'; +import configService from '../utils/configService'; +import { uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService'; + +export default function useMeetingFormDrawer({ open, onClose, onSuccess, meetingId = null }) { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const [prompts, setPrompts] = useState([]); + const [selectedAudioFile, setSelectedAudioFile] = useState(null); + const [audioUploading, setAudioUploading] = useState(false); + const [audioUploadProgress, setAudioUploadProgress] = useState(0); + const [audioUploadMessage, setAudioUploadMessage] = useState(''); + const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024); + + const isEdit = Boolean(meetingId); + + const fetchOptions = useCallback(async () => { + try { + const [usersResponse, promptsResponse] = await Promise.all([ + apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)), + apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))), + ]); + setUsers(usersResponse.data.users || []); + setPrompts(promptsResponse.data.prompts || []); + } catch { + message.error('加载会议表单选项失败'); + } + }, [message]); + + const loadAudioUploadConfig = useCallback(async () => { + try { + const nextMaxAudioSize = await configService.getMaxAudioSize(); + setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); + } catch { + setMaxAudioSize(100 * 1024 * 1024); + } + }, []); + + const fetchMeeting = useCallback(async () => { + try { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); + const meeting = response.data; + form.setFieldsValue({ + title: meeting.title, + meeting_time: dayjs(meeting.meeting_time), + attendee_ids: meeting.attendee_ids || meeting.attendees?.map((attendee) => attendee.user_id).filter(Boolean) || [], + prompt_id: meeting.prompt_id, + tags: meeting.tags?.map((tag) => tag.name) || [], + }); + } catch { + message.error('加载会议数据失败'); + } + }, [form, meetingId, message]); + + useEffect(() => { + if (!open) { + return; + } + + fetchOptions(); + loadAudioUploadConfig(); + + if (isEdit) { + fetchMeeting(); + return; + } + + form.resetFields(); + form.setFieldsValue({ meeting_time: dayjs() }); + setSelectedAudioFile(null); + setAudioUploading(false); + setAudioUploadProgress(0); + setAudioUploadMessage(''); + }, [fetchMeeting, fetchOptions, form, isEdit, loadAudioUploadConfig, open]); + + const handleAudioBeforeUpload = (file) => { + const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); + if (validationMessage) { + message.warning(validationMessage); + return Upload.LIST_IGNORE; + } + + setSelectedAudioFile(file); + return false; + }; + + const clearSelectedAudio = () => { + setSelectedAudioFile(null); + setAudioUploadProgress(0); + setAudioUploadMessage(''); + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + setLoading(true); + + const payload = { + ...values, + meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'), + attendee_ids: values.attendee_ids || [], + tags: values.tags?.join(',') || '', + }; + + if (isEdit) { + await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload); + message.success('会议更新成功'); + onSuccess?.(); + onClose(); + return; + } + + const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); + if (response.code !== '200') { + return; + } + + const newMeetingId = response.data.meeting_id; + if (selectedAudioFile) { + setAudioUploading(true); + setAudioUploadProgress(0); + setAudioUploadMessage('正在上传音频文件...'); + try { + await uploadMeetingAudio({ + meetingId: newMeetingId, + file: selectedAudioFile, + promptId: values.prompt_id, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + setAudioUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total))); + } + setAudioUploadMessage('正在上传音频文件...'); + }, + }); + setAudioUploadProgress(100); + setAudioUploadMessage('上传完成,正在启动转录任务...'); + message.success('会议创建成功,音频已开始上传处理'); + } catch (uploadError) { + message.warning(uploadError?.response?.data?.message || uploadError?.response?.data?.detail || '会议已创建,但音频上传失败,请在详情页重试'); + } finally { + setAudioUploading(false); + } + } else { + message.success('会议创建成功'); + } + + onSuccess?.(newMeetingId); + onClose(); + } catch (error) { + if (!error?.errorFields) { + message.error(error?.response?.data?.message || error?.response?.data?.detail || '操作失败'); + } + } finally { + setLoading(false); + } + }; + + return { + form, + isEdit, + loading, + users, + prompts, + selectedAudioFile, + audioUploading, + audioUploadProgress, + audioUploadMessage, + maxAudioSize, + handleAudioBeforeUpload, + clearSelectedAudio, + handleSubmit, + }; +} diff --git a/frontend/src/pages/AccountSettings.jsx b/frontend/src/pages/AccountSettings.jsx index 12978ab..642ad23 100644 --- a/frontend/src/pages/AccountSettings.jsx +++ b/frontend/src/pages/AccountSettings.jsx @@ -25,10 +25,11 @@ import { SaveOutlined, UserOutlined, } from '@ant-design/icons'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { API_ENDPOINTS, buildApiUrl } from '../config/api'; import StatusTag from '../components/StatusTag'; import ActionButton from '../components/ActionButton'; +import { updateStoredUser } from '../services/authSessionService'; import './AccountSettings.css'; const { Title, Paragraph, Text } = Typography; @@ -43,30 +44,6 @@ const formatDateTime = (value) => { return date.toLocaleString('zh-CN', { hour12: false }); }; -const buildUpdatedAuthPayload = (nextUser) => { - const raw = localStorage.getItem('iMeetingUser'); - if (!raw || raw === 'undefined' || raw === 'null') { - return nextUser; - } - - try { - const savedAuth = JSON.parse(raw); - if (savedAuth && typeof savedAuth === 'object' && savedAuth.user) { - return { - ...savedAuth, - user: { - ...savedAuth.user, - ...nextUser, - }, - }; - } - } catch (error) { - console.error('Failed to parse saved auth payload:', error); - } - - return nextUser; -}; - const AccountSettings = ({ user, onUpdateUser }) => { const { message, modal } = App.useApp(); const [profileForm] = Form.useForm(); @@ -84,7 +61,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { const fetchUserData = useCallback(async () => { try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); profileForm.setFieldsValue({ username: response.data.username, caption: response.data.caption, @@ -103,7 +80,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { setMcpLoading(true); setMcpError(''); try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.MCP_CONFIG(user.user_id))); + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.MCP_CONFIG(user.user_id))); setMcpConfig(response.data || null); } catch (error) { setMcpConfig(null); @@ -146,21 +123,20 @@ const AccountSettings = ({ user, onUpdateUser }) => { if (avatarFile) { const formData = new FormData(); formData.append('file', avatarFile); - const uploadRes = await apiClient.post( + const uploadRes = await httpService.post( buildApiUrl(API_ENDPOINTS.USERS.AVATAR(user.user_id)), formData, ); currentAvatarUrl = uploadRes.data.avatar_url; } - const updateRes = await apiClient.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE(user.user_id)), { + const updateRes = await httpService.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE(user.user_id)), { caption: values.caption, email: values.email, avatar_url: currentAvatarUrl, }); - const nextAuthPayload = buildUpdatedAuthPayload(updateRes.data); - localStorage.setItem('iMeetingUser', JSON.stringify(nextAuthPayload)); + const nextAuthPayload = updateStoredUser(updateRes.data); onUpdateUser?.(nextAuthPayload); setAvatarFile(null); setPreviewAvatar(updateRes.data.avatar_url || null); @@ -175,7 +151,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { const handlePasswordUpdate = async (values) => { setPasswordLoading(true); try { - await apiClient.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE_PASSWORD(user.user_id)), { + await httpService.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE_PASSWORD(user.user_id)), { old_password: values.old_password, new_password: values.new_password, }); @@ -207,7 +183,7 @@ const AccountSettings = ({ user, onUpdateUser }) => { onOk: async () => { setMcpRefreshing(true); try { - const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.MCP_REGENERATE(user.user_id))); + const response = await httpService.post(buildApiUrl(API_ENDPOINTS.USERS.MCP_REGENERATE(user.user_id))); setMcpConfig(response.data || null); setMcpError(''); message.success('MCP Secret 已重新生成'); diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index 53daa66..8483921 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo } from 'react'; import { Card, Table, @@ -10,7 +10,6 @@ import { Button, Tag, Select, - App, Modal, Descriptions, Badge, @@ -35,16 +34,13 @@ import { ClockCircleOutlined, CloseOutlined, } from '@ant-design/icons'; -import apiClient from '../utils/apiClient'; -import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import ActionButton from '../components/ActionButton'; import useSystemPageSize from '../hooks/useSystemPageSize'; +import useAdminDashboardPage from '../hooks/useAdminDashboardPage'; import './AdminDashboard.css'; const { Text } = Typography; -const AUTO_REFRESH_INTERVAL = 30; - const TASK_TYPE_MAP = { transcription: { text: '转录', color: 'blue' }, summary: { text: '总结', color: 'green' }, @@ -61,181 +57,34 @@ const STATUS_MAP = { const formatResourcePercent = (value) => `${Number(value || 0).toFixed(1)}%`; const AdminDashboard = () => { - const { message, modal } = App.useApp(); - const inFlightRef = useRef(false); - const mountedRef = useRef(true); - - const [stats, setStats] = useState(null); - const [onlineUsers, setOnlineUsers] = useState([]); - const [usersList, setUsersList] = useState([]); - const [tasks, setTasks] = useState([]); - const [resources, setResources] = useState(null); - const [loading, setLoading] = useState(true); - const [taskLoading, setTaskLoading] = useState(false); - const [lastUpdatedAt, setLastUpdatedAt] = useState(null); - - const [taskType, setTaskType] = useState('all'); - const [taskStatus, setTaskStatus] = useState('all'); + const { + stats, + onlineUsers, + usersList, + tasks, + resources, + loading, + taskLoading, + lastUpdatedAt, + taskType, + setTaskType, + taskStatus, + setTaskStatus, + autoRefresh, + setAutoRefresh, + countdown, + showMeetingModal, + meetingDetails, + meetingLoading, + fetchAllData, + handleKickUser, + handleViewMeeting, + handleDownloadTranscript, + closeMeetingModal, + taskCompletionRate, + } = useAdminDashboardPage(); const pageSize = useSystemPageSize(10); - const [autoRefresh, setAutoRefresh] = useState(true); - const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL); - - const [showMeetingModal, setShowMeetingModal] = useState(false); - const [meetingDetails, setMeetingDetails] = useState(null); - const [meetingLoading, setMeetingLoading] = useState(false); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - const fetchStats = useCallback(async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS)); - if (response.code === '200') setStats(response.data); - }, []); - - const fetchOnlineUsers = useCallback(async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS)); - if (response.code === '200') setOnlineUsers(response.data.users || []); - }, []); - - const fetchUsersList = useCallback(async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS)); - if (response.code === '200') setUsersList(response.data.users || []); - }, []); - - const fetchTasks = useCallback(async () => { - try { - setTaskLoading(true); - const params = new URLSearchParams(); - if (taskType !== 'all') params.append('task_type', taskType); - if (taskStatus !== 'all') params.append('status', taskStatus); - params.append('limit', '20'); - const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`)); - if (response.code === '200') setTasks(response.data.tasks || []); - } finally { - setTaskLoading(false); - } - }, [taskStatus, taskType]); - - const fetchResources = useCallback(async () => { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES)); - if (response.code === '200') setResources(response.data); - }, []); - - const fetchAllData = useCallback(async ({ silent = false } = {}) => { - if (inFlightRef.current) return; - inFlightRef.current = true; - - try { - if (!silent && mountedRef.current) { - setLoading(true); - } - await Promise.all([fetchStats(), fetchOnlineUsers(), fetchUsersList(), fetchTasks(), fetchResources()]); - if (mountedRef.current) { - setLastUpdatedAt(new Date()); - setCountdown(AUTO_REFRESH_INTERVAL); - } - } catch (err) { - console.error('获取数据失败:', err); - if (mountedRef.current && !silent) { - message.error('加载数据失败,请稍后重试'); - } - } finally { - inFlightRef.current = false; - if (mountedRef.current && !silent) { - setLoading(false); - } - } - }, [fetchOnlineUsers, fetchResources, fetchStats, fetchTasks, fetchUsersList, message]); - - useEffect(() => { - fetchAllData(); - }, [fetchAllData]); - - useEffect(() => { - if (!autoRefresh || showMeetingModal) return; - - const timer = setInterval(() => { - setCountdown((prev) => { - if (prev <= 1) { - fetchAllData({ silent: true }); - return AUTO_REFRESH_INTERVAL; - } - return prev - 1; - }); - }, 1000); - - return () => clearInterval(timer); - }, [autoRefresh, fetchAllData, showMeetingModal]); - - useEffect(() => { - fetchTasks(); - }, [fetchTasks]); - - const handleKickUser = (u) => { - modal.confirm({ - title: '踢出用户', - content: `确定要踢出用户"${u.caption}"吗?该用户将被强制下线。`, - okText: '确定', - okType: 'danger', - cancelText: '取消', - onOk: async () => { - try { - const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.KICK_USER(u.user_id))); - if (response.code === '200') { - message.success('用户已被踢出'); - fetchOnlineUsers(); - } - } catch { - message.error('踢出用户失败'); - } - }, - }); - }; - - const handleViewMeeting = async (meetingId) => { - setMeetingLoading(true); - setShowMeetingModal(true); - try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); - if (response.code === '200') setMeetingDetails(response.data); - } catch { - message.error('获取会议详情失败'); - } finally { - setMeetingLoading(false); - } - }; - - const handleDownloadTranscript = async (meetingId) => { - try { - const response = await apiClient.get(buildApiUrl(`/api/meetings/${meetingId}/transcript`)); - if (response.code === '200') { - const dataStr = JSON.stringify(response.data, null, 2); - const blob = new Blob([dataStr], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `transcript_${meetingId}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } - } catch { - message.error('下载失败'); - } - }; - - const taskCompletionRate = useMemo(() => { - const all = tasks.length || 1; - const completed = tasks.filter((item) => item.status === 'completed').length; - return Math.round((completed / all) * 100); - }, [tasks]); - const resourceRows = useMemo(() => ([ { key: 'cpu', @@ -524,11 +373,8 @@ const AdminDashboard = () => { { - setShowMeetingModal(false); - setMeetingDetails(null); - }} - footer={[]} + onCancel={closeMeetingModal} + footer={[]} width={620} > {meetingLoading ? ( diff --git a/frontend/src/pages/ClientManagement.jsx b/frontend/src/pages/ClientManagement.jsx index 0296e49..af763b9 100644 --- a/frontend/src/pages/ClientManagement.jsx +++ b/frontend/src/pages/ClientManagement.jsx @@ -36,7 +36,7 @@ import { RocketOutlined, SaveOutlined, } from '@ant-design/icons'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import AdminModuleShell from '../components/AdminModuleShell'; import ActionButton from '../components/ActionButton'; @@ -82,7 +82,7 @@ const ClientManagement = () => { const fetchPlatforms = useCallback(async () => { try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform'))); + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform'))); if (response.code === '200') { const { tree = [], items = [] } = response.data || {}; setPlatforms({ tree, items }); @@ -101,7 +101,7 @@ const ClientManagement = () => { const fetchClients = useCallback(async () => { setLoading(true); try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST)); + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST)); if (response.code === '200') { setClients(response.data.clients || []); } @@ -165,10 +165,10 @@ const ClientManagement = () => { }; if (isEditing) { - await apiClient.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)), payload); + await httpService.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)), payload); message.success('版本更新成功'); } else { - await apiClient.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload); + await httpService.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload); message.success('版本创建成功'); } @@ -189,7 +189,7 @@ const ClientManagement = () => { okType: 'danger', onOk: async () => { try { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(item.id))); + await httpService.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(item.id))); message.success('删除成功'); fetchClients(); } catch { @@ -202,7 +202,7 @@ const ClientManagement = () => { const handleToggleActive = async (item, checked) => { setUpdatingStatusId(item.id); try { - await apiClient.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(item.id)), { + await httpService.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(item.id)), { is_active: checked, }); setClients((prev) => prev.map((client) => ( @@ -232,7 +232,7 @@ const ClientManagement = () => { uploadFormData.append('platform_code', platformCode); try { - const response = await apiClient.post( + const response = await httpService.post( buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPLOAD), uploadFormData, { headers: { 'Content-Type': 'multipart/form-data' } }, diff --git a/frontend/src/pages/CreateMeeting.jsx b/frontend/src/pages/CreateMeeting.jsx index 77e6738..b387ade 100644 --- a/frontend/src/pages/CreateMeeting.jsx +++ b/frontend/src/pages/CreateMeeting.jsx @@ -9,7 +9,7 @@ import { } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import dayjs from 'dayjs'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import configService from '../utils/configService'; import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService'; @@ -31,7 +31,7 @@ const CreateMeeting = () => { const fetchUsers = useCallback(async () => { try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); setUsers(res.data.users || []); } catch { setUsers([]); @@ -40,7 +40,7 @@ const CreateMeeting = () => { const fetchPrompts = useCallback(async () => { try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); setPrompts(res.data.prompts || []); } catch { setPrompts([]); @@ -88,7 +88,7 @@ const CreateMeeting = () => { attendee_ids: values.attendee_ids, tags: values.tags?.join(',') || '' }; - const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); + const res = await httpService.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); if (res.code === '200') { const meetingId = res.data.meeting_id; diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 93fd68b..08daa73 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -29,7 +29,7 @@ import { UserOutlined, } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { API_ENDPOINTS, buildApiUrl } from '../config/api'; import ActionButton from '../components/ActionButton'; import MeetingTimeline from '../components/MeetingTimeline'; @@ -113,9 +113,9 @@ const Dashboard = ({ user }) => { const fetchVoiceprintData = useCallback(async () => { try { setVoiceprintLoading(true); - const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id))); + const statusResponse = await httpService.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id))); setVoiceprintStatus(statusResponse.data); - const templateResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.TEMPLATE)); + const templateResponse = await httpService.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.TEMPLATE)); setVoiceprintTemplate(templateResponse.data); } catch (error) { console.error('获取声纹数据失败:', error); @@ -148,7 +148,7 @@ const Dashboard = ({ user }) => { if (isLoadMore) setLoadingMore(true); else setLoading(true); - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params: { user_id: user.user_id, page, @@ -182,7 +182,7 @@ const Dashboard = ({ user }) => { const fetchMeetingsStats = useCallback(async () => { try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), { + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), { params: { user_id: user.user_id }, }); setMeetingsStats(response.data); @@ -193,7 +193,7 @@ const Dashboard = ({ user }) => { const fetchUserData = useCallback(async () => { try { - const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); + const userResponse = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); setUserInfo(userResponse.data); } catch { message.error('获取用户信息失败'); @@ -218,7 +218,7 @@ const Dashboard = ({ user }) => { const handleDeleteMeeting = async (meetingId) => { try { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId))); + await httpService.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId))); meetingCacheService.clearAll(); fetchMeetings(1, false); fetchMeetingsStats(); @@ -236,7 +236,7 @@ const Dashboard = ({ user }) => { const handleVoiceprintUpload = async (formData) => { try { - await apiClient.post(buildApiUrl(API_ENDPOINTS.VOICEPRINT.UPLOAD(user.user_id)), formData, { + await httpService.post(buildApiUrl(API_ENDPOINTS.VOICEPRINT.UPLOAD(user.user_id)), formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); await fetchVoiceprintData(); @@ -255,7 +255,7 @@ const Dashboard = ({ user }) => { okType: 'danger', onOk: async () => { try { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.VOICEPRINT.DELETE(user.user_id))); + await httpService.delete(buildApiUrl(API_ENDPOINTS.VOICEPRINT.DELETE(user.user_id))); await fetchVoiceprintData(); message.success('声纹已删除'); } catch (error) { diff --git a/frontend/src/pages/EditKnowledgeBase.jsx b/frontend/src/pages/EditKnowledgeBase.jsx index a41f95a..fea400e 100644 --- a/frontend/src/pages/EditKnowledgeBase.jsx +++ b/frontend/src/pages/EditKnowledgeBase.jsx @@ -7,7 +7,7 @@ import { ArrowLeftOutlined, SaveOutlined, EditOutlined } from '@ant-design/icons'; import { useNavigate, useParams } from 'react-router-dom'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import MarkdownEditor from '../components/MarkdownEditor'; @@ -23,7 +23,7 @@ const EditKnowledgeBase = () => { const fetchKbDetail = useCallback(async () => { try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id))); + const response = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id))); form.setFieldsValue(response.data); } catch { message.error('加载知识库详情失败'); @@ -39,7 +39,7 @@ const EditKnowledgeBase = () => { const onFinish = async (values) => { setSaving(true); try { - await apiClient.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), values); + await httpService.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), values); message.success('更新成功'); navigate('/knowledge-base'); } catch { diff --git a/frontend/src/pages/EditMeeting.jsx b/frontend/src/pages/EditMeeting.jsx index 0eed0b2..492b0d3 100644 --- a/frontend/src/pages/EditMeeting.jsx +++ b/frontend/src/pages/EditMeeting.jsx @@ -8,7 +8,7 @@ import { } from '@ant-design/icons'; import { useNavigate, useParams } from 'react-router-dom'; import dayjs from 'dayjs'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; const { Title } = Typography; @@ -26,9 +26,9 @@ const EditMeeting = () => { const fetchData = useCallback(async () => { try { const [uRes, pRes, mRes] = await Promise.all([ - apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)), - apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))), - apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))) + httpService.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)), + httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))), + httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))) ]); setUsers(uRes.data.users || []); @@ -61,7 +61,7 @@ const EditMeeting = () => { attendee_ids: values.attendee_ids, tags: values.tags?.join(',') || '' }; - await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload); + await httpService.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload); message.success('会议更新成功'); navigate(`/meetings/${meeting_id}`); } catch (error) { diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx index a88cd8d..c59cfd6 100644 --- a/frontend/src/pages/HomePage.jsx +++ b/frontend/src/pages/HomePage.jsx @@ -8,7 +8,7 @@ import { LockOutlined, ArrowRightOutlined, } from '@ant-design/icons'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import menuService from '../services/menuService'; import BrandLogo from '../components/BrandLogo'; @@ -35,24 +35,16 @@ const HomePage = ({ onLogin }) => { const handleLogin = async (values) => { setLoading(true); try { - const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), { + const response = await httpService.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), { username: values.username, password: values.password }); if (response.code === '200') { message.success('登录成功'); - - // 关键修复:保存整个 data 对象,因为它包含了 token 和 user 两个部分 - const authData = response.data; - localStorage.setItem('iMeetingUser', JSON.stringify(authData)); menuService.clearCache(); - - // 通知 App 组件更新状态 - onLogin(authData); - - // 入口页会根据当前用户的首个菜单做动态跳转 - window.location.href = '/'; + + onLogin(response.data); } else { message.error(response.message || '登录失败'); } diff --git a/frontend/src/pages/KnowledgeBasePage.jsx b/frontend/src/pages/KnowledgeBasePage.jsx index e3050bf..ad8207a 100644 --- a/frontend/src/pages/KnowledgeBasePage.jsx +++ b/frontend/src/pages/KnowledgeBasePage.jsx @@ -13,7 +13,7 @@ import { CheckCircleOutlined, InfoCircleOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { Link, useNavigate } from 'react-router-dom'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import ContentViewer from '../components/ContentViewer'; import ActionButton from '../components/ActionButton'; @@ -49,7 +49,7 @@ const KnowledgeBasePage = ({ user }) => { const loadKbDetail = useCallback(async (id) => { try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id))); + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id))); setSelectedKb(res.data); } catch { message.error('加载知识库详情失败'); @@ -58,7 +58,7 @@ const KnowledgeBasePage = ({ user }) => { const fetchAllKbs = useCallback(async () => { try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST)); + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST)); const sorted = (res.data.kbs || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); setKbs(sorted); if (sorted.length > 0 && !selectedKb) { @@ -78,7 +78,7 @@ const KnowledgeBasePage = ({ user }) => { search: searchQuery || undefined, tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined }; - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params }); + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params }); setMeetings(res.data.meetings || []); setMeetingsPagination({ page: res.data.page, total: res.data.total }); } catch { @@ -90,7 +90,7 @@ const KnowledgeBasePage = ({ user }) => { const fetchAvailableTags = useCallback(async () => { try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); setAvailableTags(res.data?.slice(0, 10) || []); } catch { setAvailableTags([]); @@ -99,7 +99,7 @@ const KnowledgeBasePage = ({ user }) => { const fetchPrompts = useCallback(async () => { try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK'))); + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK'))); setAvailablePrompts(res.data.prompts || []); const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0]; if (def) setSelectedPromptId(def.id); @@ -130,7 +130,7 @@ const KnowledgeBasePage = ({ user }) => { setGenerating(true); setTaskProgress(10); try { - const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), { + const res = await httpService.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), { user_prompt: userPrompt, source_meeting_ids: selectedMeetings.join(','), prompt_id: selectedPromptId @@ -138,7 +138,7 @@ const KnowledgeBasePage = ({ user }) => { const taskId = res.data.task_id; const interval = setInterval(async () => { - const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId))); + const statusRes = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId))); const s = statusRes.data; setTaskProgress(s.progress || 20); if (s.status === 'completed') { @@ -166,7 +166,7 @@ const KnowledgeBasePage = ({ user }) => { okText: '删除', okType: 'danger', onOk: async () => { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(kb.kb_id))); + await httpService.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(kb.kb_id))); if (selectedKb?.kb_id === kb.kb_id) setSelectedKb(null); fetchAllKbs(); message.success('删除成功'); diff --git a/frontend/src/pages/MeetingCenterPage.jsx b/frontend/src/pages/MeetingCenterPage.jsx index edf8b55..69b2e78 100644 --- a/frontend/src/pages/MeetingCenterPage.jsx +++ b/frontend/src/pages/MeetingCenterPage.jsx @@ -24,7 +24,7 @@ import { UserOutlined, } from '@ant-design/icons'; import { useLocation, useNavigate } from 'react-router-dom'; -import apiClient from '../utils/apiClient'; +import httpService from '../services/httpService'; import { API_ENDPOINTS, buildApiUrl } from '../config/api'; import ActionButton from '../components/ActionButton'; import CenterPager from '../components/CenterPager'; @@ -123,7 +123,7 @@ const MeetingCenterPage = ({ user }) => { setLoading(true); try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { + const res = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params: { user_id: user.user_id, page: nextPage, @@ -176,7 +176,7 @@ const MeetingCenterPage = ({ user }) => { okType: 'danger', onOk: async () => { try { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting.meeting_id))); + await httpService.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting.meeting_id))); message.success('会议已删除'); meetingCacheService.clearAll(); const nextPage = page > 1 && meetings.length === 1 ? page - 1 : page; diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx index 41d787b..8f0fdfd 100644 --- a/frontend/src/pages/MeetingDetails.jsx +++ b/frontend/src/pages/MeetingDetails.jsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import React from 'react'; import { Card, Row, Col, Button, Space, Typography, Tag, Avatar, - Tooltip, Progress, Spin, App, Dropdown, + Tooltip, Progress, Spin, Dropdown, Divider, List, Tabs, Input, Upload, Empty, Drawer, Select, Switch } from 'antd'; import { @@ -13,7 +12,6 @@ import { EyeOutlined, FileTextOutlined, PartitionOutlined, SaveOutlined, CloseOutlined, StarFilled, RobotOutlined, DownloadOutlined, - CheckOutlined, MoreOutlined, AudioOutlined, CopyOutlined } from '@ant-design/icons'; import MarkdownRenderer from '../components/MarkdownRenderer'; @@ -22,22 +20,20 @@ import MindMap from '../components/MindMap'; import ActionButton from '../components/ActionButton'; import AudioPlayerBar from '../components/AudioPlayerBar'; import TranscriptTimeline from '../components/TranscriptTimeline'; -import apiClient from '../utils/apiClient'; -import tools from '../utils/tools'; -import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import QRCodeModal from '../components/QRCodeModal'; import MeetingFormDrawer from '../components/MeetingFormDrawer'; -import configService from '../utils/configService'; -import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService'; +import { AUDIO_UPLOAD_ACCEPT } from '../services/meetingAudioService'; +import useMeetingDetailsPage from '../hooks/useMeetingDetailsPage'; +import tools from '../utils/tools'; -const { Title, Text } = Typography; +const { Title, Text, Paragraph } = Typography; const { TextArea } = Input; -/* ── 发言人头像颜色池 ── */ const AVATAR_COLORS = [ '#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2', '#2f54eb', '#faad14', ]; + const getSpeakerColor = (speakerId) => AVATAR_COLORS[(speakerId ?? 0) % AVATAR_COLORS.length]; const getSummaryDisplayContent = (content) => { @@ -45,793 +41,103 @@ const getSummaryDisplayContent = (content) => { return content.replace(/^\s*#\s*(会议总结|AI总结|AI 总结)\s*\n+/i, ''); }; -const generateRandomPassword = (length = 4) => { - const charset = '0123456789'; - return Array.from({ length }, () => charset[Math.floor(Math.random() * charset.length)]).join(''); -}; - -const TRANSCRIPT_INITIAL_RENDER_COUNT = 80; -const TRANSCRIPT_RENDER_STEP = 120; - -const findTranscriptIndexByTime = (segments, timeMs) => { - let left = 0; - let right = segments.length - 1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const segment = segments[mid]; - - if (timeMs < segment.start_time_ms) { - right = mid - 1; - } else if (timeMs > segment.end_time_ms) { - left = mid + 1; - } else { - return mid; - } - } - - return -1; -}; - const MeetingDetails = ({ user }) => { - const { meeting_id } = useParams(); - const navigate = useNavigate(); - const { message, modal } = App.useApp(); + const { + meetingId, + meeting, + loading, + transcript, + transcriptLoading, + audioUrl, + editingSpeakers, + setEditingSpeakers, + speakerList, + transcriptionStatus, + currentHighlightIndex, + showSummaryDrawer, + setShowSummaryDrawer, + summaryLoading, + summaryResourcesLoading, + userPrompt, + setUserPrompt, + promptList, + selectedPromptId, + setSelectedPromptId, + summaryTaskProgress, + summaryTaskMessage, + llmModels, + selectedModelCode, + setSelectedModelCode, + showSpeakerDrawer, + setShowSpeakerDrawer, + viewingPrompt, + setViewingPrompt, + editDrawerOpen, + setEditDrawerOpen, + showQRModal, + setShowQRModal, + isUploading, + displayUploadProgress, + uploadStatusMessage, + playbackRate, + accessPasswordEnabled, + accessPasswordDraft, + setAccessPasswordDraft, + savingAccessPassword, + showTranscriptEditDrawer, + setShowTranscriptEditDrawer, + editingSegments, + setEditingSegments, + isEditingSummary, + setIsEditingSummary, + editingSummaryContent, + setEditingSummaryContent, + inlineSpeakerEdit, + inlineSpeakerEditSegmentId, + inlineSpeakerValue, + setInlineSpeakerValue, + inlineSegmentEditId, + inlineSegmentValue, + setInlineSegmentValue, + savingInlineEdit, + transcriptVisibleCount, + audioRef, + transcriptRefs, + isMeetingOwner, + creatorName, + isSummaryRunning, + displayTranscriptionProgress, + displaySummaryProgress, + summaryDisabledReason, + isSummaryActionDisabled, + validateAudioBeforeUpload, + handleUploadAudioRequest, + fetchMeetingDetails, + handleTimeUpdate, + handleTranscriptScroll, + jumpToTime, + saveAccessPassword, + handleAccessPasswordSwitchChange, + copyAccessPassword, + openAudioUploadPicker, + startInlineSpeakerEdit, + saveInlineSpeakerEdit, + cancelInlineSpeakerEdit, + startInlineSegmentEdit, + saveInlineSegmentEdit, + cancelInlineSegmentEdit, + changePlaybackRate, + handleStartTranscription, + handleDeleteMeeting, + generateSummary, + openSummaryDrawer, + downloadSummaryMd, + saveTranscriptEdits, + openSummaryEditDrawer, + saveSummaryContent, + saveSpeakerTags, + } = useMeetingDetailsPage({ user }); - const [meeting, setMeeting] = useState(null); - const [loading, setLoading] = useState(true); - const [transcript, setTranscript] = useState([]); - const [transcriptLoading, setTranscriptLoading] = useState(false); - const [audioUrl, setAudioUrl] = useState(null); - - // 发言人 - const [editingSpeakers, setEditingSpeakers] = useState({}); - const [speakerList, setSpeakerList] = useState([]); - - // 转录状态 - const [transcriptionStatus, setTranscriptionStatus] = useState(null); - const [transcriptionProgress, setTranscriptionProgress] = useState(0); - const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1); - - // AI 总结 - const [showSummaryDrawer, setShowSummaryDrawer] = useState(false); - const [summaryLoading, setSummaryLoading] = useState(false); - const [summaryResourcesLoading, setSummaryResourcesLoading] = useState(false); - const [userPrompt, setUserPrompt] = useState(''); - const [promptList, setPromptList] = useState([]); - const [selectedPromptId, setSelectedPromptId] = useState(null); - const [summaryTaskProgress, setSummaryTaskProgress] = useState(0); - const [summaryTaskMessage, setSummaryTaskMessage] = useState(''); - const [llmModels, setLlmModels] = useState([]); - const [selectedModelCode, setSelectedModelCode] = useState(null); - - // Drawer 状态 - const [showSpeakerDrawer, setShowSpeakerDrawer] = useState(false); - const [viewingPrompt, setViewingPrompt] = useState(null); - const [editDrawerOpen, setEditDrawerOpen] = useState(false); - const [showQRModal, setShowQRModal] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); - const [uploadStatusMessage, setUploadStatusMessage] = useState(''); - const [playbackRate, setPlaybackRate] = useState(1); - const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false); - const [accessPasswordDraft, setAccessPasswordDraft] = useState(''); - const [savingAccessPassword, setSavingAccessPassword] = useState(false); - const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024); - - // 转录编辑 Drawer - const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false); - const [editingSegments, setEditingSegments] = useState({}); - - // 总结内容编辑(同窗口) - const [isEditingSummary, setIsEditingSummary] = useState(false); - const [editingSummaryContent, setEditingSummaryContent] = useState(''); - const [inlineSpeakerEdit, setInlineSpeakerEdit] = useState(null); - const [inlineSpeakerEditSegmentId, setInlineSpeakerEditSegmentId] = useState(null); - const [inlineSpeakerValue, setInlineSpeakerValue] = useState(''); - const [inlineSegmentEditId, setInlineSegmentEditId] = useState(null); - const [inlineSegmentValue, setInlineSegmentValue] = useState(''); - const [savingInlineEdit, setSavingInlineEdit] = useState(false); - const [transcriptVisibleCount, setTranscriptVisibleCount] = useState(TRANSCRIPT_INITIAL_RENDER_COUNT); - - const audioRef = useRef(null); - const transcriptRefs = useRef([]); - const statusCheckIntervalRef = useRef(null); - const summaryPollIntervalRef = useRef(null); - const summaryBootstrapTimeoutRef = useRef(null); - const activeSummaryTaskIdRef = useRef(null); - const isMeetingOwner = user?.user_id === meeting?.creator_id; - const creatorName = meeting?.creator_username || '未知创建人'; - const hasUploadedAudio = Boolean(audioUrl); - const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status); - const isSummaryRunning = summaryLoading; - const displayUploadProgress = Math.max(0, Math.min(uploadProgress, 100)); - const displayTranscriptionProgress = Math.max(0, Math.min(transcriptionProgress, 100)); - const displaySummaryProgress = Math.max(0, Math.min(summaryTaskProgress, 100)); - const summaryDisabledReason = isUploading - ? '音频上传中,暂不允许重新总结' - : !hasUploadedAudio - ? '请先上传音频后再总结' - : isTranscriptionRunning - ? '转录进行中,完成后会自动总结' - : ''; - const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading; - - /* ══════════════════ 数据获取 ══════════════════ */ - - // The initial bootstrap should run only when the meeting id changes. - // Polling helpers are intentionally excluded here to avoid restarting intervals on every render. - useEffect(() => { - fetchMeetingDetails(); - fetchTranscript(); - loadAudioUploadConfig(); - return () => { - if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); - if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); - if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current); - }; - }, [meeting_id]); // eslint-disable-line react-hooks/exhaustive-deps - - const loadAudioUploadConfig = async () => { - try { - const nextMaxAudioSize = await configService.getMaxAudioSize(); - setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024); - } catch { - setMaxAudioSize(100 * 1024 * 1024); - } - }; - - // Summary resources are loaded lazily when the drawer opens; the existing prompt/model caches gate repeat fetches. - useEffect(() => { - if (!showSummaryDrawer) { - return; - } - - if (promptList.length > 0 && llmModels.length > 0) { - return; - } - - fetchSummaryResources(); - }, [showSummaryDrawer]); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - transcriptRefs.current = []; - setTranscriptVisibleCount(Math.min(transcript.length, TRANSCRIPT_INITIAL_RENDER_COUNT)); - }, [transcript]); - - useEffect(() => { - if (currentHighlightIndex < 0 || currentHighlightIndex < transcriptVisibleCount || transcriptVisibleCount >= transcript.length) { - return; - } - - setTranscriptVisibleCount((prev) => Math.min( - transcript.length, - Math.max(prev + TRANSCRIPT_RENDER_STEP, currentHighlightIndex + 20) - )); - }, [currentHighlightIndex, transcript.length, transcriptVisibleCount]); - - useEffect(() => { - if (currentHighlightIndex < 0) { - return; - } - - transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, [currentHighlightIndex, transcriptVisibleCount]); - - const clearSummaryBootstrapPolling = () => { - if (summaryBootstrapTimeoutRef.current) { - clearTimeout(summaryBootstrapTimeoutRef.current); - summaryBootstrapTimeoutRef.current = null; - } - }; - - const applyMeetingDetailsState = (meetingData, options = {}) => { - const { allowSummaryBootstrap = true } = options; - setMeeting(meetingData); - if (meetingData.prompt_id) { - setSelectedPromptId(meetingData.prompt_id); - } - setAccessPasswordEnabled(Boolean(meetingData.access_password)); - setAccessPasswordDraft(meetingData.access_password || ''); - - if (meetingData.transcription_status) { - const ts = meetingData.transcription_status; - setTranscriptionStatus(ts); - setTranscriptionProgress(ts.progress || 0); - if (['pending', 'processing'].includes(ts.status) && ts.task_id) { - startStatusPolling(ts.task_id); - } - } else { - setTranscriptionStatus(null); - setTranscriptionProgress(0); - } - - if (meetingData.llm_status) { - const llmStatus = meetingData.llm_status; - clearSummaryBootstrapPolling(); - setSummaryTaskProgress(llmStatus.progress || 0); - setSummaryTaskMessage( - llmStatus.message - || (llmStatus.status === 'processing' - ? 'AI 正在分析会议内容...' - : llmStatus.status === 'pending' - ? 'AI 总结任务排队中...' - : '') - ); - if (['pending', 'processing'].includes(llmStatus.status) && llmStatus.task_id) { - startSummaryPolling(llmStatus.task_id); - } else { - setSummaryLoading(false); - } - } else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) { - if (!activeSummaryTaskIdRef.current) { - setSummaryLoading(true); - setSummaryTaskProgress(0); - setSummaryTaskMessage('转录完成,正在启动 AI 分析...'); - } - if (allowSummaryBootstrap) { - scheduleSummaryBootstrapPolling(); - } - } else { - clearSummaryBootstrapPolling(); - if (!activeSummaryTaskIdRef.current) { - setSummaryLoading(false); - setSummaryTaskProgress(0); - setSummaryTaskMessage(''); - } - } - - const hasAudioFile = Boolean(meetingData.audio_file_path && String(meetingData.audio_file_path).length > 5); - setAudioUrl(hasAudioFile ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null); - }; - - const scheduleSummaryBootstrapPolling = (attempt = 0) => { - if (summaryPollIntervalRef.current || activeSummaryTaskIdRef.current) { - return; - } - clearSummaryBootstrapPolling(); - if (attempt >= 10) { - setSummaryLoading(false); - setSummaryTaskMessage(''); - return; - } - summaryBootstrapTimeoutRef.current = setTimeout(async () => { - summaryBootstrapTimeoutRef.current = null; - try { - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))); - const meetingData = response.data; - applyMeetingDetailsState(meetingData, { allowSummaryBootstrap: false }); - if (meetingData.llm_status || meetingData.summary) { - return; - } - } catch { - if (attempt >= 9) { - setSummaryLoading(false); - setSummaryTaskMessage(''); - return; - } - } - scheduleSummaryBootstrapPolling(attempt + 1); - }, attempt === 0 ? 1200 : 2000); - }; - - const fetchMeetingDetails = async (options = {}) => { - const { showPageLoading = true } = options; - try { - if (showPageLoading) { - setLoading(true); - } - const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))); - applyMeetingDetailsState(response.data); - return response.data; - } catch { - message.error('加载会议详情失败'); - return null; - } finally { - if (showPageLoading) { - setLoading(false); - } - } - }; - - const fetchTranscript = async () => { - setTranscriptLoading(true); - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))); - const segments = Array.isArray(res.data) ? res.data : []; - setTranscript(segments); - - const speakerMap = new Map(); - segments.forEach((segment) => { - if (segment?.speaker_id == null || speakerMap.has(segment.speaker_id)) { - return; - } - speakerMap.set(segment.speaker_id, { - speaker_id: segment.speaker_id, - speaker_tag: segment.speaker_tag || `发言人 ${segment.speaker_id}`, - }); - }); - - const list = Array.from(speakerMap.values()).sort((a, b) => a.speaker_id - b.speaker_id); - setSpeakerList(list); - const init = {}; - list.forEach(s => init[s.speaker_id] = s.speaker_tag); - setEditingSpeakers(init); - } catch { - setTranscript([]); - setSpeakerList([]); - setEditingSpeakers({}); - } finally { - setTranscriptLoading(false); - } - }; - - const startStatusPolling = (taskId) => { - if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); - const interval = setInterval(async () => { - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.TRANSCRIPTION_STATUS(taskId))); - const status = res.data; - setTranscriptionStatus(status); - setTranscriptionProgress(status.progress || 0); - setMeeting(prev => (prev ? { ...prev, transcription_status: status } : prev)); - if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) { - clearInterval(interval); - statusCheckIntervalRef.current = null; - if (status.status === 'completed') { - fetchTranscript(); - fetchMeetingDetails({ showPageLoading: false }); - } - } - } catch { - clearInterval(interval); - statusCheckIntervalRef.current = null; - } - }, 3000); - statusCheckIntervalRef.current = interval; - }; - - const fetchPromptList = async () => { - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))); - setPromptList(res.data.prompts || []); - const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0]; - if (def) setSelectedPromptId(def.id); - } catch (error) { - console.debug('加载提示词列表失败:', error); - } - }; - - const fetchLlmModels = async () => { - try { - const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS)); - const models = Array.isArray(res.data) ? res.data : (res.data?.models || []); - setLlmModels(models); - const def = models.find(m => m.is_default); - if (def) setSelectedModelCode(def.model_code); - } catch (error) { - console.debug('加载模型列表失败:', error); - } - }; - - const fetchSummaryResources = async () => { - setSummaryResourcesLoading(true); - try { - await Promise.allSettled([ - promptList.length > 0 ? Promise.resolve() : fetchPromptList(), - llmModels.length > 0 ? Promise.resolve() : fetchLlmModels(), - ]); - } finally { - setSummaryResourcesLoading(false); - } - }; - - const startSummaryPolling = (taskId, options = {}) => { - const { closeDrawerOnComplete = false } = options; - if (!taskId) return; - if (summaryPollIntervalRef.current && activeSummaryTaskIdRef.current === taskId) return; - if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); - - activeSummaryTaskIdRef.current = taskId; - setSummaryLoading(true); - - const poll = async () => { - try { - const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId))); - const status = statusRes.data; - setSummaryTaskProgress(status.progress || 0); - setSummaryTaskMessage(status.message || 'AI 正在分析会议内容...'); - setMeeting(prev => (prev ? { ...prev, llm_status: status } : prev)); - - if (status.status === 'completed') { - clearInterval(interval); - summaryPollIntervalRef.current = null; - activeSummaryTaskIdRef.current = null; - setSummaryLoading(false); - if (closeDrawerOnComplete) { - setShowSummaryDrawer(false); - } - fetchMeetingDetails({ showPageLoading: false }); - } else if (status.status === 'failed') { - clearInterval(interval); - summaryPollIntervalRef.current = null; - activeSummaryTaskIdRef.current = null; - setSummaryLoading(false); - message.error(status.error_message || '生成总结失败'); - } - } catch (error) { - clearInterval(interval); - summaryPollIntervalRef.current = null; - activeSummaryTaskIdRef.current = null; - setSummaryLoading(false); - message.error(error?.response?.data?.message || '获取总结状态失败'); - } - }; - - const interval = setInterval(poll, 3000); - summaryPollIntervalRef.current = interval; - poll(); - }; - - /* ══════════════════ 操作 ══════════════════ */ - - const handleTimeUpdate = () => { - if (!audioRef.current) return; - const timeMs = audioRef.current.currentTime * 1000; - const idx = findTranscriptIndexByTime(transcript, timeMs); - if (idx !== -1 && idx !== currentHighlightIndex) { - setCurrentHighlightIndex(idx); - transcriptRefs.current[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }; - - const handleTranscriptScroll = (event) => { - if (transcriptVisibleCount >= transcript.length) { - return; - } - - const { scrollTop, clientHeight, scrollHeight } = event.currentTarget; - if (scrollHeight - scrollTop - clientHeight > 240) { - return; - } - - setTranscriptVisibleCount((prev) => Math.min(transcript.length, prev + TRANSCRIPT_RENDER_STEP)); - }; - - const jumpToTime = (ms) => { - if (audioRef.current) { - audioRef.current.currentTime = ms / 1000; - audioRef.current.play(); - } - }; - - const handleUploadAudio = async (file) => { - const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); - if (validationMessage) { - message.warning(validationMessage); - throw new Error(validationMessage); - } - - setIsUploading(true); - setUploadProgress(0); - setUploadStatusMessage('正在上传音频文件...'); - try { - await uploadMeetingAudio({ - meetingId: meeting_id, - file, - promptId: meeting?.prompt_id, - modelCode: selectedModelCode, - onUploadProgress: (progressEvent) => { - if (progressEvent.total) { - setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total))); - } - setUploadStatusMessage('正在上传音频文件...'); - }, - }); - setUploadProgress(100); - setUploadStatusMessage('上传完成,正在启动转录任务...'); - message.success('音频上传成功'); - setTranscript([]); - setSpeakerList([]); - setEditingSpeakers({}); - await fetchMeetingDetails({ showPageLoading: false }); - await fetchTranscript(); - } catch (error) { - message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败'); - throw error; - } finally { - setIsUploading(false); - setUploadProgress(0); - setUploadStatusMessage(''); - } - }; - - const saveAccessPassword = async () => { - const nextPassword = accessPasswordEnabled ? accessPasswordDraft.trim() : null; - if (accessPasswordEnabled && !nextPassword) { - message.warning('开启访问密码后,请先输入密码'); - return; - } - - setSavingAccessPassword(true); - try { - const res = await apiClient.put( - buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meeting_id)), - { password: nextPassword } - ); - const savedPassword = res.data?.password || null; - setMeeting((prev) => (prev ? { ...prev, access_password: savedPassword } : prev)); - setAccessPasswordEnabled(Boolean(savedPassword)); - setAccessPasswordDraft(savedPassword || ''); - message.success(res.message || '访问密码已更新'); - } catch (error) { - message.error(error?.response?.data?.message || '访问密码更新失败'); - } finally { - setSavingAccessPassword(false); - } - }; - - const handleAccessPasswordSwitchChange = async (checked) => { - setAccessPasswordEnabled(checked); - if (checked) { - const existingPassword = (meeting?.access_password || accessPasswordDraft || '').trim(); - setAccessPasswordDraft(existingPassword || generateRandomPassword()); - return; - } - - if (!checked) { - setAccessPasswordDraft(''); - setSavingAccessPassword(true); - try { - const res = await apiClient.put( - buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meeting_id)), - { password: null } - ); - setMeeting((prev) => (prev ? { ...prev, access_password: null } : prev)); - message.success(res.message || '访问密码已关闭'); - } catch (error) { - setAccessPasswordEnabled(true); - setAccessPasswordDraft(meeting?.access_password || ''); - message.error(error?.response?.data?.message || '访问密码更新失败'); - } finally { - setSavingAccessPassword(false); - } - } - }; - - const copyAccessPassword = async () => { - if (!accessPasswordDraft) { - message.warning('当前没有可复制的访问密码'); - return; - } - await navigator.clipboard.writeText(accessPasswordDraft); - message.success('访问密码已复制'); - }; - - const openAudioUploadPicker = () => { - document.getElementById('audio-upload-input')?.click(); - }; - - const startInlineSpeakerEdit = (speakerId, currentTag, segmentId) => { - setInlineSpeakerEdit(speakerId); - setInlineSpeakerEditSegmentId(`speaker-${speakerId}-${segmentId}`); - setInlineSpeakerValue(currentTag || `发言人 ${speakerId}`); - }; - - const cancelInlineSpeakerEdit = () => { - setInlineSpeakerEdit(null); - setInlineSpeakerEditSegmentId(null); - setInlineSpeakerValue(''); - }; - - const saveInlineSpeakerEdit = async () => { - if (inlineSpeakerEdit == null) return; - const nextTag = inlineSpeakerValue.trim(); - if (!nextTag) { - message.warning('发言人名称不能为空'); - return; - } - setSavingInlineEdit(true); - try { - await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/speaker-tags/batch`), { - updates: [{ speaker_id: inlineSpeakerEdit, new_tag: nextTag }] - }); - setTranscript(prev => prev.map(item => ( - item.speaker_id === inlineSpeakerEdit - ? { ...item, speaker_tag: nextTag } - : item - ))); - setSpeakerList(prev => prev.map(item => ( - item.speaker_id === inlineSpeakerEdit - ? { ...item, speaker_tag: nextTag } - : item - ))); - setEditingSpeakers(prev => ({ ...prev, [inlineSpeakerEdit]: nextTag })); - message.success('发言人名称已更新'); - cancelInlineSpeakerEdit(); - } catch (error) { - message.error(error?.response?.data?.message || '更新发言人名称失败'); - } finally { - setSavingInlineEdit(false); - } - }; - - const startInlineSegmentEdit = (segment) => { - setInlineSegmentEditId(segment.segment_id); - setInlineSegmentValue(segment.text_content || ''); - }; - - const cancelInlineSegmentEdit = () => { - setInlineSegmentEditId(null); - setInlineSegmentValue(''); - }; - - const saveInlineSegmentEdit = async () => { - if (inlineSegmentEditId == null) return; - setSavingInlineEdit(true); - try { - await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/transcript/batch`), { - updates: [{ segment_id: inlineSegmentEditId, text_content: inlineSegmentValue }] - }); - setTranscript(prev => prev.map(item => ( - item.segment_id === inlineSegmentEditId - ? { ...item, text_content: inlineSegmentValue } - : item - ))); - message.success('转录内容已更新'); - cancelInlineSegmentEdit(); - } catch (error) { - message.error(error?.response?.data?.message || '更新转录内容失败'); - } finally { - setSavingInlineEdit(false); - } - }; - - const changePlaybackRate = (nextRate) => { - setPlaybackRate(nextRate); - if (audioRef.current) { - audioRef.current.playbackRate = nextRate; - } - }; - - const handleStartTranscription = async () => { - try { - const res = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/transcription/start`)); - if (res.data?.task_id) { - message.success('转录任务已启动'); - setTranscriptionStatus({ status: 'processing' }); - startStatusPolling(res.data.task_id); - } - } catch (e) { - message.error(e?.response?.data?.detail || '启动转录失败'); - } - }; - - const handleDeleteMeeting = () => { - if (!isMeetingOwner) { - message.warning('仅会议创建人可删除会议'); - return; - } - modal.confirm({ - title: '删除会议', - content: '确定要删除此会议吗?此操作无法撤销。', - okText: '删除', - okType: 'danger', - onOk: async () => { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id))); - navigate('/dashboard'); - } - }); - }; - - const generateSummary = async () => { - if (!isMeetingOwner) { - message.warning('仅会议创建人可重新总结'); - return; - } - if (isUploading) { - message.warning('音频上传中,暂不允许重新总结'); - return; - } - if (!hasUploadedAudio) { - message.warning('请先上传音频后再总结'); - return; - } - if (isTranscriptionRunning) { - message.warning('转录进行中,暂不允许重新总结'); - return; - } - setSummaryLoading(true); - setSummaryTaskProgress(0); - try { - const res = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/generate-summary-async`), { - user_prompt: userPrompt, - prompt_id: selectedPromptId, - model_code: selectedModelCode - }); - startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true }); - } catch (error) { - message.error(error?.response?.data?.message || '生成总结失败'); - setSummaryLoading(false); - } - }; - - const openSummaryDrawer = () => { - if (!isMeetingOwner) { - message.warning('仅会议创建人可重新总结'); - return; - } - if (isUploading) { - message.warning('音频上传中,暂不允许重新总结'); - return; - } - if (!hasUploadedAudio) { - message.warning('请先上传音频后再总结'); - return; - } - if (isTranscriptionRunning) { - message.warning('转录进行中,完成后会自动总结'); - return; - } - setShowSummaryDrawer(true); - }; - - const downloadSummaryMd = () => { - if (!meeting?.summary) { message.warning('暂无总结内容'); return; } - const blob = new Blob([meeting.summary], { type: 'text/markdown;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${meeting.title || 'summary'}_总结.md`; - a.click(); - URL.revokeObjectURL(url); - }; - - const saveTranscriptEdits = async () => { - try { - const updates = Object.values(editingSegments).map(s => ({ - segment_id: s.segment_id, - text_content: s.text_content, - })); - await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/transcript/batch`), { updates }); - message.success('转录内容已更新'); - setShowTranscriptEditDrawer(false); - fetchTranscript(); - } catch (error) { - console.debug('批量更新转录失败:', error); - message.error('更新失败'); - } - }; - - /* ── 总结内容编辑 ── */ - const openSummaryEditDrawer = () => { - if (!isMeetingOwner) { - message.warning('仅会议创建人可编辑总结'); - return; - } - setEditingSummaryContent(meeting?.summary || ''); - setIsEditingSummary(true); - }; - - const saveSummaryContent = async () => { - if (!isMeetingOwner) { - message.warning('仅会议创建人可编辑总结'); - return; - } - try { - await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), { - title: meeting.title, - meeting_time: meeting.meeting_time, - summary: editingSummaryContent, - tags: meeting.tags?.map(t => t.name).join(',') || '', - }); - message.success('总结已保存'); - setMeeting(prev => (prev ? { ...prev, summary: editingSummaryContent } : prev)); - setIsEditingSummary(false); - } catch { message.error('保存失败'); } - }; - - /* ── 更多操作菜单 ── */ const panelMoreMenuItems = [ { key: 'delete', icon: , label: '删除会议', danger: true, onClick: handleDeleteMeeting }, ]; @@ -841,13 +147,12 @@ const MeetingDetails = ({ user }) => { { key: 'upload', icon: , label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker }, ]; - /* ══════════════════ 渲染 ══════════════════ */ - - if (loading) return
; + if (loading) { + return
; + } return (
- {/* ── 标题 Header ── */} { 参会人: {meeting.attendees?.length - ? meeting.attendees.map(a => typeof a === 'string' ? a : a.caption).join('、') + ? meeting.attendees.map((attendee) => typeof attendee === 'string' ? attendee : attendee.caption).join('、') : '未指定'} @@ -924,7 +229,6 @@ const MeetingDetails = ({ user }) => { )} - {/* ── 转录进度条 ── */} {transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
@@ -956,35 +260,21 @@ const MeetingDetails = ({ user }) => { )} - {/* ── 隐藏的上传 input ── */} { - const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService)); - if (validationMessage) { - message.warning(validationMessage); - return Upload.LIST_IGNORE; - } - return true; - }} - customRequest={async ({ file, onSuccess, onError }) => { - try { - await handleUploadAudio(file); - onSuccess?.({}, file); - } catch (error) { - onError?.(error); - } + const validationMessage = validateAudioBeforeUpload(file); + return validationMessage ? Upload.LIST_IGNORE : true; }} + customRequest={handleUploadAudioRequest} style={{ display: 'none' }} > - {/* ── 主内容:左转录 右总结 ── */} - {/* 左列: 语音转录 */} { style={{ height: 'calc(100vh - 220px)', display: 'flex', flexDirection: 'column' }} styles={{ body: { display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', padding: 0 } }} > - {/* 转录标题栏 */}
<AudioOutlined style={{ marginRight: 6 }} />语音转录 {isMeetingOwner ? ( @@ -1000,7 +289,6 @@ const MeetingDetails = ({ user }) => { ) : null}
- {/* 音频播放器 */}
{ />
- {/* 转录时间轴 */}
{ - {/* 右列: AI 总结 / 思维导图 */} { label: , children: (
- {/* 操作栏 */}
{isMeetingOwner ? ( isEditingSummary ? ( @@ -1086,7 +371,6 @@ const MeetingDetails = ({ user }) => { ) ) : null}
- {/* 内容区 */}
{isEditingSummary ? (
@@ -1130,9 +414,6 @@ const MeetingDetails = ({ user }) => { - {/* ═══════════ Drawers ═══════════ */} - - {/* 总结生成 Drawer */} { ) : null } > - {/* 模板选择 */}
选择总结模板 {summaryResourcesLoading ? : null} - {promptList.length ? promptList.map(p => { - const isSelected = selectedPromptId === p.id; - const isSystem = Number(p.is_system) === 1; + {promptList.length ? promptList.map((prompt) => { + const isSelected = selectedPromptId === prompt.id; + const isSystem = Number(prompt.is_system) === 1; return ( setSelectedPromptId(p.id)} + onClick={() => setSelectedPromptId(prompt.id)} style={{ borderRadius: 10, cursor: 'pointer', @@ -1174,15 +454,15 @@ const MeetingDetails = ({ user }) => { {isSelected && 已选} - {p.name} + {prompt.name} {isSystem ? 系统 : 个人} - {p.is_default ? }>默认 : null} - } onClick={e => { e.stopPropagation(); setViewingPrompt(p); }} /> + {prompt.is_default ? }>默认 : null} + } onClick={(event) => { event.stopPropagation(); setViewingPrompt(prompt); }} /> - {p.desc && {p.desc}} + {prompt.desc && {prompt.desc}} ); @@ -1190,7 +470,6 @@ const MeetingDetails = ({ user }) => {
- {/* LLM 模型选择 */}
选择 AI 模型 @@ -1203,30 +482,28 @@ const MeetingDetails = ({ user }) => { allowClear loading={summaryResourcesLoading} > - {llmModels.map(m => ( - + {llmModels.map((model) => ( + - {m.model_name} - {m.provider} - {m.is_default ? 默认 : null} + {model.model_name} + {model.provider} + {model.is_default ? 默认 : null} ))}
- {/* 额外要求 */}
额外要求 (可选)