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,
t.status,
t.progress,
t.result,
t.error_message,
t.created_at,
t.completed_at,

View File

@ -11,7 +11,7 @@ from typing import Optional, Dict, Any, List
from pathlib import Path
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.services.async_transcription_service import AsyncTranscriptionService
from app.services.background_task_runner import KeyedBackgroundTaskRunner
@ -164,7 +164,7 @@ class AsyncMeetingService:
# 6. 导出MD文件到音频同目录
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文件路径
self._update_task_in_db(task_id, 'completed', 100, result=md_path)
@ -337,8 +337,8 @@ class AsyncMeetingService:
print(f"使用模型 {model_code} 调用失败: {e}")
return None
def _export_summary_md(self, meeting_id: int, summary_content: str) -> Optional[str]:
"""将总结内容导出为MD文件保存到音频同目录返回文件路径"""
def _export_summary_md(self, meeting_id: int, summary_content: str, task_id: Optional[str] = None) -> Optional[str]:
"""将总结内容导出为MD文件保存到音频同目录返回 /uploads/... 相对路径"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
@ -348,16 +348,9 @@ class AsyncMeetingService:
audio = cursor.fetchone()
title = meeting['title'] if meeting else f"meeting_{meeting_id}"
# 始终以 AUDIO_DIR 为基准,避免数据库中的绝对路径指向不可写目录
if audio and audio.get('file_path'):
audio_path = Path(audio['file_path'])
# 提取 meeting_id 层级的子目录(如 "226" 或 "226/sub"
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
audio_path = BASE_DIR / str(audio['file_path']).lstrip('/')
md_dir = audio_path.parent
else:
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()
if not safe_title:
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_str = str(md_path)
print(f"总结MD文件已保存: {md_path_str}")
return md_path_str
relative_md_path = "/" + str(md_path.relative_to(BASE_DIR)).replace("\\", "/")
print(f"总结MD文件已保存: {relative_md_path}")
return relative_md_path
except Exception as e:
print(f"导出总结MD文件失败: {e}")
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`
+ 本次数据库升级包含 `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/声纹冗余列
+ 升级后 `llm_tasks.result` 将统一为 `/uploads/...` 相对路径,与音频文件路径保持一致

View File

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

View File

@ -86,11 +86,13 @@ const AdminDashboard = () => {
showMeetingModal,
meetingDetails,
meetingLoading,
selectedTaskRecord,
fetchAllData,
handleKickUser,
handleViewMeeting,
handleDownloadTranscript,
handleDownloadAudio,
handleDownloadSummaryResult,
closeMeetingModal,
taskCompletionRate,
} = useAdminDashboardPage();
@ -172,7 +174,7 @@ const AdminDashboard = () => {
render: (text, record) => (
<Space>
{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}
<span>{text || '-'}</span>
</Space>
@ -407,6 +409,7 @@ const AdminDashboard = () => {
{meetingDetails.meeting_time ? new Date(meetingDetails.meeting_time).toLocaleString() : '-'}
</Descriptions.Item>
<Descriptions.Item label="使用模版">{meetingDetails.prompt_name || '默认模版'}</Descriptions.Item>
{selectedTaskRecord?.task_type !== 'summary' ? (
<Descriptions.Item label="音频信息">
<Space size="middle">
<span>{formatAudioDuration(meetingDetails.audio_duration)}</span>
@ -422,10 +425,22 @@ const AdminDashboard = () => {
) : null}
</Space>
</Descriptions.Item>
) : null}
<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)}>
下载转录结果 (JSON)
</ActionButton>
)}
</Descriptions.Item>
</Descriptions>
) : (