refactor: 第三方平台架构改造(Adapter Protocol + Gateway)

Phase 1: 异常体系统一
- 新增 PlatformError / PlatformErrorType 标准定义
- 改造所有 Provider 异常抛出为 PlatformError
- 注册全局 PlatformError exception handler

Phase 2: Adapter Protocol
- 新增 app/ai/adapters/base.py(PlatformAdapter + SyncCapable + TaskCapable + CallbackCapable)
- 新增 app/ai/adapters/constants.py(Method 常量)
- 新增 PlatformConfigLoader(config/platform-config.yaml)

Phase 3: HTTP Client 统一
- ViduProvider 从 aiohttp 迁移到 httpx(注入方式)
- VolcengineCaptionService 改为注入 http_client
- lifespan 统一管理所有 Client 创建和关闭

Phase 4: Gateway 骨架 + Adapter 实现
- 新增 ViduAdapter / VolcengineArkAdapter / VolcengineCaptionAdapter
- 新增 PlatformGateway(call_sync / submit_task / query_task / handle_webhook)
- 新增 LLMGateway(带 Fallback 降级链)
- lifespan 注册所有 Adapter 和 Gateway

Phase 6: 清理与验证
- 从 Settings 移除 VIDU_BASE_URL / VOLCENGINE_BASE_URL
- Provider 改为从 PlatformConfigLoader 读取 base_url
- 清理 volcengine_caption_service 全局单例
- config_loader 默认路径改为 platform-config.yaml
- Scheduler 注入共享 HTTP client
- vidu.py 回调路由使用 Adapter 验签和解析
- ruff 全量通过,应用启动测试通过
This commit is contained in:
小鱼开发
2026-05-04 16:07:16 +08:00
parent 0c921aca11
commit e58159fc42
34 changed files with 3688 additions and 1030 deletions
@@ -14,8 +14,10 @@ import json
import logging
import httpx
from fastapi import Request
from app.config import get_settings
from app.core.exceptions import PlatformError, PlatformErrorType
from app.schemas.caption import (
AutoAlignResult,
CaptionResult,
@@ -26,13 +28,43 @@ from app.schemas.caption import (
logger = logging.getLogger(__name__)
class VolcengineCaptionError(Exception):
"""火山引擎字幕服务异常"""
def _map_caption_error(status: int, message: str, code: int | None = None) -> PlatformError:
"""火山字幕错误映射为标准 PlatformError"""
# 火山字幕业务错误码映射
error_mapping = {
1001: (PlatformErrorType.BAD_REQUEST, False), # 参数无效
1002: (PlatformErrorType.AUTH_FAILED, False), # 无权限
1003: (PlatformErrorType.RATE_LIMIT, True), # 超频(可重试)
1010: (PlatformErrorType.BAD_REQUEST, False), # 音频过长
1011: (PlatformErrorType.BAD_REQUEST, False), # 音频过大
1012: (PlatformErrorType.BAD_REQUEST, False), # 格式无效
1013: (PlatformErrorType.BAD_REQUEST, False), # 音频静音
}
def __init__(self, message: str, code: int = None, original_error: Exception = None):
super().__init__(message)
self.code = code
self.original_error = original_error
if code is not None and code in error_mapping:
error_type, retryable = error_mapping[code]
return PlatformError(
message, platform="volcengine_caption",
retryable=retryable, error_type=error_type,
status_code=status,
)
# HTTP 状态码映射
http_mapping = {
429: (PlatformErrorType.RATE_LIMIT, True),
401: (PlatformErrorType.AUTH_FAILED, False),
403: (PlatformErrorType.AUTH_FAILED, False),
400: (PlatformErrorType.BAD_REQUEST, False),
500: (PlatformErrorType.SERVER_ERROR, True),
502: (PlatformErrorType.SERVER_ERROR, True),
503: (PlatformErrorType.SERVER_ERROR, True),
}
error_type, retryable = http_mapping.get(status, (PlatformErrorType.UNKNOWN, False))
return PlatformError(
message, platform="volcengine_caption",
retryable=retryable, error_type=error_type,
status_code=status,
)
class VolcengineCaptionService:
@@ -59,30 +91,45 @@ class VolcengineCaptionService:
1013: "音频静音",
}
def __init__(self, appid: str | None = None, token: str | None = None):
def __init__(
self,
appid: str | None = None,
token: str | None = None,
client: httpx.AsyncClient | None = None,
):
"""
初始化字幕服务
Args:
appid: 应用ID,默认从 Settings 读取
token: 鉴权Token,默认从 Settings 读取
client: 外部注入的 httpx.AsyncClient(由 lifespan 管理生命周期)
"""
settings = get_settings()
self.appid = appid or settings.VOLCENGINE_CAPTION_APPID or ""
self.token = token or settings.VOLCENGINE_CAPTION_TOKEN or ""
if not self.appid:
raise VolcengineCaptionError("VOLCENGINE_CAPTION_APPID 未配置")
raise PlatformError(
"VOLCENGINE_CAPTION_APPID 未配置",
platform="volcengine_caption",
retryable=False,
error_type=PlatformErrorType.BAD_REQUEST,
)
if not self.token:
raise VolcengineCaptionError("VOLCENGINE_CAPTION_TOKEN 未配置")
raise PlatformError(
"VOLCENGINE_CAPTION_TOKEN 未配置",
platform="volcengine_caption",
retryable=False,
error_type=PlatformErrorType.BAD_REQUEST,
)
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取 HTTP 客户端"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT)
return self._client
if client is not None:
self.client = client
self._owns_client = False
else:
self.client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT)
self._owns_client = True
def _get_headers(self) -> dict:
"""获取请求头"""
@@ -119,7 +166,7 @@ class VolcengineCaptionService:
Raises:
VolcengineCaptionError: 提交失败
"""
client = await self._get_client()
client = self.client
params = {
"appid": self.appid,
@@ -144,19 +191,31 @@ class VolcengineCaptionService:
data = response.json()
if "id" not in data:
raise VolcengineCaptionError(f"提交任务失败: {data.get('message', '未知错误')}")
raise _map_caption_error(
500,
f"提交任务失败: {data.get('message', '未知错误')}",
)
task_id = data["id"]
logger.info(f"字幕任务已提交: {task_id}")
return task_id
except PlatformError:
raise
except httpx.HTTPStatusError as e:
raise VolcengineCaptionError(
raise _map_caption_error(
e.response.status_code,
f"HTTP错误: {e.response.status_code}",
original_error=e,
)
) from e
except (httpx.NetworkError, httpx.TimeoutException) as e:
raise PlatformError(
f"字幕服务网络错误: {e}",
platform="volcengine_caption",
retryable=True,
error_type=PlatformErrorType.TIMEOUT,
) from e
except Exception as e:
raise VolcengineCaptionError(f"提交任务失败: {str(e)}", original_error=e)
raise _map_caption_error(500, f"提交任务失败: {str(e)}") from e
async def query_caption_task(
self,
@@ -176,7 +235,7 @@ class VolcengineCaptionService:
Raises:
VolcengineCaptionError: 查询失败
"""
client = await self._get_client()
client = self.client
params = {
"appid": self.appid,
@@ -195,13 +254,22 @@ class VolcengineCaptionService:
return self._parse_caption_result(data)
except PlatformError:
raise
except httpx.HTTPStatusError as e:
raise VolcengineCaptionError(
raise _map_caption_error(
e.response.status_code,
f"HTTP错误: {e.response.status_code}",
original_error=e,
)
) from e
except (httpx.NetworkError, httpx.TimeoutException) as e:
raise PlatformError(
f"字幕服务网络错误: {e}",
platform="volcengine_caption",
retryable=True,
error_type=PlatformErrorType.TIMEOUT,
) from e
except Exception as e:
raise VolcengineCaptionError(f"查询任务失败: {str(e)}", original_error=e)
raise _map_caption_error(500, f"查询任务失败: {str(e)}") from e
async def generate_caption(
self,
@@ -258,17 +326,21 @@ class VolcengineCaptionService:
# 仍在处理中
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > max_wait_time:
raise VolcengineCaptionError(f"字幕生成超时: 已等待 {max_wait_time}s")
raise _map_caption_error(
504, f"字幕生成超时: 已等待 {max_wait_time}s",
)
await asyncio.sleep(self.DEFAULT_POLL_INTERVAL)
retries += 1
else:
# 其他错误
error_msg = self.ERROR_CODES.get(result.code, f"未知错误: {result.code}")
raise VolcengineCaptionError(
f"字幕生成失败: {error_msg} ({result.message})", code=result.code
raise _map_caption_error(
500,
f"字幕生成失败: {error_msg} ({result.message})",
code=result.code,
)
raise VolcengineCaptionError("字幕生成超时: 超过最大重试次数")
raise _map_caption_error(504, "字幕生成超时: 超过最大重试次数")
async def submit_auto_align_task(
self,
@@ -289,7 +361,7 @@ class VolcengineCaptionService:
Returns:
任务ID
"""
client = await self._get_client()
client = self.client
params = {
"appid": self.appid,
@@ -313,14 +385,19 @@ class VolcengineCaptionService:
data = response.json()
if "id" not in data:
raise VolcengineCaptionError(f"提交打轴任务失败: {data.get('message', '未知错误')}")
raise _map_caption_error(
500,
f"提交打轴任务失败: {data.get('message', '未知错误')}",
)
task_id = data["id"]
logger.info(f"打轴任务已提交: {task_id}")
return task_id
except PlatformError:
raise
except Exception as e:
raise VolcengineCaptionError(f"提交打轴任务失败: {str(e)}", original_error=e)
raise _map_caption_error(500, f"提交打轴任务失败: {str(e)}") from e
async def query_auto_align_task(
self,
@@ -337,7 +414,7 @@ class VolcengineCaptionService:
Returns:
打轴结果
"""
client = await self._get_client()
client = self.client
params = {
"appid": self.appid,
@@ -370,8 +447,10 @@ class VolcengineCaptionService:
utterances=caption_result.utterances,
)
except PlatformError:
raise
except Exception as e:
raise VolcengineCaptionError(f"查询打轴任务失败: {str(e)}", original_error=e)
raise _map_caption_error(500, f"查询打轴任务失败: {str(e)}") from e
async def auto_align_caption(
self,
@@ -413,16 +492,20 @@ class VolcengineCaptionService:
elif result.code == 2000:
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > max_wait_time:
raise VolcengineCaptionError(f"打轴超时: 已等待 {max_wait_time}s")
raise _map_caption_error(
504, f"打轴超时: 已等待 {max_wait_time}s",
)
await asyncio.sleep(self.DEFAULT_POLL_INTERVAL)
retries += 1
else:
error_msg = self.ERROR_CODES.get(result.code, f"未知错误: {result.code}")
raise VolcengineCaptionError(
f"打轴失败: {error_msg} ({result.message})", code=result.code
raise _map_caption_error(
500,
f"打轴失败: {error_msg} ({result.message})",
code=result.code,
)
raise VolcengineCaptionError("打轴超时: 超过最大重试次数")
raise _map_caption_error(504, "打轴超时: 超过最大重试次数")
def _parse_caption_result(self, data: dict) -> CaptionResult:
"""解析 API 响应为 CaptionResult"""
@@ -543,24 +626,12 @@ class VolcengineCaptionService:
return "\n".join(lines).strip()
async def close(self):
"""关闭 HTTP 客户端"""
if self._client and not self._client.is_closed:
await self._client.aclose()
"""关闭 HTTP 客户端。仅在自己创建 Client 时关闭。"""
if self._owns_client and self.client and not self.client.is_closed:
await self.client.aclose()
# 全局服务单例
_caption_service: VolcengineCaptionService | None = None
async def get_caption_service() -> VolcengineCaptionService:
"""获取字幕服务单例"""
global _caption_service
if _caption_service is None:
_caption_service = VolcengineCaptionService()
return _caption_service
def reset_caption_service():
"""重置字幕服务单例(用于测试)"""
global _caption_service
_caption_service = None
async def get_caption_service(request: Request) -> VolcengineCaptionService:
"""FastAPI Depends:从 app.state 获取全局字幕服务实例。"""
return request.app.state.volcengine_caption_service