真人素材库修复相关BUG

This commit is contained in:
2026-07-07 10:48:52 +08:00
parent ab995ce3cf
commit 126754c933
20 changed files with 630 additions and 79 deletions
@@ -0,0 +1,31 @@
"""private portrait validate once
Revision ID: e7038d02c355
Revises: 6fc75582f6f9
Create Date: 2026-07-07 10:47:00.436752
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7038d02c355'
down_revision: Union[str, None] = '6fc75582f6f9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('uq_private_portrait_asset_groups_one_active_project', 'private_portrait_asset_groups', ['project_id'], unique=True, postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'"))
op.create_index('uq_private_portrait_validate_sessions_one_group_active_project', 'private_portrait_validate_sessions', ['project_id'], unique=True, postgresql_where=sa.text("status = 'group_active'"))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uq_private_portrait_validate_sessions_one_group_active_project', table_name='private_portrait_validate_sessions', postgresql_where=sa.text("status = 'group_active'"))
op.drop_index('uq_private_portrait_asset_groups_one_active_project', table_name='private_portrait_asset_groups', postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'"))
# ### end Alembic commands ###
+7 -6
View File
@@ -49,6 +49,7 @@ from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.notification import create_notification from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import sync_pending_orders, process_refund from app.services.payment import sync_pending_orders, process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
@@ -1449,15 +1450,15 @@ async def admin_list_generation_records(
query = query.offset(offset).limit(page_size) query = query.offset(offset).limit(page_size)
result = await db.execute(query) result = await db.execute(query)
rows = result.all() rows = result.all()
refs_map = await batch_resolve_private_portrait_reference_display_urls(
db,
{record.id: json.loads(record.media_references) if record.media_references else None for record, _username, _project_name, _industry, _industry_label in rows},
user_id=user_id,
)
items = [] items = []
for record, username, project_name, industry, industry_label in rows: for record, username, project_name, industry, industry_label in rows:
refs = None refs = refs_map.get(record.id)
if record.media_references:
try:
refs = json.loads(record.media_references)
except (json.JSONDecodeError, TypeError):
refs = None
items.append({ items.append({
"id": record.id, "id": record.id,
"user_id": record.user_id, "user_id": record.user_id,
+18 -8
View File
@@ -33,6 +33,7 @@ from app.services.resource_accounting_service import (
record_generation_record_generated_resource, record_generation_record_generated_resource,
safe_file_size, safe_file_size,
) )
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.generation_billing_service import ( from app.services.generation_billing_service import (
@@ -59,9 +60,9 @@ router = APIRouter(prefix="/generation-records", tags=["generation"])
logger = logging.getLogger("videogen") logger = logging.getLogger("videogen")
def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRecordOut: def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
refs = None refs = refs_override
if record.media_references: if refs is None and record.media_references:
try: try:
refs = json.loads(record.media_references) refs = json.loads(record.media_references)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
@@ -191,13 +192,18 @@ async def list_records(
result = await db.execute(query) result = await db.execute(query)
rows = result.all() rows = result.all()
refs_map = await batch_resolve_private_portrait_reference_display_urls(
db,
{record.id: json.loads(record.media_references) if record.media_references else None for record, _project_name in rows},
user_id=current_user.id,
)
return { return {
"total": int(total), "total": int(total),
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"items": [ "items": [
_record_to_out(record, project_name) _record_to_out(record, project_name, refs_override=refs_map.get(record.id))
for record, project_name in rows for record, project_name in rows
], ],
} }
@@ -240,11 +246,12 @@ async def optimize(
row = existing.first() row = existing.first()
if row: if row:
record, project_name = row record, project_name = row
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return OptimizeResult( return OptimizeResult(
optimized_prompt=record.optimized_prompt or "", optimized_prompt=record.optimized_prompt or "",
text_credits_cost=record.text_credits_cost or 0.00, text_credits_cost=record.text_credits_cost or 0.00,
text_tokens_used=record.text_tokens_used or 0, text_tokens_used=record.text_tokens_used or 0,
record=_record_to_out(record, project_name), record=_record_to_out(record, project_name, refs_override=refs),
) )
# Check project exists and belongs to user # Check project exists and belongs to user
@@ -370,11 +377,12 @@ async def optimize(
record.text_tokens_used = token_usage["total_tokens"] record.text_tokens_used = token_usage["total_tokens"]
await db.flush() await db.flush()
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return OptimizeResult( return OptimizeResult(
optimized_prompt=optimized, optimized_prompt=optimized,
text_credits_cost=round(text_credits, 2), text_credits_cost=round(text_credits, 2),
# text_tokens_used=token_usage["total_tokens"], # text_tokens_used=token_usage["total_tokens"],
record=_record_to_out(record, project.name), record=_record_to_out(record, project.name, refs_override=refs),
) )
@@ -502,7 +510,8 @@ async def generate(
) )
await db.flush() await db.flush()
return _record_to_out(record, project_name) refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return _record_to_out(record, project_name, refs_override=refs)
@router.post("/{record_id}/retry") @router.post("/{record_id}/retry")
@@ -578,7 +587,8 @@ async def retry_generation(
) )
await db.flush() await db.flush()
return _record_to_out(record, project_name) refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
return _record_to_out(record, project_name, refs_override=refs)
@router.put("/{record_id}/prompt") @router.put("/{record_id}/prompt")
+11 -3
View File
@@ -36,6 +36,7 @@ from app.services.generation_billing_service import (
from app.services.generation_history_delete_service import batch_delete_generation_history_items from app.services.generation_history_delete_service import batch_delete_generation_history_items
from app.services.generation_log_service import log_task_event from app.services.generation_log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
@@ -173,7 +174,8 @@ async def create_task(
await db.commit() await db.commit()
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试") raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
return record_to_out(task) refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
return record_to_out(task, media_references=refs)
@router.get( @router.get(
@@ -280,7 +282,12 @@ async def list_tasks(
) )
else: else:
items_sorted = items items_sorted = items
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin) for i in items_sorted]) refs_map = await batch_resolve_private_portrait_reference_display_urls(
db,
{item.id: record_to_out(task=item, is_admin=is_admin).media_references for item in items_sorted},
user_id=None if is_admin else current_user.id,
)
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin, media_references=refs_map.get(i.id)) for i in items_sorted])
@router.get( @router.get(
@@ -521,7 +528,8 @@ async def get_task(
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
return record_to_out(task) refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
return record_to_out(task, media_references=refs)
@router.delete( @router.delete(
+28 -11
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from urllib.parse import unquote from urllib.parse import urlencode, unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -12,6 +12,7 @@ from app.enums.private_portrait import (
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
@@ -22,6 +23,7 @@ from app.schemas.private_portrait import (
PrivatePortraitDeleteOut, PrivatePortraitDeleteOut,
PrivatePortraitConfigOut, PrivatePortraitConfigOut,
PrivatePortraitProjectCreate, PrivatePortraitProjectCreate,
PrivatePortraitProjectCreateWithValidateOut,
PrivatePortraitProjectListOut, PrivatePortraitProjectListOut,
PrivatePortraitProjectOut, PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate, PrivatePortraitProjectUpdate,
@@ -88,10 +90,20 @@ async def get_my_private_portrait_config(current_user: User = Depends(get_curren
return await get_user_private_portrait_config(db, user_id=current_user.id) return await get_user_private_portrait_config(db, user_id=current_user.id)
@router.post("/private-portrait/projects", response_model=PrivatePortraitProjectOut) @router.post("/private-portrait/projects", response_model=PrivatePortraitProjectCreateWithValidateOut)
async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await create_project(db, user_id=current_user.id, payload=payload) project = await create_project(db, user_id=current_user.id, payload=payload)
out = project_to_out(project) session = await create_validate_session(
db,
user_id=current_user.id,
project_id=project.id,
callback_redirect_url=payload.callback_redirect_url,
)
out = PrivatePortraitProjectCreateWithValidateOut(
project=project_to_out(project),
validate_session=validate_session_to_out(session),
poll_interval_ms=2000,
)
await db.commit() await db.commit()
return out return out
@@ -105,10 +117,11 @@ async def list_private_portrait_projects(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=status) query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status)
await refresh_project_counters(db, [item.id for item in items]) await refresh_project_counters(db, [item.id for item in items])
await db.commit() await db.commit()
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=status) items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status)
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size) return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@@ -159,15 +172,19 @@ async def private_portrait_validate_callback(session_id: str, request: Request,
params.pop("session_id", None) params.pop("session_id", None)
params.pop("redirect_url", None) params.pop("redirect_url", None)
session = await handle_validate_callback(db, session_id=session_id, query_params=params) session = await handle_validate_callback(db, session_id=session_id, query_params=params)
redirect_session_id = session.id redirect_params = {
redirect_status = session.status "session_id": session.id,
redirect_result_code = session.result_code or "" "status": session.status,
"resultCode": session.result_code or "",
}
if session.remote_group_id:
redirect_params["remote_group_id"] = session.remote_group_id
response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id} response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id}
await db.commit() await db.commit()
if redirect_url: if redirect_url:
sep = "&" if "?" in redirect_url else "?" base_url = unquote(redirect_url)
url = f"{unquote(redirect_url)}{sep}session_id={redirect_session_id}&status={redirect_status}&resultCode={redirect_result_code}" sep = "&" if "?" in base_url else "?"
return RedirectResponse(url=url) return RedirectResponse(url=f"{base_url}{sep}{urlencode(redirect_params)}")
return response return response
@@ -56,7 +56,9 @@ class ArkPrivatePortraitAction(str, Enum):
class PrivatePortraitProjectStatus(str, Enum): class PrivatePortraitProjectStatus(str, Enum):
VALIDATING = "validating"
ACTIVE = "active" ACTIVE = "active"
VALIDATE_FAILED = "validate_failed"
DELETED = "deleted" DELETED = "deleted"
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import ( from app.enums.private_portrait import (
@@ -23,6 +23,12 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
Index("idx_private_portrait_asset_groups_project_status", "project_id", "status"), Index("idx_private_portrait_asset_groups_project_status", "project_id", "status"),
Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_status"), Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_status"),
Index("idx_private_portrait_asset_groups_remote_project_name", "remote_project_name"), Index("idx_private_portrait_asset_groups_remote_project_name", "remote_project_name"),
Index(
"uq_private_portrait_asset_groups_one_active_project",
"project_id",
unique=True,
postgresql_where=text("deleted_at IS NULL AND status = 'active'"),
),
) )
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import PrivatePortraitValidateSessionStatus from app.enums.private_portrait import PrivatePortraitValidateSessionStatus
@@ -17,6 +17,12 @@ class PrivatePortraitValidateSession(Base, TimestampMixin):
Index("idx_private_portrait_validate_sessions_user_project", "user_id", "project_id"), Index("idx_private_portrait_validate_sessions_user_project", "user_id", "project_id"),
Index("idx_private_portrait_validate_sessions_byted_token", "byted_token"), Index("idx_private_portrait_validate_sessions_byted_token", "byted_token"),
Index("idx_private_portrait_validate_sessions_status_created", "status", "created_at"), Index("idx_private_portrait_validate_sessions_status_created", "status", "created_at"),
Index(
"uq_private_portrait_validate_sessions_one_group_active_project",
"project_id",
unique=True,
postgresql_where=text("status = 'group_active'"),
),
) )
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
@@ -56,6 +56,21 @@ class GenerationAIReference(BaseModel):
description="后端回填的火山 Asset ID。前端传入时不可信,创建任务时以后端查库为准", description="后端回填的火山 Asset ID。前端传入时不可信,创建任务时以后端查库为准",
examples=["asset-20260318071009-xxxxx"], examples=["asset-20260318071009-xxxxx"],
) )
provider_url: str | None = Field(
None,
description="供应商专用素材地址。真人素材生成时通常为 asset://remote_asset_id;仅用于后端排查和生成链路,不用于前端预览",
examples=["asset://asset-20260318071009-xxxxx"],
)
display_url: str | None = Field(
None,
description="前端展示用素材地址。真人素材历史响应会回填为可预览图片/视频/音频 URL",
examples=["/uploads/images/2026/07/06/demo.jpg"],
)
preview_url: str | None = Field(
None,
description="前端预览用素材地址;通常与 display_url 一致",
examples=["/uploads/images/2026/07/06/demo.jpg"],
)
class GenerationAITaskCreate(BaseModel): class GenerationAITaskCreate(BaseModel):
@@ -22,6 +22,7 @@ class PrivatePortraitAdminConfigUpdate(BaseModel):
class PrivatePortraitProjectCreate(BaseModel): class PrivatePortraitProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128) name: str = Field(..., min_length=1, max_length=128)
description: str | None = Field(None, max_length=2000) description: str | None = Field(None, max_length=2000)
callback_redirect_url: str | None = Field(None, description="项目创建时真人认证完成后的手机端提示页地址")
class PrivatePortraitProjectUpdate(BaseModel): class PrivatePortraitProjectUpdate(BaseModel):
@@ -80,6 +81,14 @@ class PrivatePortraitValidateSessionOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
project: PrivatePortraitProjectOut
validate_session: PrivatePortraitValidateSessionOut
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒")
class PrivatePortraitAssetGroupOut(BaseModel): class PrivatePortraitAssetGroupOut(BaseModel):
id: str id: str
user_id: str | None = None user_id: str | None = None
@@ -54,7 +54,7 @@ from app.services.generation_history_meta_service import (
build_empty_history_meta, build_empty_history_meta,
) )
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls, resolve_private_portrait_references
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
IMAGE_DEFAULT_SIZE = "2K" IMAGE_DEFAULT_SIZE = "2K"
@@ -88,6 +88,22 @@ def _parse_json(text: str | None):
return None return None
async def _resolve_task_reference_display_map(db: AsyncSession, tasks: list[ChatGenerationTask], *, user_id: str | None = None) -> dict[str, list[dict] | None]:
return await batch_resolve_private_portrait_reference_display_urls(
db,
{task.id: _parse_json(task.media_references) for task in tasks},
user_id=user_id,
)
async def _resolve_generation_record_reference_display_map(db: AsyncSession, records: list[GenerationRecord], *, user_id: str | None = None) -> dict[str, list[dict] | None]:
return await batch_resolve_private_portrait_reference_display_urls(
db,
{record.id: _parse_json(record.media_references) for record in records},
user_id=user_id,
)
async def _get_image_engine(db: AsyncSession, engine_id: str | None) -> ImageEngine: async def _get_image_engine(db: AsyncSession, engine_id: str | None) -> ImageEngine:
query = select(ImageEngine).where(ImageEngine.is_active == True) query = select(ImageEngine).where(ImageEngine.is_active == True)
if engine_id: if engine_id:
@@ -462,8 +478,9 @@ def record_to_out(
generated_resource_id: str | None = None, generated_resource_id: str | None = None,
file_name: str | None = None, file_name: str | None = None,
history_meta: GenerationHistoryMeta | None = None, history_meta: GenerationHistoryMeta | None = None,
media_references: list[dict] | None = None,
) -> GenerationAITaskOut: ) -> GenerationAITaskOut:
refs = _parse_json(task.media_references) refs = media_references if media_references is not None else _parse_json(task.media_references)
snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json)) snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json))
source = GenerationHistorySourceEnum.CHAT_TASK source = GenerationHistorySourceEnum.CHAT_TASK
@@ -688,8 +705,9 @@ def generation_record_to_history_out(
project_name: str | None = None, project_name: str | None = None,
generated_resource_id: str | None = None, generated_resource_id: str | None = None,
file_name: str | None = None, file_name: str | None = None,
media_references: list[dict] | None = None,
) -> GenerationAIRecordHistoryItemOut: ) -> GenerationAIRecordHistoryItemOut:
refs = _parse_json(record.media_references) refs = media_references if media_references is not None else _parse_json(record.media_references)
return GenerationAIRecordHistoryItemOut( return GenerationAIRecordHistoryItemOut(
id=record.id, id=record.id,
source_type="generation_record", source_type="generation_record",
@@ -814,6 +832,8 @@ async def list_generation_record_history_grouped_days(
source_ids=all_record_ids, source_ids=all_record_ids,
resource_type=gen_type, resource_type=gen_type,
) )
all_records = [record for _generated_day, _day_total, rows in raw_groups for record, _project_name in rows]
reference_display_map = await _resolve_generation_record_reference_display_map(db, all_records, user_id=user_id)
groups = [ groups = [
{ {
@@ -825,6 +845,7 @@ async def list_generation_record_history_grouped_days(
project_name, project_name,
generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"),
file_name=resource_info_map.get(record.id, {}).get("file_name"), file_name=resource_info_map.get(record.id, {}).get("file_name"),
media_references=reference_display_map.get(record.id),
) )
for record, project_name in rows for record, project_name in rows
], ],
@@ -887,6 +908,7 @@ async def list_generation_record_history_day_items(
source_ids=[record.id for record, _project_name in rows], source_ids=[record.id for record, _project_name in rows],
resource_type=gen_type, resource_type=gen_type,
) )
reference_display_map = await _resolve_generation_record_reference_display_map(db, [record for record, _project_name in rows], user_id=user_id)
return { return {
"generated_date": target_day.strftime("%Y-%m-%d"), "generated_date": target_day.strftime("%Y-%m-%d"),
@@ -899,6 +921,7 @@ async def list_generation_record_history_day_items(
project_name, project_name,
generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(record.id, {}).get("resource_id"),
file_name=resource_info_map.get(record.id, {}).get("file_name"), file_name=resource_info_map.get(record.id, {}).get("file_name"),
media_references=reference_display_map.get(record.id),
) )
for record, project_name in rows for record, project_name in rows
], ],
@@ -989,6 +1012,8 @@ async def list_generation_history_grouped_days(
source=source, source=source,
chat_task_ids=all_task_ids, chat_task_ids=all_task_ids,
) )
all_tasks = [task for _generated_day, _day_total, tasks in raw_groups for task in tasks]
reference_display_map = await _resolve_task_reference_display_map(db, all_tasks, user_id=user_id)
groups = [ groups = [
{ {
@@ -1000,6 +1025,7 @@ async def list_generation_history_grouped_days(
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"), file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id), history_meta=history_meta_map.get(task.id),
media_references=reference_display_map.get(task.id),
) )
for task in tasks for task in tasks
], ],
@@ -1082,6 +1108,7 @@ async def list_generation_history_day_items(
source=source, source=source,
chat_task_ids=task_ids, chat_task_ids=task_ids,
) )
reference_display_map = await _resolve_task_reference_display_map(db, tasks, user_id=user_id)
return { return {
"generated_date": target_day.strftime("%Y-%m-%d"), "generated_date": target_day.strftime("%Y-%m-%d"),
@@ -1094,6 +1121,7 @@ async def list_generation_history_day_items(
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"), generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"), file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id), history_meta=history_meta_map.get(task.id),
media_references=reference_display_map.get(task.id),
) )
for task in tasks for task in tasks
], ],
@@ -23,6 +23,7 @@ from app.enums.private_portrait import (
PrivatePortraitEventSource, PrivatePortraitEventSource,
PrivatePortraitEventStatus, PrivatePortraitEventStatus,
PrivatePortraitEventType, PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
PrivatePortraitValidateSessionStatus, PrivatePortraitValidateSessionStatus,
) )
@@ -165,8 +166,69 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
) )
async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> PrivatePortraitAssetGroup | None:
return (
await db.execute(
select(PrivatePortraitAssetGroup)
.where(
PrivatePortraitAssetGroup.project_id == project_id,
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
PrivatePortraitAssetGroup.deleted_at.is_(None),
)
.order_by(PrivatePortraitAssetGroup.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePortraitProject) -> PrivatePortraitValidateSession | None:
active_group = await _get_existing_active_group(db, project_id=project.id)
if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
success_session = (
await db.execute(
select(PrivatePortraitValidateSession)
.where(
PrivatePortraitValidateSession.project_id == project.id,
PrivatePortraitValidateSession.status == PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value,
)
.order_by(PrivatePortraitValidateSession.updated_at.desc())
.limit(1)
)
).scalar_one_or_none()
if success_session:
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
now = datetime.now(timezone.utc)
pending_session = (
await db.execute(
select(PrivatePortraitValidateSession)
.where(
PrivatePortraitValidateSession.project_id == project.id,
PrivatePortraitValidateSession.status.in_(
[
PrivatePortraitValidateSessionStatus.CREATED.value,
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value,
]
),
PrivatePortraitValidateSession.expired_at.is_not(None),
PrivatePortraitValidateSession.expired_at > now,
)
.order_by(PrivatePortraitValidateSession.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
return pending_session
async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession: async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession:
project = await get_user_project(db, user_id=user_id, project_id=project_id) project = await get_user_project(db, user_id=user_id, project_id=project_id)
reusable_session = await _ensure_project_can_validate(db, project=project)
if reusable_session:
return reusable_session
project.status = PrivatePortraitProjectStatus.VALIDATING.value
session = PrivatePortraitValidateSession( session = PrivatePortraitValidateSession(
id=generate_id(), id=generate_id(),
user_id=user_id, user_id=user_id,
@@ -192,11 +254,11 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
except Exception as exc: except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = _exception_message(exc) session.error_message = _exception_message(exc)
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() await db.flush()
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) 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 raise
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession: async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
filters = [PrivatePortraitValidateSession.id == session_id] filters = [PrivatePortraitValidateSession.id == session_id]
if user_id is not None: if user_id is not None:
@@ -209,6 +271,9 @@ async def get_validate_session(db: AsyncSession, *, user_id: str | None, session
async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_params: dict[str, Any]) -> PrivatePortraitValidateSession: async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_params: dict[str, Any]) -> PrivatePortraitValidateSession:
session = await get_validate_session(db, user_id=None, session_id=session_id) session = await get_validate_session(db, user_id=None, session_id=session_id)
if session.status == PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value:
return session
session.raw_callback_json = _json(query_params) session.raw_callback_json = _json(query_params)
session.result_code = str(query_params.get("resultCode") or query_params.get("result_code") or "") or None session.result_code = str(query_params.get("resultCode") or query_params.get("result_code") or "") or None
session.algorithm_base_resp_code = str(query_params.get("algorithmBaseRespCode") or query_params.get("algorithm_base_resp_code") or "") or None session.algorithm_base_resp_code = str(query_params.get("algorithmBaseRespCode") or query_params.get("algorithm_base_resp_code") or "") or None
@@ -218,9 +283,13 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
session.byted_token = str(token) session.byted_token = str(token)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_RECEIVED.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"query_params": query_params, "remote_project_name": session.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_RECEIVED.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"query_params": query_params, "remote_project_name": session.remote_project_name})
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == session.project_id).limit(1))).scalar_one_or_none()
if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE: if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE:
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value
session.error_message = f"真人认证失败:resultCode={session.result_code}" session.error_message = f"真人认证失败:resultCode={session.result_code}"
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_FAILED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, error=session.error_message) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_FAILED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, error=session.error_message)
return session return session
@@ -229,10 +298,22 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
if not session.byted_token: if not session.byted_token:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = "Callback 未返回 BytedToken" session.error_message = "Callback 未返回 BytedToken"
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() await db.flush()
raise HTTPException(status_code=400, detail=session.error_message) raise HTTPException(status_code=400, detail=session.error_message)
try: try:
existing_group = await _get_existing_active_group(db, project_id=session.project_id)
if existing_group:
session.remote_group_id = existing_group.remote_group_id
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
if project:
project.status = PrivatePortraitProjectStatus.ACTIVE.value
await db.flush()
await db.refresh(session)
return session
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"remote_project_name": session.remote_project_name}) log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"remote_project_name": session.remote_project_name})
resp = await ArkPrivateAssetClient().get_visual_validate_result(project_name=session.remote_project_name, byted_token=session.byted_token) resp = await ArkPrivateAssetClient().get_visual_validate_result(project_name=session.remote_project_name, byted_token=session.byted_token)
group_id = resp.get("GroupId") or resp.get("groupId") group_id = resp.get("GroupId") or resp.get("groupId")
@@ -242,7 +323,8 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
session.raw_response_json = _json(resp) session.raw_response_json = _json(resp)
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == session.project_id).limit(1))).scalar_one() if not project:
raise RuntimeError("真人素材项目不存在")
remote_group_name = _remote_group_name(session.user_id, project.name) remote_group_name = _remote_group_name(session.user_id, project.name)
group = PrivatePortraitAssetGroup( group = PrivatePortraitAssetGroup(
id=generate_id(), id=generate_id(),
@@ -256,6 +338,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
raw_response_json=_json(resp), raw_response_json=_json(resp),
) )
db.add(group) db.add(group)
project.status = PrivatePortraitProjectStatus.ACTIVE.value
await db.flush() await db.flush()
try: try:
await ArkPrivateAssetClient().update_asset_group(project_name=session.remote_project_name, group_id=group_id, name=remote_group_name, title=remote_group_name, description=project.description) await ArkPrivateAssetClient().update_asset_group(project_name=session.remote_project_name, group_id=group_id, name=remote_group_name, title=remote_group_name, description=project.description)
@@ -269,12 +352,16 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
except Exception as exc: except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = _exception_message(exc) session.error_message = _exception_message(exc)
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
await db.flush() 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) 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 raise
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitAssetGroup: async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitAssetGroup:
project = await get_user_project(db, user_id=user_id, project_id=project_id)
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
result = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.user_id == user_id, PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, PrivatePortraitAssetGroup.deleted_at.is_(None)).order_by(PrivatePortraitAssetGroup.created_at.desc()).limit(1)) result = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.user_id == user_id, PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, PrivatePortraitAssetGroup.deleted_at.is_(None)).order_by(PrivatePortraitAssetGroup.created_at.desc()).limit(1))
group = result.scalar_one_or_none() group = result.scalar_one_or_none()
if not group: if not group:
@@ -284,6 +371,8 @@ async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id
async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset: async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset:
project = await get_user_project(db, user_id=user_id, project_id=project_id) project = await get_user_project(db, user_id=user_id, project_id=project_id)
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="项目正在真人认证或认证未通过,不能上传素材")
if payload.asset_type != PrivatePortraitAssetType.IMAGE.value: if payload.asset_type != PrivatePortraitAssetType.IMAGE.value:
raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材") raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材")
user = await _lock_user_for_upload(db, user_id=user_id) user = await _lock_user_for_upload(db, user_id=user_id)
@@ -17,7 +17,7 @@ from app.enums.private_portrait import (
PrivatePortraitProjectStatus, PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus, PrivatePortraitRemoteDeleteStatus,
) )
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
from app.services.operation_log_service import log_operation_event from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -74,19 +74,19 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort
name_slug=slug, name_slug=slug,
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
description=payload.description, description=payload.description,
status=PrivatePortraitProjectStatus.ACTIVE.value, status=PrivatePortraitProjectStatus.VALIDATING.value,
) )
db.add(project) db.add(project)
await db.flush() await db.flush()
log_operation_event( log_operation_event(
domain=DOMAIN, domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_CREATE.value, event_type=PrivatePortraitEventType.PROJECT_CREATE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value, event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.API.value, source=PrivatePortraitEventSource.API.value,
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
message="创建真人素材项目", message="创建待认证真人素材项目",
detail={"name": project.name, "remote_project_name": project.remote_project_name}, detail={"name": project.name, "remote_project_name": project.remote_project_name, "status": project.status},
) )
return project return project
@@ -109,7 +109,12 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
if payload.description is not None: if payload.description is not None:
project.description = payload.description project.description = payload.description
if payload.status is not None: if payload.status is not None:
if payload.status not in {PrivatePortraitProjectStatus.ACTIVE.value}: allowed_statuses = {
PrivatePortraitProjectStatus.VALIDATING.value,
PrivatePortraitProjectStatus.ACTIVE.value,
PrivatePortraitProjectStatus.VALIDATE_FAILED.value,
}
if payload.status not in allowed_statuses:
raise HTTPException(status_code=400, detail="项目状态不支持") raise HTTPException(status_code=400, detail="项目状态不支持")
project.status = payload.status project.status = payload.status
await db.flush() await db.flush()
@@ -133,7 +138,15 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
return project return project
async def list_projects(db: AsyncSession, *, user_id: str | None, page: int = 1, page_size: int = 20, keyword: str | None = None, status: str | None = None) -> tuple[list[PrivatePortraitProject], int]: async def list_projects(
db: AsyncSession,
*,
user_id: str | None,
page: int = 1,
page_size: int = 20,
keyword: str | None = None,
status: str | None = None,
) -> tuple[list[PrivatePortraitProject], int]:
page = max(1, page) page = max(1, page)
page_size = min(max(1, page_size), 100) page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitProject.deleted_at.is_(None)] filters = [PrivatePortraitProject.deleted_at.is_(None)]
@@ -4,7 +4,7 @@ from copy import deepcopy
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import ( from app.enums.private_portrait import (
@@ -58,6 +58,162 @@ def _normalize_ref_type(value: Any) -> str:
return str(value or "").strip().lower() return str(value or "").strip().lower()
def _remote_asset_id_from_asset_uri(url: Any) -> str | None:
value = str(url or "").strip()
if not value.startswith(PRIVATE_PORTRAIT_ASSET_URI_PREFIX):
return None
remote_asset_id = value[len(PRIVATE_PORTRAIT_ASSET_URI_PREFIX):].strip()
return remote_asset_id or None
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
# preview_url 是本地上传预览,remote_url 是火山 GetAsset 返回的远程资源 URLsource_url 是兜底公网上传地址。
return asset.preview_url or asset.remote_url or asset.source_url or None
def _fill_private_portrait_reference_display_fields(ref: Any, asset: PrivatePortraitAsset) -> None:
provider_url = str(_ref_get(ref, "provider_url") or _ref_get(ref, "url") or "").strip()
if provider_url.startswith(PRIVATE_PORTRAIT_ASSET_URI_PREFIX):
_ref_set(ref, "provider_url", provider_url)
display_url = _asset_display_url(asset)
if display_url:
# 返回给前端的 url 必须可预览;供应商专用 asset:// 保留到 provider_url,避免管理后台和客户端展示黑图。
_ref_set(ref, "url", display_url)
_ref_set(ref, "display_url", display_url)
_ref_set(ref, "preview_url", display_url)
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
_ref_set(ref, "private_asset_id", asset.id)
if asset.remote_asset_id:
_ref_set(ref, "remote_asset_id", asset.remote_asset_id)
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
if expected_ref_type:
_ref_set(ref, "type", expected_ref_type)
if not _ref_get(ref, "name") and asset.name:
_ref_set(ref, "name", asset.name)
async def resolve_private_portrait_reference_display_urls(
db: AsyncSession,
media_references: list[Any] | None,
*,
user_id: str | None = None,
) -> list[Any] | None:
"""把历史响应里的 asset:// 引用补成前端可预览 URL。
生成任务入库时 url 使用 asset://remote_asset_id 传给供应商;但客户端/管理后台展示不能直接用
asset://。这里批量根据 private_asset_id 或 asset://remote_asset_id 查本地素材,并把响应中的 url
改成 preview_url/remote_url/source_url,同时保留 provider_url=asset://... 供排查。
"""
if not media_references:
return media_references
refs = deepcopy(media_references)
private_asset_ids: list[str] = []
remote_asset_ids: list[str] = []
for ref in refs:
source = _ref_get(ref, "source")
private_asset_id = _ref_get(ref, "private_asset_id")
remote_asset_id = _ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url"))
if source == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value or remote_asset_id:
if private_asset_id:
private_asset_ids.append(str(private_asset_id))
if remote_asset_id:
remote_asset_ids.append(str(remote_asset_id))
private_asset_ids = list(dict.fromkeys(private_asset_ids))
remote_asset_ids = list(dict.fromkeys(remote_asset_ids))
if not private_asset_ids and not remote_asset_ids:
return refs
filters = []
if private_asset_ids:
filters.append(PrivatePortraitAsset.id.in_(private_asset_ids))
if remote_asset_ids:
filters.append(PrivatePortraitAsset.remote_asset_id.in_(remote_asset_ids))
stmt = select(PrivatePortraitAsset).where(or_(*filters))
if user_id is not None:
stmt = stmt.where(PrivatePortraitAsset.user_id == user_id)
rows = await db.execute(stmt)
assets = list(rows.scalars().all())
by_id = {asset.id: asset for asset in assets}
by_remote_id = {asset.remote_asset_id: asset for asset in assets if asset.remote_asset_id}
for ref in refs:
private_asset_id = str(_ref_get(ref, "private_asset_id") or "").strip()
remote_asset_id = str(_ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url")) or "").strip()
asset = by_id.get(private_asset_id) or by_remote_id.get(remote_asset_id)
if not asset:
continue
_fill_private_portrait_reference_display_fields(ref, asset)
return refs
async def batch_resolve_private_portrait_reference_display_urls(
db: AsyncSession,
references_by_key: dict[Any, list[Any] | None],
*,
user_id: str | None = None,
) -> dict[Any, list[Any] | None]:
if not references_by_key:
return {}
copied: dict[Any, list[Any] | None] = {
key: deepcopy(refs) if refs else refs
for key, refs in references_by_key.items()
}
private_asset_ids: list[str] = []
remote_asset_ids: list[str] = []
for refs in copied.values():
if not refs:
continue
for ref in refs:
source = _ref_get(ref, "source")
private_asset_id = _ref_get(ref, "private_asset_id")
remote_asset_id = _ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url"))
if source == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value or remote_asset_id:
if private_asset_id:
private_asset_ids.append(str(private_asset_id))
if remote_asset_id:
remote_asset_ids.append(str(remote_asset_id))
private_asset_ids = list(dict.fromkeys(private_asset_ids))
remote_asset_ids = list(dict.fromkeys(remote_asset_ids))
if not private_asset_ids and not remote_asset_ids:
return copied
filters = []
if private_asset_ids:
filters.append(PrivatePortraitAsset.id.in_(private_asset_ids))
if remote_asset_ids:
filters.append(PrivatePortraitAsset.remote_asset_id.in_(remote_asset_ids))
stmt = select(PrivatePortraitAsset).where(or_(*filters))
if user_id is not None:
stmt = stmt.where(PrivatePortraitAsset.user_id == user_id)
rows = await db.execute(stmt)
assets = list(rows.scalars().all())
by_id = {asset.id: asset for asset in assets}
by_remote_id = {asset.remote_asset_id: asset for asset in assets if asset.remote_asset_id}
for refs in copied.values():
if not refs:
continue
for ref in refs:
private_asset_id = str(_ref_get(ref, "private_asset_id") or "").strip()
remote_asset_id = str(_ref_get(ref, "remote_asset_id") or _remote_asset_id_from_asset_uri(_ref_get(ref, "url")) or "").strip()
asset = by_id.get(private_asset_id) or by_remote_id.get(remote_asset_id)
if not asset:
continue
_fill_private_portrait_reference_display_fields(ref, asset)
return copied
async def resolve_private_portrait_references( async def resolve_private_portrait_references(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -123,10 +279,16 @@ async def resolve_private_portrait_references(
if expected_ref_type and ref_type and ref_type != expected_ref_type: if expected_ref_type and ref_type and ref_type != expected_ref_type:
raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}") raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}"
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value) _ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
_ref_set(ref, "private_asset_id", asset.id) _ref_set(ref, "private_asset_id", asset.id)
_ref_set(ref, "remote_asset_id", asset.remote_asset_id) _ref_set(ref, "remote_asset_id", asset.remote_asset_id)
_ref_set(ref, "url", f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}") _ref_set(ref, "url", provider_url)
_ref_set(ref, "provider_url", provider_url)
display_url = _asset_display_url(asset)
if display_url:
_ref_set(ref, "display_url", display_url)
_ref_set(ref, "preview_url", display_url)
if expected_ref_type: if expected_ref_type:
_ref_set(ref, "type", expected_ref_type) _ref_set(ref, "type", expected_ref_type)
if not _ref_get(ref, "name") and asset.name: if not _ref_get(ref, "name") and asset.name:
+2
View File
@@ -34,6 +34,7 @@ import PopularPage from './pages/PopularPage';
import CreativePlazaPage from './pages/CreativePlazaPage'; import CreativePlazaPage from './pages/CreativePlazaPage';
import TeamManagementPage from './pages/TeamManagementPage'; import TeamManagementPage from './pages/TeamManagementPage';
import JoinTeamPage from './pages/JoinTeamPage'; import JoinTeamPage from './pages/JoinTeamPage';
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
import { useAuthStore } from './store/useAuthStore'; import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore(); const { user, loading, checkAuth } = useAuthStore();
@@ -92,6 +93,7 @@ const App = () => {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/private-portrait-authorized" element={<PrivatePortraitAuthorizeResult />} />
<Route <Route
path="/" path="/"
element={ element={
+7 -3
View File
@@ -8,7 +8,7 @@ import type {
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult, User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification, Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut, PrivatePortraitProjectCreateWithValidateOut, PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
} from '../types'; } from '../types';
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'; const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
// ── Auth ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
@@ -756,8 +756,12 @@ export async function getPrivatePortraitProjects(params: { page?: number; pageSi
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/projects?${query.toString()}`); return api.get<PrivatePortraitProjectListOut>(`/private-portrait/projects?${query.toString()}`);
} }
export async function createPrivatePortraitProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> { export async function createPrivatePortraitProject(payload: { name: string; description?: string | null; callbackRedirectUrl?: string | null }): Promise<PrivatePortraitProjectCreateWithValidateOut> {
return api.post<PrivatePortraitProject>('/private-portrait/projects', payload); return api.post<PrivatePortraitProjectCreateWithValidateOut>('/private-portrait/projects', {
name: payload.name,
description: payload.description || null,
callback_redirect_url: payload.callbackRedirectUrl || null,
});
} }
export async function updatePrivatePortraitProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> { export async function updatePrivatePortraitProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
@@ -1,22 +1,47 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, Row, Space, Typography, message } from 'antd'; import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
import { createPrivatePortraitProject, getPrivatePortraitProjects } from '../../../api'; import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
import PrivatePortraitProjectList from './ProjectList'; import PrivatePortraitProjectList from './ProjectList';
import PrivatePortraitProjectDetail from './ProjectDetail'; import PrivatePortraitProjectDetail from './ProjectDetail';
const VALIDATE_SUCCESS_STATUS = 'group_active';
const POLL_INTERVAL_FALLBACK = 2000;
const PrivatePortraitLibraryPanel: React.FC = () => { const PrivatePortraitLibraryPanel: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selected, setSelected] = useState<PrivatePortraitProject | null>(null); const [selected, setSelected] = useState<PrivatePortraitProject | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createdProject, setCreatedProject] = useState<PrivatePortraitProject | null>(null);
const [validateSession, setValidateSession] = useState<PrivatePortraitValidateSession | null>(null);
const [polling, setPolling] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const timerRef = useRef<number | null>(null);
const clearPollTimer = () => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
const resetCreateModal = () => {
clearPollTimer();
setCreateOpen(false);
setCreating(false);
setPolling(false);
setCreatedProject(null);
setValidateSession(null);
form.resetFields();
};
const loadProjects = async () => { const loadProjects = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getPrivatePortraitProjects({ pageSize: 100 }); const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items); setProjects(res.items);
setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null)); setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null));
} catch (e: any) { } catch (e: any) {
@@ -27,27 +52,75 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
}; };
useEffect(() => { loadProjects(); }, []); useEffect(() => { loadProjects(); }, []);
useEffect(() => () => clearPollTimer(), []);
const finishCreateSuccess = async (projectId: string) => {
clearPollTimer();
setPolling(false);
message.success('真人认证完成,项目组已创建成功');
setCreateOpen(false);
setValidateSession(null);
setCreatedProject(null);
form.resetFields();
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items);
setSelected(res.items.find((item) => item.id === projectId) || res.items[0] || null);
};
const startPolling = (sessionId: string, projectId: string, intervalMs: number) => {
clearPollTimer();
setPolling(true);
const run = async () => {
try {
const next = await getPrivatePortraitValidateSession(sessionId);
setValidateSession(next);
if (next.status === VALIDATE_SUCCESS_STATUS) {
await finishCreateSuccess(projectId);
return;
}
if (['callback_failed', 'failed', 'expired'].includes(next.status)) {
clearPollTimer();
setPolling(false);
message.error(next.errorMessage || '真人认证未完成,请重新创建项目组');
}
} catch (e: any) {
clearPollTimer();
setPolling(false);
message.error(e?.message || '轮询真人认证状态失败');
}
};
timerRef.current = window.setInterval(run, Math.max(1000, intervalMs || POLL_INTERVAL_FALLBACK));
void run();
};
const handleCreate = async () => { const handleCreate = async () => {
const values = await form.validateFields(); const values = await form.validateFields();
setCreating(true);
try { try {
const project = await createPrivatePortraitProject(values); const callbackRedirectUrl = `${window.location.origin}/private-portrait-authorized`;
message.success('项目组已创建'); const res = await createPrivatePortraitProject({ ...values, callbackRedirectUrl });
setCreateOpen(false); setCreatedProject(res.project);
form.resetFields(); setValidateSession(res.validateSession);
await loadProjects(); message.success('请使用手机扫码完成人脸认证');
setSelected(project); if (res.validateSession?.id) {
startPolling(res.validateSession.id, res.project.id, res.pollIntervalMs || POLL_INTERVAL_FALLBACK);
}
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '创建项目组失败'); message.error(e?.message || '创建项目组认证二维码失败');
} finally {
setCreating(false);
} }
}; };
const h5Link = validateSession?.h5Link || '';
const isSuccess = validateSession?.status === VALIDATE_SUCCESS_STATUS;
return ( return (
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"> Active AI </Typography.Text> <Typography.Text type="secondary"></Typography.Text>
</div> </div>
<Space> <Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button> <Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button>
@@ -68,7 +141,17 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
)} )}
</Col> </Col>
</Row> </Row>
<Modal title="新建真人素材项目组" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={handleCreate} okText="创建"> <Modal
title="新建真人素材项目组"
open={createOpen}
onCancel={resetCreateModal}
footer={validateSession ? [<Button key="close" onClick={resetCreateModal}></Button>] : undefined}
onOk={validateSession ? undefined : handleCreate}
okText="开始认证并创建"
confirmLoading={creating}
maskClosable={!polling}
>
{!validateSession ? (
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}> <Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}>
<Input placeholder="例如:达人A、客户B、张三人像" /> <Input placeholder="例如:达人A、客户B、张三人像" />
@@ -76,7 +159,28 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
<Form.Item label="描述" name="description"> <Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" /> <Input.TextArea rows={3} placeholder="可选" />
</Form.Item> </Form.Item>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</Form> </Form>
) : (
<Space direction="vertical" align="center" size={16} style={{ width: '100%' }}>
{isSuccess ? (
<CheckCircleOutlined style={{ fontSize: 54, color: '#22c55e' }} />
) : h5Link ? (
<QRCode value={h5Link} size={220} />
) : (
<Spin />
)}
<div style={{ textAlign: 'center' }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>{createdProject?.name || '真人素材项目组'}</Typography.Title>
<Typography.Text type={isSuccess ? 'success' : 'secondary'}>
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
</Typography.Text>
</div>
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
</Space>
)}
</Modal> </Modal>
</div> </div>
); );
@@ -1,11 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Card, Popconfirm, Space, Typography, message } from 'antd'; import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
import { DeleteOutlined, ReloadOutlined, SafetyCertificateOutlined, UploadOutlined } from '@ant-design/icons'; import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api'; import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid'; import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload'; import PrivatePortraitAssetUpload from './AssetUpload';
import PrivatePortraitValidateModal from './ValidateModal';
interface Props { interface Props {
project: PrivatePortraitProject; project: PrivatePortraitProject;
@@ -17,7 +16,6 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [validateOpen, setValidateOpen] = useState(false);
const loadAssets = async () => { const loadAssets = async () => {
setLoading(true); setLoading(true);
@@ -65,13 +63,14 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
} }
}; };
const canUpload = project.status === 'active';
return ( return (
<Card <Card
title={<span>{project.name}</span>} title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
extra={( extra={(
<Space> <Space>
<Button icon={<SafetyCertificateOutlined />} onClick={() => setValidateOpen(true)}></Button> <Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}></Button>
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadOpen(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button> <Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button>
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}> <Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button> <Button danger icon={<DeleteOutlined />}></Button>
@@ -81,9 +80,13 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
style={{ borderRadius: 12 }} style={{ borderRadius: 12 }}
> >
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph> <Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
{!canUpload && (
<Typography.Paragraph style={{ color: '#f97316' }}>
</Typography.Paragraph>
)}
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} /> <PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} /> <PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
<PrivatePortraitValidateModal projectId={project.id} open={validateOpen} onClose={() => setValidateOpen(false)} onCreated={() => onChanged()} />
</Card> </Card>
); );
}; };
@@ -0,0 +1,31 @@
import React, { useMemo } from 'react';
import { Button, Card, Result, Typography } from 'antd';
const successStatuses = new Set(['group_active', 'callback_success']);
const PrivatePortraitAuthorizeResult: React.FC = () => {
const params = useMemo(() => new URLSearchParams(window.location.search), []);
const status = params.get('status') || '';
const resultCode = params.get('resultCode') || '';
const isSuccess = successStatuses.has(status) || resultCode === '10000';
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f8fafc', padding: 20 }}>
<Card style={{ width: '100%', maxWidth: 520, borderRadius: 18 }}>
<Result
status={isSuccess ? 'success' : 'error'}
title={isSuccess ? '真人认证已完成' : '真人认证未完成'}
subTitle={isSuccess ? '请回到电脑端查看,项目组已创建成功。' : '请回到电脑端重新发起创建项目组。'}
extra={[
<Button key="close" type="primary" onClick={() => window.close()}></Button>,
]}
/>
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 0 }}>
{status || '-'}{resultCode || '-'}
</Typography.Paragraph>
</Card>
</div>
);
};
export default PrivatePortraitAuthorizeResult;
+10
View File
@@ -134,6 +134,9 @@ export interface MediaReference {
source?: string; source?: string;
private_asset_id?: string; private_asset_id?: string;
remote_asset_id?: string; remote_asset_id?: string;
providerUrl?: string;
displayUrl?: string;
previewUrl?: string;
} }
export interface GenerationRecord { export interface GenerationRecord {
@@ -297,6 +300,13 @@ export interface PrivatePortraitValidateSession {
updatedAt?: string | null; updatedAt?: string | null;
} }
export interface PrivatePortraitProjectCreateWithValidateOut {
project: PrivatePortraitProject;
validateSession: PrivatePortraitValidateSession;
pollIntervalMs: number;
}
export interface PrivatePortraitAsset { export interface PrivatePortraitAsset {
id: string; id: string;
userId?: string | null; userId?: string | null;