refactor: 统一任务框架 Phase 3 - 用 TaskService/FailoverEngine 替代 FallbackOrchestrator

核心重构:
- 移除 FallbackOrchestrator,用 TaskService + FailoverEngine 替代
- TaskService 作为统一入口,支持 SYNC/ASYNC 两种任务模式
- FailoverEngine 实现候选遍历、重试、故障转移逻辑
- 新增 AttemptFunc/AttemptResult 协议,统一尝试结果表示

功能改进:
- 流式响应首字节探测(30s 超时,空流触发故障转移)
- 流式取消归因优化(区分客户端断连 vs 服务端中断)
- 新增 OpenAI Sora 视频取消路由 POST /v1/videos/{task_id}/cancel
- OpenAI 流式请求自动添加 stream_options.include_usage

代码规范:
- 修复 loguru 日志格式(%s → {})
- 新增 FORMAT_CONVERSION_ENABLED 环境变量说明

测试覆盖:
- test_failover_engine.py: FailoverEngine 单元测试
- test_task_service_async_execute.py: TaskService ASYNC 模式测试
- test_video_cancel_e2e.py: 视频取消端到端测试
This commit is contained in:
fawney19
2026-02-02 21:16:28 +08:00
parent ebe1a8d3e3
commit ed68aebfb0
53 changed files with 3966 additions and 2256 deletions

View File

@@ -7,7 +7,7 @@ import pytest
from src.config.settings import config
from src.core.api_format.conversion.internal_video import VideoStatus
from src.services.billing.formula_engine import BillingIncompleteError
from src.services.task.application import TaskApplicationService
from src.services.task.service import TaskService
def _make_task(**overrides: Any) -> SimpleNamespace:
@@ -81,18 +81,18 @@ async def test_video_finalize_failed_records_cost_zero(monkeypatch: pytest.Monke
lambda _self, **_kwargs: {"duration_seconds": 4},
)
monkeypatch.setattr(
"src.services.task.application.BillingRuleService.find_rule",
"src.services.billing.rule_service.BillingRuleService.find_rule",
lambda *_args, **_kwargs: None,
)
# Mock update_settled_billing (used by finalize_video_task)
update_settled = MagicMock(return_value=True)
monkeypatch.setattr(
"src.services.task.application.UsageService.update_settled_billing",
"src.services.usage.service.UsageService.update_settled_billing",
update_settled,
)
app = TaskApplicationService(db)
await app.finalize_video_task(task)
svc = TaskService(db)
await svc.finalize_video_task(task)
# billing_snapshot should be written back to task.request_metadata
assert task.request_metadata["billing_snapshot"]["cost"] == 0.0
@@ -113,18 +113,18 @@ async def test_video_finalize_completed_no_rule(monkeypatch: pytest.MonkeyPatch)
lambda _self, **_kwargs: {"duration_seconds": 4},
)
monkeypatch.setattr(
"src.services.task.application.BillingRuleService.find_rule",
"src.services.billing.rule_service.BillingRuleService.find_rule",
lambda *_args, **_kwargs: None,
)
# Mock update_settled_billing (used by finalize_video_task)
update_settled = MagicMock(return_value=True)
monkeypatch.setattr(
"src.services.task.application.UsageService.update_settled_billing",
"src.services.usage.service.UsageService.update_settled_billing",
update_settled,
)
app = TaskApplicationService(db)
await app.finalize_video_task(task)
svc = TaskService(db)
await svc.finalize_video_task(task)
assert task.request_metadata["billing_snapshot"]["status"] == "no_rule"
@@ -161,7 +161,7 @@ async def test_video_finalize_strict_mode_missing_required_marks_failed(
# Mock update_settled_billing (used by finalize_video_task)
update_settled = MagicMock(return_value=True)
monkeypatch.setattr(
"src.services.task.application.UsageService.update_settled_billing",
"src.services.usage.service.UsageService.update_settled_billing",
update_settled,
)
@@ -169,15 +169,15 @@ async def test_video_finalize_strict_mode_missing_required_marks_failed(
try:
config.billing_strict_mode = True
monkeypatch.setattr(
"src.services.task.application.FormulaEngine.evaluate",
"src.services.billing.formula_engine.FormulaEngine.evaluate",
MagicMock(
side_effect=BillingIncompleteError(
"Missing required dimensions", missing_required=["duration_seconds"]
)
),
)
app = TaskApplicationService(db)
await app.finalize_video_task(task)
svc = TaskService(db)
await svc.finalize_video_task(task)
finally:
config.billing_strict_mode = old