From df251b83f59c3f4e95497772ebfb45534761432b Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Mon, 6 Jul 2026 16:46:00 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9C=9F=E4=BA=BA=E7=B4=A0=E6=9D=90=E5=BA=93?= =?UTF-8?q?=E6=8E=88=E6=9D=83BUG=E4=BF=AE=E5=A4=8D=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-api/.env | 1 - video-gen-api/app/config.py | 2 - video-gen-api/app/enums/private_portrait.py | 15 +--- .../services/private_portrait/ark_client.py | 90 ++++++++++++++++--- .../private_portrait/asset_service.py | 19 +++- .../private_portrait/project_service.py | 52 ++--------- 6 files changed, 106 insertions(+), 73 deletions(-) diff --git a/video-gen-api/.env b/video-gen-api/.env index 92e0e7c2..44f7ccca 100644 --- a/video-gen-api/.env +++ b/video-gen-api/.env @@ -3,7 +3,6 @@ APP_NAME=VideoGen API APP_VERSION=1.0.0 DEBUG=false SECRET_KEY=local-dev-secret-key-not-for-production -APP_ENV=test # Database (PostgreSQL) #DATABASE_URL=postgresql+asyncpg://videogen_test:Yr7kM7kDj75izCiA@180.184.42.66:5432/videogen_test diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py index 596665f4..9ded341f 100644 --- a/video-gen-api/app/config.py +++ b/video-gen-api/app/config.py @@ -7,8 +7,6 @@ class Settings(BaseSettings): APP_NAME: str = "VideoGen API" APP_VERSION: str = "1.0.0" DEBUG: bool = False - # 运行环境,用于生成火山私域真人素材 ProjectName:local/test/online。 - APP_ENV: str = "local" SECRET_KEY: str = "change-me" DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db" diff --git a/video-gen-api/app/enums/private_portrait.py b/video-gen-api/app/enums/private_portrait.py index fa47a900..1594dc0f 100644 --- a/video-gen-api/app/enums/private_portrait.py +++ b/video-gen-api/app/enums/private_portrait.py @@ -5,15 +5,9 @@ from enum import Enum # 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。 PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5 -# ProjectName 由服务层按 {env}-{user_id}-{项目名slug} 生成并快照到 private_portrait_projects.remote_project_name。 -PRIVATE_PORTRAIT_PROJECT_ENV_LOCAL = "local" -PRIVATE_PORTRAIT_PROJECT_ENV_TEST = "test" -PRIVATE_PORTRAIT_PROJECT_ENV_ONLINE = "online" -PRIVATE_PORTRAIT_PROJECT_ENV_VALUES = { - PRIVATE_PORTRAIT_PROJECT_ENV_LOCAL, - PRIVATE_PORTRAIT_PROJECT_ENV_TEST, - PRIVATE_PORTRAIT_PROJECT_ENV_ONLINE, -} +# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。 +# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。 +PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default" PRIVATE_PORTRAIT_GROUP_TYPE = "LivenessFace" PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time" @@ -132,9 +126,8 @@ class PrivatePortraitEventType(str, Enum): PROJECT_CREATE = "PROJECT_CREATE" PROJECT_UPDATE = "PROJECT_UPDATE" PROJECT_DELETE = "PROJECT_DELETE" - PROJECT_REMOTE_NAME_LOCKED = "PROJECT_REMOTE_NAME_LOCKED" - VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE" + VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED" VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED" VALIDATE_CALLBACK_SUCCESS = "VALIDATE_CALLBACK_SUCCESS" VALIDATE_CALLBACK_FAILED = "VALIDATE_CALLBACK_FAILED" diff --git a/video-gen-api/app/services/private_portrait/ark_client.py b/video-gen-api/app/services/private_portrait/ark_client.py index 8b6b3a35..59ae84ff 100644 --- a/video-gen-api/app/services/private_portrait/ark_client.py +++ b/video-gen-api/app/services/private_portrait/ark_client.py @@ -27,6 +27,29 @@ class ArkPrivateAssetClientError(RuntimeError): pass +class ArkPrivateAssetRemoteError(ArkPrivateAssetClientError): + def __init__(self, *, action: str, code: str, message: str, request_id: str | None = None, raw: dict[str, Any] | None = None): + self.action = action + self.code = code + self.message = message + self.request_id = request_id + self.raw = raw or {} + super().__init__(f"{action} 调用失败:{code} {message}" + (f" RequestId={request_id}" if request_id else "")) + + +def _remote_error_http_status(code: str) -> int: + normalized = (code or "").lower() + if "notfound" in normalized or normalized.startswith("not_found"): + return 404 + if "invalid" in normalized or "missing" in normalized or "unsupported" in normalized or "limit" in normalized: + return 400 + if "unauthorized" in normalized or "forbidden" in normalized or "permission" in normalized or "denied" in normalized: + return 403 + if "throttl" in normalized or "rate" in normalized: + return 429 + return 502 + + class ArkPrivateAssetClient: """火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。""" @@ -109,6 +132,24 @@ class ArkPrivateAssetClient: remote_request_id=result.get("RequestId") or result.get("request_id"), ) return result + except ArkPrivateAssetRemoteError as exc: + log_remote_api_event( + domain=DOMAIN, + remote_action=action_value, + event_type=PrivatePortraitEventType.ARK_API_CALL_FAILED.value, + event_status=PrivatePortraitEventStatus.FAILED.value, + source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value, + request=payload, + response=exc.raw, + remote_request_id=exc.request_id, + remote_code=exc.code, + remote_message=exc.message, + ) + if self.for_celery: + raise + raise HTTPException(status_code=_remote_error_http_status(exc.code), detail={"message": exc.message, "code": exc.code, "request_id": exc.request_id}) from exc + except HTTPException: + raise except Exception as exc: log_remote_api_event( domain=DOMAIN, @@ -119,7 +160,9 @@ class ArkPrivateAssetClient: request=payload, remote_message=str(exc), ) - raise + if self.for_celery: + raise + raise HTTPException(status_code=502, detail=f"火山私域素材接口调用失败:{self._exception_message(exc)}") from exc def _call_sync(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]: try: @@ -140,6 +183,7 @@ class ArkPrivateAssetClient: "https", ) # volcengine SDK 的 ApiInfo.query 必须是 dict,不能传 "Action=xxx&Version=xxx" 字符串。 + # volcengine SDK 签名时要求 body 是 bytes。 # ServiceInfo 默认 scheme='http',这里必须显式传 https,避免请求走 http://host:80。 api_info = { action.value: ApiInfo( @@ -151,26 +195,22 @@ class ArkPrivateAssetClient: ) } service = Service(service_info, api_info) - - # volcengine SDK 在签名阶段会对 request.body 做 hashlib.sha256(body)。 - # Python 3 下 hashlib.sha256 只能接收 bytes/bytearray,不能接收 dict 或 str。 body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") try: raw = service.json(action.value, {}, body) except Exception as exc: + resp = self._extract_response_from_exception(exc) + if resp: + self._raise_remote_error_if_any(action.value, resp) message = self._exception_message(exc) if "ConnectTimeout" in message or "timed out" in message or "Connection" in message: - raise HTTPException(status_code=502, detail=f"火山私域素材接口连接失败:{message}") from exc + raise ArkPrivateAssetClientError(f"{action.value} 连接火山私域素材接口失败:{message}") from exc raise ArkPrivateAssetClientError(f"{action.value} 请求火山私域素材接口异常:{message}") from exc resp = self._normalize_response(raw) + self._raise_remote_error_if_any(action.value, resp) metadata = resp.get("ResponseMetadata") if isinstance(resp, dict) else None - error = metadata.get("Error") if isinstance(metadata, dict) else None request_id = metadata.get("RequestId") if isinstance(metadata, dict) else None - if error: - code = error.get("Code") or "ArkPrivateAssetError" - message = error.get("Message") or str(error) - raise ArkPrivateAssetClientError(f"{action.value} 调用失败:{code} {message}") if isinstance(resp, dict) and isinstance(resp.get("Result"), dict): result = dict(resp["Result"]) if request_id: @@ -182,6 +222,36 @@ class ArkPrivateAssetClient: return resp return {"raw": resp, "RequestId": request_id} + @classmethod + def _raise_remote_error_if_any(cls, action: str, resp: dict[str, Any]) -> None: + metadata = resp.get("ResponseMetadata") if isinstance(resp, dict) else None + error = metadata.get("Error") if isinstance(metadata, dict) else None + if not error: + return + request_id = metadata.get("RequestId") if isinstance(metadata, dict) else None + code = error.get("Code") or "ArkPrivateAssetError" + message = error.get("Message") or str(error) + raise ArkPrivateAssetRemoteError(action=action, code=code, message=message, request_id=request_id, raw=resp) + + @classmethod + def _extract_response_from_exception(cls, exc: Exception) -> dict[str, Any] | None: + # volcengine SDK 在 HTTP 非 2xx 时会 raise Exception(resp.text.encode("utf-8")),这里把 bytes JSON 还原,避免业务错误变 500。 + if not exc.args: + return None + raw = exc.args[0] + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8", errors="ignore") + if not isinstance(raw, str): + return None + raw = raw.strip() + if not raw or not raw.startswith("{"): + return None + try: + obj = json.loads(raw) + except json.JSONDecodeError: + return None + return obj if isinstance(obj, dict) else None + @staticmethod def _exception_message(exc: Exception) -> str: if exc.args: diff --git a/video-gen-api/app/services/private_portrait/asset_service.py b/video-gen-api/app/services/private_portrait/asset_service.py index eb4f5495..fa4fc1fd 100644 --- a/video-gen-api/app/services/private_portrait/asset_service.py +++ b/video-gen-api/app/services/private_portrait/asset_service.py @@ -52,6 +52,17 @@ def _loads(data: str | None) -> Any: return None + +def _exception_message(exc: Exception) -> str: + if isinstance(exc, HTTPException): + detail = exc.detail + if isinstance(detail, dict): + message = detail.get("message") or detail.get("detail") or detail + return str(message) + return str(detail) + return str(exc) + + def _public_url(url: str) -> str: if url.startswith(("http://", "https://")): return url @@ -177,9 +188,9 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: return session except Exception as exc: session.status = PrivatePortraitValidateSessionStatus.FAILED.value - session.error_message = str(exc) + session.error_message = _exception_message(exc) await db.flush() - log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc) + log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc) raise @@ -252,7 +263,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p return session except Exception as exc: session.status = PrivatePortraitValidateSessionStatus.FAILED.value - session.error_message = str(exc) + session.error_message = _exception_message(exc) await db.flush() log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, exc=exc) raise @@ -314,7 +325,7 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo return asset except Exception as exc: asset.status = PrivatePortraitAssetStatus.FAILED.value - asset.error_message = str(exc) + asset.error_message = _exception_message(exc) await db.flush() log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc) raise diff --git a/video-gen-api/app/services/private_portrait/project_service.py b/video-gen-api/app/services/private_portrait/project_service.py index 68e558f1..6b111f7f 100644 --- a/video-gen-api/app/services/private_portrait/project_service.py +++ b/video-gen-api/app/services/private_portrait/project_service.py @@ -7,9 +7,8 @@ from fastapi import HTTPException from sqlalchemy import case, func, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.enums.private_portrait import ( - PRIVATE_PORTRAIT_PROJECT_ENV_VALUES, + PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, PrivatePortraitAssetGroupStatus, PrivatePortraitAssetStatus, PrivatePortraitEventSource, @@ -28,26 +27,12 @@ DOMAIN = "private_portrait" def _safe_slug(value: str, *, max_length: int = 80) -> str: value = (value or "").strip().lower() - # 先保留常见英文数字连字符;中文等字符统一转 _,避免火山 ProjectName 字符限制不明确导致失败。 + # 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。 value = re.sub(r"[^a-z0-9_-]+", "_", value) value = re.sub(r"_+", "_", value).strip("_-") return (value[:max_length] or "project") -def get_private_portrait_env() -> str: - env = str(getattr(settings, "APP_ENV", "local") or "local").strip().lower() - if env not in PRIVATE_PORTRAIT_PROJECT_ENV_VALUES: - env = "local" - return env - - -def build_remote_project_name(*, user_id: str, project_name: str) -> tuple[str, str]: - slug = _safe_slug(project_name) - user_part = _safe_slug(user_id, max_length=40) - env = get_private_portrait_env() - return f"{env}-{user_part}-{slug}"[:256], slug - - def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut: return PrivatePortraitProjectOut( id=project.id, @@ -81,13 +66,13 @@ async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) - async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject: - remote_project_name, slug = build_remote_project_name(user_id=user_id, project_name=payload.name) + slug = _safe_slug(payload.name) project = PrivatePortraitProject( id=generate_id(), user_id=user_id, name=payload.name.strip(), name_slug=slug, - remote_project_name=remote_project_name, + remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, description=payload.description, status=PrivatePortraitProjectStatus.ACTIVE.value, ) @@ -106,13 +91,6 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort return project -async def _project_has_remote_resources(db: AsyncSession, *, project_id: str) -> bool: - session_count = (await db.execute(select(func.count(PrivatePortraitValidateSession.id)).where(PrivatePortraitValidateSession.project_id == project_id))).scalar_one() or 0 - group_count = (await db.execute(select(func.count(PrivatePortraitAssetGroup.id)).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.remote_group_id.is_not(None)))).scalar_one() or 0 - asset_count = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.remote_asset_id.is_not(None)))).scalar_one() or 0 - return bool(session_count or group_count or asset_count) - - async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject: project = await get_user_project(db, user_id=user_id, project_id=project_id) before = { @@ -122,28 +100,12 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay "description": project.description, "status": project.status, } - remote_name_locked = False if payload.name is not None: new_name = payload.name.strip() if new_name and new_name != project.name: - has_remote = await _project_has_remote_resources(db, project_id=project.id) project.name = new_name - if not has_remote: - remote_project_name, slug = build_remote_project_name(user_id=user_id, project_name=new_name) - project.name_slug = slug - project.remote_project_name = remote_project_name - else: - remote_name_locked = True - log_operation_event( - domain=DOMAIN, - event_type=PrivatePortraitEventType.PROJECT_REMOTE_NAME_LOCKED.value, - event_status=PrivatePortraitEventStatus.SKIPPED.value, - source=PrivatePortraitEventSource.API.value, - user_id=user_id, - project_id=project.id, - message="项目已有远程资源,仅修改展示名,remote_project_name 保持不变", - detail={"remote_project_name": project.remote_project_name}, - ) + project.name_slug = _safe_slug(new_name) + project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME if payload.description is not None: project.description = payload.description if payload.status is not None: @@ -166,7 +128,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay user_id=user_id, project_id=project.id, message="更新真人素材项目", - detail={"before": before, "after": after, "remote_name_locked": remote_name_locked}, + detail={"before": before, "after": after}, ) return project