mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
fix: 处理状态不再因相对 data 目录失败,并停止终态轮询
桌面端项目配置改为使用绝对数据目录,cwd 无效时不再 mkdir 失败。 处理页在完成、失败、404 和 5xx 后停止 2 秒轮询。 状态接口上的预期 HTTPException 原样返回,不再记成 Sentry 错误;意外异常仍记录并返回 500。 Fixes #151 Fixes PYTHON-FASTAPI-4 Fixes PYTHON-FASTAPI-E Co-authored-by: Kris K <zhouxiaoka@users.noreply.github.com>
This commit is contained in:
@@ -764,7 +764,11 @@ async def get_processing_status(
|
||||
status = processing_service.get_processing_status(project_id, str(latest_task.id))
|
||||
|
||||
return status
|
||||
except Exception as e:
|
||||
except HTTPException:
|
||||
# 404 等预期响应原样返回。记成 exception 会被 Sentry 当成故障,
|
||||
# 外层再包一层 500 会让处理页把「项目不存在」当成服务器错误一直重试。
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("获取处理状态失败: %s", project_id)
|
||||
raise HTTPException(status_code=500, detail="获取处理状态失败,请稍后重试")
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from enum import Enum
|
||||
|
||||
from ..models.project import ProjectType
|
||||
from ..core.database import get_db
|
||||
from ..core.path_utils import get_projects_directory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,12 +51,29 @@ class ProcessingParams:
|
||||
max_topics_per_chunk: int = 8
|
||||
|
||||
|
||||
def resolve_project_directory(project_id: str) -> Path:
|
||||
"""Resolve a project directory independent of the process cwd.
|
||||
|
||||
Desktop launches set AUTOCLIP_DATA_DIR / AUTOCLIP_APP_DIR and often have a
|
||||
cwd where a relative ``data/projects`` mkdir fails. Web and Docker keep
|
||||
using the shared data directory (project root ``data/`` unless overridden).
|
||||
An absolute path is used as-is so callers can pass an explicit directory.
|
||||
"""
|
||||
candidate = Path(project_id)
|
||||
if candidate.is_absolute():
|
||||
project_dir = candidate
|
||||
else:
|
||||
project_dir = get_projects_directory() / project_id
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
return project_dir
|
||||
|
||||
|
||||
class ProjectConfigManager:
|
||||
"""项目配置管理器"""
|
||||
|
||||
def __init__(self, project_id: str):
|
||||
self.project_id = project_id
|
||||
self.project_dir = Path(f"data/projects/{project_id}")
|
||||
self.project_dir = resolve_project_directory(project_id)
|
||||
self.config_path = self.project_dir / "config.yaml"
|
||||
# 使用绝对路径指向项目根目录的prompt文件夹
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Processing-status path: project dirs must not depend on cwd, and expected HTTP errors stay expected."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.api.v1.projects import get_processing_status
|
||||
from backend.services.config_manager import ProjectConfigManager, resolve_project_directory
|
||||
|
||||
|
||||
def _remove_cwd(monkeypatch, tmp_path: Path) -> None:
|
||||
missing = tmp_path / "missing-cwd"
|
||||
missing.mkdir()
|
||||
monkeypatch.chdir(missing)
|
||||
missing.rmdir()
|
||||
|
||||
|
||||
def test_resolve_project_directory_uses_data_dir_when_cwd_is_gone(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "desktop-data"
|
||||
monkeypatch.setenv("AUTOCLIP_DATA_DIR", str(data_dir))
|
||||
_remove_cwd(monkeypatch, tmp_path)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
Path("data/projects/project-1").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
project_dir = resolve_project_directory("project-1")
|
||||
|
||||
assert project_dir == data_dir / "projects" / "project-1"
|
||||
assert project_dir.is_absolute()
|
||||
assert project_dir.is_dir()
|
||||
|
||||
|
||||
def test_desktop_mode_uses_app_dir_without_explicit_data_dir(tmp_path, monkeypatch):
|
||||
app_dir = tmp_path / "Application Support" / "AutoClip"
|
||||
monkeypatch.delenv("AUTOCLIP_DATA_DIR", raising=False)
|
||||
monkeypatch.setenv("AUTOCLIP_DESKTOP_MODE", "1")
|
||||
monkeypatch.setenv("AUTOCLIP_APP_DIR", str(app_dir))
|
||||
_remove_cwd(monkeypatch, tmp_path)
|
||||
|
||||
manager = ProjectConfigManager("clip-9")
|
||||
|
||||
assert manager.project_dir == app_dir / "projects" / "clip-9"
|
||||
assert manager.config_path == manager.project_dir / "config.yaml"
|
||||
assert manager.project_dir.is_dir()
|
||||
|
||||
|
||||
def test_absolute_directory_argument_is_unchanged(tmp_path):
|
||||
explicit = tmp_path / "explicit-project"
|
||||
manager = ProjectConfigManager(str(explicit))
|
||||
assert manager.project_dir == explicit
|
||||
assert explicit.is_dir()
|
||||
|
||||
|
||||
def test_web_mode_stays_under_configured_data_dir_not_cwd(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "docker-data"
|
||||
work = tmp_path / "somewhere-else"
|
||||
work.mkdir()
|
||||
monkeypatch.setenv("AUTOCLIP_DATA_DIR", str(data_dir))
|
||||
monkeypatch.delenv("AUTOCLIP_DESKTOP_MODE", raising=False)
|
||||
monkeypatch.delenv("AUTOCLIP_MODE", raising=False)
|
||||
monkeypatch.chdir(work)
|
||||
|
||||
project_dir = resolve_project_directory("web-project")
|
||||
|
||||
assert project_dir == data_dir / "projects" / "web-project"
|
||||
assert work not in project_dir.parents
|
||||
|
||||
|
||||
def test_status_404_is_not_rewritten(caplog):
|
||||
project_service = Mock()
|
||||
project_service.get.return_value = None
|
||||
processing_service = Mock()
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(get_processing_status("missing", project_service, processing_service))
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
processing_service.get_processing_status.assert_not_called()
|
||||
assert not any("获取处理状态失败" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_status_unexpected_error_still_reports_and_returns_500(caplog):
|
||||
task = Mock()
|
||||
task.id = "task-1"
|
||||
task.created_at = 1
|
||||
project = Mock()
|
||||
project.tasks = [task]
|
||||
project_service = Mock()
|
||||
project_service.get.return_value = project
|
||||
processing_service = Mock()
|
||||
processing_service.get_processing_status.side_effect = RuntimeError("disk failed")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(get_processing_status("project-1", project_service, processing_service))
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert any("获取处理状态失败" in record.message for record in caplog.records)
|
||||
assert any(record.exc_info and record.exc_info[0] is RuntimeError for record in caplog.records)
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
httpStatusOf,
|
||||
normalizeProcessingPhase,
|
||||
shouldStopProcessingPoll,
|
||||
toProcessingView,
|
||||
} from './src/pages/processingStatusPoll.ts'
|
||||
|
||||
test('completion and failure stop polling, including task_status from the API', () => {
|
||||
assert.equal(normalizeProcessingPhase({ status: 'completed' }), 'completed')
|
||||
assert.equal(normalizeProcessingPhase({ task_status: 'completed' }), 'completed')
|
||||
assert.equal(normalizeProcessingPhase({ task_status: 'failed' }), 'error')
|
||||
assert.equal(normalizeProcessingPhase({ status: 'error' }), 'error')
|
||||
assert.equal(normalizeProcessingPhase({ task_status: 'cancelled' }), 'error')
|
||||
assert.equal(normalizeProcessingPhase({ status: 'pending' }), 'pending')
|
||||
assert.equal(normalizeProcessingPhase({ task_status: 'running' }), 'processing')
|
||||
|
||||
assert.equal(shouldStopProcessingPoll({ phase: 'completed' }), true)
|
||||
assert.equal(shouldStopProcessingPoll({ phase: 'error' }), true)
|
||||
assert.equal(shouldStopProcessingPoll({ phase: 'processing' }), false)
|
||||
assert.equal(shouldStopProcessingPoll({ phase: 'pending' }), false)
|
||||
})
|
||||
|
||||
test('404 and 5xx stop polling; timeouts do not', () => {
|
||||
assert.equal(shouldStopProcessingPoll({ httpStatus: 404 }), true)
|
||||
assert.equal(shouldStopProcessingPoll({ httpStatus: 500 }), true)
|
||||
assert.equal(shouldStopProcessingPoll({ httpStatus: 503 }), true)
|
||||
assert.equal(shouldStopProcessingPoll({ httpStatus: 408 }), false)
|
||||
assert.equal(shouldStopProcessingPoll({}), false)
|
||||
assert.equal(httpStatusOf({ response: { status: 404 } }), 404)
|
||||
assert.equal(httpStatusOf({ code: 'ECONNABORTED' }), undefined)
|
||||
})
|
||||
|
||||
test('view keeps orchestrator task_progress when status is absent', () => {
|
||||
const view = toProcessingView({ task_status: 'running', task_progress: 40, error_message: null })
|
||||
assert.equal(view.status, 'processing')
|
||||
assert.equal(view.progress, 40)
|
||||
assert.equal(view.error_message, undefined)
|
||||
})
|
||||
@@ -4,25 +4,22 @@ import { Layout, Card, Progress, Steps, Typography, Button, Alert, Space, Spin,
|
||||
import { CheckCircleOutlined, LoadingOutlined, ExclamationCircleOutlined, ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { projectApi } from '../services/api'
|
||||
import { useProjectStore } from '../store/useProjectStore'
|
||||
import {
|
||||
httpStatusOf,
|
||||
shouldStopProcessingPoll,
|
||||
toProcessingView,
|
||||
type ProcessingStatusView,
|
||||
} from './processingStatusPoll'
|
||||
|
||||
const { Content } = Layout
|
||||
const { Title, Text } = Typography
|
||||
const { Step } = Steps
|
||||
|
||||
interface ProcessingStatus {
|
||||
status: 'processing' | 'completed' | 'error'
|
||||
current_step: number
|
||||
total_steps: number
|
||||
step_name: string
|
||||
progress: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
const ProcessingPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { currentProject, setCurrentProject } = useProjectStore()
|
||||
const [status, setStatus] = useState<ProcessingStatus | null>(null)
|
||||
const [status, setStatus] = useState<ProcessingStatusView | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const steps = [
|
||||
@@ -36,12 +33,71 @@ const ProcessingPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
|
||||
let stopped = false
|
||||
let notifiedRetry = false
|
||||
let timer = 0
|
||||
|
||||
const stop = () => {
|
||||
stopped = true
|
||||
if (timer) window.clearInterval(timer)
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (stopped) return
|
||||
|
||||
try {
|
||||
const statusData = await projectApi.getProcessingStatus(id)
|
||||
if (stopped) return
|
||||
const view = toProcessingView(statusData)
|
||||
setStatus(view)
|
||||
|
||||
if (shouldStopProcessingPoll({ phase: view.status })) {
|
||||
stop()
|
||||
}
|
||||
|
||||
if (view.status === 'completed') {
|
||||
message.success('🎉 视频处理完成!正在跳转到结果页面...')
|
||||
window.setTimeout(() => navigate(`/project/${id}`), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (view.status === 'error') {
|
||||
const errorMsg = view.error_message || '处理过程中发生未知错误'
|
||||
message.error(`处理失败: ${errorMsg}`)
|
||||
message.info('您可以返回首页重新上传文件或联系技术支持', 5)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (stopped) return
|
||||
console.error('Check status error:', error)
|
||||
const httpStatus = httpStatusOf(error)
|
||||
|
||||
if (shouldStopProcessingPoll({ httpStatus })) {
|
||||
stop()
|
||||
if (httpStatus === 404) {
|
||||
message.error('项目不存在或已被删除')
|
||||
window.setTimeout(() => navigate('/'), 2000)
|
||||
} else {
|
||||
message.error('获取处理状态失败,请刷新页面重试')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (notifiedRetry) return
|
||||
notifiedRetry = true
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
message.warning('网络连接超时,正在重试...')
|
||||
} else {
|
||||
message.error('获取处理状态失败,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadProject()
|
||||
const interval = setInterval(checkStatus, 2000) // 每2秒检查一次状态
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [id])
|
||||
timer = window.setInterval(() => { void checkStatus() }, 2000)
|
||||
|
||||
return () => stop()
|
||||
}, [id, navigate])
|
||||
|
||||
const loadProject = async () => {
|
||||
if (!id) return
|
||||
@@ -80,45 +136,6 @@ const ProcessingPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
const statusData = await projectApi.getProcessingStatus(id)
|
||||
setStatus(statusData)
|
||||
|
||||
// 如果处理完成,跳转到项目详情页
|
||||
if (statusData.status === 'completed') {
|
||||
message.success('🎉 视频处理完成!正在跳转到结果页面...')
|
||||
setTimeout(() => {
|
||||
navigate(`/project/${id}`)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 如果处理失败,显示详细错误信息
|
||||
if (statusData.status === 'error') {
|
||||
const errorMsg = statusData.error_message || '处理过程中发生未知错误'
|
||||
message.error(`处理失败: ${errorMsg}`)
|
||||
|
||||
// 提供重试选项
|
||||
message.info('您可以返回首页重新上传文件或联系技术支持', 5)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Check status error:', error)
|
||||
|
||||
// 根据错误类型提供不同的处理建议
|
||||
if (error.response?.status === 404) {
|
||||
message.error('项目不存在或已被删除')
|
||||
setTimeout(() => navigate('/'), 2000)
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
message.warning('网络连接超时,正在重试...')
|
||||
} else {
|
||||
message.error('获取处理状态失败,请刷新页面重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getStepStatus = (stepIndex: number) => {
|
||||
if (!status) return 'wait'
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface ProcessingStatusPayload {
|
||||
status?: string | null
|
||||
task_status?: string | null
|
||||
current_step?: number | null
|
||||
total_steps?: number | null
|
||||
step_name?: string | null
|
||||
progress?: number | null
|
||||
task_progress?: number | null
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export type ProcessingPhase = 'processing' | 'completed' | 'error' | 'pending'
|
||||
|
||||
export interface ProcessingStatusView {
|
||||
status: ProcessingPhase
|
||||
current_step: number
|
||||
total_steps: number
|
||||
step_name: string
|
||||
progress: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
const COMPLETED = new Set(['completed', 'success'])
|
||||
const FAILED = new Set(['error', 'failed', 'cancelled', 'canceled'])
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
export function normalizeProcessingPhase(data: ProcessingStatusPayload | null | undefined): ProcessingPhase | 'unknown' {
|
||||
const raw = text(data?.status) || text(data?.task_status)
|
||||
if (COMPLETED.has(raw)) return 'completed'
|
||||
if (FAILED.has(raw)) return 'error'
|
||||
if (raw === 'pending' || raw === 'waiting') return 'pending'
|
||||
if (raw === 'processing' || raw === 'running') return 'processing'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function toProcessingView(data: ProcessingStatusPayload): ProcessingStatusView {
|
||||
const phase = normalizeProcessingPhase(data)
|
||||
const progress = data.progress ?? data.task_progress ?? 0
|
||||
return {
|
||||
status: phase === 'unknown' ? 'pending' : phase,
|
||||
current_step: data.current_step ?? 0,
|
||||
total_steps: data.total_steps ?? 6,
|
||||
step_name: data.step_name || '',
|
||||
progress: Number.isFinite(progress) ? progress : 0,
|
||||
error_message: data.error_message || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function httpStatusOf(error: unknown): number | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined
|
||||
const status = (error as { response?: { status?: unknown } }).response?.status
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/** Stop the processing-page poll on a terminal pipeline state or a terminal HTTP failure. */
|
||||
export function shouldStopProcessingPoll(input: {
|
||||
phase?: ProcessingPhase | 'unknown'
|
||||
httpStatus?: number
|
||||
}): boolean {
|
||||
if (input.phase === 'completed' || input.phase === 'error') return true
|
||||
if (input.httpStatus === 404) return true
|
||||
return typeof input.httpStatus === 'number' && input.httpStatus >= 500 && input.httpStatus < 600
|
||||
}
|
||||
Reference in New Issue
Block a user