diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index a9daf84..cf9585c 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -53,6 +53,19 @@ def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = tags_data = cursor.fetchall() return [Tag(**tag) for tag in tags_data] +def _sync_attendees(cursor, meeting_id: int, attendee_ids: Optional[List[int]]) -> None: + attendee_id_list = [] + if attendee_ids: + attendee_id_list = list(dict.fromkeys(int(user_id) for user_id in attendee_ids if user_id is not None)) + + cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) + + if attendee_id_list: + cursor.executemany( + 'INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', + [(meeting_id, user_id) for user_id in attendee_id_list] + ) + def _get_meeting_overall_status(meeting_id: int) -> dict: """ 获取会议的整体进度状态(包含转译和LLM两个阶段) @@ -248,7 +261,7 @@ def get_meetings( meeting_list.append(Meeting( meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], summary=meeting['summary'], created_at=meeting['created_at'], audio_file_path=meeting['audio_file_path'], - attendees=attendees, creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list, + attendees=attendees, attendee_ids=[row['user_id'] for row in attendees_data], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list, access_password=meeting.get('access_password'), overall_status=progress_info.get('overall_status'), overall_progress=progress_info.get('overall_progress'), @@ -319,7 +332,7 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren cursor = connection.cursor(dictionary=True) query = ''' SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, - m.user_id as creator_id, u.caption as creator_username, + m.user_id as creator_id, u.caption as creator_username, m.prompt_id, af.file_path as audio_file_path, af.duration as audio_duration, p.name as prompt_name, m.access_password FROM meetings m @@ -341,7 +354,9 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren meeting_data = Meeting( meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, + attendee_ids=[row['user_id'] for row in attendees_data], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags, + prompt_id=meeting.get('prompt_id'), prompt_name=meeting.get('prompt_name'), access_password=meeting.get('access_password') ) @@ -385,17 +400,24 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D # 使用 _process_tags 来处理标签创建 if meeting_request.tags: _process_tags(cursor, meeting_request.tags, current_user['user_id']) - meeting_query = 'INSERT INTO meetings (user_id, title, meeting_time, summary, tags, created_at) VALUES (%s, %s, %s, %s, %s, %s)' - cursor.execute(meeting_query, (meeting_request.user_id, meeting_request.title, meeting_request.meeting_time, None, meeting_request.tags, datetime.now().isoformat())) + meeting_query = ''' + INSERT INTO meetings (user_id, title, meeting_time, summary, tags, prompt_id, created_at) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ''' + cursor.execute( + meeting_query, + ( + current_user['user_id'], + meeting_request.title, + meeting_request.meeting_time, + None, + meeting_request.tags, + meeting_request.prompt_id or 0, + datetime.now().isoformat(), + ), + ) meeting_id = cursor.lastrowid - # 根据 caption 查找用户ID并插入参会人 - if meeting_request.attendees: - captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()] - if captions: - placeholders = ','.join(['%s'] * len(captions)) - cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions) - for row in cursor.fetchall(): - cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id'])) + _sync_attendees(cursor, meeting_id, meeting_request.attendee_ids) connection.commit() return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id}) @@ -403,7 +425,7 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + cursor.execute("SELECT user_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) meeting = cursor.fetchone() if not meeting: return create_api_response(code="404", message="Meeting not found") @@ -412,17 +434,20 @@ def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, curre # 使用 _process_tags 来处理标签创建 if meeting_request.tags: _process_tags(cursor, meeting_request.tags, current_user['user_id']) - update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s WHERE meeting_id = %s' - cursor.execute(update_query, (meeting_request.title, meeting_request.meeting_time, meeting_request.summary, meeting_request.tags, meeting_id)) - cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) - # 根据 caption 查找用户ID并插入参会人 - if meeting_request.attendees: - captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()] - if captions: - placeholders = ','.join(['%s'] * len(captions)) - cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions) - for row in cursor.fetchall(): - cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id'])) + update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s, prompt_id = %s WHERE meeting_id = %s' + cursor.execute( + update_query, + ( + meeting_request.title, + meeting_request.meeting_time, + meeting_request.summary, + meeting_request.tags, + meeting_request.prompt_id if meeting_request.prompt_id is not None else meeting['prompt_id'], + meeting_id, + ), + ) + if meeting_request.attendee_ids is not None: + _sync_attendees(cursor, meeting_id, meeting_request.attendee_ids) connection.commit() # 同步导出总结MD文件 if meeting_request.summary: @@ -453,7 +478,7 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre cursor = connection.cursor(dictionary=True) query = ''' SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, - m.user_id as creator_id, u.caption as creator_username, af.file_path as audio_file_path, + m.user_id as creator_id, u.caption as creator_username, m.prompt_id, af.file_path as audio_file_path, m.access_password FROM meetings m JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id WHERE m.meeting_id = %s @@ -471,7 +496,9 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre meeting_data = Meeting( meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, + attendee_ids=[row['user_id'] for row in attendees_data], creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags, + prompt_id=meeting.get('prompt_id'), access_password=meeting.get('access_password') ) if meeting.get('audio_file_path'): @@ -1023,7 +1050,7 @@ def get_meeting_navigation( return create_api_response(code="500", message=f"获取导航信息失败: {str(e)}") @router.get("/meetings/{meeting_id}/preview-data") -def get_meeting_preview_data(meeting_id: int): +def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None): """ 获取会议预览数据(无需登录认证) 用于二维码扫描后的预览页面 @@ -1055,6 +1082,32 @@ def get_meeting_preview_data(meeting_id: int): if not meeting: return create_api_response(code="404", message="会议不存在") + stored_password = (meeting.get('access_password') or '').strip() + provided_password = (password or '').strip() + + if stored_password: + if not provided_password: + return create_api_response( + code="401", + message="此会议受密码保护", + data={ + "meeting_id": meeting_id, + "title": meeting['title'], + "requires_password": True + } + ) + + if provided_password != stored_password: + return create_api_response( + code="401", + message="密码错误", + data={ + "meeting_id": meeting_id, + "title": meeting['title'], + "requires_password": True + } + ) + # 获取整体进度状态(两阶段) progress_info = _get_meeting_overall_status(meeting_id) overall_status = progress_info["overall_status"] diff --git a/backend/app/core/database.py b/backend/app/core/database.py index cfbab7c..f11d079 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -10,14 +10,14 @@ def get_db_connection(): connection = None try: connection = mysql.connector.connect(**DATABASE_CONFIG) - yield connection except Error as e: print(f"数据库连接错误: {e}") raise HTTPException(status_code=500, detail="数据库连接失败") + try: + yield connection finally: if connection and connection.is_connected(): try: - # 确保清理任何未读结果 if connection.unread_result: connection.consume_results() connection.close() diff --git a/backend/app/models/models.py b/backend/app/models/models.py index e7587eb..3007da0 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -76,6 +76,7 @@ class Meeting(BaseModel): creator_username: str created_at: datetime.datetime attendees: List[AttendeeInfo] + attendee_ids: Optional[List[int]] = None tags: List[Tag] audio_file_path: Optional[str] = None audio_duration: Optional[float] = None @@ -98,7 +99,7 @@ class TranscriptSegment(BaseModel): class CreateMeetingRequest(BaseModel): title: str meeting_time: datetime.datetime - attendees: str # 逗号分隔的姓名 + attendee_ids: List[int] description: Optional[str] = None tags: Optional[str] = None # 逗号分隔 prompt_id: Optional[int] = None @@ -106,7 +107,7 @@ class CreateMeetingRequest(BaseModel): class UpdateMeetingRequest(BaseModel): title: Optional[str] = None meeting_time: Optional[datetime.datetime] = None - attendees: Optional[str] = None + attendee_ids: Optional[List[int]] = None description: Optional[str] = None tags: Optional[str] = None summary: Optional[str] = None diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 5abc93e..2a1bd61 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -33,6 +33,42 @@ server { proxy_send_timeout 300s; } + location = /docs { + proxy_pass http://backend:8000/docs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /redoc { + proxy_pass http://backend:8000/redoc; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /openapi.json { + proxy_pass http://backend:8000/openapi.json; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /docs/oauth2-redirect { + proxy_pass http://backend:8000/docs/oauth2-redirect; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # 上传文件代理 (使用 ^~ 提高优先级,避免被静态文件正则匹配拦截) location ^~ /uploads/ { proxy_pass http://backend:8000; @@ -57,4 +93,4 @@ server { location = /50x.html { root /usr/share/nginx/html; } -} \ No newline at end of file +} diff --git a/frontend/src/components/MeetingFormDrawer.jsx b/frontend/src/components/MeetingFormDrawer.jsx index 632fd92..e303fa1 100644 --- a/frontend/src/components/MeetingFormDrawer.jsx +++ b/frontend/src/components/MeetingFormDrawer.jsx @@ -7,7 +7,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api'; const { TextArea } = Input; -const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) => { +const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => { const { message } = App.useApp(); const [form] = Form.useForm(); const [loading, setLoading] = useState(false); @@ -47,7 +47,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) form.setFieldsValue({ title: meeting.title, meeting_time: dayjs(meeting.meeting_time), - attendees: meeting.attendees?.map((a) => (typeof a === 'string' ? a : a.caption)) || [], + attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [], prompt_id: meeting.prompt_id, tags: meeting.tags?.map((t) => t.name) || [], description: meeting.description, @@ -66,7 +66,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) const payload = { ...values, meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'), - attendees: values.attendees?.join(',') || '', + attendee_ids: values.attendee_ids || [], tags: values.tags?.join(',') || '', }; @@ -74,7 +74,6 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload); message.success('会议更新成功'); } else { - payload.creator_id = user.user_id; const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); if (res.code === '200') { message.success('会议创建成功'); @@ -87,7 +86,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) onClose(); } catch (error) { if (!error?.errorFields) { - message.error(error?.response?.data?.message || '操作失败'); + message.error(error?.response?.data?.message || error?.response?.data?.detail || '操作失败'); } } finally { setLoading(false); @@ -127,10 +126,10 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) - + diff --git a/frontend/src/config/api.js b/frontend/src/config/api.js index e0cb4e3..dfc8abd 100644 --- a/frontend/src/config/api.js +++ b/frontend/src/config/api.js @@ -36,6 +36,7 @@ const API_CONFIG = { UPLOAD_IMAGE: (meetingId) => `/api/meetings/${meetingId}/upload-image`, REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary`, NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation`, + PREVIEW_DATA: (meetingId) => `/api/meetings/${meetingId}/preview-data`, LLM_MODELS: '/api/llm-models/active' }, ADMIN: { diff --git a/frontend/src/pages/CreateMeeting.jsx b/frontend/src/pages/CreateMeeting.jsx index 6c9c7bf..60dd3b5 100644 --- a/frontend/src/pages/CreateMeeting.jsx +++ b/frontend/src/pages/CreateMeeting.jsx @@ -17,7 +17,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api'; const { Title, Text } = Typography; const { TextArea } = Input; -const CreateMeeting = ({ user }) => { +const CreateMeeting = () => { const navigate = useNavigate(); const { message } = App.useApp(); const [form] = Form.useForm(); @@ -50,8 +50,7 @@ const CreateMeeting = ({ user }) => { const payload = { ...values, meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'), - attendees: values.attendees.join(','), - creator_id: user.user_id + attendee_ids: values.attendee_ids }; const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload); if (res.code === '200') { @@ -59,7 +58,7 @@ const CreateMeeting = ({ user }) => { navigate(`/meetings/${res.data.meeting_id}`); } } catch (error) { - message.error(error.response?.data?.message || '创建失败'); + message.error(error.response?.data?.message || error.response?.data?.detail || '创建失败'); } finally { setLoading(false); } @@ -93,9 +92,9 @@ const CreateMeeting = ({ user }) => { - + diff --git a/frontend/src/pages/EditMeeting.jsx b/frontend/src/pages/EditMeeting.jsx index 68dc2d1..546e48f 100644 --- a/frontend/src/pages/EditMeeting.jsx +++ b/frontend/src/pages/EditMeeting.jsx @@ -15,7 +15,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../config/api'; const { Title } = Typography; const { TextArea } = Input; -const EditMeeting = ({ user }) => { +const EditMeeting = () => { const { meeting_id } = useParams(); const navigate = useNavigate(); const { message } = App.useApp(); @@ -44,7 +44,7 @@ const EditMeeting = ({ user }) => { form.setFieldsValue({ ...meeting, meeting_time: dayjs(meeting.meeting_time), - attendees: meeting.attendees.map(a => typeof a === 'string' ? a : a.caption), + attendee_ids: meeting.attendee_ids || meeting.attendees.map(a => a.user_id).filter(Boolean), tags: meeting.tags?.map(t => t.name) || [] }); } catch (e) { @@ -60,14 +60,14 @@ const EditMeeting = ({ user }) => { const payload = { ...values, meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'), - attendees: values.attendees.join(','), + attendee_ids: values.attendee_ids, tags: values.tags?.join(',') || '' }; await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload); message.success('会议更新成功'); navigate(`/meetings/${meeting_id}`); } catch (error) { - message.error('更新失败'); + message.error(error.response?.data?.message || error.response?.data?.detail || '更新失败'); } finally { setLoading(false); } @@ -101,9 +101,9 @@ const EditMeeting = ({ user }) => { - + diff --git a/frontend/src/pages/MeetingPreview.jsx b/frontend/src/pages/MeetingPreview.jsx index 3c59e8f..1a6b567 100644 --- a/frontend/src/pages/MeetingPreview.jsx +++ b/frontend/src/pages/MeetingPreview.jsx @@ -26,25 +26,45 @@ const MeetingPreview = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordRequired, setPasswordRequired] = useState(false); const [isAuthorized, setIsAuthorized] = useState(false); useEffect(() => { + setMeeting(null); + setError(null); + setPassword(''); + setPasswordError(''); + setPasswordRequired(false); + setIsAuthorized(false); fetchPreview(); }, [meeting_id]); - const fetchPreview = async (pwd = null) => { + const fetchPreview = async (pwd = '') => { setLoading(true); try { - const url = buildApiUrl(`/api/meetings/preview/${meeting_id}${pwd ? `?password=${pwd}` : ''}`); + const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id); + const url = buildApiUrl(`${endpoint}${pwd ? `?password=${encodeURIComponent(pwd)}` : ''}`); const res = await apiClient.get(url); setMeeting(res.data); setIsAuthorized(true); + setPasswordRequired(false); + setPasswordError(''); setError(null); } catch (err) { - if (err.response?.status === 401) { + const responseCode = String(err?.response?.data?.code || ''); + if (responseCode === '401') { + setMeeting(null); setIsAuthorized(false); + setPasswordRequired(true); + setPasswordError(err?.response?.data?.message || ''); + setError(null); } else { - setError('无法加载会议预览'); + setMeeting(null); + setIsAuthorized(false); + setPasswordRequired(false); + setPasswordError(''); + setError(err?.response?.data?.message || '无法加载会议预览'); } } finally { setLoading(false); @@ -61,7 +81,9 @@ const MeetingPreview = () => { if (loading && !meeting) return
; - if (!isAuthorized) { + if (error) return } />; + + if (passwordRequired && !isAuthorized) { return (
@@ -73,9 +95,16 @@ const MeetingPreview = () => { size="large" placeholder="访问密码" value={password} - onChange={e => setPassword(e.target.value)} + status={passwordError ? 'error' : undefined} + onChange={e => { + setPassword(e.target.value); + if (passwordError) { + setPasswordError(''); + } + }} onPressEnter={handleVerify} /> + {passwordError ? {passwordError} : null}
@@ -86,8 +115,6 @@ const MeetingPreview = () => { ); } - if (error) return } />; - return (
diff --git a/frontend/src/pages/admin/ModelManagement.jsx b/frontend/src/pages/admin/ModelManagement.jsx index 2abf685..b1b3175 100644 --- a/frontend/src/pages/admin/ModelManagement.jsx +++ b/frontend/src/pages/admin/ModelManagement.jsx @@ -15,6 +15,7 @@ import { Space, Switch, Table, + Tag, Tabs, } from 'antd'; import { DeleteOutlined, EditOutlined, ExperimentOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined } from '@ant-design/icons'; diff --git a/start-external.sh b/start-external.sh new file mode 100755 index 0000000..31a015f --- /dev/null +++ b/start-external.sh @@ -0,0 +1,217 @@ +#!/bin/bash + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_banner() { + echo -e "${BLUE}" + cat << "EOF" + _ __ __ _ _ + (_) \/ | ___ ___| |_(_)_ __ __ _ + | | |\/| |/ _ \/ _ \ __| | '_ \ / _` | + | | | | | __/ __/ |_| | | | | (_| | + |_|_| |_|\___|\___|\__|_|_| |_|\__, | + |___/ + External Middleware Deployment +EOF + echo -e "${NC}" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +check_dependencies() { + print_info "检查系统依赖..." + + if ! command -v docker &> /dev/null; then + print_error "未安装 Docker,请先安装 Docker" + exit 1 + fi + + if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + print_error "未安装 Docker Compose,请先安装 Docker Compose" + exit 1 + fi + + print_success "系统依赖检查通过" +} + +init_compose_cmd() { + if docker compose version &> /dev/null; then + COMPOSE_CMD="docker compose" + else + COMPOSE_CMD="docker-compose" + fi +} + +check_env_files() { + print_info "检查环境变量配置..." + + if [ ! -f .env ]; then + print_warning ".env 文件不存在,从模板创建..." + cp .env.example .env + print_warning "请编辑 .env 文件,配置访问端口、BASE_URL、QWEN_API_KEY 等参数" + print_warning "按任意键继续,或 Ctrl+C 退出..." + read -n 1 -s + fi + + if [ ! -f backend/.env ]; then + print_warning "backend/.env 文件不存在,从模板创建..." + cp backend/.env.example backend/.env + print_warning "请编辑 backend/.env 文件,配置外部数据库和 Redis 参数" + print_warning "按任意键继续,或 Ctrl+C 退出..." + read -n 1 -s + fi + + print_success "环境变量文件检查完成" +} + +load_external_env() { + print_info "加载外部数据库与 Redis 配置..." + + set -a + source backend/.env + set +a + + DB_PORT="${DB_PORT:-3306}" + REDIS_PORT="${REDIS_PORT:-6379}" + REDIS_DB="${REDIS_DB:-0}" + REDIS_PASSWORD="${REDIS_PASSWORD:-}" + + local required_vars=(DB_HOST DB_USER DB_PASSWORD DB_NAME REDIS_HOST) + for var_name in "${required_vars[@]}"; do + if [ -z "${!var_name}" ]; then + print_error "backend/.env 缺少必填配置: ${var_name}" + exit 1 + fi + done + + print_success "外部中间件配置已加载" +} + +create_directories() { + print_info "创建必要的目录..." + + mkdir -p data/uploads + mkdir -p data/logs/backend + mkdir -p data/logs/frontend + mkdir -p backups + + print_success "目录创建完成" +} + +create_override_file() { + OVERRIDE_FILE="$(mktemp /tmp/imeeting-external-compose.XXXXXX.yml)" + trap 'rm -f "$OVERRIDE_FILE"' EXIT + + cat > "$OVERRIDE_FILE" </dev/null | grep -c '"Health":"healthy"' || echo "0") + + if [ "$healthy_count" -eq 2 ]; then + print_success "前后端服务已就绪" + return 0 + fi + + echo -ne "\r等待中... (${waited}s/${max_wait}s) 健康: ${healthy_count}/2" + sleep 5 + waited=$((waited + 5)) + done + + echo "" + print_warning "服务启动超时,请手动检查状态" + return 1 +} + +show_status() { + print_info "服务状态:" + $COMPOSE_CMD ps backend frontend +} + +show_access_info() { + echo "" + print_success "===================================" + print_success " iMeeting 外部中间件模式部署完成!" + print_success "===================================" + echo "" + echo -e "${GREEN}访问地址:${NC}" + echo -e " HTTP访问: ${BLUE}http://localhost${NC}" + echo -e " API文档: ${BLUE}http://localhost/docs${NC}" + echo "" + echo -e "${YELLOW}当前模式:${NC}" + echo -e " 仅启动: ${BLUE}backend + frontend${NC}" + echo -e " 外部依赖: ${BLUE}backend/.env 中配置的 MySQL / Redis${NC}" + echo "" + echo -e "${YELLOW}常用命令:${NC}" + echo -e " 查看日志: ${BLUE}$COMPOSE_CMD logs -f backend frontend${NC}" + echo -e " 停止服务: ${BLUE}./stop-external.sh${NC}" + echo -e " 查看状态: ${BLUE}$COMPOSE_CMD ps backend frontend${NC}" + echo "" +} + +main() { + print_banner + check_dependencies + init_compose_cmd + check_env_files + load_external_env + create_directories + create_override_file + start_services + echo "" + wait_for_health + echo "" + show_status + show_access_info +} + +main "$@" diff --git a/stop-external.sh b/stop-external.sh new file mode 100755 index 0000000..d80d4e8 --- /dev/null +++ b/stop-external.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if docker compose version &> /dev/null; then + COMPOSE_CMD="docker compose" +else + COMPOSE_CMD="docker-compose" +fi + +print_info "当前应用服务状态:" +$COMPOSE_CMD ps backend frontend + +echo "" +echo -e "${YELLOW}是否保留应用容器?${NC}" +echo "1) 仅停止 backend/frontend(保留容器)" +echo "2) 停止并删除 backend/frontend 容器" +read -p "请选择 (1/2): " choice + +case $choice in + 1) + print_info "停止应用服务..." + $COMPOSE_CMD stop backend frontend + print_success "应用服务已停止,外部数据库和 Redis 未受影响" + ;; + 2) + print_info "停止并删除应用容器..." + $COMPOSE_CMD stop backend frontend + $COMPOSE_CMD rm -f backend frontend + print_success "应用容器已删除,外部数据库和 Redis 未受影响" + ;; + *) + print_warning "无效选择,仅停止应用服务" + $COMPOSE_CMD stop backend frontend + ;; +esac