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:
Cursor Agent
2026-09-23 02:29:44 +00:00
co-authored by Kris K
parent d42e80779e
commit ba45acf032
6 changed files with 306 additions and 56 deletions
+5 -1
View File
@@ -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="获取处理状态失败,请稍后重试")
+19 -1
View File
@@ -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)
+41
View File
@@ -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)
})
+71 -54
View File
@@ -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
}