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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user