2026-01-19 11:03:08 +00:00
|
|
|
|
from fastapi import APIRouter, UploadFile, File, Form, Depends, BackgroundTasks, Request, Header
|
|
|
|
|
|
from fastapi.responses import StreamingResponse, Response
|
|
|
|
|
|
from app.models.models import Meeting, TranscriptSegment, TranscriptionTaskStatus, CreateMeetingRequest, UpdateMeetingRequest, SpeakerTagUpdateRequest, BatchSpeakerTagUpdateRequest, BatchTranscriptUpdateRequest, Tag
|
|
|
|
|
|
from app.core.database import get_db_connection
|
|
|
|
|
|
from app.core.config import BASE_DIR, AUDIO_DIR, MARKDOWN_DIR, ALLOWED_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS
|
|
|
|
|
|
import app.core.config as config_module
|
|
|
|
|
|
from app.services.llm_service import LLMService
|
|
|
|
|
|
from app.services.async_transcription_service import AsyncTranscriptionService
|
|
|
|
|
|
from app.services.async_meeting_service import async_meeting_service
|
|
|
|
|
|
from app.services.audio_service import handle_audio_upload
|
|
|
|
|
|
from app.services.system_config_service import SystemConfigService
|
|
|
|
|
|
from app.utils.audio_parser import get_audio_duration
|
|
|
|
|
|
from app.core.auth import get_current_user, get_optional_current_user
|
|
|
|
|
|
from app.core.response import create_api_response
|
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
from urllib.parse import quote
|
|
|
|
|
|
import os
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import mimetypes
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
llm_service = LLMService()
|
|
|
|
|
|
transcription_service = AsyncTranscriptionService()
|
|
|
|
|
|
|
|
|
|
|
|
class GenerateSummaryRequest(BaseModel):
|
|
|
|
|
|
user_prompt: Optional[str] = ""
|
|
|
|
|
|
prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版
|
2026-03-26 06:55:12 +00:00
|
|
|
|
model_code: Optional[str] = None # LLM模型编码,如果不指定则使用默认模型
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not tag_string:
|
|
|
|
|
|
return []
|
|
|
|
|
|
tag_names = [name.strip() for name in tag_string.split(',') if name.strip()]
|
|
|
|
|
|
if not tag_names:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
# 如果提供了 creator_id,则创建不存在的标签
|
|
|
|
|
|
if creator_id:
|
|
|
|
|
|
insert_ignore_query = "INSERT IGNORE INTO tags (name, creator_id) VALUES (%s, %s)"
|
|
|
|
|
|
cursor.executemany(insert_ignore_query, [(name, creator_id) for name in tag_names])
|
|
|
|
|
|
|
|
|
|
|
|
# 查询所有标签信息
|
|
|
|
|
|
format_strings = ', '.join(['%s'] * len(tag_names))
|
|
|
|
|
|
cursor.execute(f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", tuple(tag_names))
|
|
|
|
|
|
tags_data = cursor.fetchall()
|
|
|
|
|
|
return [Tag(**tag) for tag in tags_data]
|
|
|
|
|
|
|
2026-03-27 07:43:08 +00:00
|
|
|
|
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]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
|
def _get_meeting_overall_status(meeting_id: int) -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取会议的整体进度状态(包含转译和LLM两个阶段)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
dict: {
|
|
|
|
|
|
"overall_status": "pending" | "transcribing" | "summarizing" | "completed" | "failed",
|
|
|
|
|
|
"overall_progress": 0-100,
|
|
|
|
|
|
"current_stage": "transcription" | "llm" | "completed",
|
|
|
|
|
|
"transcription": {status, progress, task_id, error_message, created_at},
|
|
|
|
|
|
"llm": {status, progress, task_id, error_message, created_at}
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 获取转译状态
|
|
|
|
|
|
transcription_status = transcription_service.get_meeting_transcription_status(meeting_id)
|
|
|
|
|
|
trans_data = {
|
|
|
|
|
|
"status": transcription_status.get('status', 'pending') if transcription_status else 'pending',
|
|
|
|
|
|
"progress": transcription_status.get('progress', 0) if transcription_status else 0,
|
|
|
|
|
|
"task_id": transcription_status.get('task_id') if transcription_status else None,
|
|
|
|
|
|
"error_message": transcription_status.get('error_message') if transcription_status else None,
|
|
|
|
|
|
"created_at": transcription_status.get('created_at') if transcription_status else None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 获取LLM状态
|
|
|
|
|
|
llm_status = async_meeting_service.get_meeting_llm_status(meeting_id)
|
|
|
|
|
|
llm_data = {
|
|
|
|
|
|
"status": llm_status.get('status', 'pending') if llm_status else 'pending',
|
|
|
|
|
|
"progress": llm_status.get('progress', 0) if llm_status else 0,
|
|
|
|
|
|
"task_id": llm_status.get('task_id') if llm_status else None,
|
|
|
|
|
|
"error_message": llm_status.get('error_message') if llm_status else None,
|
|
|
|
|
|
"created_at": llm_status.get('created_at') if llm_status else None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 计算整体状态和进度
|
|
|
|
|
|
trans_status_val = trans_data["status"]
|
|
|
|
|
|
llm_status_val = llm_data["status"]
|
|
|
|
|
|
|
|
|
|
|
|
# 判断是否有失败
|
|
|
|
|
|
if trans_status_val == 'failed':
|
|
|
|
|
|
overall_status = "failed"
|
|
|
|
|
|
current_stage = "transcription"
|
|
|
|
|
|
overall_progress = 0
|
|
|
|
|
|
elif llm_status_val == 'failed':
|
|
|
|
|
|
overall_status = "failed"
|
|
|
|
|
|
current_stage = "llm"
|
|
|
|
|
|
overall_progress = 50 # 转译已完成
|
|
|
|
|
|
# 判断当前阶段
|
|
|
|
|
|
elif trans_status_val == 'completed' and llm_status_val == 'completed':
|
|
|
|
|
|
overall_status = "completed"
|
|
|
|
|
|
current_stage = "completed"
|
|
|
|
|
|
overall_progress = 100
|
|
|
|
|
|
elif trans_status_val == 'completed':
|
|
|
|
|
|
# 转译完成,进入LLM阶段
|
|
|
|
|
|
if llm_status_val in ['pending', 'processing']:
|
|
|
|
|
|
overall_status = "summarizing"
|
|
|
|
|
|
current_stage = "llm"
|
|
|
|
|
|
overall_progress = 50 + int(llm_data["progress"] * 0.5)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# llm还未开始
|
|
|
|
|
|
overall_status = "summarizing"
|
|
|
|
|
|
current_stage = "llm"
|
|
|
|
|
|
overall_progress = 50
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 还在转译阶段
|
|
|
|
|
|
if trans_status_val in ['pending', 'processing']:
|
|
|
|
|
|
overall_status = "transcribing"
|
|
|
|
|
|
current_stage = "transcription"
|
|
|
|
|
|
overall_progress = int(trans_data["progress"] * 0.5)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 转译还未开始
|
|
|
|
|
|
overall_status = "pending"
|
|
|
|
|
|
current_stage = "transcription"
|
|
|
|
|
|
overall_progress = 0
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"overall_status": overall_status,
|
|
|
|
|
|
"overall_progress": overall_progress,
|
|
|
|
|
|
"current_stage": current_stage,
|
|
|
|
|
|
"transcription": trans_data,
|
|
|
|
|
|
"llm": llm_data
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings")
|
|
|
|
|
|
def get_meetings(
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
user_id: Optional[int] = None,
|
|
|
|
|
|
page: int = 1,
|
|
|
|
|
|
page_size: Optional[int] = None,
|
|
|
|
|
|
search: Optional[str] = None,
|
|
|
|
|
|
tags: Optional[str] = None,
|
|
|
|
|
|
filter_type: str = "all"
|
|
|
|
|
|
):
|
|
|
|
|
|
# 使用配置的默认页面大小
|
|
|
|
|
|
if page_size is None:
|
|
|
|
|
|
page_size = SystemConfigService.get_timeline_pagesize(default=10)
|
|
|
|
|
|
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建WHERE子句
|
|
|
|
|
|
where_conditions = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
# 用户过滤
|
|
|
|
|
|
if user_id:
|
|
|
|
|
|
# 需要联表查询参与者
|
|
|
|
|
|
has_attendees_join = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
has_attendees_join = False
|
|
|
|
|
|
|
|
|
|
|
|
# 按类型过滤 (created/attended/all)
|
|
|
|
|
|
if user_id:
|
|
|
|
|
|
if filter_type == "created":
|
|
|
|
|
|
where_conditions.append("m.user_id = %s")
|
|
|
|
|
|
params.append(user_id)
|
|
|
|
|
|
elif filter_type == "attended":
|
|
|
|
|
|
where_conditions.append("m.user_id != %s AND a.user_id = %s")
|
|
|
|
|
|
params.extend([user_id, user_id])
|
|
|
|
|
|
has_attendees_join = True
|
|
|
|
|
|
else: # all
|
|
|
|
|
|
where_conditions.append("(m.user_id = %s OR a.user_id = %s)")
|
|
|
|
|
|
params.extend([user_id, user_id])
|
|
|
|
|
|
has_attendees_join = True
|
|
|
|
|
|
|
|
|
|
|
|
# 搜索关键词过滤
|
|
|
|
|
|
if search and search.strip():
|
|
|
|
|
|
search_pattern = f"%{search.strip()}%"
|
|
|
|
|
|
where_conditions.append("(m.title LIKE %s OR u.caption LIKE %s)")
|
|
|
|
|
|
params.extend([search_pattern, search_pattern])
|
|
|
|
|
|
|
|
|
|
|
|
# 标签过滤
|
|
|
|
|
|
if tags and tags.strip():
|
|
|
|
|
|
tag_list = [t.strip() for t in tags.split(',') if t.strip()]
|
|
|
|
|
|
if tag_list:
|
|
|
|
|
|
# 使用JSON_CONTAINS或LIKE查询
|
|
|
|
|
|
tag_conditions = []
|
|
|
|
|
|
for tag in tag_list:
|
|
|
|
|
|
tag_conditions.append("m.tags LIKE %s")
|
|
|
|
|
|
params.append(f"%{tag}%")
|
|
|
|
|
|
where_conditions.append(f"({' OR '.join(tag_conditions)})")
|
|
|
|
|
|
|
|
|
|
|
|
# 构建基础查询
|
|
|
|
|
|
base_query = '''
|
|
|
|
|
|
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, m.access_password,
|
|
|
|
|
|
m.user_id as creator_id, u.caption as creator_username, MAX(af.file_path) as audio_file_path
|
|
|
|
|
|
FROM meetings m
|
2026-03-26 06:55:12 +00:00
|
|
|
|
JOIN sys_users u ON m.user_id = u.user_id
|
2026-01-19 11:03:08 +00:00
|
|
|
|
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
if has_attendees_join:
|
|
|
|
|
|
base_query += " LEFT JOIN attendees a ON m.meeting_id = a.meeting_id"
|
|
|
|
|
|
|
|
|
|
|
|
# 添加WHERE子句
|
|
|
|
|
|
if where_conditions:
|
|
|
|
|
|
base_query += f" WHERE {' AND '.join(where_conditions)}"
|
|
|
|
|
|
|
|
|
|
|
|
# 获取总数 - 需要在添加 GROUP BY 之前
|
|
|
|
|
|
count_base = base_query # 保存一份不含GROUP BY的查询
|
|
|
|
|
|
if has_attendees_join:
|
|
|
|
|
|
# 如果有联表,使用子查询计数
|
|
|
|
|
|
count_query = f"SELECT COUNT(DISTINCT m.meeting_id) as total {count_base[count_base.find('FROM'):]}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 没有联表,直接计数
|
|
|
|
|
|
count_query = f"SELECT COUNT(*) as total {count_base[count_base.find('FROM'):]}"
|
|
|
|
|
|
|
|
|
|
|
|
cursor.execute(count_query, params)
|
|
|
|
|
|
total = cursor.fetchone()['total']
|
|
|
|
|
|
|
|
|
|
|
|
# 添加GROUP BY(因为使用了MAX聚合函数,总是需要GROUP BY)
|
|
|
|
|
|
base_query += " GROUP BY m.meeting_id"
|
|
|
|
|
|
|
|
|
|
|
|
# 计算分页
|
|
|
|
|
|
total_pages = (total + page_size - 1) // page_size
|
|
|
|
|
|
has_more = page < total_pages
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
|
|
|
|
|
|
|
# 添加排序和分页
|
|
|
|
|
|
query = f"{base_query} ORDER BY m.meeting_time DESC, m.created_at DESC LIMIT %s OFFSET %s"
|
|
|
|
|
|
params.extend([page_size, offset])
|
|
|
|
|
|
|
|
|
|
|
|
cursor.execute(query, params)
|
|
|
|
|
|
meetings = cursor.fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
meeting_list = []
|
|
|
|
|
|
for meeting in meetings:
|
2026-03-26 06:55:12 +00:00
|
|
|
|
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
|
2026-01-19 11:03:08 +00:00
|
|
|
|
cursor.execute(attendees_query, (meeting['meeting_id'],))
|
|
|
|
|
|
attendees_data = cursor.fetchall()
|
|
|
|
|
|
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
|
|
|
|
|
tags_list = _process_tags(cursor, meeting.get('tags'))
|
2026-03-26 06:55:12 +00:00
|
|
|
|
progress_info = _get_meeting_overall_status(meeting['meeting_id'])
|
2026-01-19 11:03:08 +00:00
|
|
|
|
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'],
|
2026-03-27 07:43:08 +00:00
|
|
|
|
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,
|
2026-03-26 06:55:12 +00:00
|
|
|
|
access_password=meeting.get('access_password'),
|
|
|
|
|
|
overall_status=progress_info.get('overall_status'),
|
|
|
|
|
|
overall_progress=progress_info.get('overall_progress'),
|
|
|
|
|
|
current_stage=progress_info.get('current_stage'),
|
2026-01-19 11:03:08 +00:00
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
return create_api_response(code="200", message="获取会议列表成功", data={
|
|
|
|
|
|
"meetings": meeting_list,
|
|
|
|
|
|
"total": total,
|
|
|
|
|
|
"page": page,
|
|
|
|
|
|
"page_size": page_size,
|
|
|
|
|
|
"total_pages": total_pages,
|
|
|
|
|
|
"has_more": has_more
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/stats")
|
|
|
|
|
|
def get_meetings_stats(
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
user_id: Optional[int] = None
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取会议统计数据:全部会议、我创建的会议、我参加的会议数量
|
|
|
|
|
|
"""
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
|
|
|
|
|
|
if not user_id:
|
|
|
|
|
|
return create_api_response(code="400", message="user_id is required")
|
|
|
|
|
|
|
|
|
|
|
|
# 获取全部会议数量(创建的 + 参加的)
|
|
|
|
|
|
all_query = '''
|
|
|
|
|
|
SELECT COUNT(DISTINCT m.meeting_id) as count
|
|
|
|
|
|
FROM meetings m
|
|
|
|
|
|
LEFT JOIN attendees a ON m.meeting_id = a.meeting_id
|
|
|
|
|
|
WHERE m.user_id = %s OR a.user_id = %s
|
|
|
|
|
|
'''
|
|
|
|
|
|
cursor.execute(all_query, (user_id, user_id))
|
|
|
|
|
|
all_count = cursor.fetchone()['count']
|
|
|
|
|
|
|
|
|
|
|
|
# 获取我创建的会议数量
|
|
|
|
|
|
created_query = '''
|
|
|
|
|
|
SELECT COUNT(*) as count
|
|
|
|
|
|
FROM meetings m
|
|
|
|
|
|
WHERE m.user_id = %s
|
|
|
|
|
|
'''
|
|
|
|
|
|
cursor.execute(created_query, (user_id,))
|
|
|
|
|
|
created_count = cursor.fetchone()['count']
|
|
|
|
|
|
|
|
|
|
|
|
# 获取我参加的会议数量(不包括我创建的)
|
|
|
|
|
|
attended_query = '''
|
|
|
|
|
|
SELECT COUNT(DISTINCT a.meeting_id) as count
|
|
|
|
|
|
FROM attendees a
|
|
|
|
|
|
JOIN meetings m ON a.meeting_id = m.meeting_id
|
|
|
|
|
|
WHERE a.user_id = %s AND m.user_id != %s
|
|
|
|
|
|
'''
|
|
|
|
|
|
cursor.execute(attended_query, (user_id, user_id))
|
|
|
|
|
|
attended_count = cursor.fetchone()['count']
|
|
|
|
|
|
|
|
|
|
|
|
return create_api_response(code="200", message="获取会议统计成功", data={
|
|
|
|
|
|
"all_meetings": all_count,
|
|
|
|
|
|
"created_meetings": created_count,
|
|
|
|
|
|
"attended_meetings": attended_count
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}")
|
|
|
|
|
|
def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
query = '''
|
|
|
|
|
|
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags,
|
2026-03-27 07:43:08 +00:00
|
|
|
|
m.user_id as creator_id, u.caption as creator_username, m.prompt_id,
|
2026-01-22 07:23:28 +00:00
|
|
|
|
af.file_path as audio_file_path, af.duration as audio_duration,
|
|
|
|
|
|
p.name as prompt_name, m.access_password
|
|
|
|
|
|
FROM meetings m
|
2026-03-26 06:55:12 +00:00
|
|
|
|
JOIN sys_users u ON m.user_id = u.user_id
|
2026-01-22 07:23:28 +00:00
|
|
|
|
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
|
|
|
|
|
|
LEFT JOIN prompts p ON m.prompt_id = p.id
|
2026-01-19 11:03:08 +00:00
|
|
|
|
WHERE m.meeting_id = %s
|
|
|
|
|
|
'''
|
|
|
|
|
|
cursor.execute(query, (meeting_id,))
|
|
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
2026-03-26 06:55:12 +00:00
|
|
|
|
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
|
2026-01-19 11:03:08 +00:00
|
|
|
|
cursor.execute(attendees_query, (meeting['meeting_id'],))
|
|
|
|
|
|
attendees_data = cursor.fetchall()
|
|
|
|
|
|
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
|
|
|
|
|
tags = _process_tags(cursor, meeting.get('tags'))
|
|
|
|
|
|
cursor.close()
|
|
|
|
|
|
meeting_data = Meeting(
|
|
|
|
|
|
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
|
|
|
|
|
summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees,
|
2026-03-27 07:43:08 +00:00
|
|
|
|
attendee_ids=[row['user_id'] for row in attendees_data],
|
2026-01-19 11:03:08 +00:00
|
|
|
|
creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags,
|
2026-03-27 07:43:08 +00:00
|
|
|
|
prompt_id=meeting.get('prompt_id'),
|
2026-01-22 07:23:28 +00:00
|
|
|
|
prompt_name=meeting.get('prompt_name'),
|
2026-01-19 11:03:08 +00:00
|
|
|
|
access_password=meeting.get('access_password')
|
|
|
|
|
|
)
|
2026-01-22 07:23:28 +00:00
|
|
|
|
# 只有路径长度大于5(排除空串或占位符)才认为有录音
|
|
|
|
|
|
if meeting.get('audio_file_path') and len(meeting['audio_file_path']) > 5:
|
2026-01-19 11:03:08 +00:00
|
|
|
|
meeting_data.audio_file_path = meeting['audio_file_path']
|
2026-01-22 07:23:28 +00:00
|
|
|
|
meeting_data.audio_duration = meeting['audio_duration']
|
2026-01-19 11:03:08 +00:00
|
|
|
|
try:
|
|
|
|
|
|
transcription_status_data = transcription_service.get_meeting_transcription_status(meeting_id)
|
|
|
|
|
|
if transcription_status_data:
|
|
|
|
|
|
meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}")
|
|
|
|
|
|
return create_api_response(code="200", message="获取会议详情成功", data=meeting_data)
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/transcript")
|
|
|
|
|
|
def get_meeting_transcript(meeting_id: int, current_user: Optional[dict] = Depends(get_optional_current_user)):
|
|
|
|
|
|
"""获取会议转录内容(支持公开访问用于预览)"""
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
if not cursor.fetchone():
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
|
|
|
|
|
transcript_query = '''
|
|
|
|
|
|
SELECT segment_id, meeting_id, speaker_id, speaker_tag, start_time_ms, end_time_ms, text_content
|
|
|
|
|
|
FROM transcript_segments WHERE meeting_id = %s ORDER BY start_time_ms ASC
|
|
|
|
|
|
'''
|
|
|
|
|
|
cursor.execute(transcript_query, (meeting_id,))
|
|
|
|
|
|
segments = cursor.fetchall()
|
|
|
|
|
|
transcript_segments = [TranscriptSegment(
|
|
|
|
|
|
segment_id=s['segment_id'], meeting_id=s['meeting_id'], speaker_id=s['speaker_id'],
|
|
|
|
|
|
speaker_tag=s['speaker_tag'] if s['speaker_tag'] else f"发言人 {s['speaker_id']}",
|
|
|
|
|
|
start_time_ms=s['start_time_ms'], end_time_ms=s['end_time_ms'], text_content=s['text_content']
|
|
|
|
|
|
) for s in segments]
|
|
|
|
|
|
return create_api_response(code="200", message="获取转录内容成功", data=transcript_segments)
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/meetings")
|
|
|
|
|
|
def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
# 使用 _process_tags 来处理标签创建
|
|
|
|
|
|
if meeting_request.tags:
|
|
|
|
|
|
_process_tags(cursor, meeting_request.tags, current_user['user_id'])
|
2026-03-27 07:43:08 +00:00
|
|
|
|
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(),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-01-19 11:03:08 +00:00
|
|
|
|
meeting_id = cursor.lastrowid
|
2026-03-27 07:43:08 +00:00
|
|
|
|
_sync_attendees(cursor, meeting_id, meeting_request.attendee_ids)
|
2026-01-19 11:03:08 +00:00
|
|
|
|
connection.commit()
|
|
|
|
|
|
return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id})
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/meetings/{meeting_id}")
|
|
|
|
|
|
def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
2026-03-27 07:43:08 +00:00
|
|
|
|
cursor.execute("SELECT user_id, prompt_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
2026-01-19 11:03:08 +00:00
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
|
|
|
|
|
if meeting['user_id'] != current_user['user_id']:
|
|
|
|
|
|
return create_api_response(code="403", message="Permission denied")
|
|
|
|
|
|
# 使用 _process_tags 来处理标签创建
|
|
|
|
|
|
if meeting_request.tags:
|
|
|
|
|
|
_process_tags(cursor, meeting_request.tags, current_user['user_id'])
|
2026-03-27 07:43:08 +00:00
|
|
|
|
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)
|
2026-01-19 11:03:08 +00:00
|
|
|
|
connection.commit()
|
2026-03-26 06:55:12 +00:00
|
|
|
|
# 同步导出总结MD文件
|
|
|
|
|
|
if meeting_request.summary:
|
|
|
|
|
|
async_meeting_service._export_summary_md(meeting_id, meeting_request.summary)
|
2026-01-19 11:03:08 +00:00
|
|
|
|
return create_api_response(code="200", message="Meeting updated successfully")
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/meetings/{meeting_id}")
|
|
|
|
|
|
def delete_meeting(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
|
|
|
|
|
if meeting['user_id'] != current_user['user_id']:
|
|
|
|
|
|
return create_api_response(code="403", message="Permission denied")
|
|
|
|
|
|
cursor.execute("DELETE FROM transcript_segments WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
cursor.execute("DELETE FROM audio_files WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
cursor.execute("DELETE FROM attachments WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
cursor.execute("DELETE FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
connection.commit()
|
|
|
|
|
|
return create_api_response(code="200", message="Meeting deleted successfully")
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/edit")
|
|
|
|
|
|
def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
query = '''
|
|
|
|
|
|
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags,
|
2026-03-27 07:43:08 +00:00
|
|
|
|
m.user_id as creator_id, u.caption as creator_username, m.prompt_id, af.file_path as audio_file_path,
|
2026-01-19 11:03:08 +00:00
|
|
|
|
m.access_password
|
2026-03-26 06:55:12 +00:00
|
|
|
|
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
|
2026-01-19 11:03:08 +00:00
|
|
|
|
WHERE m.meeting_id = %s
|
|
|
|
|
|
'''
|
|
|
|
|
|
cursor.execute(query, (meeting_id,))
|
|
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
2026-03-26 06:55:12 +00:00
|
|
|
|
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
|
2026-01-19 11:03:08 +00:00
|
|
|
|
cursor.execute(attendees_query, (meeting['meeting_id'],))
|
|
|
|
|
|
attendees_data = cursor.fetchall()
|
|
|
|
|
|
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
|
|
|
|
|
tags = _process_tags(cursor, meeting.get('tags'))
|
|
|
|
|
|
cursor.close()
|
|
|
|
|
|
meeting_data = Meeting(
|
|
|
|
|
|
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
|
|
|
|
|
summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees,
|
2026-03-27 07:43:08 +00:00
|
|
|
|
attendee_ids=[row['user_id'] for row in attendees_data],
|
2026-01-19 11:03:08 +00:00
|
|
|
|
creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags,
|
2026-03-27 07:43:08 +00:00
|
|
|
|
prompt_id=meeting.get('prompt_id'),
|
2026-01-19 11:03:08 +00:00
|
|
|
|
access_password=meeting.get('access_password')
|
|
|
|
|
|
)
|
|
|
|
|
|
if meeting.get('audio_file_path'):
|
|
|
|
|
|
meeting_data.audio_file_path = meeting['audio_file_path']
|
|
|
|
|
|
try:
|
|
|
|
|
|
transcription_status_data = transcription_service.get_meeting_transcription_status(meeting_id)
|
|
|
|
|
|
if transcription_status_data:
|
|
|
|
|
|
meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}")
|
|
|
|
|
|
return create_api_response(code="200", message="获取会议编辑信息成功", data=meeting_data)
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/meetings/upload-audio")
|
|
|
|
|
|
async def upload_audio(
|
|
|
|
|
|
audio_file: UploadFile = File(...),
|
|
|
|
|
|
meeting_id: int = Form(...),
|
|
|
|
|
|
auto_summarize: str = Form("true"),
|
|
|
|
|
|
prompt_id: Optional[int] = Form(None), # 可选的提示词模版ID
|
2026-03-30 08:22:09 +00:00
|
|
|
|
model_code: Optional[str] = Form(None), # 可选的总结模型编码
|
2026-01-19 11:03:08 +00:00
|
|
|
|
background_tasks: BackgroundTasks = None,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user)
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
音频文件上传接口
|
|
|
|
|
|
|
|
|
|
|
|
上传音频文件并启动转录任务,可选择是否自动生成总结
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
audio_file: 音频文件
|
|
|
|
|
|
meeting_id: 会议ID
|
|
|
|
|
|
auto_summarize: 是否自动生成总结("true"/"false",默认"true")
|
|
|
|
|
|
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
2026-03-30 08:22:09 +00:00
|
|
|
|
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
2026-01-19 11:03:08 +00:00
|
|
|
|
background_tasks: FastAPI后台任务
|
|
|
|
|
|
current_user: 当前登录用户
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
HTTP 200: 处理成功,返回任务ID
|
|
|
|
|
|
HTTP 400/403/404/500: 各种错误情况
|
|
|
|
|
|
"""
|
|
|
|
|
|
auto_summarize_bool = auto_summarize.lower() in ("true", "1", "yes")
|
|
|
|
|
|
|
2026-03-30 08:22:09 +00:00
|
|
|
|
model_code = model_code.strip() if model_code else None
|
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
|
# 0. 如果没有传入 prompt_id,尝试获取默认模版ID
|
|
|
|
|
|
if prompt_id is None:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor()
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1"
|
|
|
|
|
|
)
|
|
|
|
|
|
prompt_id = cursor.fetchone()[0]
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 文件类型验证
|
|
|
|
|
|
file_extension = os.path.splitext(audio_file.filename)[1].lower()
|
|
|
|
|
|
if file_extension not in ALLOWED_EXTENSIONS:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="400",
|
|
|
|
|
|
message=f"不支持的文件类型。支持的类型: {', '.join(ALLOWED_EXTENSIONS)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 文件大小验证
|
|
|
|
|
|
max_file_size = SystemConfigService.get_max_audio_size(default=100) * 1024 * 1024 # MB转字节
|
|
|
|
|
|
if audio_file.size > max_file_size:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="400",
|
|
|
|
|
|
message=f"文件大小超过 {max_file_size // (1024 * 1024)}MB 限制"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 保存音频文件到磁盘
|
|
|
|
|
|
meeting_dir = AUDIO_DIR / str(meeting_id)
|
|
|
|
|
|
meeting_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
|
|
|
|
|
absolute_path = meeting_dir / unique_filename
|
|
|
|
|
|
relative_path = absolute_path.relative_to(BASE_DIR)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(absolute_path, "wb") as buffer:
|
|
|
|
|
|
shutil.copyfileobj(audio_file.file, buffer)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"保存文件失败: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
# 3.5 获取音频时长
|
|
|
|
|
|
audio_duration = 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
audio_duration = get_audio_duration(str(absolute_path))
|
|
|
|
|
|
print(f"音频时长: {audio_duration}秒")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"警告: 获取音频时长失败,但不影响后续流程: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
file_path = '/' + str(relative_path)
|
|
|
|
|
|
file_name = audio_file.filename
|
|
|
|
|
|
file_size = audio_file.size
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 调用 audio_service 处理文件(权限检查、数据库更新、启动转录)
|
|
|
|
|
|
result = handle_audio_upload(
|
|
|
|
|
|
file_path=file_path,
|
|
|
|
|
|
file_name=file_name,
|
|
|
|
|
|
file_size=file_size,
|
|
|
|
|
|
meeting_id=meeting_id,
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
auto_summarize=auto_summarize_bool,
|
|
|
|
|
|
background_tasks=background_tasks,
|
|
|
|
|
|
prompt_id=prompt_id,
|
2026-03-30 08:22:09 +00:00
|
|
|
|
model_code=model_code,
|
2026-01-19 11:03:08 +00:00
|
|
|
|
duration=audio_duration # 传递时长参数
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 如果不成功,删除已保存的文件并返回错误
|
|
|
|
|
|
if not result["success"]:
|
|
|
|
|
|
if absolute_path.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.remove(absolute_path)
|
|
|
|
|
|
print(f"Deleted file due to processing error: {absolute_path}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"Warning: Failed to delete file {absolute_path}: {e}")
|
|
|
|
|
|
return result["response"]
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 返回成功响应
|
|
|
|
|
|
transcription_task_id = result["transcription_task_id"]
|
|
|
|
|
|
message_suffix = ""
|
|
|
|
|
|
if transcription_task_id:
|
|
|
|
|
|
if auto_summarize_bool:
|
|
|
|
|
|
message_suffix = ",正在进行转录和总结"
|
|
|
|
|
|
else:
|
|
|
|
|
|
message_suffix = ",正在进行转录"
|
|
|
|
|
|
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="200",
|
|
|
|
|
|
message="Audio file uploaded successfully" +
|
|
|
|
|
|
(" and replaced existing file" if result["replaced_existing"] else "") +
|
|
|
|
|
|
message_suffix,
|
|
|
|
|
|
data={
|
|
|
|
|
|
"file_name": result["file_info"]["file_name"],
|
|
|
|
|
|
"file_path": result["file_info"]["file_path"],
|
|
|
|
|
|
"task_id": transcription_task_id,
|
|
|
|
|
|
"transcription_started": transcription_task_id is not None,
|
|
|
|
|
|
"auto_summarize": auto_summarize_bool,
|
2026-03-30 08:22:09 +00:00
|
|
|
|
"model_code": model_code,
|
2026-01-19 11:03:08 +00:00
|
|
|
|
"replaced_existing": result["replaced_existing"],
|
|
|
|
|
|
"previous_transcription_cleared": result["replaced_existing"] and result["has_transcription"]
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/audio")
|
|
|
|
|
|
def get_audio_file(meeting_id: int, current_user: Optional[dict] = Depends(get_optional_current_user)):
|
|
|
|
|
|
"""获取音频文件信息(支持公开访问用于预览)"""
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT file_name, file_path, file_size, upload_time FROM audio_files WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
audio_file = cursor.fetchone()
|
|
|
|
|
|
if not audio_file:
|
|
|
|
|
|
return create_api_response(code="404", message="Audio file not found for this meeting")
|
|
|
|
|
|
return create_api_response(code="200", message="Audio file found", data=audio_file)
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/audio/stream")
|
|
|
|
|
|
async def stream_audio_file(
|
|
|
|
|
|
meeting_id: int,
|
|
|
|
|
|
range: Optional[str] = Header(None, alias="Range")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
音频文件流式传输端点,支持HTTP Range请求(Safari浏览器必需)
|
|
|
|
|
|
无需登录认证,用于前端audio标签直接访问
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 获取音频文件信息
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT file_name, file_path, file_size FROM audio_files WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
audio_file = cursor.fetchone()
|
|
|
|
|
|
if not audio_file:
|
|
|
|
|
|
return Response(content="Audio file not found", status_code=404)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建完整文件路径
|
|
|
|
|
|
file_path = BASE_DIR / audio_file['file_path'].lstrip('/')
|
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
|
return Response(content="Audio file not found on disk", status_code=404)
|
|
|
|
|
|
|
|
|
|
|
|
# 总是使用实际文件大小(不依赖数据库记录,防止文件被优化后大小不匹配)
|
|
|
|
|
|
file_size = os.path.getsize(file_path)
|
|
|
|
|
|
file_name = audio_file['file_name']
|
|
|
|
|
|
|
|
|
|
|
|
# 根据文件扩展名确定MIME类型
|
|
|
|
|
|
extension = os.path.splitext(file_name)[1].lower()
|
|
|
|
|
|
mime_types = {
|
|
|
|
|
|
'.mp3': 'audio/mpeg',
|
|
|
|
|
|
'.m4a': 'audio/mp4', # 标准 MIME type,Safari 兼容
|
|
|
|
|
|
'.wav': 'audio/wav',
|
|
|
|
|
|
'.mpeg': 'audio/mpeg',
|
|
|
|
|
|
'.mp4': 'audio/mp4',
|
|
|
|
|
|
'.webm': 'audio/webm'
|
|
|
|
|
|
}
|
|
|
|
|
|
content_type = mime_types.get(extension, 'audio/mpeg')
|
|
|
|
|
|
|
|
|
|
|
|
# 处理Range请求
|
|
|
|
|
|
start = 0
|
|
|
|
|
|
end = file_size - 1
|
|
|
|
|
|
|
|
|
|
|
|
if range:
|
|
|
|
|
|
# 解析Range头: "bytes=start-end" 或 "bytes=start-"
|
|
|
|
|
|
try:
|
|
|
|
|
|
range_spec = range.replace("bytes=", "")
|
|
|
|
|
|
if "-" in range_spec:
|
|
|
|
|
|
parts = range_spec.split("-")
|
|
|
|
|
|
if parts[0]:
|
|
|
|
|
|
start = int(parts[0])
|
|
|
|
|
|
if parts[1]:
|
|
|
|
|
|
end = int(parts[1])
|
|
|
|
|
|
except (ValueError, IndexError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# 确保范围有效
|
|
|
|
|
|
if start >= file_size:
|
|
|
|
|
|
return Response(
|
|
|
|
|
|
content="Range Not Satisfiable",
|
|
|
|
|
|
status_code=416,
|
|
|
|
|
|
headers={"Content-Range": f"bytes */{file_size}"}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
end = min(end, file_size - 1)
|
|
|
|
|
|
content_length = end - start + 1
|
|
|
|
|
|
|
|
|
|
|
|
# 对所有文件名统一使用RFC 5987标准的URL编码格式
|
|
|
|
|
|
# 这样可以正确处理中文、特殊字符等所有情况
|
|
|
|
|
|
encoded_filename = quote(file_name)
|
|
|
|
|
|
filename_header = f"inline; filename*=UTF-8''{encoded_filename}"
|
|
|
|
|
|
|
|
|
|
|
|
# 生成器函数用于流式读取文件
|
|
|
|
|
|
def iter_file():
|
|
|
|
|
|
with open(file_path, 'rb') as f:
|
|
|
|
|
|
f.seek(start)
|
|
|
|
|
|
remaining = content_length
|
|
|
|
|
|
chunk_size = 64 * 1024 # 64KB chunks
|
|
|
|
|
|
while remaining > 0:
|
|
|
|
|
|
read_size = min(chunk_size, remaining)
|
|
|
|
|
|
data = f.read(read_size)
|
|
|
|
|
|
if not data:
|
|
|
|
|
|
break
|
|
|
|
|
|
remaining -= len(data)
|
|
|
|
|
|
yield data
|
|
|
|
|
|
|
|
|
|
|
|
# 根据是否有Range请求返回不同的响应
|
|
|
|
|
|
if range:
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
iter_file(),
|
|
|
|
|
|
status_code=206, # Partial Content
|
|
|
|
|
|
media_type=content_type,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
|
|
|
|
|
"Accept-Ranges": "bytes",
|
|
|
|
|
|
"Content-Length": str(content_length),
|
|
|
|
|
|
"Content-Disposition": filename_header,
|
|
|
|
|
|
"Cache-Control": "public, max-age=31536000", # 1年缓存
|
|
|
|
|
|
"X-Content-Type-Options": "nosniff"
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
iter_file(),
|
|
|
|
|
|
status_code=200,
|
|
|
|
|
|
media_type=content_type,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Accept-Ranges": "bytes",
|
|
|
|
|
|
"Content-Length": str(file_size),
|
|
|
|
|
|
"Content-Disposition": filename_header,
|
|
|
|
|
|
"Cache-Control": "public, max-age=31536000", # 1年缓存
|
|
|
|
|
|
"X-Content-Type-Options": "nosniff"
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/transcription/status")
|
|
|
|
|
|
def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
status_info = transcription_service.get_meeting_transcription_status(meeting_id)
|
|
|
|
|
|
if not status_info:
|
|
|
|
|
|
return create_api_response(code="404", message="No transcription task found for this meeting")
|
|
|
|
|
|
return create_api_response(code="200", message="Transcription status retrieved", data=status_info)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to get meeting transcription status: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/meetings/{meeting_id}/transcription/start")
|
|
|
|
|
|
def start_meeting_transcription(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
if not cursor.fetchone():
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
|
|
|
|
|
cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,))
|
|
|
|
|
|
audio_file = cursor.fetchone()
|
|
|
|
|
|
if not audio_file:
|
|
|
|
|
|
return create_api_response(code="400", message="No audio file found for this meeting")
|
|
|
|
|
|
existing_status = transcription_service.get_meeting_transcription_status(meeting_id)
|
|
|
|
|
|
if existing_status and existing_status['status'] in ['pending', 'processing']:
|
|
|
|
|
|
return create_api_response(code="409", message="Transcription task already exists", data={
|
|
|
|
|
|
"task_id": existing_status['task_id'], "status": existing_status['status']
|
|
|
|
|
|
})
|
|
|
|
|
|
task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path'])
|
|
|
|
|
|
return create_api_response(code="200", message="Transcription task started successfully", data={
|
|
|
|
|
|
"task_id": task_id, "meeting_id": meeting_id
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to start transcription: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/meetings/{meeting_id}/upload-image")
|
|
|
|
|
|
async def upload_image(meeting_id: int, image_file: UploadFile = File(...), current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
file_extension = os.path.splitext(image_file.filename)[1].lower()
|
|
|
|
|
|
if file_extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
|
|
|
|
return create_api_response(code="400", message=f"Unsupported image type. Allowed types: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}")
|
|
|
|
|
|
max_image_size = getattr(config_module, 'MAX_IMAGE_SIZE', 10 * 1024 * 1024)
|
|
|
|
|
|
if image_file.size > max_image_size:
|
|
|
|
|
|
return create_api_response(code="400", message=f"Image size exceeds {max_image_size // (1024 * 1024)}MB limit")
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
|
|
|
|
|
if meeting['user_id'] != current_user['user_id']:
|
|
|
|
|
|
return create_api_response(code="403", message="Permission denied")
|
|
|
|
|
|
meeting_dir = MARKDOWN_DIR / str(meeting_id)
|
|
|
|
|
|
meeting_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
|
|
|
|
|
absolute_path = meeting_dir / unique_filename
|
|
|
|
|
|
relative_path = absolute_path.relative_to(BASE_DIR)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(absolute_path, "wb") as buffer:
|
|
|
|
|
|
shutil.copyfileobj(image_file.file, buffer)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to save image: {str(e)}")
|
|
|
|
|
|
return create_api_response(code="200", message="Image uploaded successfully", data={
|
|
|
|
|
|
"file_name": image_file.filename, "file_path": '/'+ str(relative_path)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/meetings/{meeting_id}/speaker-tags")
|
|
|
|
|
|
def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor()
|
|
|
|
|
|
update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s'
|
|
|
|
|
|
cursor.execute(update_query, (request.new_tag, meeting_id, request.speaker_id))
|
|
|
|
|
|
if cursor.rowcount == 0:
|
|
|
|
|
|
return create_api_response(code="404", message="No segments found for this speaker")
|
|
|
|
|
|
connection.commit()
|
|
|
|
|
|
return create_api_response(code="200", message="Speaker tag updated successfully", data={'updated_count': cursor.rowcount})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to update speaker tag: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/meetings/{meeting_id}/speaker-tags/batch")
|
|
|
|
|
|
def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor()
|
|
|
|
|
|
total_updated = 0
|
|
|
|
|
|
for update_item in request.updates:
|
|
|
|
|
|
update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s'
|
|
|
|
|
|
cursor.execute(update_query, (update_item.new_tag, meeting_id, update_item.speaker_id))
|
|
|
|
|
|
total_updated += cursor.rowcount
|
|
|
|
|
|
connection.commit()
|
|
|
|
|
|
return create_api_response(code="200", message="Speaker tags updated successfully", data={'total_updated': total_updated})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to batch update speaker tags: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/meetings/{meeting_id}/transcript/batch")
|
|
|
|
|
|
def batch_update_transcript(meeting_id: int, request: BatchTranscriptUpdateRequest, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor()
|
|
|
|
|
|
total_updated = 0
|
|
|
|
|
|
for update_item in request.updates:
|
|
|
|
|
|
cursor.execute("SELECT segment_id FROM transcript_segments WHERE segment_id = %s AND meeting_id = %s", (update_item.segment_id, meeting_id))
|
|
|
|
|
|
if not cursor.fetchone():
|
|
|
|
|
|
continue
|
|
|
|
|
|
update_query = 'UPDATE transcript_segments SET text_content = %s WHERE segment_id = %s AND meeting_id = %s'
|
|
|
|
|
|
cursor.execute(update_query, (update_item.text_content, update_item.segment_id, meeting_id))
|
|
|
|
|
|
total_updated += cursor.rowcount
|
|
|
|
|
|
connection.commit()
|
|
|
|
|
|
return create_api_response(code="200", message="Transcript updated successfully", data={'total_updated': total_updated})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to update transcript: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/summaries")
|
|
|
|
|
|
def get_meeting_summaries(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
if not cursor.fetchone():
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
|
|
|
|
|
summaries = llm_service.get_meeting_summaries(meeting_id)
|
|
|
|
|
|
return create_api_response(code="200", message="Summaries retrieved successfully", data={"summaries": summaries})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to get summaries: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/summaries/{summary_id}")
|
|
|
|
|
|
def get_summary_detail(meeting_id: int, summary_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
query = "SELECT id, summary_content, user_prompt, created_at FROM meeting_summaries WHERE id = %s AND meeting_id = %s"
|
|
|
|
|
|
cursor.execute(query, (summary_id, meeting_id))
|
|
|
|
|
|
summary = cursor.fetchone()
|
|
|
|
|
|
if not summary:
|
|
|
|
|
|
return create_api_response(code="404", message="Summary not found")
|
|
|
|
|
|
return create_api_response(code="200", message="Summary detail retrieved", data=summary)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to get summary detail: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/meetings/{meeting_id}/generate-summary-async")
|
|
|
|
|
|
def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
if not cursor.fetchone():
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
2026-03-26 06:55:12 +00:00
|
|
|
|
# 传递 prompt_id 和 model_code 参数给服务层
|
|
|
|
|
|
task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id, request.model_code)
|
2026-01-19 11:03:08 +00:00
|
|
|
|
background_tasks.add_task(async_meeting_service._process_task, task_id)
|
|
|
|
|
|
return create_api_response(code="200", message="Summary generation task has been accepted.", data={
|
|
|
|
|
|
"task_id": task_id, "status": "pending", "meeting_id": meeting_id
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to start summary generation: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/llm-tasks")
|
|
|
|
|
|
def get_meeting_llm_tasks(meeting_id: int, current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
|
|
|
|
|
if not cursor.fetchone():
|
|
|
|
|
|
return create_api_response(code="404", message="Meeting not found")
|
|
|
|
|
|
tasks = async_meeting_service.get_meeting_llm_tasks(meeting_id)
|
|
|
|
|
|
return create_api_response(code="200", message="LLM tasks retrieved successfully", data={
|
|
|
|
|
|
"tasks": tasks, "total": len(tasks)
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to get LLM tasks: {str(e)}")
|
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
|
@router.get("/llm-models/active")
|
|
|
|
|
|
def list_active_llm_models(current_user: dict = Depends(get_current_user)):
|
|
|
|
|
|
"""获取所有激活的LLM模型列表(供普通用户选择)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"SELECT model_code, model_name, provider, is_default FROM llm_model_config WHERE is_active = 1 ORDER BY is_default DESC, model_code ASC"
|
|
|
|
|
|
)
|
|
|
|
|
|
models = cursor.fetchall()
|
2026-03-30 08:22:09 +00:00
|
|
|
|
return create_api_response(code="200", message="获取模型列表成功", data=models)
|
2026-03-26 06:55:12 +00:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}")
|
2026-01-19 11:03:08 +00:00
|
|
|
|
@router.get("/meetings/{meeting_id}/navigation")
|
|
|
|
|
|
def get_meeting_navigation(
|
|
|
|
|
|
meeting_id: int,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user),
|
|
|
|
|
|
user_id: Optional[int] = None,
|
|
|
|
|
|
filter_type: str = "all",
|
|
|
|
|
|
search: Optional[str] = None,
|
|
|
|
|
|
tags: Optional[str] = None
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取当前会议在列表中的上一条和下一条
|
|
|
|
|
|
|
|
|
|
|
|
Query params:
|
|
|
|
|
|
- user_id: 当前用户ID
|
|
|
|
|
|
- filter_type: 筛选类型 ('all', 'created', 'attended')
|
|
|
|
|
|
- search: 搜索关键词 (可选)
|
|
|
|
|
|
- tags: 标签列表,逗号分隔 (可选)
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建WHERE子句 - 与get_meetings保持一致
|
|
|
|
|
|
where_conditions = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
has_attendees_join = False
|
|
|
|
|
|
|
|
|
|
|
|
# 按类型过滤
|
|
|
|
|
|
if user_id:
|
|
|
|
|
|
if filter_type == "created":
|
|
|
|
|
|
where_conditions.append("m.user_id = %s")
|
|
|
|
|
|
params.append(user_id)
|
|
|
|
|
|
elif filter_type == "attended":
|
|
|
|
|
|
where_conditions.append("m.user_id != %s AND a.user_id = %s")
|
|
|
|
|
|
params.extend([user_id, user_id])
|
|
|
|
|
|
has_attendees_join = True
|
|
|
|
|
|
else: # all
|
|
|
|
|
|
where_conditions.append("(m.user_id = %s OR a.user_id = %s)")
|
|
|
|
|
|
params.extend([user_id, user_id])
|
|
|
|
|
|
has_attendees_join = True
|
|
|
|
|
|
|
|
|
|
|
|
# 搜索关键词过滤
|
|
|
|
|
|
if search and search.strip():
|
|
|
|
|
|
search_pattern = f"%{search.strip()}%"
|
|
|
|
|
|
where_conditions.append("(m.title LIKE %s OR u.caption LIKE %s)")
|
|
|
|
|
|
params.extend([search_pattern, search_pattern])
|
|
|
|
|
|
|
|
|
|
|
|
# 标签过滤
|
|
|
|
|
|
if tags and tags.strip():
|
|
|
|
|
|
tag_list = [t.strip() for t in tags.split(',') if t.strip()]
|
|
|
|
|
|
if tag_list:
|
|
|
|
|
|
tag_conditions = []
|
|
|
|
|
|
for tag in tag_list:
|
|
|
|
|
|
tag_conditions.append("m.tags LIKE %s")
|
|
|
|
|
|
params.append(f"%{tag}%")
|
|
|
|
|
|
where_conditions.append(f"({' OR '.join(tag_conditions)})")
|
|
|
|
|
|
|
|
|
|
|
|
# 构建查询 - 只获取meeting_id,按meeting_time降序排序
|
|
|
|
|
|
query = '''
|
|
|
|
|
|
SELECT m.meeting_id
|
|
|
|
|
|
FROM meetings m
|
2026-03-26 06:55:12 +00:00
|
|
|
|
JOIN sys_users u ON m.user_id = u.user_id
|
2026-01-19 11:03:08 +00:00
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
if has_attendees_join:
|
|
|
|
|
|
query += " LEFT JOIN attendees a ON m.meeting_id = a.meeting_id"
|
|
|
|
|
|
|
|
|
|
|
|
if where_conditions:
|
|
|
|
|
|
query += f" WHERE {' AND '.join(where_conditions)}"
|
|
|
|
|
|
|
|
|
|
|
|
if has_attendees_join:
|
|
|
|
|
|
query += " GROUP BY m.meeting_id"
|
|
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY m.meeting_time DESC, m.created_at DESC"
|
|
|
|
|
|
|
|
|
|
|
|
cursor.execute(query, params)
|
|
|
|
|
|
all_meetings = cursor.fetchall()
|
|
|
|
|
|
all_meeting_ids = [m['meeting_id'] for m in all_meetings]
|
|
|
|
|
|
|
|
|
|
|
|
# 找到当前会议在列表中的位置
|
|
|
|
|
|
try:
|
|
|
|
|
|
current_index = all_meeting_ids.index(meeting_id)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return create_api_response(code="200", message="当前会议不在筛选结果中", data={
|
|
|
|
|
|
'prev_meeting_id': None,
|
|
|
|
|
|
'next_meeting_id': None,
|
|
|
|
|
|
'current_index': None,
|
|
|
|
|
|
'total_count': len(all_meeting_ids)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 计算上一条和下一条
|
|
|
|
|
|
prev_meeting_id = all_meeting_ids[current_index - 1] if current_index > 0 else None
|
|
|
|
|
|
next_meeting_id = all_meeting_ids[current_index + 1] if current_index < len(all_meeting_ids) - 1 else None
|
|
|
|
|
|
|
|
|
|
|
|
return create_api_response(code="200", message="获取导航信息成功", data={
|
|
|
|
|
|
'prev_meeting_id': prev_meeting_id,
|
|
|
|
|
|
'next_meeting_id': next_meeting_id,
|
|
|
|
|
|
'current_index': current_index,
|
|
|
|
|
|
'total_count': len(all_meeting_ids)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"获取导航信息失败: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/preview-data")
|
2026-03-27 07:43:08 +00:00
|
|
|
|
def get_meeting_preview_data(meeting_id: int, password: Optional[str] = None):
|
2026-01-19 11:03:08 +00:00
|
|
|
|
"""
|
|
|
|
|
|
获取会议预览数据(无需登录认证)
|
|
|
|
|
|
用于二维码扫描后的预览页面
|
|
|
|
|
|
|
|
|
|
|
|
返回状态码说明:
|
|
|
|
|
|
- 200: 会议已完成(summary已生成)
|
|
|
|
|
|
- 400: 会议处理中(转译或总结阶段)
|
|
|
|
|
|
- 503: 处理失败(转译或总结失败)
|
|
|
|
|
|
- 504: 数据异常(流程完成但summary未生成)
|
|
|
|
|
|
- 404: 会议不存在
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
|
|
|
|
|
|
# 检查会议是否存在,并获取基本信息
|
|
|
|
|
|
query = '''
|
2026-04-01 08:36:52 +00:00
|
|
|
|
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id, m.tags,
|
2026-01-19 11:03:08 +00:00
|
|
|
|
m.user_id as creator_id, u.caption as creator_username,
|
|
|
|
|
|
p.name as prompt_name, m.access_password
|
|
|
|
|
|
FROM meetings m
|
2026-03-26 06:55:12 +00:00
|
|
|
|
JOIN sys_users u ON m.user_id = u.user_id
|
2026-01-19 11:03:08 +00:00
|
|
|
|
LEFT JOIN prompts p ON m.prompt_id = p.id
|
|
|
|
|
|
WHERE m.meeting_id = %s
|
|
|
|
|
|
'''
|
|
|
|
|
|
cursor.execute(query, (meeting_id,))
|
|
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="会议不存在")
|
|
|
|
|
|
|
2026-03-27 07:43:08 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-19 11:03:08 +00:00
|
|
|
|
# 获取整体进度状态(两阶段)
|
|
|
|
|
|
progress_info = _get_meeting_overall_status(meeting_id)
|
|
|
|
|
|
overall_status = progress_info["overall_status"]
|
|
|
|
|
|
|
|
|
|
|
|
# 根据整体状态返回不同响应
|
|
|
|
|
|
|
|
|
|
|
|
# 情况1: 任一阶段失败 → 返回503
|
|
|
|
|
|
if overall_status == "failed":
|
|
|
|
|
|
failed_stage = progress_info["current_stage"]
|
|
|
|
|
|
error_info = progress_info["transcription"] if failed_stage == "transcription" else progress_info["llm"]
|
|
|
|
|
|
error_message = error_info["error_message"] or "处理失败"
|
|
|
|
|
|
|
|
|
|
|
|
stage_name = "转译" if failed_stage == "transcription" else "总结"
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="503",
|
|
|
|
|
|
message=f"会议{stage_name}失败: {error_message}",
|
|
|
|
|
|
data={
|
|
|
|
|
|
"meeting_id": meeting_id,
|
|
|
|
|
|
"title": meeting['title'],
|
|
|
|
|
|
"processing_status": progress_info
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 情况2: 处理中(转译或总结阶段)→ 返回400
|
|
|
|
|
|
if overall_status in ["pending", "transcribing", "summarizing"]:
|
|
|
|
|
|
stage_descriptions = {
|
|
|
|
|
|
"pending": "等待开始",
|
|
|
|
|
|
"transcribing": "正在转译音频",
|
|
|
|
|
|
"summarizing": "正在生成总结"
|
|
|
|
|
|
}
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="400",
|
|
|
|
|
|
message=f"会议正在处理中: {stage_descriptions[overall_status]}",
|
|
|
|
|
|
data={
|
|
|
|
|
|
"meeting_id": meeting_id,
|
|
|
|
|
|
"title": meeting['title'],
|
|
|
|
|
|
"processing_status": progress_info
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 情况3: 全部完成但Summary缺失 → 返回504
|
|
|
|
|
|
if overall_status == "completed" and not meeting['summary']:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="504",
|
|
|
|
|
|
message="处理已完成,AI总结尚未同步,请稍后重试",
|
|
|
|
|
|
data={
|
|
|
|
|
|
"meeting_id": meeting_id,
|
|
|
|
|
|
"title": meeting['title'],
|
|
|
|
|
|
"processing_status": progress_info
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 情况4: 全部完成 → 返回200,提供完整预览数据
|
|
|
|
|
|
if overall_status == "completed" and meeting['summary']:
|
|
|
|
|
|
# 获取参会人员信息
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
2026-03-26 06:55:12 +00:00
|
|
|
|
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
|
2026-01-19 11:03:08 +00:00
|
|
|
|
cursor.execute(attendees_query, (meeting_id,))
|
|
|
|
|
|
attendees_data = cursor.fetchall()
|
|
|
|
|
|
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
2026-04-01 09:41:35 +00:00
|
|
|
|
cursor.execute(
|
|
|
|
|
|
'''
|
|
|
|
|
|
SELECT COUNT(DISTINCT speaker_id) AS participant_count
|
|
|
|
|
|
FROM transcript_segments
|
|
|
|
|
|
WHERE meeting_id = %s AND speaker_id IS NOT NULL
|
|
|
|
|
|
''',
|
|
|
|
|
|
(meeting_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
speaker_count_row = cursor.fetchone() or {}
|
|
|
|
|
|
participant_count = speaker_count_row.get('participant_count') or len(attendees)
|
2026-04-01 08:36:52 +00:00
|
|
|
|
tags = _process_tags(cursor, meeting.get('tags'))
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
# 组装返回数据
|
|
|
|
|
|
preview_data = {
|
|
|
|
|
|
"meeting_id": meeting['meeting_id'],
|
|
|
|
|
|
"title": meeting['title'],
|
|
|
|
|
|
"meeting_time": meeting['meeting_time'],
|
|
|
|
|
|
"summary": meeting['summary'],
|
|
|
|
|
|
"creator_username": meeting['creator_username'],
|
|
|
|
|
|
"prompt_id": meeting['prompt_id'],
|
|
|
|
|
|
"prompt_name": meeting['prompt_name'],
|
|
|
|
|
|
"attendees": attendees,
|
2026-04-01 09:41:35 +00:00
|
|
|
|
"attendees_count": participant_count,
|
2026-04-01 08:36:52 +00:00
|
|
|
|
"tags": tags,
|
2026-01-19 11:03:08 +00:00
|
|
|
|
"has_password": bool(meeting.get('access_password')),
|
|
|
|
|
|
"processing_status": progress_info # 附带进度信息供调试
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return create_api_response(code="200", message="获取会议预览数据成功", data=preview_data)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(code="500", message=f"Failed to get meeting preview data: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
# 访问密码管理相关API
|
|
|
|
|
|
|
|
|
|
|
|
class AccessPasswordRequest(BaseModel):
|
|
|
|
|
|
password: Optional[str] = None # None表示关闭密码
|
|
|
|
|
|
|
|
|
|
|
|
class VerifyPasswordRequest(BaseModel):
|
|
|
|
|
|
password: str
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/meetings/{meeting_id}/access-password")
|
|
|
|
|
|
def update_meeting_access_password(
|
|
|
|
|
|
meeting_id: int,
|
|
|
|
|
|
request: AccessPasswordRequest,
|
|
|
|
|
|
current_user: dict = Depends(get_current_user)
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
设置或关闭会议访问密码(仅创建人可操作)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
meeting_id: 会议ID
|
|
|
|
|
|
request.password: 密码字符串(None表示关闭密码)
|
|
|
|
|
|
current_user: 当前登录用户
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
API响应,包含操作结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
|
|
|
|
|
|
# 检查会议是否存在且当前用户是创建人
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"SELECT meeting_id, user_id FROM meetings WHERE meeting_id = %s",
|
|
|
|
|
|
(meeting_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="会议不存在")
|
|
|
|
|
|
|
|
|
|
|
|
if meeting['user_id'] != current_user['user_id']:
|
|
|
|
|
|
return create_api_response(code="403", message="仅创建人可以设置访问密码")
|
|
|
|
|
|
|
|
|
|
|
|
# 更新访问密码
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"UPDATE meetings SET access_password = %s WHERE meeting_id = %s",
|
|
|
|
|
|
(request.password, meeting_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
connection.commit()
|
|
|
|
|
|
|
|
|
|
|
|
if request.password:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="200",
|
|
|
|
|
|
message="访问密码已设置",
|
|
|
|
|
|
data={"password": request.password}
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="200",
|
|
|
|
|
|
message="访问密码已关闭",
|
|
|
|
|
|
data={"password": None}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="500",
|
|
|
|
|
|
message=f"设置访问密码失败: {str(e)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/meetings/{meeting_id}/verify-password")
|
|
|
|
|
|
def verify_meeting_password(meeting_id: int, request: VerifyPasswordRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
验证会议访问密码(无需登录认证)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
meeting_id: 会议ID
|
|
|
|
|
|
request.password: 要验证的密码
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
API响应,包含验证结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as connection:
|
|
|
|
|
|
cursor = connection.cursor(dictionary=True)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取会议的访问密码
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"SELECT access_password FROM meetings WHERE meeting_id = %s",
|
|
|
|
|
|
(meeting_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
meeting = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if not meeting:
|
|
|
|
|
|
return create_api_response(code="404", message="会议不存在")
|
|
|
|
|
|
|
|
|
|
|
|
# 验证密码
|
|
|
|
|
|
stored_password = meeting.get('access_password')
|
|
|
|
|
|
|
|
|
|
|
|
if not stored_password:
|
|
|
|
|
|
# 没有设置密码,直接通过
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="200",
|
|
|
|
|
|
message="该会议未设置访问密码",
|
|
|
|
|
|
data={"verified": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if request.password == stored_password:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="200",
|
|
|
|
|
|
message="密码验证成功",
|
|
|
|
|
|
data={"verified": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="200",
|
|
|
|
|
|
message="密码错误",
|
|
|
|
|
|
data={"verified": False}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return create_api_response(
|
|
|
|
|
|
code="500",
|
|
|
|
|
|
message=f"验证密码失败: {str(e)}"
|
|
|
|
|
|
)
|