codex/dev
mula.liu 2026-04-09 20:51:05 +08:00
parent 861d7e3463
commit 27fa9317c5
6 changed files with 94 additions and 56 deletions

View File

@ -319,6 +319,7 @@ async def monitor_tasks(
m.title as meeting_title, m.title as meeting_title,
t.status, t.status,
t.progress, t.progress,
t.result,
t.error_message, t.error_message,
t.created_at, t.created_at,
t.completed_at, t.completed_at,

View File

@ -11,7 +11,7 @@ from typing import Optional, Dict, Any, List
from pathlib import Path from pathlib import Path
import redis import redis
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR, BASE_DIR
from app.core.database import get_db_connection from app.core.database import get_db_connection
from app.services.async_transcription_service import AsyncTranscriptionService from app.services.async_transcription_service import AsyncTranscriptionService
from app.services.background_task_runner import KeyedBackgroundTaskRunner from app.services.background_task_runner import KeyedBackgroundTaskRunner
@ -164,7 +164,7 @@ class AsyncMeetingService:
# 6. 导出MD文件到音频同目录 # 6. 导出MD文件到音频同目录
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...") self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
md_path = self._export_summary_md(meeting_id, summary_content) md_path = self._export_summary_md(meeting_id, summary_content, task_id=task_id)
# 7. 任务完成result保存MD文件路径 # 7. 任务完成result保存MD文件路径
self._update_task_in_db(task_id, 'completed', 100, result=md_path) self._update_task_in_db(task_id, 'completed', 100, result=md_path)
@ -337,8 +337,8 @@ class AsyncMeetingService:
print(f"使用模型 {model_code} 调用失败: {e}") print(f"使用模型 {model_code} 调用失败: {e}")
return None return None
def _export_summary_md(self, meeting_id: int, summary_content: str) -> Optional[str]: def _export_summary_md(self, meeting_id: int, summary_content: str, task_id: Optional[str] = None) -> Optional[str]:
"""将总结内容导出为MD文件保存到音频同目录返回文件路径""" """将总结内容导出为MD文件保存到音频同目录返回 /uploads/... 相对路径"""
try: try:
with get_db_connection() as connection: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
@ -348,16 +348,9 @@ class AsyncMeetingService:
audio = cursor.fetchone() audio = cursor.fetchone()
title = meeting['title'] if meeting else f"meeting_{meeting_id}" title = meeting['title'] if meeting else f"meeting_{meeting_id}"
# 始终以 AUDIO_DIR 为基准,避免数据库中的绝对路径指向不可写目录
if audio and audio.get('file_path'): if audio and audio.get('file_path'):
audio_path = Path(audio['file_path']) audio_path = BASE_DIR / str(audio['file_path']).lstrip('/')
# 提取 meeting_id 层级的子目录(如 "226" 或 "226/sub" md_dir = audio_path.parent
try:
rel = audio_path.relative_to(AUDIO_DIR)
md_dir = AUDIO_DIR / rel.parent
except ValueError:
# file_path 不在 AUDIO_DIR 下(如 Docker 绝对路径),取最后一级目录名
md_dir = AUDIO_DIR / audio_path.parent.name
else: else:
md_dir = AUDIO_DIR / str(meeting_id) md_dir = AUDIO_DIR / str(meeting_id)
@ -365,11 +358,15 @@ class AsyncMeetingService:
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip() safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip()
if not safe_title: if not safe_title:
safe_title = f"meeting_{meeting_id}" safe_title = f"meeting_{meeting_id}"
md_path = md_dir / f"{safe_title}_总结.md" timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
task_suffix = ""
if task_id:
task_suffix = f"_{str(task_id).replace('-', '')[:8]}"
md_path = md_dir / f"{safe_title}_总结_{timestamp_suffix}{task_suffix}.md"
md_path.write_text(summary_content, encoding='utf-8') md_path.write_text(summary_content, encoding='utf-8')
md_path_str = str(md_path) relative_md_path = "/" + str(md_path.relative_to(BASE_DIR)).replace("\\", "/")
print(f"总结MD文件已保存: {md_path_str}") print(f"总结MD文件已保存: {relative_md_path}")
return md_path_str return relative_md_path
except Exception as e: except Exception as e:
print(f"导出总结MD文件失败: {e}") print(f"导出总结MD文件失败: {e}")
return None return None

View File

@ -0,0 +1,9 @@
-- 将 llm_tasks.result 统一为 /uploads/... 相对路径
-- 仅处理能明确识别出 /uploads/ 前缀的历史绝对路径记录
UPDATE `llm_tasks`
SET `result` = SUBSTRING(`result`, LOCATE('/uploads/', `result`))
WHERE `result` IS NOT NULL
AND `result` <> ''
AND `result` NOT LIKE '/uploads/%'
AND LOCATE('/uploads/', `result`) > 0;

View File

@ -7,4 +7,6 @@
# 升级前确认 # 升级前确认
+ 后端运行环境需提供 `ffmpeg``ffprobe` + 后端运行环境需提供 `ffmpeg``ffprobe`
+ 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql` + 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql`
+ 本次数据库升级还需执行 `backend/sql/migrations/normalize_llm_task_result_paths.sql`
+ 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列 + 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列
+ 升级后 `llm_tasks.result` 将统一为 `/uploads/...` 相对路径,与音频文件路径保持一致

View File

@ -27,6 +27,7 @@ export default function useAdminDashboardPage() {
const [showMeetingModal, setShowMeetingModal] = useState(false); const [showMeetingModal, setShowMeetingModal] = useState(false);
const [meetingDetails, setMeetingDetails] = useState(null); const [meetingDetails, setMeetingDetails] = useState(null);
const [meetingLoading, setMeetingLoading] = useState(false); const [meetingLoading, setMeetingLoading] = useState(false);
const [selectedTaskRecord, setSelectedTaskRecord] = useState(null);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@ -155,9 +156,10 @@ export default function useAdminDashboardPage() {
}); });
}; };
const handleViewMeeting = async (meetingId) => { const handleViewMeeting = async (meetingId, taskRecord = null) => {
setMeetingLoading(true); setMeetingLoading(true);
setShowMeetingModal(true); setShowMeetingModal(true);
setSelectedTaskRecord(taskRecord);
try { try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId))); const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
if (response.code === '200') { if (response.code === '200') {
@ -190,35 +192,45 @@ export default function useAdminDashboardPage() {
} }
}; };
const handleDownloadAudio = async (meetingId, audioFilePath) => { const downloadResourceByPath = useCallback((resourcePath, fallbackFileName) => {
try { const normalizedPath = typeof resourcePath === 'string' ? resourcePath.trim() : '';
const response = await fetch(buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meetingId)}/stream`), { if (!normalizedPath) {
credentials: 'include', message.error('文件路径不存在');
}); return;
if (!response.ok) {
throw new Error(`audio download failed: ${response.status}`);
} }
const blob = await response.blob(); const href = buildApiUrl(normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`);
const url = URL.createObjectURL(blob); const fileNameFromPath = normalizedPath.split('/').pop();
const link = document.createElement('a'); const link = document.createElement('a');
const fileNameFromPath = audioFilePath?.split('/').pop(); link.href = href;
const fallbackExtension = fileNameFromPath?.includes('.') ? '' : '.mp3'; link.download = fileNameFromPath || fallbackFileName;
link.href = url;
link.download = fileNameFromPath || `meeting_audio_${meetingId}${fallbackExtension}`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); }, [message]);
const handleDownloadAudio = (meetingId, audioFilePath) => {
try {
downloadResourceByPath(audioFilePath, `meeting_audio_${meetingId}.mp3`);
} catch (error) { } catch (error) {
console.error('下载音频失败:', error); console.error('下载音频失败:', error);
message.error('下载音频失败'); message.error('下载音频失败');
} }
}; };
const handleDownloadSummaryResult = (taskId, resultPath) => {
try {
downloadResourceByPath(resultPath, `summary_${taskId}.md`);
} catch (error) {
console.error('下载总结结果失败:', error);
message.error('下载总结结果失败');
}
};
const closeMeetingModal = () => { const closeMeetingModal = () => {
setShowMeetingModal(false); setShowMeetingModal(false);
setMeetingDetails(null); setMeetingDetails(null);
setSelectedTaskRecord(null);
}; };
const taskCompletionRate = useMemo(() => { const taskCompletionRate = useMemo(() => {
@ -246,12 +258,14 @@ export default function useAdminDashboardPage() {
showMeetingModal, showMeetingModal,
meetingDetails, meetingDetails,
meetingLoading, meetingLoading,
selectedTaskRecord,
fetchAllData, fetchAllData,
fetchOnlineUsers, fetchOnlineUsers,
handleKickUser, handleKickUser,
handleViewMeeting, handleViewMeeting,
handleDownloadTranscript, handleDownloadTranscript,
handleDownloadAudio, handleDownloadAudio,
handleDownloadSummaryResult,
closeMeetingModal, closeMeetingModal,
taskCompletionRate, taskCompletionRate,
}; };

View File

@ -86,11 +86,13 @@ const AdminDashboard = () => {
showMeetingModal, showMeetingModal,
meetingDetails, meetingDetails,
meetingLoading, meetingLoading,
selectedTaskRecord,
fetchAllData, fetchAllData,
handleKickUser, handleKickUser,
handleViewMeeting, handleViewMeeting,
handleDownloadTranscript, handleDownloadTranscript,
handleDownloadAudio, handleDownloadAudio,
handleDownloadSummaryResult,
closeMeetingModal, closeMeetingModal,
taskCompletionRate, taskCompletionRate,
} = useAdminDashboardPage(); } = useAdminDashboardPage();
@ -172,7 +174,7 @@ const AdminDashboard = () => {
render: (text, record) => ( render: (text, record) => (
<Space> <Space>
{record.meeting_id ? ( {record.meeting_id ? (
<ActionButton tone="view" variant="iconSm" tooltip="查看详情" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id)} /> <ActionButton tone="view" variant="iconSm" tooltip="查看详情" icon={<SearchOutlined />} onClick={() => handleViewMeeting(record.meeting_id, record)} />
) : null} ) : null}
<span>{text || '-'}</span> <span>{text || '-'}</span>
</Space> </Space>
@ -407,6 +409,7 @@ const AdminDashboard = () => {
{meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'} {meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="使用模版">{meetingDetails.prompt_name || '默认模版'}</Descriptions.Item> <Descriptions.Item label="使用模版">{meetingDetails.prompt_name || '默认模版'}</Descriptions.Item>
{selectedTaskRecord?.task_type !== 'summary' ? (
<Descriptions.Item label="音频信息"> <Descriptions.Item label="音频信息">
<Space size="middle"> <Space size="middle">
<span>{formatAudioDuration(meetingDetails.audio_duration)}</span> <span>{formatAudioDuration(meetingDetails.audio_duration)}</span>
@ -422,10 +425,22 @@ const AdminDashboard = () => {
) : null} ) : null}
</Space> </Space>
</Descriptions.Item> </Descriptions.Item>
) : null}
<Descriptions.Item label="操作"> <Descriptions.Item label="操作">
{selectedTaskRecord?.task_type === 'summary' ? (
<ActionButton
tone="view"
variant="textLg"
icon={<FileTextOutlined />}
onClick={() => handleDownloadSummaryResult(selectedTaskRecord.task_id, selectedTaskRecord.result)}
>
下载总结结果md
</ActionButton>
) : (
<ActionButton tone="view" variant="textLg" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}> <ActionButton tone="view" variant="textLg" icon={<FileTextOutlined />} onClick={() => handleDownloadTranscript(meetingDetails.meeting_id)}>
下载转录结果 (JSON) 下载转录结果 (JSON)
</ActionButton> </ActionButton>
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
) : ( ) : (