diff --git a/backend/app/api/endpoints/admin_settings.py b/backend/app/api/endpoints/admin_settings.py index 7e3108e..5747d01 100644 --- a/backend/app/api/endpoints/admin_settings.py +++ b/backend/app/api/endpoints/admin_settings.py @@ -157,8 +157,3 @@ async def test_audio_model_config(request: AudioModelTestRequest, current_user=D @router.get("/system-config/public") async def get_public_system_config(): 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)): - return admin_settings_service.get_system_config_compat() diff --git a/backend/app/app_factory.py b/backend/app/app_factory.py index f30b58f..cd96791 100644 --- a/backend/app/app_factory.py +++ b/backend/app/app_factory.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_swagger_ui_html from fastapi.staticfiles import StaticFiles +import contextlib from app.api.endpoints import ( admin, @@ -24,6 +25,7 @@ from app.api.endpoints import ( ) from app.core.config import UPLOAD_DIR from app.core.middleware import TerminalCheckMiddleware +from app.mcp import create_mcp_http_app, get_mcp_session_manager from app.services.system_config_service import SystemConfigService @@ -48,6 +50,11 @@ def create_app() -> FastAPI: if UPLOAD_DIR.exists(): app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") + mcp_session_manager = get_mcp_session_manager() + mcp_asgi_app = create_mcp_http_app() + if mcp_asgi_app is not None: + app.mount("/mcp", mcp_asgi_app, name="mcp") + 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"]) @@ -88,6 +95,19 @@ def create_app() -> FastAPI: "version": "1.1.0", } + if mcp_session_manager is not None: + @app.on_event("startup") + async def startup_mcp_session_manager(): + exit_stack = contextlib.AsyncExitStack() + await exit_stack.enter_async_context(mcp_session_manager.run()) + app.state.mcp_exit_stack = exit_stack + + @app.on_event("shutdown") + async def shutdown_mcp_session_manager(): + exit_stack = getattr(app.state, "mcp_exit_stack", None) + if exit_stack is not None: + await exit_stack.aclose() + SystemConfigService.ensure_builtin_parameters() return app diff --git a/backend/app/mcp/__init__.py b/backend/app/mcp/__init__.py new file mode 100644 index 0000000..488d3e2 --- /dev/null +++ b/backend/app/mcp/__init__.py @@ -0,0 +1,3 @@ +from app.mcp.server import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp + +__all__ = ["create_mcp_http_app", "get_mcp_session_manager", "MCPHeaderAuthApp"] diff --git a/backend/app/mcp/context.py b/backend/app/mcp/context.py new file mode 100644 index 0000000..4d80432 --- /dev/null +++ b/backend/app/mcp/context.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from contextvars import ContextVar, Token +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, Optional + + +@dataclass(slots=True) +class MCPRequestContext: + user: Dict[str, Any] + bot_id: str + credential_id: int + authenticated_at: datetime + + +current_mcp_request: ContextVar[Optional[MCPRequestContext]] = ContextVar( + "current_mcp_request", + default=None, +) + + +def set_current_mcp_request(request: MCPRequestContext) -> Token: + return current_mcp_request.set(request) + + +def reset_current_mcp_request(token: Token) -> None: + current_mcp_request.reset(token) + + +def require_mcp_request() -> MCPRequestContext: + request = current_mcp_request.get() + if request is None: + raise RuntimeError("MCP request context is unavailable") + return request diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py new file mode 100644 index 0000000..b075bf2 --- /dev/null +++ b/backend/app/mcp/server.py @@ -0,0 +1,259 @@ +""" +Backend-integrated MCP Streamable HTTP server. +""" +from __future__ import annotations + +from datetime import datetime +import hmac +import json +from typing import Any, Dict, Optional + +from fastapi.responses import JSONResponse +from starlette.datastructures import Headers + +try: + from mcp.server.fastmcp import FastMCP +except ImportError: # pragma: no cover - runtime dependency + FastMCP = None + +from app.core.config import APP_CONFIG +from app.core.database import get_db_connection +from app.mcp.context import ( + MCPRequestContext, + require_mcp_request, + reset_current_mcp_request, + set_current_mcp_request, +) +from app.services import meeting_service + + +def _build_absolute_url(path: str) -> str: + normalized_base = APP_CONFIG["base_url"].rstrip("/") + normalized_path = path if path.startswith("/") else f"/{path}" + return f"{normalized_base}{normalized_path}" + + +def _parse_api_response(response: JSONResponse) -> Dict[str, Any]: + body = response.body.decode("utf-8") if isinstance(response.body, (bytes, bytearray)) else response.body + return json.loads(body) + + +def _load_user_meetings(current_user: Dict[str, Any], filter_type: str) -> list[Dict[str, Any]]: + meetings: list[Dict[str, Any]] = [] + page = 1 + page_size = 100 + + while True: + payload = _parse_api_response( + meeting_service.get_meetings( + current_user=current_user, + user_id=current_user["user_id"], + page=page, + page_size=page_size, + filter_type=filter_type, + ) + ) + if payload.get("code") != "200": + raise ValueError(payload.get("message") or "获取会议列表失败") + + data = payload.get("data") or {} + meetings.extend(data.get("meetings") or []) + if not data.get("has_more"): + break + page += 1 + + return meetings + + +def _find_user_meeting(current_user: Dict[str, Any], meeting_id: int) -> Dict[str, Any]: + for filter_type in ("created", "attended"): + for meeting in _load_user_meetings(current_user, filter_type): + if int(meeting.get("meeting_id")) == int(meeting_id): + return meeting + + raise ValueError("会议不存在,或当前账号无权访问该会议") + + +def _get_meeting_preview_payload(meeting_id: int, access_password: Optional[str]) -> Dict[str, Any]: + return _parse_api_response( + meeting_service.get_meeting_preview_data( + meeting_id=meeting_id, + password=access_password, + ) + ) + + +def _get_mcp_user(bot_id: str, bot_secret: str) -> Optional[Dict[str, Any]]: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + """ + SELECT + m.id, + m.user_id, + m.bot_id, + m.bot_secret, + m.status, + u.username, + u.caption, + u.email, + u.role_id + FROM sys_user_mcp m + JOIN sys_users u ON m.user_id = u.user_id + WHERE m.bot_id = %s + LIMIT 1 + """, + (bot_id,), + ) + record = cursor.fetchone() + if not record: + return None + + if int(record.get("status") or 0) != 1: + return None + + if not hmac.compare_digest(str(record.get("bot_secret") or ""), bot_secret): + return None + + cursor.execute( + "UPDATE sys_user_mcp SET last_used_at = NOW() WHERE id = %s", + (record["id"],), + ) + connection.commit() + + return { + "credential_id": record["id"], + "bot_id": record["bot_id"], + "user": { + "user_id": record["user_id"], + "username": record["username"], + "caption": record["caption"], + "email": record["email"], + "role_id": record["role_id"], + }, + } + + + + +class MCPHeaderAuthApp: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + bot_id = (headers.get("x-bot-id") or "").strip() + bot_secret = (headers.get("x-bot-secret") or "").strip() + + if not bot_id or not bot_secret: + response = JSONResponse( + status_code=401, + content={"error": "Missing X-Bot-Id or X-Bot-Secret"}, + ) + await response(scope, receive, send) + return + + auth_record = _get_mcp_user(bot_id, bot_secret) + if not auth_record: + response = JSONResponse( + status_code=401, + content={"error": "Invalid MCP credentials"}, + ) + await response(scope, receive, send) + return + + token = set_current_mcp_request( + MCPRequestContext( + user=auth_record["user"], + bot_id=auth_record["bot_id"], + credential_id=auth_record["credential_id"], + authenticated_at=datetime.utcnow(), + ) + ) + + try: + await self.app(scope, receive, send) + finally: + reset_current_mcp_request(token) + + +if FastMCP is not None: + mcp_server = FastMCP( + "iMeeting MCP", + json_response=True, + stateless_http=True, + streamable_http_path="/", + ) + + @mcp_server.tool() + def get_my_meetings() -> Dict[str, Any]: + """获取当前登录用户的会议列表,并按我创建/我参加分开返回。""" + current_user = require_mcp_request().user + created_meetings = _load_user_meetings(current_user, "created") + attended_meetings = _load_user_meetings(current_user, "attended") + + return { + "user": { + "user_id": current_user["user_id"], + "username": current_user.get("username"), + "caption": current_user.get("caption"), + }, + "created_meetings": created_meetings, + "attended_meetings": attended_meetings, + "counts": { + "created": len(created_meetings), + "attended": len(attended_meetings), + "all": len(created_meetings) + len(attended_meetings), + }, + } + + + @mcp_server.tool() + def get_meeting_preview_url(meeting_id: int) -> Dict[str, Any]: + """获取指定会议的预览地址。""" + current_user = require_mcp_request().user + meeting = _find_user_meeting(current_user, meeting_id) + preview_payload = _get_meeting_preview_payload(meeting_id, meeting.get("access_password")) + + if preview_payload.get("code") != "200": + return { + "meeting_id": meeting_id, + "title": meeting.get("title"), + "message": preview_payload.get("message"), + "status": (preview_payload.get("data") or {}).get("processing_status"), + } + + return { + "meeting_id": meeting_id, + "title": meeting.get("title"), + "preview_url": _build_absolute_url(f"/meetings/preview/{meeting_id}"), + "requires_password": bool(meeting.get("access_password")), + "access_password": meeting.get("access_password"), + "preview_data": preview_payload.get("data"), + } +else: # pragma: no cover - graceful fallback without runtime dependency + mcp_server = None + + +def get_mcp_server(): + return mcp_server + + +def get_mcp_session_manager(): + if mcp_server is None: + return None + return mcp_server.session_manager + + +def create_mcp_http_app(): + if mcp_server is None: + return None + return MCPHeaderAuthApp(mcp_server.streamable_http_app()) + + +def get_mcp_asgi_app(): + return create_mcp_http_app() diff --git a/backend/app/services/admin_settings_service.py b/backend/app/services/admin_settings_service.py index dae8bc1..3dba0dd 100644 --- a/backend/app/services/admin_settings_service.py +++ b/backend/app/services/admin_settings_service.py @@ -628,35 +628,3 @@ def get_public_system_config(): ) 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/async_knowledge_base_service.py b/backend/app/services/async_knowledge_base_service.py index a54384e..9fe1527 100644 --- a/backend/app/services/async_knowledge_base_service.py +++ b/backend/app/services/async_knowledge_base_service.py @@ -45,7 +45,7 @@ class AsyncKnowledgeBaseService: """ cursor.execute(query, (task_id, user_id, kb_id, user_prompt, prompt_id)) else: - # Fallback to the old method if no cursor is provided + # 无外部事务时,使用服务内的持久化入口。 self._save_task_to_db(task_id, user_id, kb_id, user_prompt, prompt_id) current_time = datetime.now().isoformat() diff --git a/backend/app/services/jwt_service.py b/backend/app/services/jwt_service.py index ab62afe..ece2c48 100644 --- a/backend/app/services/jwt_service.py +++ b/backend/app/services/jwt_service.py @@ -16,7 +16,7 @@ class JWTService: @staticmethod def _get_access_token_expire_minutes() -> int: - expire_days = SystemConfigService.get_token_expire_days(default=7) + expire_days = SystemConfigService.get_token_expire_days() return max(1, expire_days) * 24 * 60 def create_access_token(self, data: Dict[str, Any]) -> str: diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 1ea72c4..b511e7e 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -421,10 +421,6 @@ class LLMService: print(error_msg) yield f"error: {error_msg}" - def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]: - """兼容旧调用入口。""" - return self.stream_llm_api(prompt) - def call_llm_api( self, prompt: Optional[str] = None, @@ -524,10 +520,6 @@ class LLMService: except Exception as e: raise LLMServiceError(str(e)) from e - def _call_llm_api(self, prompt: str) -> Optional[str]: - """兼容旧调用入口。""" - return self.call_llm_api(prompt) - def test_model(self, config: Dict[str, Any], prompt: Optional[str] = None) -> Dict[str, Any]: params = self.build_call_params_from_config(config) test_prompt = prompt or "请用一句中文回复:LLM测试成功。" diff --git a/backend/app/services/meeting_service.py b/backend/app/services/meeting_service.py index 9644b97..53cdddb 100644 --- a/backend/app/services/meeting_service.py +++ b/backend/app/services/meeting_service.py @@ -1,15 +1,13 @@ -from fastapi import APIRouter, UploadFile, File, Form, Depends, BackgroundTasks, Request, Header +from fastapi import UploadFile, BackgroundTasks 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_upload_task_service import audio_upload_task_service from app.services.system_config_service import SystemConfigService -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 @@ -19,7 +17,6 @@ from collections import defaultdict import os import uuid import shutil -import mimetypes llm_service = LLMService() @@ -291,7 +288,7 @@ def _get_meeting_overall_status(meeting_id: int) -> dict: return _build_meeting_overall_status(transcription_status, llm_status) def get_meetings( - current_user: dict = Depends(get_current_user), + current_user: dict, user_id: Optional[int] = None, page: int = 1, page_size: Optional[int] = None, @@ -301,7 +298,7 @@ def get_meetings( ): # 使用配置的默认页面大小 if page_size is None: - page_size = SystemConfigService.get_page_size(default=10) + page_size = SystemConfigService.get_page_size() with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -426,7 +423,7 @@ def get_meetings( }) def get_meetings_stats( - current_user: dict = Depends(get_current_user), + current_user: dict, user_id: Optional[int] = None ): """ @@ -473,7 +470,7 @@ def get_meetings_stats( "attended_meetings": attended_count }) -def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_current_user)): +def get_meeting_details(meeting_id: int, current_user: dict): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) query = ''' @@ -523,7 +520,7 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren 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)): +def get_meeting_transcript(meeting_id: int, current_user: Optional[dict]): """获取会议转录内容(支持公开访问用于预览)""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -543,7 +540,7 @@ def get_meeting_transcript(meeting_id: int, current_user: Optional[dict] = Depen ) 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)): +def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) # 使用 _process_tags 来处理标签创建 @@ -570,7 +567,7 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D 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)): +def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict): 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,)) @@ -602,7 +599,7 @@ def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, curre 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)): +def delete_meeting(meeting_id: int, current_user: dict): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) @@ -619,7 +616,7 @@ def delete_meeting(meeting_id: int, current_user: dict = Depends(get_current_use 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)): +def get_meeting_for_edit(meeting_id: int, current_user: dict): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) query = ''' @@ -658,13 +655,13 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre 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), # 可选的总结模型编码 + audio_file: UploadFile, + meeting_id: int, + auto_summarize: str = "true", + prompt_id: Optional[int] = None, # 可选的提示词模版ID + model_code: Optional[str] = None, # 可选的总结模型编码 background_tasks: BackgroundTasks = None, - current_user: dict = Depends(get_current_user) + current_user: dict | None = None ): """ 音频文件上传接口 @@ -713,7 +710,7 @@ async def upload_audio( ) # 2. 文件大小验证 - max_file_size = SystemConfigService.get_max_audio_size(default=100) * 1024 * 1024 # MB转字节 + max_file_size = SystemConfigService.get_max_audio_size() * 1024 * 1024 # MB转字节 if audio_file.size > max_file_size: return create_api_response( code="400", @@ -757,7 +754,7 @@ async def upload_audio( } ) -def get_audio_file(meeting_id: int, current_user: Optional[dict] = Depends(get_optional_current_user)): +def get_audio_file(meeting_id: int, current_user: Optional[dict]): """获取音频文件信息(支持公开访问用于预览)""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -769,7 +766,7 @@ def get_audio_file(meeting_id: int, current_user: Optional[dict] = Depends(get_o async def stream_audio_file( meeting_id: int, - range: Optional[str] = Header(None, alias="Range") + range: Optional[str] = None ): """ 音频文件流式传输端点,支持HTTP Range请求(Safari浏览器必需) @@ -880,7 +877,7 @@ async def stream_audio_file( } ) -def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depends(get_current_user)): +def get_meeting_transcription_status(meeting_id: int, current_user: dict): try: status_info = transcription_service.get_meeting_transcription_status(meeting_id) if not status_info: @@ -892,7 +889,7 @@ def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depen def start_meeting_transcription( meeting_id: int, background_tasks: BackgroundTasks, - current_user: dict = Depends(get_current_user) + current_user: dict ): try: with get_db_connection() as connection: @@ -923,11 +920,11 @@ def start_meeting_transcription( 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)): +async def upload_image(meeting_id: int, image_file: UploadFile, current_user: dict): 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) + max_image_size = SystemConfigService.get_max_image_size() * 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: @@ -952,7 +949,7 @@ async def upload_image(meeting_id: int, image_file: UploadFile = File(...), curr "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)): +def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, current_user: dict): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -968,7 +965,7 @@ def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, curren 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)): +def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateRequest, current_user: dict): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -985,7 +982,7 @@ def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateReq 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)): +def batch_update_transcript(meeting_id: int, request: BatchTranscriptUpdateRequest, current_user: dict): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -1005,7 +1002,7 @@ def batch_update_transcript(meeting_id: int, request: BatchTranscriptUpdateReque 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)): +def get_meeting_summaries(meeting_id: int, current_user: dict): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -1017,7 +1014,7 @@ def get_meeting_summaries(meeting_id: int, current_user: dict = Depends(get_curr 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)): +def get_summary_detail(meeting_id: int, summary_id: int, current_user: dict): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -1030,7 +1027,7 @@ def get_summary_detail(meeting_id: int, summary_id: int, current_user: dict = De 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)): +def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequest, background_tasks: BackgroundTasks, current_user: dict): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -1067,7 +1064,7 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ 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)): +def get_meeting_llm_tasks(meeting_id: int, current_user: dict): try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -1081,7 +1078,7 @@ def get_meeting_llm_tasks(meeting_id: int, current_user: dict = Depends(get_curr 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)): +def list_active_llm_models(current_user: dict): """获取所有激活的LLM模型列表(供普通用户选择)""" try: with get_db_connection() as connection: @@ -1095,7 +1092,7 @@ def list_active_llm_models(current_user: dict = Depends(get_current_user)): return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}") def get_meeting_navigation( meeting_id: int, - current_user: dict = Depends(get_current_user), + current_user: dict, user_id: Optional[int] = None, filter_type: str = "all", search: Optional[str] = None, @@ -1359,7 +1356,7 @@ class VerifyPasswordRequest(BaseModel): def update_meeting_access_password( meeting_id: int, request: AccessPasswordRequest, - current_user: dict = Depends(get_current_user) + current_user: dict ): """ 设置或关闭会议访问密码(仅创建人可操作) diff --git a/backend/app/services/system_config_service.py b/backend/app/services/system_config_service.py index 8d811a0..1a000f6 100644 --- a/backend/app/services/system_config_service.py +++ b/backend/app/services/system_config_service.py @@ -5,9 +5,8 @@ from typing import Optional, Dict, Any from app.core.database import get_db_connection class SystemConfigService: - """系统配置服务 - 优先从新配置表读取,兼容 dict_data(system_config) 回退""" + """系统配置服务。""" - DICT_TYPE = 'system_config' PUBLIC_CATEGORY = 'public' DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1' CACHE_TTL_SECONDS = 60 @@ -17,14 +16,15 @@ class SystemConfigService: PAGE_SIZE = 'page_size' DEFAULT_RESET_PASSWORD = 'default_reset_password' MAX_AUDIO_SIZE = 'max_audio_size' + MAX_IMAGE_SIZE = 'max_image_size' TOKEN_EXPIRE_DAYS = 'token_expire_days' # 品牌配置 APP_NAME = 'app_name' - BRANDING_CONSOLE_SUBTITLE = 'branding_console_subtitle' - BRANDING_PREVIEW_TITLE = 'branding_preview_title' - BRANDING_LOGIN_WELCOME = 'branding_login_welcome' - BRANDING_FOOTER_TEXT = 'branding_footer_text' + CONSOLE_SUBTITLE = 'console_subtitle' + PREVIEW_TITLE = 'preview_title' + LOGIN_WELCOME = 'login_welcome' + FOOTER_TEXT = 'footer_text' # 声纹配置 VOICEPRINT_TEMPLATE_TEXT = 'voiceprint_template_text' @@ -42,6 +42,98 @@ class SystemConfigService: _config_cache: Dict[str, tuple[float, Any]] = {} _category_cache: Dict[str, tuple[float, Dict[str, Any]]] = {} _all_configs_cache: tuple[float, Dict[str, Any]] | None = None + BUILTIN_PARAMETERS = [ + { + "param_key": TOKEN_EXPIRE_DAYS, + "param_name": "Token过期时间", + "param_value": "7", + "value_type": "number", + "category": "system", + "description": "控制登录 token 的过期时间,单位:天。", + "is_active": 1, + }, + { + "param_key": DEFAULT_RESET_PASSWORD, + "param_name": "默认重置密码", + "param_value": "123456", + "value_type": "string", + "category": "system", + "description": "管理员重置用户密码时使用的默认密码。", + "is_active": 1, + }, + { + "param_key": PAGE_SIZE, + "param_name": "系统分页大小", + "param_value": "10", + "value_type": "number", + "category": PUBLIC_CATEGORY, + "description": "系统通用分页数量。", + "is_active": 1, + }, + { + "param_key": MAX_AUDIO_SIZE, + "param_name": "音频上传大小限制", + "param_value": "100", + "value_type": "number", + "category": PUBLIC_CATEGORY, + "description": "音频上传大小限制,单位:MB。", + "is_active": 1, + }, + { + "param_key": MAX_IMAGE_SIZE, + "param_name": "图片上传大小限制", + "param_value": "10", + "value_type": "number", + "category": PUBLIC_CATEGORY, + "description": "图片上传大小限制,单位:MB。", + "is_active": 1, + }, + { + "param_key": APP_NAME, + "param_name": "系统名称", + "param_value": "iMeeting", + "value_type": "string", + "category": PUBLIC_CATEGORY, + "description": "前端应用标题。", + "is_active": 1, + }, + { + "param_key": CONSOLE_SUBTITLE, + "param_name": "控制台副标题", + "param_value": "智能会议协作平台", + "value_type": "string", + "category": PUBLIC_CATEGORY, + "description": "登录后控制台副标题。", + "is_active": 1, + }, + { + "param_key": PREVIEW_TITLE, + "param_name": "会议预览标题", + "param_value": "会议预览", + "value_type": "string", + "category": PUBLIC_CATEGORY, + "description": "会议预览页标题。", + "is_active": 1, + }, + { + "param_key": LOGIN_WELCOME, + "param_name": "登录欢迎语", + "param_value": "欢迎登录 iMeeting,请输入您的账号信息。", + "value_type": "string", + "category": PUBLIC_CATEGORY, + "description": "登录页欢迎语。", + "is_active": 1, + }, + { + "param_key": FOOTER_TEXT, + "param_name": "页脚文案", + "param_value": "©2026 iMeeting", + "value_type": "string", + "category": PUBLIC_CATEGORY, + "description": "系统页脚文案。", + "is_active": 1, + }, + ] @classmethod def _is_cache_valid(cls, cached_at: float) -> bool: @@ -244,10 +336,7 @@ class SystemConfigService: audio_row = cursor.fetchone() cursor.close() if audio_row: - cfg = cls._build_audio_runtime_config(audio_row) - if cfg.get("max_size_bytes") is not None and cfg.get("voiceprint_max_size") is None: - cfg["voiceprint_max_size"] = cfg["max_size_bytes"] - return cfg + return cls._build_audio_runtime_config(audio_row) return None except Exception: @@ -275,35 +364,8 @@ class SystemConfigService: cls._set_cached_config(dict_code, value) return value - # 2) 兼容旧 sys_dict_data - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - query = """ - SELECT extension_attr - FROM sys_dict_data - WHERE dict_type = %s AND dict_code = %s AND status = 1 - LIMIT 1 - """ - cursor.execute(query, (cls.DICT_TYPE, dict_code)) - result = cursor.fetchone() - cursor.close() - - if result and result['extension_attr']: - try: - ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr'] - resolved_value = ext_attr.get('value', default_value) - cls._set_cached_config(dict_code, resolved_value) - return resolved_value - except (json.JSONDecodeError, AttributeError): - pass - - cls._set_cached_config(dict_code, default_value) - return default_value - - except Exception as e: - print(f"Error getting config {dict_code}: {e}") - return default_value + cls._set_cached_config(dict_code, default_value) + return default_value @classmethod def get_config_attribute(cls, dict_code: str, attr_name: str, default_value: Any = None) -> Any: @@ -325,32 +387,7 @@ class SystemConfigService: if model_json is not None: return model_json.get(attr_name, default_value) - # 2) 兼容旧 sys_dict_data - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - query = """ - SELECT extension_attr - FROM sys_dict_data - WHERE dict_type = %s AND dict_code = %s AND status = 1 - LIMIT 1 - """ - cursor.execute(query, (cls.DICT_TYPE, dict_code)) - result = cursor.fetchone() - cursor.close() - - if result and result['extension_attr']: - try: - ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr'] - return ext_attr.get(attr_name, default_value) - except (json.JSONDecodeError, AttributeError): - pass - - return default_value - - except Exception as e: - print(f"Error getting config attribute {dict_code}.{attr_name}: {e}") - return default_value + return default_value @classmethod def get_model_runtime_config(cls, model_code: str) -> Optional[Dict[str, Any]]: @@ -433,49 +470,7 @@ class SystemConfigService: except Exception as e: print(f"Error setting config in sys_system_parameters {dict_code}: {e}") - # 2) 回退写入旧 sys_dict_data - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - - # 检查配置是否存在 - cursor.execute( - "SELECT id FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s", - (cls.DICT_TYPE, dict_code) - ) - existing = cursor.fetchone() - - extension_attr = json.dumps({"value": value}, ensure_ascii=False) - - if existing: - # 更新现有配置 - update_query = """ - UPDATE sys_dict_data - SET extension_attr = %s, update_time = NOW() - WHERE dict_type = %s AND dict_code = %s - """ - cursor.execute(update_query, (extension_attr, cls.DICT_TYPE, dict_code)) - else: - # 插入新配置 - if not label_cn: - label_cn = dict_code - - insert_query = """ - INSERT INTO sys_dict_data ( - dict_type, dict_code, parent_code, label_cn, - extension_attr, status, sort_order - ) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0) - """ - cursor.execute(insert_query, (cls.DICT_TYPE, dict_code, label_cn, extension_attr)) - - conn.commit() - cursor.close() - cls.invalidate_cache() - return True - - except Exception as e: - print(f"Error setting config {dict_code}: {e}") - return False + return False @classmethod def get_all_configs(cls) -> Dict[str, Any]: @@ -491,7 +486,6 @@ class SystemConfigService: if cls._all_configs_cache and not cls._is_cache_valid(cls._all_configs_cache[0]): cls._all_configs_cache = None - # 1) 新参数表 try: with get_db_connection() as conn: cursor = conn.cursor(dictionary=True) @@ -512,39 +506,7 @@ class SystemConfigService: return dict(configs) except Exception as e: print(f"Error getting all configs from sys_system_parameters: {e}") - - # 2) 兼容旧 sys_dict_data - try: - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - query = """ - SELECT dict_code, label_cn, extension_attr - FROM sys_dict_data - WHERE dict_type = %s AND status = 1 - ORDER BY sort_order - """ - cursor.execute(query, (cls.DICT_TYPE,)) - results = cursor.fetchall() - cursor.close() - - configs = {} - for row in results: - if row['extension_attr']: - try: - ext_attr = json.loads(row['extension_attr']) if isinstance(row['extension_attr'], str) else row['extension_attr'] - configs[row['dict_code']] = ext_attr.get('value') - except (json.JSONDecodeError, AttributeError): - configs[row['dict_code']] = None - else: - configs[row['dict_code']] = None - - with cls._cache_lock: - cls._all_configs_cache = (time.time(), configs) - return dict(configs) - - except Exception as e: - print(f"Error getting all configs: {e}") - return {} + return {} @classmethod def get_configs_by_category(cls, category: str) -> Dict[str, Any]: @@ -598,22 +560,10 @@ class SystemConfigService: @classmethod def ensure_builtin_parameters(cls) -> None: """确保内建系统参数存在,避免后台参数页缺少关键配置项。""" - builtin_parameters = [ - { - "param_key": cls.TOKEN_EXPIRE_DAYS, - "param_name": "Token过期时间", - "param_value": "7", - "value_type": "number", - "category": "system", - "description": "控制登录 token 的过期时间,单位:天。", - "is_active": 1, - }, - ] - try: with get_db_connection() as conn: cursor = conn.cursor() - for item in builtin_parameters: + for item in cls.BUILTIN_PARAMETERS: cursor.execute( """ INSERT INTO sys_system_parameters @@ -644,10 +594,7 @@ class SystemConfigService: audio_cfg = cls.get_active_audio_model_config("asr") if audio_cfg.get("vocabulary_id"): return audio_cfg["vocabulary_id"] - audio_vocab = cls.get_config_attribute('audio_model', 'vocabulary_id') - if audio_vocab: - return audio_vocab - return cls.get_config(cls.ASR_VOCABULARY_ID) + return cls.get_config_attribute('audio_model', 'vocabulary_id') # 声纹配置获取方法(直接使用通用方法) @classmethod @@ -658,9 +605,7 @@ class SystemConfigService: @classmethod def get_voiceprint_max_size(cls, default: int = 5242880) -> int: """获取声纹文件大小限制 (bytes), 默认5MB""" - value = cls.get_config_attribute('voiceprint_model', 'max_size_bytes', None) - if value is None: - value = cls.get_config_attribute('voiceprint_model', 'voiceprint_max_size', default) + value = cls.get_config_attribute('voiceprint_model', 'max_size_bytes', default) try: return int(value) except (ValueError, TypeError): @@ -694,90 +639,93 @@ class SystemConfigService: return default @classmethod - def get_page_size(cls, default: int = 10) -> int: + def get_page_size(cls) -> int: """获取系统通用分页数量。""" - value = cls.get_config(cls.PAGE_SIZE, str(default)) + value = cls.get_config(cls.PAGE_SIZE) + if value is None: + raise RuntimeError("系统参数 page_size 缺失") try: return int(value) except (ValueError, TypeError): - return default + raise RuntimeError(f"系统参数 page_size 非法: {value!r}") from None @classmethod - def get_default_reset_password(cls, default: str = "111111") -> str: + def get_default_reset_password(cls) -> str: """获取默认重置密码""" - return cls.get_config(cls.DEFAULT_RESET_PASSWORD, default) + value = cls.get_config(cls.DEFAULT_RESET_PASSWORD) + if value is None: + raise RuntimeError("系统参数 default_reset_password 缺失") + normalized = str(value).strip() + if not normalized: + raise RuntimeError("系统参数 default_reset_password 不能为空") + return normalized @classmethod - def get_max_audio_size(cls, default: int = 100) -> int: + def get_max_audio_size(cls) -> int: """获取上传音频文件大小限制(MB)""" - value = cls.get_config(cls.MAX_AUDIO_SIZE, str(default)) + value = cls.get_config(cls.MAX_AUDIO_SIZE) + if value is None: + raise RuntimeError("系统参数 max_audio_size 缺失") try: return int(value) except (ValueError, TypeError): - return default + raise RuntimeError(f"系统参数 max_audio_size 非法: {value!r}") from None @classmethod - def get_token_expire_days(cls, default: int = 7) -> int: + def get_max_image_size(cls) -> int: + """获取上传图片大小限制(MB)""" + value = cls.get_config(cls.MAX_IMAGE_SIZE) + if value is None: + raise RuntimeError("系统参数 max_image_size 缺失") + try: + return int(value) + except (ValueError, TypeError): + raise RuntimeError(f"系统参数 max_image_size 非法: {value!r}") from None + + @classmethod + def get_token_expire_days(cls) -> int: """获取访问 token 过期时间(天)。""" - value = cls.get_config(cls.TOKEN_EXPIRE_DAYS, str(default)) + value = cls.get_config(cls.TOKEN_EXPIRE_DAYS) + if value is None: + raise RuntimeError("系统参数 token_expire_days 缺失") try: normalized = int(value) except (ValueError, TypeError): - return default - return normalized if normalized > 0 else default + raise RuntimeError(f"系统参数 token_expire_days 非法: {value!r}") from None + if normalized <= 0: + raise RuntimeError(f"系统参数 token_expire_days 非法: {value!r}") + return normalized @classmethod def get_public_configs(cls) -> Dict[str, Any]: """获取提供给前端初始化使用的公开参数。""" + cls.ensure_builtin_parameters() public_configs = cls.get_configs_by_category(cls.PUBLIC_CATEGORY) + required_keys = [ + cls.APP_NAME, + cls.CONSOLE_SUBTITLE, + cls.PREVIEW_TITLE, + cls.LOGIN_WELCOME, + cls.FOOTER_TEXT, + cls.PAGE_SIZE, + cls.MAX_AUDIO_SIZE, + cls.MAX_IMAGE_SIZE, + ] + missing_keys = [key for key in required_keys if str(public_configs.get(key) or "").strip() == ""] + if missing_keys: + raise RuntimeError(f"公开系统参数缺失: {', '.join(missing_keys)}") - app_name = str(public_configs.get(cls.APP_NAME) or "智听云平台") - console_subtitle = str( - public_configs.get("console_subtitle") - or public_configs.get(cls.BRANDING_CONSOLE_SUBTITLE) - or "iMeeting控制台" - ) - preview_title = str( - public_configs.get("preview_title") - or public_configs.get(cls.BRANDING_PREVIEW_TITLE) - or "会议预览" - ) - login_welcome = str( - public_configs.get("login_welcome") - or public_configs.get(cls.BRANDING_LOGIN_WELCOME) - or "欢迎回来,请输入您的登录凭证。" - ) - footer_text = str( - public_configs.get("footer_text") - or public_configs.get(cls.BRANDING_FOOTER_TEXT) - or "©2026 智听云平台" - ) - - raw_page_size = public_configs.get(cls.PAGE_SIZE, "10") - try: - page_size = int(raw_page_size) - except (ValueError, TypeError): - page_size = 10 - - raw_max_audio_size = public_configs.get(cls.MAX_AUDIO_SIZE, "100") - try: - max_audio_size_mb = int(raw_max_audio_size) - except (ValueError, TypeError): - max_audio_size_mb = 100 - - raw_max_image_size = public_configs.get("max_image_size", "10") - try: - max_image_size_mb = int(raw_max_image_size) - except (ValueError, TypeError): - max_image_size_mb = 10 + page_size = cls.get_page_size() + max_audio_size_mb = cls.get_max_audio_size() + max_image_size_mb = cls.get_max_image_size() return { **public_configs, - "app_name": app_name, - "console_subtitle": console_subtitle, - "preview_title": preview_title, - "login_welcome": login_welcome, - "footer_text": footer_text, + "app_name": str(public_configs[cls.APP_NAME]).strip(), + "console_subtitle": str(public_configs[cls.CONSOLE_SUBTITLE]).strip(), + "preview_title": str(public_configs[cls.PREVIEW_TITLE]).strip(), + "login_welcome": str(public_configs[cls.LOGIN_WELCOME]).strip(), + "footer_text": str(public_configs[cls.FOOTER_TEXT]).strip(), "page_size": str(page_size), "PAGE_SIZE": page_size, "max_audio_size": str(max_audio_size_mb), diff --git a/backend/requirements.txt b/backend/requirements.txt index 7601f59..9230438 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ # Core Application Framework fastapi uvicorn +mcp # Database & Cache mysql-connector-python diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af7ba02..c271183 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { ConfigProvider, theme, App as AntdApp } from 'antd'; import zhCN from 'antd/locale/zh_CN'; @@ -88,26 +88,30 @@ const DefaultMenuRedirect = ({ user }) => { function App() { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [bootstrapError, setBootstrapError] = useState(null); - useEffect(() => { + const bootstrapApp = useCallback(async () => { + setIsLoading(true); + setBootstrapError(null); setUser(getStoredUser()); - setIsLoading(false); - }, []); - useEffect(() => { - let active = true; - - configService.getBrandingConfig().then((branding) => { - if (active && branding?.app_name) { + try { + const branding = await configService.getBrandingConfig(); + if (branding?.app_name) { document.title = branding.app_name; } - }).catch(() => {}); - - return () => { - active = false; - }; + } catch (error) { + console.error('Load public config failed:', error); + setBootstrapError(error); + } finally { + setIsLoading(false); + } }, []); + useEffect(() => { + bootstrapApp(); + }, [bootstrapApp]); + const handleLogin = (authData) => { if (authData) { menuService.clearCache(); @@ -136,6 +140,30 @@ function App() { ); } + if (bootstrapError) { + return ( +
系统公开配置加载失败
+ +