增加了mcp server

codex/dev
mula.liu 2026-04-14 09:56:57 +08:00
parent dbf6f72dac
commit d69b346f4c
25 changed files with 803 additions and 414 deletions

View File

@ -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()

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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()

View File

@ -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)}")

View File

@ -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()

View File

@ -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:

View File

@ -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测试成功。"

View File

@ -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
):
"""
设置或关闭会议访问密码仅创建人可操作

View File

@ -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),

View File

@ -1,6 +1,7 @@
# Core Application Framework
fastapi
uvicorn
mcp
# Database & Cache
mysql-connector-python

View File

@ -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 (
<div className="app-loading">
<div className="loading-spinner"></div>
<p>系统公开配置加载失败</p>
<button
type="button"
onClick={bootstrapApp}
style={{
marginTop: 16,
padding: '10px 18px',
borderRadius: 999,
border: 'none',
background: '#1d4ed8',
color: '#fff',
cursor: 'pointer',
}}
>
重新加载
</button>
</div>
);
}
return (
<ConfigProvider
locale={zhCN}

View File

@ -10,7 +10,7 @@ import {
import { useNavigate, useLocation } from 'react-router-dom';
import menuService from '../services/menuService';
import { renderMenuIcon } from '../utils/menuIcons';
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
import configService from '../utils/configService';
const { Header, Content, Sider } = Layout;
const { useBreakpoint } = Grid;
@ -21,7 +21,7 @@ const MainLayout = ({ children, user, onLogout }) => {
const [drawerOpen, setDrawerOpen] = useState(false);
const [openKeys, setOpenKeys] = useState([]);
const [activeMenuKey, setActiveMenuKey] = useState(null);
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
const [branding, setBranding] = useState(() => configService.getCachedBrandingConfig());
const navigate = useNavigate();
const location = useLocation();
const screens = useBreakpoint();
@ -43,8 +43,23 @@ const MainLayout = ({ children, user, onLogout }) => {
}, []);
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []);
if (branding) {
return;
}
let active = true;
configService.getBrandingConfig().then((nextBranding) => {
if (active) {
setBranding(nextBranding);
}
}).catch((error) => {
console.error('Load branding config failed:', error);
});
return () => {
active = false;
};
}, [branding]);
useEffect(() => {
if (isMobile) {
@ -332,6 +347,10 @@ const MainLayout = ({ children, user, onLogout }) => {
</>
);
if (!branding) {
return null;
}
return (
<Layout className="main-layout-shell">
{isMobile ? (

View File

@ -86,7 +86,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
<div>
<Text type="secondary">
支持 {AUDIO_UPLOAD_ACCEPT.replace(/\./g, '').toUpperCase()}
<br/>音频文件最大 {configService.formatFileSize(maxAudioSize)}
<br/>音频文件最大 {maxAudioSize ? configService.formatFileSize(maxAudioSize) : '加载中'}
</Text>
</div>
<Upload accept={AUDIO_UPLOAD_ACCEPT} showUploadList={false} beforeUpload={handleAudioBeforeUpload}>

View File

@ -41,7 +41,6 @@ const API_CONFIG = {
LLM_MODELS: '/api/llm-models/active'
},
ADMIN: {
SYSTEM_CONFIG: '/api/admin/system-config',
PARAMETERS: '/api/admin/parameters',
PARAMETER_DETAIL: (paramKey) => `/api/admin/parameters/${paramKey}`,
PARAMETER_DELETE: (paramKey) => `/api/admin/parameters/${paramKey}`,

View File

@ -97,7 +97,7 @@ export default function useMeetingDetailsPage({ user }) {
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
const [savingAccessPassword, setSavingAccessPassword] = useState(false);
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
const [maxAudioSize, setMaxAudioSize] = useState(() => configService.getCachedMaxAudioSize());
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
const [editingSegments, setEditingSegments] = useState({});
@ -146,24 +146,24 @@ export default function useMeetingDetailsPage({ user }) {
const loadAudioUploadConfig = useCallback(async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch {
setMaxAudioSize(100 * 1024 * 1024);
setMaxAudioSize(nextMaxAudioSize);
} catch (error) {
message.error(error?.message || '加载音频上传限制失败');
}
}, []);
}, [message]);
const fetchPromptList = useCallback(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];
const defaultPrompt = res.data.prompts?.find((prompt) => prompt.is_default);
if (defaultPrompt) {
setSelectedPromptId(defaultPrompt.id);
}
} catch (error) {
console.debug('加载提示词列表失败:', error);
message.error(error?.message || '加载提示词列表失败');
}
}, []);
}, [message]);
const fetchLlmModels = useCallback(async () => {
try {
@ -175,9 +175,9 @@ export default function useMeetingDetailsPage({ user }) {
setSelectedModelCode(defaultModel.model_code);
}
} catch (error) {
console.debug('加载模型列表失败:', error);
message.error(error?.message || '加载模型列表失败');
}
}, []);
}, [message]);
const fetchSummaryResources = useCallback(async () => {
setSummaryResourcesLoading(true);
@ -479,6 +479,12 @@ export default function useMeetingDetailsPage({ user }) {
}, [currentHighlightIndex, transcriptVisibleCount]);
const validateAudioBeforeUpload = (file) => {
if (!maxAudioSize) {
const configErrorMessage = '系统音频上传限制未加载完成,请稍后重试';
message.error(configErrorMessage);
return configErrorMessage;
}
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);

View File

@ -16,7 +16,7 @@ export default function useMeetingFormDrawer({ open, onClose, onSuccess, meeting
const [audioUploading, setAudioUploading] = useState(false);
const [audioUploadProgress, setAudioUploadProgress] = useState(0);
const [audioUploadMessage, setAudioUploadMessage] = useState('');
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
const [maxAudioSize, setMaxAudioSize] = useState(() => configService.getCachedMaxAudioSize());
const isEdit = Boolean(meetingId);
@ -36,11 +36,11 @@ export default function useMeetingFormDrawer({ open, onClose, onSuccess, meeting
const loadAudioUploadConfig = useCallback(async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch {
setMaxAudioSize(100 * 1024 * 1024);
setMaxAudioSize(nextMaxAudioSize);
} catch (error) {
message.error(error?.message || '加载音频上传限制失败');
}
}, []);
}, [message]);
const fetchMeeting = useCallback(async () => {
try {
@ -80,6 +80,11 @@ export default function useMeetingFormDrawer({ open, onClose, onSuccess, meeting
}, [fetchMeeting, fetchOptions, form, isEdit, loadAudioUploadConfig, open]);
const handleAudioBeforeUpload = (file) => {
if (!maxAudioSize) {
message.error('系统音频上传限制未加载完成,请稍后重试');
return Upload.LIST_IGNORE;
}
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);

View File

@ -1,24 +1,28 @@
import { useEffect, useState } from 'react';
import configService from '../utils/configService';
const normalizePageSize = (value, fallback) => {
const normalizePageSize = (value) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return fallback;
return null;
}
return Math.min(100, Math.max(5, Math.floor(numericValue)));
};
const useSystemPageSize = (fallback = 10, options = {}) => {
const useSystemPageSize = (fallbackOrOptions = {}, maybeOptions = {}) => {
const options = typeof fallbackOrOptions === 'object' && fallbackOrOptions !== null
? fallbackOrOptions
: maybeOptions;
const { suspendUntilReady = false } = options;
const [pageSize, setPageSize] = useState(() => {
const cachedPageSize = configService.getCachedPageSize();
if (cachedPageSize) {
return normalizePageSize(cachedPageSize, fallback);
return normalizePageSize(cachedPageSize);
}
return suspendUntilReady ? null : normalizePageSize(fallback, 10);
return null;
});
const [isReady, setIsReady] = useState(() => pageSize !== null);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
@ -27,26 +31,27 @@ const useSystemPageSize = (fallback = 10, options = {}) => {
if (!active) {
return;
}
setPageSize(normalizePageSize(size, fallback));
setPageSize(normalizePageSize(size));
setError(null);
setIsReady(true);
}).catch(() => {
}).catch((nextError) => {
if (!active) {
return;
}
setPageSize((prev) => prev ?? normalizePageSize(fallback, 10));
setError(nextError);
setIsReady(true);
});
return () => {
active = false;
};
}, [fallback]);
}, []);
if (suspendUntilReady) {
return { pageSize, isReady };
return { pageSize, isReady, error };
}
return pageSize ?? normalizePageSize(fallback, 10);
return pageSize;
};
export default useSystemPageSize;

View File

@ -27,7 +27,7 @@ const CreateMeeting = () => {
const [audioUploading, setAudioUploading] = useState(false);
const [audioUploadProgress, setAudioUploadProgress] = useState(0);
const [audioUploadMessage, setAudioUploadMessage] = useState('');
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
const [maxAudioSize, setMaxAudioSize] = useState(() => configService.getCachedMaxAudioSize());
const fetchUsers = useCallback(async () => {
try {
@ -50,11 +50,11 @@ const CreateMeeting = () => {
const loadAudioUploadConfig = useCallback(async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch {
setMaxAudioSize(100 * 1024 * 1024);
setMaxAudioSize(nextMaxAudioSize);
} catch (error) {
message.error(error?.message || '加载音频上传限制失败');
}
}, []);
}, [message]);
useEffect(() => {
fetchUsers();
@ -63,6 +63,11 @@ const CreateMeeting = () => {
}, [fetchPrompts, fetchUsers, loadAudioUploadConfig]);
const handleAudioBeforeUpload = (file) => {
if (!maxAudioSize) {
message.error('系统音频上传限制未加载完成,请稍后重试');
return Upload.LIST_IGNORE;
}
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
@ -184,7 +189,7 @@ const CreateMeeting = () => {
<Text strong style={{ display: 'block', marginBottom: 4 }}>上传会议音频</Text>
<Text type="secondary">
创建后会自动沿用会议详情页同一套上传与转录逻辑支持 {AUDIO_UPLOAD_ACCEPT.replace(/\./g, '').toUpperCase()}
最大 {configService.formatFileSize(maxAudioSize)}
最大 {maxAudioSize ? configService.formatFileSize(maxAudioSize) : '加载中'}
</Text>
</div>
<Upload accept={AUDIO_UPLOAD_ACCEPT} showUploadList={false} beforeUpload={handleAudioBeforeUpload}>

View File

@ -12,7 +12,7 @@ import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import menuService from '../services/menuService';
import BrandLogo from '../components/BrandLogo';
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
import configService from '../utils/configService';
const { Title, Paragraph, Text } = Typography;
@ -25,12 +25,31 @@ const HOME_TAGLINE = '让每一次谈话都产生价值';
const HomePage = ({ onLogin }) => {
const [loading, setLoading] = useState(false);
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
const [branding, setBranding] = useState(() => configService.getCachedBrandingConfig());
const { message } = App.useApp();
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []);
if (branding) {
return;
}
let active = true;
configService.getBrandingConfig().then((nextBranding) => {
if (active) {
setBranding(nextBranding);
}
}).catch((error) => {
console.error('Load branding config failed:', error);
});
return () => {
active = false;
};
}, [branding]);
if (!branding) {
return null;
}
const handleLogin = async (values) => {
setLoading(true);

View File

@ -101,10 +101,11 @@ const KnowledgeBasePage = ({ user }) => {
try {
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];
const def = res.data.prompts?.find((prompt) => prompt.is_default);
if (def) setSelectedPromptId(def.id);
} catch {
setAvailablePrompts([]);
setSelectedPromptId(null);
}
}, []);

View File

@ -102,7 +102,7 @@ const MeetingCenterPage = ({ user }) => {
const [searchValue, setSearchValue] = useState('');
const [filterType, setFilterType] = useState('all');
const [page, setPage] = useState(1);
const { pageSize, isReady: pageSizeReady } = useSystemPageSize(10, { suspendUntilReady: true });
const { pageSize, isReady: pageSizeReady, error: pageSizeError } = useSystemPageSize({ suspendUntilReady: true });
const [total, setTotal] = useState(0);
const [formDrawerOpen, setFormDrawerOpen] = useState(false);
const [editingMeetingId, setEditingMeetingId] = useState(null);
@ -153,6 +153,12 @@ const MeetingCenterPage = ({ user }) => {
meetingCacheService.clearAll();
}, [pageSize]);
useEffect(() => {
if (pageSizeError) {
message.error(pageSizeError.message || '分页配置加载失败');
}
}, [message, pageSizeError]);
useEffect(() => {
if (!pageSizeReady || !pageSize) {
return;
@ -248,6 +254,10 @@ const MeetingCenterPage = ({ user }) => {
<div className="meeting-center-empty">
<Empty description="加载分页配置中..." />
</div>
) : !pageSize ? (
<div className="meeting-center-empty">
<Empty description="分页配置加载失败,请刷新后重试" />
</div>
) : meetings.length ? (
<>
<Row gutter={[16, 16]}>

View File

@ -9,7 +9,7 @@ import MindMap from '../components/MindMap';
import AudioPlayerBar from '../components/AudioPlayerBar';
import TranscriptTimeline from '../components/TranscriptTimeline';
import tools from '../utils/tools';
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
import configService from '../utils/configService';
import './MeetingPreview.css';
const { Content } = Layout;
@ -26,15 +26,30 @@ const MeetingPreview = () => {
const [passwordError, setPasswordError] = useState('');
const [passwordRequired, setPasswordRequired] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
const [branding, setBranding] = useState(() => configService.getCachedBrandingConfig());
const [transcript, setTranscript] = useState([]);
const [audioUrl, setAudioUrl] = useState('');
const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1);
const [playbackRate, setPlaybackRate] = useState(1);
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []);
if (branding) {
return;
}
let active = true;
configService.getBrandingConfig().then((nextBranding) => {
if (active) {
setBranding(nextBranding);
}
}).catch((error) => {
console.error('Load branding config failed:', error);
});
return () => {
active = false;
};
}, [branding]);
const fetchTranscriptAndAudio = useCallback(async () => {
const [transcriptRes, audioRes] = await Promise.allSettled([
@ -154,6 +169,10 @@ const MeetingPreview = () => {
fetchPreview(password);
};
if (!branding) {
return null;
}
if (loading && !meeting) {
return (
<div className="preview-container">

View File

@ -3,13 +3,62 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const PUBLIC_CONFIG_CACHE_KEY = 'imeeting_public_config_cache';
const PUBLIC_CONFIG_CACHE_TTL = 5 * 60 * 1000;
const REQUIRED_PUBLIC_CONFIG_KEYS = [
'app_name',
'console_subtitle',
'preview_title',
'login_welcome',
'footer_text',
'PAGE_SIZE',
'MAX_FILE_SIZE',
'MAX_IMAGE_SIZE',
];
export const DEFAULT_BRANDING_CONFIG = {
app_name: '智听云平台',
console_subtitle: 'iMeeting控制台',
preview_title: '会议预览',
login_welcome: '欢迎回来,请输入您的登录凭证。',
footer_text: '©2026 智听云平台',
const parsePositiveInteger = (value) => {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
};
const validatePublicConfig = (configs) => {
if (!configs || typeof configs !== 'object' || Array.isArray(configs)) {
throw new Error('公开配置返回格式非法');
}
const missingKeys = REQUIRED_PUBLIC_CONFIG_KEYS.filter((key) => String(configs[key] ?? '').trim() === '');
if (missingKeys.length > 0) {
throw new Error(`公开配置缺失: ${missingKeys.join(', ')}`);
}
const pageSize = parsePositiveInteger(configs.PAGE_SIZE ?? configs.page_size);
if (!pageSize) {
throw new Error('公开配置 PAGE_SIZE 非法');
}
const maxFileSize = parsePositiveInteger(configs.MAX_FILE_SIZE);
if (!maxFileSize) {
throw new Error('公开配置 MAX_FILE_SIZE 非法');
}
const maxImageSize = parsePositiveInteger(configs.MAX_IMAGE_SIZE);
if (!maxImageSize) {
throw new Error('公开配置 MAX_IMAGE_SIZE 非法');
}
return {
...configs,
app_name: String(configs.app_name).trim(),
console_subtitle: String(configs.console_subtitle).trim(),
preview_title: String(configs.preview_title).trim(),
login_welcome: String(configs.login_welcome).trim(),
footer_text: String(configs.footer_text).trim(),
PAGE_SIZE: pageSize,
page_size: String(pageSize),
MAX_FILE_SIZE: maxFileSize,
MAX_IMAGE_SIZE: maxImageSize,
};
};
class ConfigService {
@ -36,10 +85,11 @@ class ConfigService {
window.localStorage.removeItem(PUBLIC_CONFIG_CACHE_KEY);
return null;
}
return parsed.data;
return validatePublicConfig(parsed.data);
}
return parsed;
return validatePublicConfig(parsed);
} catch {
window.localStorage.removeItem(PUBLIC_CONFIG_CACHE_KEY);
return null;
}
}
@ -47,7 +97,7 @@ class ConfigService {
writeCachedPublicConfig(configs) {
try {
window.localStorage.setItem(PUBLIC_CONFIG_CACHE_KEY, JSON.stringify({
data: configs || {},
data: configs,
cached_at: Date.now(),
}));
} catch {
@ -57,27 +107,29 @@ class ConfigService {
extractPageSize(configs) {
const raw = configs?.PAGE_SIZE ?? configs?.page_size;
const val = parseInt(raw, 10);
if (isNaN(val) || val <= 0) {
const value = parsePositiveInteger(raw);
if (!value) {
return null;
}
return Math.min(100, Math.max(5, val));
return Math.min(100, Math.max(5, value));
}
buildBrandingConfig(configs) {
return {
...DEFAULT_BRANDING_CONFIG,
...(configs || {}),
app_name: configs.app_name,
console_subtitle: configs.console_subtitle,
preview_title: configs.preview_title,
login_welcome: configs.login_welcome,
footer_text: configs.footer_text,
};
}
applyPublicConfig(configs) {
this.configs = configs || null;
const validatedConfigs = validatePublicConfig(configs);
this.configs = validatedConfigs;
this._pageSize = this.extractPageSize(this.configs);
this.brandingConfig = this.buildBrandingConfig(this.configs);
if (this.configs) {
this.writeCachedPublicConfig(this.configs);
}
this.writeCachedPublicConfig(this.configs);
return this.configs;
}
@ -97,54 +149,62 @@ class ConfigService {
}
async loadConfigsFromServer() {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG));
return this.applyPublicConfig(response.data || {});
} catch (error) {
console.warn('Failed to load system configs, using defaults:', error);
// 返回默认配置
return this.applyPublicConfig({
MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
MAX_IMAGE_SIZE: 10 * 1024 * 1024, // 10MB
PAGE_SIZE: 10,
page_size: '10',
});
}
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PUBLIC.SYSTEM_CONFIG));
return this.applyPublicConfig(response.data || {});
}
async getMaxFileSize() {
const configs = await this.getConfigs();
return configs.MAX_FILE_SIZE || 100 * 1024 * 1024;
const maxFileSize = parsePositiveInteger(configs.MAX_FILE_SIZE);
if (!maxFileSize) {
throw new Error('公开配置 MAX_FILE_SIZE 非法');
}
return maxFileSize;
}
async getMaxAudioSize() {
return this.getMaxFileSize();
}
getCachedMaxAudioSize() {
if (!this.configs) {
return null;
}
return parsePositiveInteger(this.configs.MAX_FILE_SIZE);
}
async getMaxImageSize() {
const configs = await this.getConfigs();
return configs.MAX_IMAGE_SIZE || 10 * 1024 * 1024;
const maxImageSize = parsePositiveInteger(configs.MAX_IMAGE_SIZE);
if (!maxImageSize) {
throw new Error('公开配置 MAX_IMAGE_SIZE 非法');
}
return maxImageSize;
}
async getPageSize() {
if (this._pageSize) return this._pageSize;
try {
const configs = await this.getConfigs();
const cachedPageSize = this.extractPageSize(configs);
if (cachedPageSize) {
this._pageSize = cachedPageSize;
return cachedPageSize;
}
} catch (error) {
console.warn('Failed to load page_size config:', error.message);
if (this._pageSize) {
return this._pageSize;
}
return 10;
const configs = await this.getConfigs();
const pageSize = this.extractPageSize(configs);
if (!pageSize) {
throw new Error('公开配置 PAGE_SIZE 非法');
}
this._pageSize = pageSize;
return pageSize;
}
getCachedPageSize() {
return this._pageSize;
}
getCachedBrandingConfig() {
return this.brandingConfig;
}
async getBrandingConfig() {
if (this.brandingConfig) {
return this.brandingConfig;
@ -161,13 +221,8 @@ class ConfigService {
}
async loadBrandingConfigFromServer() {
try {
const configs = await this.getConfigs();
return this.buildBrandingConfig(configs);
} catch (error) {
console.warn('Failed to load branding configs, using defaults:', error);
return { ...DEFAULT_BRANDING_CONFIG };
}
const configs = await this.getConfigs();
return this.buildBrandingConfig(configs);
}
// 格式化文件大小为可读格式