真人素材库本地完成

This commit is contained in:
2026-07-06 15:13:41 +08:00
parent 75d1f7a428
commit ab67bdf19e
52 changed files with 4131 additions and 137 deletions
@@ -0,0 +1,228 @@
"""add private portrait assets
Revision ID: 6fc75582f6f9
Revises: 054735900c23
Create Date: 2026-07-06 14:48:50.946515
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6fc75582f6f9'
down_revision: Union[str, None] = '20260706_20260706'
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_table('private_portrait_projects',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False, comment='用户展示项目名'),
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='项目名安全 slug'),
sa.Column('remote_project_name', sa.String(length=256), nullable=False, comment='火山 ProjectName 快照'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('asset_group_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('asset_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('active_asset_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_projects_remote_project_name', 'private_portrait_projects', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_projects_user_deleted', 'private_portrait_projects', ['user_id', 'deleted_at'], unique=False, postgresql_where=sa.text('deleted_at IS NULL'))
op.create_index('idx_private_portrait_projects_user_status_created', 'private_portrait_projects', ['user_id', 'status', 'created_at'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_deleted_at'), 'private_portrait_projects', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_name'), 'private_portrait_projects', ['name'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_name_slug'), 'private_portrait_projects', ['name_slug'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_remote_project_name'), 'private_portrait_projects', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_status'), 'private_portrait_projects', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_user_id'), 'private_portrait_projects', ['user_id'], unique=False)
op.create_table('private_portrait_asset_groups',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
sa.Column('remote_group_name', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('group_type', sa.String(length=32), server_default='LivenessFace', nullable=False),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('remote_delete_error', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_asset_groups_project_status', 'private_portrait_asset_groups', ['project_id', 'status'], unique=False)
op.create_index('idx_private_portrait_asset_groups_remote_delete_status', 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
op.create_index('idx_private_portrait_asset_groups_remote_project_name', 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_asset_groups_user_project', 'private_portrait_asset_groups', ['user_id', 'project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_deleted_at'), 'private_portrait_asset_groups', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_project_id'), 'private_portrait_asset_groups', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), 'private_portrait_asset_groups', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), 'private_portrait_asset_groups', ['remote_group_name'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_status'), 'private_portrait_asset_groups', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_user_id'), 'private_portrait_asset_groups', ['user_id'], unique=False)
op.create_index('uq_private_portrait_asset_groups_remote_group_id', 'private_portrait_asset_groups', ['remote_group_id'], unique=True)
op.create_table('private_portrait_validate_sessions',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('byted_token', sa.String(length=256), nullable=True),
sa.Column('h5_link', sa.Text(), nullable=True),
sa.Column('callback_url', sa.Text(), nullable=True),
sa.Column('result_code', sa.String(length=32), nullable=True),
sa.Column('algorithm_base_resp_code', sa.String(length=64), nullable=True),
sa.Column('verify_type', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=32), server_default='created', nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('expired_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('raw_callback_json', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_validate_sessions_byted_token', 'private_portrait_validate_sessions', ['byted_token'], unique=False)
op.create_index('idx_private_portrait_validate_sessions_status_created', 'private_portrait_validate_sessions', ['status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_validate_sessions_user_project', 'private_portrait_validate_sessions', ['user_id', 'project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_byted_token'), 'private_portrait_validate_sessions', ['byted_token'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_project_id'), 'private_portrait_validate_sessions', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), 'private_portrait_validate_sessions', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), 'private_portrait_validate_sessions', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_result_code'), 'private_portrait_validate_sessions', ['result_code'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_status'), 'private_portrait_validate_sessions', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_user_id'), 'private_portrait_validate_sessions', ['user_id'], unique=False)
op.create_table('private_portrait_assets',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('group_id', sa.String(length=32), nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
sa.Column('remote_asset_id', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('asset_type', sa.String(length=16), nullable=False),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('source_url', sa.Text(), nullable=False),
sa.Column('preview_url', sa.Text(), nullable=True),
sa.Column('remote_url', sa.Text(), nullable=True),
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=32), server_default='creating', nullable=False),
sa.Column('moderation_json', sa.Text(), nullable=True),
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('poll_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('remote_delete_error', sa.Text(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['group_id'], ['private_portrait_asset_groups.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_assets_group_status_created', 'private_portrait_assets', ['group_id', 'status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_assets_next_poll_status', 'private_portrait_assets', ['next_poll_at', 'status'], unique=False, postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
op.create_index('idx_private_portrait_assets_project_status_created', 'private_portrait_assets', ['project_id', 'status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_assets_remote_delete_status', 'private_portrait_assets', ['remote_delete_status'], unique=False)
op.create_index('idx_private_portrait_assets_remote_project_name', 'private_portrait_assets', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_assets_user_status_created', 'private_portrait_assets', ['user_id', 'status', 'created_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_asset_type'), 'private_portrait_assets', ['asset_type'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_deleted_at'), 'private_portrait_assets', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_group_id'), 'private_portrait_assets', ['group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_name'), 'private_portrait_assets', ['name'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_next_poll_at'), 'private_portrait_assets', ['next_poll_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_project_id'), 'private_portrait_assets', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_asset_id'), 'private_portrait_assets', ['remote_asset_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_delete_status'), 'private_portrait_assets', ['remote_delete_status'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_group_id'), 'private_portrait_assets', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_project_name'), 'private_portrait_assets', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_status'), 'private_portrait_assets', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_user_id'), 'private_portrait_assets', ['user_id'], unique=False)
op.create_index('uq_private_portrait_assets_remote_asset_id', 'private_portrait_assets', ['remote_asset_id'], unique=True)
op.add_column('users', sa.Column('private_portrait_image_limit', sa.Integer(), server_default='5', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'private_portrait_image_limit')
op.drop_index('uq_private_portrait_assets_remote_asset_id', table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_user_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_status'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_project_name'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_group_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_delete_status'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_asset_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_project_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_next_poll_at'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_name'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_group_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_deleted_at'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_asset_type'), table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_user_status_created', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_remote_project_name', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_remote_delete_status', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_project_status_created', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_next_poll_status', table_name='private_portrait_assets', postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
op.drop_index('idx_private_portrait_assets_group_status_created', table_name='private_portrait_assets')
op.drop_table('private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_validate_sessions_user_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_status'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_result_code'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_project_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_byted_token'), table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_user_project', table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_status_created', table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_byted_token', table_name='private_portrait_validate_sessions')
op.drop_table('private_portrait_validate_sessions')
op.drop_index('uq_private_portrait_asset_groups_remote_group_id', table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_user_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_status'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_project_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_deleted_at'), table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_user_project', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_remote_project_name', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_remote_delete_status', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_project_status', table_name='private_portrait_asset_groups')
op.drop_table('private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_projects_user_id'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_status'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_remote_project_name'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_name_slug'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_name'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_deleted_at'), table_name='private_portrait_projects')
op.drop_index('idx_private_portrait_projects_user_status_created', table_name='private_portrait_projects')
op.drop_index('idx_private_portrait_projects_user_deleted', table_name='private_portrait_projects', postgresql_where=sa.text('deleted_at IS NULL'))
op.drop_index('idx_private_portrait_projects_remote_project_name', table_name='private_portrait_projects')
op.drop_table('private_portrait_projects')
# ### end Alembic commands ###
+2
View File
@@ -4,9 +4,11 @@ from app.api.admin.video_prompt_schema_config import router as video_prompt_sche
from app.api.admin.resource_capacity import router as resource_capacity_router
from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router
from app.api.admin.private_portrait import router as private_portrait_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
router.include_router(resource_capacity_router)
router.include_router(team_router)
router.include_router(home_material_router)
router.include_router(private_portrait_router)
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.schemas.private_portrait import (
PrivatePortraitAdminConfigUpdate,
PrivatePortraitAssetListOut,
PrivatePortraitConfigOut,
PrivatePortraitProjectListOut,
)
from app.services.operation_log import log_operation
from app.services.private_portrait.asset_service import (
asset_to_out,
get_user_private_portrait_config,
list_assets,
set_user_private_portrait_limit,
)
from app.services.private_portrait.project_service import list_projects, project_to_out
router = APIRouter(prefix="/admin/private-portrait", tags=["admin-private-portrait"])
def _json_detail(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
async def admin_get_private_portrait_config(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
config = await get_user_private_portrait_config(db, user_id=user_id)
await log_operation(
db,
admin.id,
admin.username,
"查看真人素材库配置",
"GET",
f"/admin/private-portrait/users/{user_id}/config",
detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")}),
)
return config
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
async def admin_update_private_portrait_config(
user_id: str,
payload: PrivatePortraitAdminConfigUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_user_private_portrait_config(db, user_id=user_id)
await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_image_limit)
after = await get_user_private_portrait_config(db, user_id=user_id)
await log_operation(
db,
admin.id,
admin.username,
f"设置真人素材库数量限制:{before.image_limit} -> {after.image_limit}",
"PUT",
f"/admin/private-portrait/users/{user_id}/config",
detail=_json_detail(
{
"target_user_id": user_id,
"before": before.model_dump(mode="json"),
"after": after.model_dump(mode="json"),
}
),
)
return after
@router.get("/projects", response_model=PrivatePortraitProjectListOut)
async def admin_list_private_portrait_projects(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
keyword: str | None = Query(None),
status: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_projects(db, user_id=user_id, page=page, page_size=page_size, keyword=keyword, status=status)
await log_operation(
db,
admin.id,
admin.username,
"查看真人素材项目列表",
"GET",
"/admin/private-portrait/projects",
detail=_json_detail(
{
"filters": {"user_id": user_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size},
"total": total,
"returned_count": len(items),
}
),
)
return PrivatePortraitProjectListOut(items=[project_to_out(item, include_user=True) for item in items], total=total, page=page, page_size=page_size)
@router.get("/assets", response_model=PrivatePortraitAssetListOut)
async def admin_list_private_portrait_assets(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
project_id: str | None = Query(None),
keyword: str | None = Query(None),
status: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size)
await log_operation(
db,
admin.id,
admin.username,
"查看真人素材列表",
"GET",
"/admin/private-portrait/assets",
detail=_json_detail(
{
"filters": {"user_id": user_id, "project_id": project_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size},
"total": total,
"returned_count": len(assets),
}
),
)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total=total, page=page, page_size=page_size)
+3 -1
View File
@@ -31,6 +31,7 @@ from app.api.v1.contact import router as contact_router
from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_router
from app.api.v1.private_portrait import router as private_portrait_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -63,4 +64,5 @@ api_router.include_router(resources_material_router)
api_router.include_router(contact_router)
api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router)
api_router.include_router(material_admin_router)
api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router)
+24 -1
View File
@@ -173,11 +173,34 @@ async def create_user(
user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus,
private_portrait_image_limit=req.private_portrait_image_limit,
)
user.credits = round(user.credits, 2)
db.add(user)
await db.flush()
await log_operation(db, admin.id, admin.username, f"创建用户 {username}", "POST", "/admin/users", ip=None)
await log_operation(
db,
admin.id,
admin.username,
f"创建用户 {username}",
"POST",
"/admin/users",
detail=json.dumps(
{
"user_id": user.id,
"username": username,
"user_type": req.user_type,
"frontend_user_kind": user.frontend_user_kind,
"private_portrait_image_limit": user.private_portrait_image_limit,
"credits": user.credits,
"phone": user.phone,
"email": user.email,
},
ensure_ascii=False,
default=str,
),
ip=None,
)
return user
@@ -0,0 +1,233 @@
from __future__ import annotations
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.enums.private_portrait import (
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User
from app.schemas.private_portrait import (
PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut,
PrivatePortraitDeleteOut,
PrivatePortraitConfigOut,
PrivatePortraitProjectCreate,
PrivatePortraitProjectListOut,
PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut,
PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.asset_service import (
DOMAIN,
asset_to_out,
create_asset,
create_validate_session,
get_user_private_portrait_config,
get_validate_session,
handle_validate_callback,
list_assets,
list_selectable_assets,
soft_delete_asset,
sync_asset_status,
validate_session_to_out,
)
from app.services.private_portrait.project_service import (
create_project,
get_user_project,
list_projects,
project_to_out,
refresh_project_counters,
soft_delete_project,
update_project,
)
router = APIRouter(tags=["private-portrait"])
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
exc=exc,
detail={"task_name": task_name},
)
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
detail={"task_name": task_name},
)
@router.get("/private-portrait/config", response_model=PrivatePortraitConfigOut)
async def get_my_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await get_user_private_portrait_config(db, user_id=current_user.id)
@router.post("/private-portrait/projects", response_model=PrivatePortraitProjectOut)
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)
out = project_to_out(project)
await db.commit()
return out
@router.get("/private-portrait/projects", response_model=PrivatePortraitProjectListOut)
async def list_private_portrait_projects(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str | None = Query(None),
status: str | None = Query(None),
current_user: User = Depends(get_current_user),
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)
await refresh_project_counters(db, [item.id for item in items])
await db.commit()
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=status)
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut)
async def get_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id))
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut)
async def update_private_portrait_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await update_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
out = project_to_out(project)
await db.commit()
return out
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut)
async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id)
project_id_snapshot = project.id
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
delete_private_portrait_project_remote.delay(project_id_snapshot)
_log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut)
async def create_private_portrait_validate_session(project_id: str, payload: PrivatePortraitValidateSessionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
session = await create_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url)
out = validate_session_to_out(session)
await db.commit()
return out
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut)
async def get_private_portrait_validate_session(session_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return validate_session_to_out(await get_validate_session(db, user_id=current_user.id, session_id=session_id))
@router.get("/private-portrait/validate-callback")
async def private_portrait_validate_callback(session_id: str, request: Request, redirect_url: str | None = None, db: AsyncSession = Depends(get_db)):
params = dict(request.query_params)
params.pop("session_id", None)
params.pop("redirect_url", None)
session = await handle_validate_callback(db, session_id=session_id, query_params=params)
redirect_session_id = session.id
redirect_status = session.status
redirect_result_code = session.result_code or ""
response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id}
await db.commit()
if redirect_url:
sep = "&" if "?" in redirect_url else "?"
url = f"{unquote(redirect_url)}{sep}session_id={redirect_session_id}&status={redirect_status}&resultCode={redirect_result_code}"
return RedirectResponse(url=url)
return response
@router.post("/private-portrait/projects/{project_id}/assets")
async def create_private_portrait_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await create_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id
out = asset_to_out(asset)
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import poll_private_portrait_asset_status
poll_private_portrait_asset_status.delay(asset_id_snapshot)
_log_task_dispatch_success(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
return out
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut)
async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/assets/{asset_id}")
async def get_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id).limit(1))).scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在")
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none()
return asset_to_out(asset, project_name=project.name if project else None)
@router.post("/private-portrait/assets/{asset_id}/sync")
async def sync_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
out = asset_to_out(asset)
await db.commit()
return out
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut)
async def delete_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id)
asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
delete_private_portrait_asset_remote.delay(asset_id_snapshot)
_log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut)
async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
+2
View File
@@ -7,6 +7,8 @@ class Settings(BaseSettings):
APP_NAME: str = "VideoGen API"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
# 运行环境,用于生成火山私域真人素材 ProjectNamelocal/test/online。
APP_ENV: str = "local"
SECRET_KEY: str = "change-me"
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
+2
View File
@@ -16,3 +16,5 @@ from app.enums.team import *
from app.enums.home_material import *
from app.enums.celery_queue import *
from app.enums.audio_reference import *
from app.enums.private_portrait import *
+6
View File
@@ -6,6 +6,7 @@ class CeleryQueue(str, Enum):
GEN_PROVIDER_POLL = "gen_provider_poll"
GEN_RESULT_DOWNLOAD = "gen_result_download"
GEN_RECOVERY = "gen_recovery"
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
DEFAULT = "default"
@@ -19,3 +20,8 @@ class CeleryTaskName(str, Enum):
STARTUP_RECOVERY = "recovery.startup_recovery_once"
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
SHOT_SPLIT_RECOVERY = "shot_replicate.recover_split_tasks_once"
PRIVATE_PORTRAIT_POLL_ASSET = "private_portrait.poll_asset_status"
PRIVATE_PORTRAIT_SYNC_DUE_ASSETS = "private_portrait.sync_due_assets"
PRIVATE_PORTRAIT_DELETE_ASSET = "private_portrait.delete_asset_remote"
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
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,
}
PRIVATE_PORTRAIT_GROUP_TYPE = "LivenessFace"
PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time"
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://"
ARK_PRIVATE_PORTRAIT_SERVICE_NAME = "ark"
ARK_PRIVATE_PORTRAIT_VERSION = "2024-01-01"
ARK_PRIVATE_PORTRAIT_REGION = "cn-beijing"
ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com"
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
# 系统侧保守限流;火山文档 GetAsset 是 100 QPS,这里预留余量为 80。
PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
"CreateVisualValidateSession": 3,
"GetVisualValidateResult": 3,
"CreateAsset": 1,
"ListAssetGroups": 10,
"ListAssets": 10,
"GetAsset": 80,
"GetAssetGroup": 10,
"UpdateAsset": 10,
"UpdateAssetGroup": 10,
"DeleteAsset": 10,
"DeleteAssetGroup": 5,
}
class ArkPrivatePortraitAction(str, Enum):
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
CREATE_ASSET = "CreateAsset"
GET_ASSET = "GetAsset"
LIST_ASSETS = "ListAssets"
LIST_ASSET_GROUPS = "ListAssetGroups"
GET_ASSET_GROUP = "GetAssetGroup"
UPDATE_ASSET = "UpdateAsset"
UPDATE_ASSET_GROUP = "UpdateAssetGroup"
DELETE_ASSET = "DeleteAsset"
DELETE_ASSET_GROUP = "DeleteAssetGroup"
class PrivatePortraitProjectStatus(str, Enum):
ACTIVE = "active"
DELETED = "deleted"
class PrivatePortraitValidateSessionStatus(str, Enum):
CREATED = "created"
CALLBACK_SUCCESS = "callback_success"
CALLBACK_FAILED = "callback_failed"
GROUP_ACTIVE = "group_active"
EXPIRED = "expired"
FAILED = "failed"
class PrivatePortraitAssetGroupStatus(str, Enum):
ACTIVE = "active"
LOCAL_DELETED = "local_deleted"
REMOTE_DELETED = "remote_deleted"
DELETE_FAILED = "delete_failed"
FAILED = "failed"
class PrivatePortraitAssetStatus(str, Enum):
CREATING = "creating"
PROCESSING = "Processing"
ACTIVE = "Active"
FAILED = "Failed"
LOCAL_DELETED = "local_deleted"
REMOTE_DELETED = "remote_deleted"
DELETE_FAILED = "delete_failed"
class PrivatePortraitAssetType(str, Enum):
IMAGE = "Image"
VIDEO = "Video"
AUDIO = "Audio"
class PrivatePortraitRemoteDeleteStatus(str, Enum):
NONE = "none"
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
class PrivatePortraitReferenceSource(str, Enum):
PRIVATE_PORTRAIT_ASSET = "private_portrait_asset"
class PrivatePortraitEventStatus(str, Enum):
SUCCESS = "success"
FAILED = "failed"
PENDING = "pending"
SKIPPED = "skipped"
WARNING = "warning"
class PrivatePortraitEventSource(str, Enum):
API = "api"
SERVICE = "service"
CALLBACK = "callback"
CELERY = "celery"
ADMIN = "admin"
SYSTEM = "system"
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_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED"
VALIDATE_CALLBACK_SUCCESS = "VALIDATE_CALLBACK_SUCCESS"
VALIDATE_CALLBACK_FAILED = "VALIDATE_CALLBACK_FAILED"
VALIDATE_GET_RESULT_START = "VALIDATE_GET_RESULT_START"
VALIDATE_GET_RESULT_SUCCESS = "VALIDATE_GET_RESULT_SUCCESS"
VALIDATE_GET_RESULT_FAILED = "VALIDATE_GET_RESULT_FAILED"
ASSET_GROUP_UPDATE_REMOTE_START = "ASSET_GROUP_UPDATE_REMOTE_START"
ASSET_GROUP_UPDATE_REMOTE_SUCCESS = "ASSET_GROUP_UPDATE_REMOTE_SUCCESS"
ASSET_GROUP_UPDATE_REMOTE_FAILED = "ASSET_GROUP_UPDATE_REMOTE_FAILED"
ASSET_CREATE_REJECT_DISABLED = "ASSET_CREATE_REJECT_DISABLED"
ASSET_CREATE_REJECT_MAX_LIMIT = "ASSET_CREATE_REJECT_MAX_LIMIT"
ASSET_CREATE_START = "ASSET_CREATE_START"
ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS"
ASSET_CREATE_FAILED = "ASSET_CREATE_FAILED"
ASSET_SYNC_START = "ASSET_SYNC_START"
ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS"
ASSET_SYNC_FAILED = "ASSET_SYNC_FAILED"
ASSET_POLL_START = "ASSET_POLL_START"
ASSET_POLL_SUCCESS = "ASSET_POLL_SUCCESS"
ASSET_POLL_FAILED = "ASSET_POLL_FAILED"
ASSET_POLL_TIMEOUT = "ASSET_POLL_TIMEOUT"
ASSET_DELETE_LOCAL = "ASSET_DELETE_LOCAL"
ASSET_DELETE_REMOTE_START = "ASSET_DELETE_REMOTE_START"
ASSET_DELETE_REMOTE_SUCCESS = "ASSET_DELETE_REMOTE_SUCCESS"
ASSET_DELETE_REMOTE_FAILED = "ASSET_DELETE_REMOTE_FAILED"
PROJECT_DELETE_REMOTE_START = "PROJECT_DELETE_REMOTE_START"
PROJECT_DELETE_REMOTE_SUCCESS = "PROJECT_DELETE_REMOTE_SUCCESS"
PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED"
TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS"
TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED"
SYNC_DUE_ASSETS_START = "SYNC_DUE_ASSETS_START"
SYNC_DUE_ASSETS_DONE = "SYNC_DUE_ASSETS_DONE"
SYNC_DUE_ASSETS_FAILED = "SYNC_DUE_ASSETS_FAILED"
REMOTE_DELETE_RECOVERY_START = "REMOTE_DELETE_RECOVERY_START"
REMOTE_DELETE_RECOVERY_DONE = "REMOTE_DELETE_RECOVERY_DONE"
REMOTE_DELETE_RECOVERY_FAILED = "REMOTE_DELETE_RECOVERY_FAILED"
REFERENCE_RESOLVE_START = "REFERENCE_RESOLVE_START"
REFERENCE_RESOLVE_SUCCESS = "REFERENCE_RESOLVE_SUCCESS"
REFERENCE_RESOLVE_FAILED = "REFERENCE_RESOLVE_FAILED"
ARK_API_CALL_START = "ARK_API_CALL_START"
ARK_API_CALL_SUCCESS = "ARK_API_CALL_SUCCESS"
ARK_API_CALL_FAILED = "ARK_API_CALL_FAILED"
ARK_API_RATE_LIMIT_WAIT = "ARK_API_RATE_LIMIT_WAIT"
ARK_API_RATE_LIMIT_REJECT = "ARK_API_RATE_LIMIT_REJECT"
+3
View File
@@ -32,6 +32,7 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.contact_request import ContactRequest
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
__all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
@@ -47,4 +48,6 @@ __all__ = [
"ShotReplicateTaskSet", "ShotReplicateSegment",
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
"PrivatePortraitProject", "PrivatePortraitValidateSession",
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
]
@@ -0,0 +1,11 @@
from app.models.private_portrait.project import PrivatePortraitProject
from app.models.private_portrait.validate_session import PrivatePortraitValidateSession
from app.models.private_portrait.asset_group import PrivatePortraitAssetGroup
from app.models.private_portrait.asset import PrivatePortraitAsset
__all__ = [
"PrivatePortraitProject",
"PrivatePortraitValidateSession",
"PrivatePortraitAssetGroup",
"PrivatePortraitAsset",
]
@@ -0,0 +1,69 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import (
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
"""火山 Asset 本地映射。生成时只信任本地 id,再批量解析成 asset://remote_asset_id。"""
__tablename__ = "private_portrait_assets"
__table_args__ = (
Index("uq_private_portrait_assets_remote_asset_id", "remote_asset_id", unique=True),
Index("idx_private_portrait_assets_user_status_created", "user_id", "status", "created_at"),
Index("idx_private_portrait_assets_project_status_created", "project_id", "status", "created_at"),
Index("idx_private_portrait_assets_group_status_created", "group_id", "status", "created_at"),
Index("idx_private_portrait_assets_remote_project_name", "remote_project_name"),
Index(
"idx_private_portrait_assets_next_poll_status",
"next_poll_at",
"status",
postgresql_where=text("deleted_at IS NULL AND next_poll_at IS NOT NULL"),
),
Index("idx_private_portrait_assets_remote_delete_status", "remote_delete_status"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_asset_groups.id", ondelete="CASCADE"), nullable=False, index=True)
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
asset_type: Mapped[str] = mapped_column(String(16), nullable=False, default=PrivatePortraitAssetType.IMAGE.value, index=True)
name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
source_url: Mapped[str] = mapped_column(Text, nullable=False)
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True)
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitAssetStatus.CREATING.value,
server_default=PrivatePortraitAssetStatus.CREATING.value,
index=True,
)
moderation_json: Mapped[str | None] = mapped_column(Text, nullable=True)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
poll_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
remote_delete_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_GROUP_TYPE,
PrivatePortraitAssetGroupStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
"""本地项目组与火山 Asset Group 的映射。"""
__tablename__ = "private_portrait_asset_groups"
__table_args__ = (
Index("uq_private_portrait_asset_groups_remote_group_id", "remote_group_id", unique=True),
Index("idx_private_portrait_asset_groups_user_project", "user_id", "project_id"),
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_project_name", "remote_project_name"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True)
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
remote_group_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_GROUP_TYPE)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitAssetGroupStatus.ACTIVE.value,
server_default=PrivatePortraitAssetGroupStatus.ACTIVE.value,
index=True,
)
remote_delete_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import PrivatePortraitProjectStatus
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。"""
__tablename__ = "private_portrait_projects"
__table_args__ = (
Index("idx_private_portrait_projects_user_status_created", "user_id", "status", "created_at"),
Index(
"idx_private_portrait_projects_user_deleted",
"user_id",
"deleted_at",
postgresql_where=text("deleted_at IS NULL"),
),
Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="用户展示项目名")
name_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="项目名安全 slug")
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True, comment="火山 ProjectName 快照")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitProjectStatus.ACTIVE.value,
server_default=PrivatePortraitProjectStatus.ACTIVE.value,
index=True,
)
asset_group_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
active_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import PrivatePortraitValidateSessionStatus
from app.models.base import Base, TimestampMixin
class PrivatePortraitValidateSession(Base, TimestampMixin):
"""火山真人认证 H5 会话。"""
__tablename__ = "private_portrait_validate_sessions"
__table_args__ = (
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_status_created", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True)
byted_token: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True)
h5_link: Mapped[str | None] = mapped_column(Text, nullable=True)
callback_url: Mapped[str | None] = mapped_column(Text, nullable=True)
result_code: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
algorithm_base_resp_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
verify_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitValidateSessionStatus.CREATED.value,
server_default=PrivatePortraitValidateSessionStatus.CREATED.value,
index=True,
)
remote_group_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_callback_json: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+6 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, String, JSON
from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.user import FrontendUserKind
@@ -39,6 +39,11 @@ class User(Base, TimestampMixin):
)
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。
private_portrait_image_limit: Mapped[int] = mapped_column(
Integer, default=5, server_default="5", nullable=False
)
@property
def must_set_password(self) -> bool:
return self.user_type == "frontend" and not self.hashed_password
+2
View File
@@ -58,6 +58,7 @@ class AdminUserOut(BaseModel):
last_login_at: NaiveDatetimeOptional = None
allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5
model_config = {"from_attributes": True}
@@ -71,6 +72,7 @@ class CreateUserRequest(BaseModel):
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
allowed_menus: list | None = None
private_portrait_image_limit: int = Field(default=5, ge=0, le=9999)
class UpdateFrontendUserKindRequest(BaseModel):
@@ -41,6 +41,21 @@ class GenerationAIReference(BaseModel):
description="参考素材时长(秒)。type=video/audio 时使用,用于视频/音频素材时长校验",
examples=[5.0],
)
source: str | None = Field(
None,
description="参考素材来源。private_portrait_asset=真人素材库;为空表示普通上传文件",
examples=["private_portrait_asset"],
)
private_asset_id: str | None = Field(
None,
description="真人素材库本地素材ID。source=private_portrait_asset 时必填,后端据此解析 remote_asset_id",
examples=["0019fxxx"],
)
remote_asset_id: str | None = Field(
None,
description="后端回填的火山 Asset ID。前端传入时不可信,创建任务时以后端查库为准",
examples=["asset-20260318071009-xxxxx"],
)
class GenerationAITaskCreate(BaseModel):
@@ -0,0 +1,172 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator
from app.enums.private_portrait import PrivatePortraitAssetType
from app.schemas.common import NaiveDatetimeOptional
class PrivatePortraitConfigOut(BaseModel):
enabled: bool
image_limit: int
used_image_count: int
remaining_image_count: int
class PrivatePortraitAdminConfigUpdate(BaseModel):
private_portrait_image_limit: int = Field(..., ge=0, le=9999, description="0 表示关闭真人素材模块;>0 表示启用并限制图片总量")
class PrivatePortraitProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
description: str | None = Field(None, max_length=2000)
class PrivatePortraitProjectUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=128)
description: str | None = Field(None, max_length=2000)
status: str | None = None
class PrivatePortraitProjectOut(BaseModel):
id: str
user_id: str | None = None
name: str
name_slug: str | None = None
remote_project_name: str | None = None
description: str | None = None
status: str
asset_group_count: int = 0
asset_count: int = 0
active_asset_count: int = 0
last_used_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class PrivatePortraitProjectListOut(BaseModel):
items: list[PrivatePortraitProjectOut]
total: int
page: int
page_size: int
class PrivatePortraitValidateSessionCreate(BaseModel):
callback_redirect_url: str | None = Field(None, description="认证完成后前端要跳转的页面。为空时使用后端默认回调页。")
class PrivatePortraitValidateSessionOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
byted_token: str | None = None
h5_link: str | None = None
callback_url: str | None = None
result_code: str | None = None
algorithm_base_resp_code: str | None = None
verify_type: str | None = None
status: str
remote_group_id: str | None = None
remote_project_name: str | None = None
expired_at: NaiveDatetimeOptional = None
error_message: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class PrivatePortraitAssetGroupOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
remote_group_id: str
remote_group_name: str | None = None
remote_project_name: str
group_type: str
status: str
remote_delete_status: str
remote_deleted_at: NaiveDatetimeOptional = None
remote_delete_error: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class PrivatePortraitAssetCreate(BaseModel):
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL")
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value)
name: str | None = Field(None, max_length=256)
@field_validator("asset_type")
@classmethod
def validate_asset_type(cls, v: str) -> str:
value = v or PrivatePortraitAssetType.IMAGE.value
if value not in {item.value for item in PrivatePortraitAssetType}:
raise ValueError("asset_type 仅支持 Image/Video/Audio")
return value
class PrivatePortraitAssetOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
project_name: str | None = None
group_id: str
remote_group_id: str
remote_asset_id: str | None = None
remote_project_name: str | None = None
asset_type: str
name: str | None = None
source_url: str
preview_url: str | None = None
remote_url: str | None = None
remote_url_expired_at: NaiveDatetimeOptional = None
status: str
moderation: dict[str, Any] | None = None
last_poll_at: NaiveDatetimeOptional = None
next_poll_at: NaiveDatetimeOptional = None
poll_count: int = 0
remote_delete_status: str
remote_deleted_at: NaiveDatetimeOptional = None
remote_delete_error: str | None = None
error_message: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class PrivatePortraitAssetListOut(BaseModel):
items: list[PrivatePortraitAssetOut]
total: int
page: int
page_size: int
class PrivatePortraitSelectableAssetOut(BaseModel):
id: str
project_id: str
project_name: str
name: str | None = None
asset_type: str
preview_url: str | None = None
status: str
created_at: NaiveDatetimeOptional = None
class PrivatePortraitSelectableAssetListOut(BaseModel):
items: list[PrivatePortraitSelectableAssetOut]
total: int
page: int
page_size: int
class PrivatePortraitDeleteOut(BaseModel):
success: bool = True
remote_delete_status: str | None = None
+1
View File
@@ -15,5 +15,6 @@ class UserOut(BaseModel):
allowed_menus: list | None = None
must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5
model_config = {"from_attributes": True}
@@ -54,6 +54,7 @@ from app.services.generation_history_meta_service import (
build_empty_history_meta,
)
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.utils.id_gen import generate_id
IMAGE_DEFAULT_SIZE = "2K"
@@ -247,6 +248,12 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
return existing
refs = [r.model_dump(exclude_none=True) for r in (req.media_references or [])]
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
now = datetime.now(timezone.utc)
task_id = generate_id()
@@ -27,6 +27,7 @@ from app.services.generation_ai_service import (
)
from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
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.utils.id_gen import generate_id
@@ -82,6 +83,12 @@ async def create_chat_generation_task_for_module(
task_id = generate_id()
now = datetime.now(timezone.utc)
refs = media_references or []
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
backend_idempotency_key = _build_backend_idempotency_key(
generation_mode=generation_mode,
gen_type=gen_type,
+2 -1
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from volcenginesdkarkruntime import AsyncArk
from app.config import settings
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
from app.models.image_engine import ImageEngine
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
from app.services.generation_provider_types import (
@@ -97,7 +98,7 @@ def _resolve_url(url: str) -> str:
# return f"data:{mime};base64,{b64}"
if url.startswith(("http://", "https://", "data:")):
if url.startswith(("http://", "https://", "data:", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
return url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
@@ -1,70 +1,8 @@
from __future__ import annotations
import json
import os
import re
import traceback
from datetime import datetime
from typing import Any
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled
MAX_LOG_FIELD_LENGTH = 20000
MAX_TRACEBACK_LENGTH = 12000
MODULE_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "ModuleGeneration")
def _safe_module_name(module: str | None) -> str:
value = str(module or "unknown_module").strip() or "unknown_module"
value = re.sub(r"[^a-zA-Z0-9_.-]+", "_", value)
return value[:120] or "unknown_module"
def _safe_dump_value(value: Any) -> Any:
"""限制单字段长度,避免超长 base64 / 响应体把日志打爆。"""
if value is None:
return None
if isinstance(value, str):
if len(value) > MAX_LOG_FIELD_LENGTH:
return value[:MAX_LOG_FIELD_LENGTH] + f"...<truncated:{len(value) - MAX_LOG_FIELD_LENGTH}>"
return value
if isinstance(value, dict):
return {str(k): _safe_dump_value(v) for k, v in value.items()}
if isinstance(value, list):
return [_safe_dump_value(v) for v in value]
return value
def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
"""构造统一异常日志 detail。日志方法必须吞异常,业务不能被日志影响。"""
detail: dict[str, Any] = dict(extra or {})
if exc is not None:
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
if len(tb) > MAX_TRACEBACK_LENGTH:
tb = tb[:MAX_TRACEBACK_LENGTH] + f"...<traceback_truncated:{len(tb) - MAX_TRACEBACK_LENGTH}>"
detail.update(
{
"exception_type": type(exc).__name__,
"exception_message": str(exc),
"traceback": tb,
}
)
return detail
def _append_module_log(module: str, entry: dict[str, Any]) -> None:
if not is_enabled():
return
try:
module_dir = os.path.join(MODULE_LOG_ROOT, _safe_module_name(module))
os.makedirs(module_dir, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(module_dir, f"{today}.log")
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
except Exception:
# 日志失败绝不能影响业务主流程。
pass
from app.services.operation_log_service import build_exception_detail, log_operation_error, log_operation_event
def log_module_event_file(
@@ -78,24 +16,19 @@ def log_module_event_file(
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
"""记录模块流程事件到 JSONL 文件。
统一落盘目录:log/ModuleGeneration/{module}/YYYY-MM-DD.log
不再写 module_generation_events 表。
"""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"log_type": "module_event",
"module": module,
"event_type": event_type,
"project_id": project_id,
"step_id": step_id,
"user_id": user_id,
"message": message,
"detail": _safe_dump_value(detail or {}),
"error": error,
}
_append_module_log(module, entry)
log_operation_event(
domain="module_generation",
module=module,
event_type=event_type,
project_id=project_id,
step_id=step_id,
user_id=user_id,
message=message,
detail=detail,
error=error,
event_status="failed" if error else "success",
source="service",
)
def log_module_prompt_event(
@@ -111,22 +44,19 @@ def log_module_prompt_event(
token_usage: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
"""记录模块 AI 提词/分析请求和响应到 JSONL 文件。"""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"log_type": "module_prompt",
"module": module,
"event_type": event_type,
"prompt_type": prompt_type,
"project_id": project_id,
"step_id": step_id,
"user_id": user_id,
"request": _safe_dump_value(request or {}),
"response": _safe_dump_value(response or {}),
"token_usage": _safe_dump_value(token_usage or {}),
"error": error,
}
_append_module_log(module, entry)
log_operation_event(
domain="module_generation",
module=module,
event_type=event_type,
project_id=project_id,
step_id=step_id,
user_id=user_id,
message=f"模块 AI 请求:{prompt_type}",
detail={"prompt_type": prompt_type, "request": request or {}, "response": response or {}, "token_usage": token_usage or {}},
error=error,
event_status="failed" if error else "success",
source="service",
)
def log_module_error(
@@ -141,23 +71,16 @@ def log_module_error(
error: str | None = None,
exc: BaseException | None = None,
) -> None:
"""记录模块异常日志。
- 兼容原有 detail/error 参数。
- 新增 exc 后自动记录 exception_type、message、traceback。
- 日志写入失败会被底层吞掉,不影响主流程。
"""
merged_detail = build_exception_detail(exc, detail)
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"log_type": "module_error",
"module": module,
"event_type": event_type,
"project_id": project_id,
"step_id": step_id,
"user_id": user_id,
"message": message,
"detail": _safe_dump_value(merged_detail),
"error": error if error is not None else (str(exc) if exc is not None else None),
}
_append_module_log(module, entry)
log_operation_error(
domain="module_generation",
module=module,
event_type=event_type,
project_id=project_id,
step_id=step_id,
user_id=user_id,
message=message,
detail=detail,
error=error if error is not None else (str(exc) if exc else None),
exc=exc,
source="service",
)
@@ -0,0 +1,198 @@
from __future__ import annotations
import json
import os
import re
import traceback
from datetime import datetime
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled
MAX_LOG_FIELD_LENGTH = 20000
MAX_TRACEBACK_LENGTH = 12000
OPERATION_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "OperationLogs")
SENSITIVE_KEY_PATTERNS = (
"secret",
"token",
"authorization",
"cookie",
"credential",
"signature",
"accesskey",
"access_key",
"api_key",
"apikey",
"security-token",
"x-tos-security-token",
)
def _safe_name(value: str | None, default: str = "unknown") -> str:
text = str(value or default).strip() or default
text = re.sub(r"[^a-zA-Z0-9_.-]+", "_", text)
return text[:120] or default
def _mask_string(value: str) -> str:
if len(value) <= 8:
return "***"
return f"{value[:4]}***{value[-4:]}"
def _is_sensitive_key(key: str) -> bool:
lower = str(key).replace("-", "_").lower()
return any(pattern in lower for pattern in SENSITIVE_KEY_PATTERNS)
def _sanitize_url(value: str) -> str:
try:
parts = urlsplit(value)
if not parts.scheme or not parts.netloc:
return value
query = []
for k, v in parse_qsl(parts.query, keep_blank_values=True):
query.append((k, _mask_string(v) if _is_sensitive_key(k) else v))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
except Exception:
return value
def sanitize_log_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, str):
text = _sanitize_url(value) if value.startswith(("http://", "https://")) else value
if len(text) > MAX_LOG_FIELD_LENGTH:
return text[:MAX_LOG_FIELD_LENGTH] + f"...<truncated:{len(text) - MAX_LOG_FIELD_LENGTH}>"
return text
if isinstance(value, dict):
output: dict[str, Any] = {}
for k, v in value.items():
key = str(k)
output[key] = "***" if _is_sensitive_key(key) else sanitize_log_value(v)
return output
if isinstance(value, list):
return [sanitize_log_value(v) for v in value]
return value
def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
detail: dict[str, Any] = dict(extra or {})
if exc is not None:
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
if len(tb) > MAX_TRACEBACK_LENGTH:
tb = tb[:MAX_TRACEBACK_LENGTH] + f"...<traceback_truncated:{len(tb) - MAX_TRACEBACK_LENGTH}>"
detail.update(
{
"exception_type": type(exc).__name__,
"exception_message": str(exc),
"traceback": tb,
}
)
return detail
def _append_operation_log(domain: str, entry: dict[str, Any]) -> None:
if not is_enabled():
return
try:
domain_dir = os.path.join(OPERATION_LOG_ROOT, _safe_name(domain, "default"))
os.makedirs(domain_dir, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
with open(os.path.join(domain_dir, f"{today}.log"), "a", encoding="utf-8") as f:
f.write(json.dumps(sanitize_log_value(entry), ensure_ascii=False, default=str) + "\n")
except Exception:
pass
def log_operation_event(
*,
domain: str,
event_type: str,
module: str | None = None,
event_status: str = "success",
source: str | None = None,
trace_id: str | None = None,
request_id: str | None = None,
user_id: str | None = None,
project_id: str | None = None,
session_id: str | None = None,
group_id: str | None = None,
asset_id: str | None = None,
task_id: str | None = None,
step_id: str | None = None,
remote_action: str | None = None,
remote_request_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
_append_operation_log(
domain,
{
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"log_type": "operation_event",
"domain": domain,
"module": module or domain,
"event_type": event_type,
"event_status": event_status,
"source": source,
"trace_id": trace_id,
"request_id": request_id,
"user_id": user_id,
"project_id": project_id,
"session_id": session_id,
"group_id": group_id,
"asset_id": asset_id,
"task_id": task_id,
"step_id": step_id,
"remote_action": remote_action,
"remote_request_id": remote_request_id,
"message": message,
"detail": detail or {},
"error": error,
},
)
def log_operation_error(*, domain: str, event_type: str, exc: BaseException | None = None, detail: dict[str, Any] | None = None, **kwargs: Any) -> None:
kwargs.setdefault("event_status", "failed")
kwargs["detail"] = build_exception_detail(exc, detail)
kwargs.setdefault("error", str(exc) if exc is not None else None)
log_operation_event(domain=domain, event_type=event_type, **kwargs)
def log_remote_api_event(
*,
domain: str,
remote_action: str,
event_type: str,
event_status: str,
request: dict[str, Any] | None = None,
response: dict[str, Any] | None = None,
remote_request_id: str | None = None,
remote_code: str | None = None,
remote_message: str | None = None,
**kwargs: Any,
) -> None:
detail = dict(kwargs.pop("detail", {}) or {})
if request is not None:
detail["request"] = request
if response is not None:
detail["response"] = response
if remote_code is not None:
detail["remote_code"] = remote_code
if remote_message is not None:
detail["remote_message"] = remote_message
log_operation_event(
domain=domain,
event_type=event_type,
event_status=event_status,
remote_action=remote_action,
remote_request_id=remote_request_id,
detail=detail,
error=remote_message if event_status == "failed" else None,
**kwargs,
)
@@ -0,0 +1 @@
# 私域真人人像素材库领域服务包。
@@ -0,0 +1,179 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
from app.config import settings
from app.enums.private_portrait import (
ARK_PRIVATE_PORTRAIT_HOST,
ARK_PRIVATE_PORTRAIT_REGION,
ARK_PRIVATE_PORTRAIT_SERVICE_NAME,
ARK_PRIVATE_PORTRAIT_VERSION,
ArkPrivatePortraitAction,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
)
from app.services.operation_log_service import log_remote_api_event
from app.services.private_portrait.rate_limiter import acquire_private_portrait_action_token
DOMAIN = "private_portrait"
class ArkPrivateAssetClientError(RuntimeError):
pass
class ArkPrivateAssetClient:
"""火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。"""
def __init__(self, *, ak: str | None = None, sk: str | None = None, for_celery: bool = False):
self.ak = ak or settings.VOLC_SMS_ACCESS_KEY_ID
self.sk = sk or settings.VOLC_SMS_SECRET_ACCESS_KEY
self.for_celery = for_celery
if not self.ak or not self.sk:
raise ArkPrivateAssetClientError("火山 AK/SK 未配置:VOLC_SMS_ACCESS_KEY_ID / VOLC_SMS_SECRET_ACCESS_KEY")
async def create_visual_validate_session(self, *, project_name: str, callback_url: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.CREATE_VISUAL_VALIDATE_SESSION, {"CallbackURL": callback_url, "ProjectName": project_name})
async def get_visual_validate_result(self, *, project_name: str, byted_token: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.GET_VISUAL_VALIDATE_RESULT, {"BytedToken": byted_token, "ProjectName": project_name})
async def create_asset(self, *, project_name: str, group_id: str, url: str, asset_type: str, name: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name}
if name:
payload["Name"] = name
return await self._call(ArkPrivatePortraitAction.CREATE_ASSET, payload)
async def get_asset(self, *, project_name: str, asset_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.GET_ASSET, {"Id": asset_id, "ProjectName": project_name})
async def list_assets(self, *, project_name: str, filter_payload: dict[str, Any] | None = None, page_number: int = 1, page_size: int = 20) -> dict[str, Any]:
payload = {"Filter": filter_payload or {}, "PageNumber": page_number, "PageSize": page_size, "ProjectName": project_name}
return await self._call(ArkPrivatePortraitAction.LIST_ASSETS, payload)
async def list_asset_groups(self, *, project_name: str, filter_payload: dict[str, Any] | None = None, page_number: int = 1, page_size: int = 20) -> dict[str, Any]:
payload = {"Filter": filter_payload or {}, "PageNumber": page_number, "PageSize": page_size, "ProjectName": project_name}
return await self._call(ArkPrivatePortraitAction.LIST_ASSET_GROUPS, payload)
async def get_asset_group(self, *, project_name: str, group_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.GET_ASSET_GROUP, {"Id": group_id, "ProjectName": project_name})
async def update_asset_group(self, *, project_name: str, group_id: str, name: str | None = None, title: str | None = None, description: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"Id": group_id, "ProjectName": project_name}
if name is not None:
payload["Name"] = name
if title is not None:
payload["Title"] = title
if description is not None:
payload["Description"] = description
return await self._call(ArkPrivatePortraitAction.UPDATE_ASSET_GROUP, payload)
async def update_asset(self, *, project_name: str, asset_id: str, name: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"Id": asset_id, "ProjectName": project_name}
if name is not None:
payload["Name"] = name
return await self._call(ArkPrivatePortraitAction.UPDATE_ASSET, payload)
async def delete_asset(self, *, project_name: str, asset_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.DELETE_ASSET, {"Id": asset_id, "ProjectName": project_name})
async def delete_asset_group(self, *, project_name: str, group_id: str) -> dict[str, Any]:
return await self._call(ArkPrivatePortraitAction.DELETE_ASSET_GROUP, {"Id": group_id, "ProjectName": project_name})
async def _call(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]:
action_value = action.value
await acquire_private_portrait_action_token(action=action_value, wait_timeout_seconds=2.0, for_celery=self.for_celery)
log_remote_api_event(
domain=DOMAIN,
remote_action=action_value,
event_type=PrivatePortraitEventType.ARK_API_CALL_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
request=payload,
)
try:
result = await asyncio.to_thread(self._call_sync, action, payload)
log_remote_api_event(
domain=DOMAIN,
remote_action=action_value,
event_type=PrivatePortraitEventType.ARK_API_CALL_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
request=payload,
response=result,
remote_request_id=result.get("RequestId") or result.get("request_id"),
)
return result
except Exception 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,
remote_message=str(exc),
)
raise
def _call_sync(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]:
try:
from volcengine.ApiInfo import ApiInfo
from volcengine.Credentials import Credentials
from volcengine.ServiceInfo import ServiceInfo
from volcengine.base.Service import Service
except Exception as exc:
raise ArkPrivateAssetClientError("缺少火山 volcengine Python SDK。请确认线上环境已安装 volcengine。") from exc
credentials = Credentials(self.ak, self.sk, ARK_PRIVATE_PORTRAIT_SERVICE_NAME, ARK_PRIVATE_PORTRAIT_REGION)
service_info = ServiceInfo(
ARK_PRIVATE_PORTRAIT_HOST,
{"Content-Type": "application/json", "Accept": "application/json"},
credentials,
10,
60,
)
api_info = {action.value: ApiInfo("POST", "/", f"Action={action.value}&Version={ARK_PRIVATE_PORTRAIT_VERSION}", {}, {})}
service = Service(service_info, api_info)
try:
raw = service.json(action.value, {}, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
except TypeError:
raw = service.json(action.value, {}, payload)
resp = self._normalize_response(raw)
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:
result["RequestId"] = request_id
return result
if isinstance(resp, dict):
if request_id:
resp.setdefault("RequestId", request_id)
return resp
return {"raw": resp, "RequestId": request_id}
@staticmethod
def _normalize_response(raw: Any) -> dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", errors="ignore")
if isinstance(raw, str):
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else {"raw": obj}
except json.JSONDecodeError:
return {"raw": raw}
return {"raw": raw}
@@ -0,0 +1,675 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import urlencode
from fastapi import HTTPException
from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT,
PRIVATE_PORTRAIT_GROUP_TYPE,
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitRemoteDeleteStatus,
PrivatePortraitValidateSessionStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
from app.models.user import User
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
from app.utils.id_gen import generate_id
DOMAIN = "private_portrait"
def _json(data: Any) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def _loads(data: str | None) -> Any:
if not data:
return None
try:
return json.loads(data)
except Exception:
return None
def _public_url(url: str) -> str:
if url.startswith(("http://", "https://")):
return url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
def _callback_url(session_id: str, callback_redirect_url: str | None = None) -> str:
base = f"{settings.BASE_URL.rstrip('/')}/api/private-portrait/validate-callback"
params = {"session_id": session_id}
if callback_redirect_url:
params["redirect_url"] = callback_redirect_url
return f"{base}?{urlencode(params)}"
def _remote_group_name(user_id: str, project_name: str) -> str:
safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in project_name.strip())[:80]
return f"{user_id}-{safe_name}"[:128]
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
used = await count_user_counting_image_assets(db, user_id=user_id)
return PrivatePortraitConfigOut(enabled=limit > 0, image_limit=limit, used_image_count=used, remaining_image_count=max(0, limit - used) if limit > 0 else 0)
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.private_portrait_image_limit = max(0, int(limit))
await db.flush()
return user
async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int:
statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.value]
total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAsset.deleted_at.is_(None), PrivatePortraitAsset.status.in_(statuses)))).scalar_one()
return int(total or 0)
async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User:
# 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。
user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
return PrivatePortraitValidateSessionOut(
id=session.id,
user_id=session.user_id if include_user else None,
project_id=session.project_id,
byted_token=session.byted_token,
h5_link=session.h5_link,
callback_url=session.callback_url,
result_code=session.result_code,
algorithm_base_resp_code=session.algorithm_base_resp_code,
verify_type=session.verify_type,
status=session.status,
remote_group_id=session.remote_group_id,
remote_project_name=session.remote_project_name,
expired_at=session.expired_at,
error_message=session.error_message,
created_at=session.created_at,
updated_at=session.updated_at,
)
def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
return PrivatePortraitAssetOut(
id=asset.id,
user_id=asset.user_id if include_user else None,
project_id=asset.project_id,
project_name=project_name,
group_id=asset.group_id,
remote_group_id=asset.remote_group_id,
remote_asset_id=asset.remote_asset_id,
remote_project_name=asset.remote_project_name,
asset_type=asset.asset_type,
name=asset.name,
source_url=asset.source_url,
preview_url=asset.preview_url,
remote_url=asset.remote_url,
remote_url_expired_at=asset.remote_url_expired_at,
status=asset.status,
moderation=_loads(asset.moderation_json),
last_poll_at=asset.last_poll_at,
next_poll_at=asset.next_poll_at,
poll_count=asset.poll_count or 0,
remote_delete_status=asset.remote_delete_status,
remote_deleted_at=asset.remote_deleted_at,
remote_delete_error=asset.remote_delete_error,
error_message=asset.error_message,
created_at=asset.created_at,
updated_at=asset.updated_at,
)
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)
session = PrivatePortraitValidateSession(
id=generate_id(),
user_id=user_id,
project_id=project.id,
status=PrivatePortraitValidateSessionStatus.CREATED.value,
remote_project_name=project.remote_project_name,
expired_at=datetime.now(timezone.utc) + timedelta(minutes=PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES),
)
session.callback_url = _callback_url(session.id, callback_redirect_url)
db.add(session)
await db.flush()
try:
resp = await ArkPrivateAssetClient().create_visual_validate_session(project_name=project.remote_project_name, callback_url=session.callback_url)
session.byted_token = resp.get("BytedToken") or resp.get("bytedToken")
session.h5_link = resp.get("H5Link") or resp.get("h5Link")
session.raw_response_json = _json(resp)
await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name})
return session
except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = str(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)
raise
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
filters = [PrivatePortraitValidateSession.id == session_id]
if user_id is not None:
filters.append(PrivatePortraitValidateSession.user_id == user_id)
session = (await db.execute(select(PrivatePortraitValidateSession).where(*filters).limit(1))).scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="真人认证会话不存在")
return session
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.raw_callback_json = _json(query_params)
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.verify_type = str(query_params.get("verify_type") or query_params.get("verifyType") or "") or None
token = query_params.get("bytedToken") or query_params.get("byted_token") or session.byted_token
if 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})
if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE:
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value
session.error_message = f"真人认证失败:resultCode={session.result_code}"
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)
return session
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value
if not session.byted_token:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = "Callback 未返回 BytedToken"
await db.flush()
raise HTTPException(status_code=400, detail=session.error_message)
try:
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)
group_id = resp.get("GroupId") or resp.get("groupId")
if not group_id:
raise RuntimeError("GetVisualValidateResult 未返回 GroupId")
session.remote_group_id = group_id
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
session.raw_response_json = _json(resp)
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == session.project_id).limit(1))).scalar_one()
remote_group_name = _remote_group_name(session.user_id, project.name)
group = PrivatePortraitAssetGroup(
id=generate_id(),
user_id=session.user_id,
project_id=session.project_id,
remote_group_id=group_id,
remote_group_name=remote_group_name,
remote_project_name=session.remote_project_name,
group_type=PRIVATE_PORTRAIT_GROUP_TYPE,
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
raw_response_json=_json(resp),
)
db.add(group)
await db.flush()
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)
except Exception as exc:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, exc=exc)
await refresh_project_counters(db, [session.project_id])
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name})
return session
except Exception as exc:
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
session.error_message = str(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
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitAssetGroup:
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()
if not group:
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
return group
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)
if payload.asset_type != PrivatePortraitAssetType.IMAGE.value:
raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材")
user = await _lock_user_for_upload(db, user_id=user_id)
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
if limit <= 0:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_DISABLED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, message="用户真人素材模块未启用")
raise HTTPException(status_code=403, detail="真人素材库未启用")
current_count = await count_user_counting_image_assets(db, user_id=user_id)
if current_count >= limit:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_MAX_LIMIT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, detail={"current_count": current_count, "limit": limit})
raise HTTPException(status_code=400, detail=f"你的真人素材库最多可上传 {limit} 张图片,请删除已有素材后再上传")
group = await get_project_active_group(db, user_id=user_id, project_id=project.id)
public_url = _public_url(payload.url)
asset = PrivatePortraitAsset(
id=generate_id(),
user_id=user_id,
project_id=project.id,
group_id=group.id,
remote_group_id=group.remote_group_id,
remote_project_name=project.remote_project_name,
asset_type=payload.asset_type,
name=payload.name,
source_url=public_url,
preview_url=payload.url,
status=PrivatePortraitAssetStatus.CREATING.value,
)
db.add(asset)
await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"limit": limit, "current_count": current_count, "remote_project_name": project.remote_project_name})
try:
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
if not remote_asset_id:
raise RuntimeError("CreateAsset 未返回素材 ID")
now = datetime.now(timezone.utc)
asset.remote_asset_id = remote_asset_id
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
asset.raw_response_json = _json(remote_resp)
await refresh_project_counters(db, [project.id])
await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name})
return asset
except Exception as exc:
asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = str(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
async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: str) -> PrivatePortraitAsset:
filters = [PrivatePortraitAsset.id == asset_id]
if user_id is not None:
filters.append(PrivatePortraitAsset.user_id == user_id)
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在")
if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="真人素材已删除")
if not asset.remote_asset_id:
raise HTTPException(status_code=400, detail="真人素材尚未创建远程 Asset")
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={
"status": asset.status,
"poll_count": int(asset.poll_count or 0),
"remote_asset_id": asset.remote_asset_id,
"remote_project_name": asset.remote_project_name,
},
)
try:
remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
status = remote_resp.get("Status") or remote_resp.get("status")
now = datetime.now(timezone.utc)
asset.last_poll_at = now
asset.poll_count = int(asset.poll_count or 0) + 1
asset.raw_response_json = _json(remote_resp)
if status:
asset.status = status
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url
asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation"))
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT:
asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = "素材入库轮询超时"
asset.next_poll_at = None
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value,
event_status=PrivatePortraitEventStatus.FAILED.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"poll_count": asset.poll_count, "max_count": PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, "remote_asset_id": asset.remote_asset_id},
error=asset.error_message,
)
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
else:
asset.next_poll_at = None
if asset.status == PrivatePortraitAssetStatus.FAILED.value and not asset.error_message:
asset.error_message = remote_resp.get("ErrorMessage") or remote_resp.get("error_message") or "素材入库失败"
await refresh_project_counters(db, [asset.project_id])
await db.flush()
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count},
)
return asset
except Exception as exc:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
raise
async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str | None = None, status: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]:
page = max(1, page)
page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitAsset.deleted_at.is_(None)]
if user_id:
filters.append(PrivatePortraitAsset.user_id == user_id)
if project_id:
filters.append(PrivatePortraitAsset.project_id == project_id)
if status:
filters.append(PrivatePortraitAsset.status == status)
if keyword:
filters.append(PrivatePortraitAsset.name.ilike(f"%{keyword.strip()}%"))
total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(*filters))).scalar_one()
result = await db.execute(select(PrivatePortraitAsset).where(*filters).order_by(PrivatePortraitAsset.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
assets = list(result.scalars().all())
project_ids = list({asset.project_id for asset in assets})
project_name_map: dict[str, str] = {}
if project_ids:
rows = await db.execute(select(PrivatePortraitProject.id, PrivatePortraitProject.name).where(PrivatePortraitProject.id.in_(project_ids)))
project_name_map = {pid: name for pid, name in rows.all()}
return assets, int(total or 0), project_name_map
async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitSelectableAssetOut], int]:
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size)
return [PrivatePortraitSelectableAssetOut(id=asset.id, project_id=asset.project_id, project_name=project_name_map.get(asset.project_id, ""), name=asset.name, asset_type=asset.asset_type, preview_url=asset.preview_url or asset.remote_url, status=asset.status, created_at=asset.created_at) for asset in assets], total
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) -> PrivatePortraitAsset:
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)).limit(1))).scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="真人素材不存在")
now = datetime.now(timezone.utc)
asset.deleted_at = now
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
await refresh_project_counters(db, [asset.project_id])
await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name})
return asset
async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none()
if not asset:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
asset_id=asset_id,
message="远程删除跳过:本地素材不存在",
)
return
if not asset.remote_asset_id:
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
asset.remote_delete_error = None
await db.flush()
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
message="远程删除跳过:素材没有 remote_asset_id",
)
return
now = datetime.now(timezone.utc)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name},
)
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = now
asset.remote_delete_error = None
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name},
)
except Exception as exc:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
asset.remote_delete_error = str(exc)
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
await db.flush()
async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortraitAssetGroup, client: ArkPrivateAssetClient | None = None) -> None:
if not group.remote_group_id:
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
group.remote_delete_error = None
await db.flush()
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=group.user_id,
project_id=group.project_id,
group_id=group.id,
message="远程删除跳过:素材组没有 remote_group_id",
)
return
client = client or ArkPrivateAssetClient(for_celery=True)
now = datetime.now(timezone.utc)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=group.user_id,
project_id=group.project_id,
group_id=group.id,
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
)
try:
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
group.remote_deleted_at = now
group.remote_delete_error = None
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=group.user_id,
project_id=group.project_id,
group_id=group.id,
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
)
except Exception as exc:
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
group.remote_delete_error = str(exc)
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, exc=exc)
await db.flush()
async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="开始远程删除真人素材项目资源",
)
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
for asset in rows.scalars().all():
await delete_asset_remote(db, asset_id=asset.id)
groups = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id))
client = ArkPrivateAssetClient(for_celery=True)
for group in groups.scalars().all():
await _delete_asset_group_remote(db, group=group, client=client)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="远程删除真人素材项目资源完成",
)
await db.flush()
async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
now = datetime.now(timezone.utc)
rows = await db.execute(
select(PrivatePortraitAsset.id)
.where(
PrivatePortraitAsset.deleted_at.is_(None),
PrivatePortraitAsset.status == PrivatePortraitAssetStatus.PROCESSING.value,
PrivatePortraitAsset.next_poll_at.is_not(None),
PrivatePortraitAsset.next_poll_at <= now,
)
.order_by(PrivatePortraitAsset.next_poll_at.asc())
.limit(limit)
)
ids = [row[0] for row in rows.all()]
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
detail={"limit": limit, "matched_count": len(ids)},
)
success_count = 0
failed_count = 0
for asset_id in ids:
try:
await sync_asset_status(db, user_id=None, asset_id=asset_id)
success_count += 1
except Exception as exc:
failed_count += 1
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
asset_id=asset_id,
exc=exc,
)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value,
source=PrivatePortraitEventSource.CELERY.value,
detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count},
)
return len(ids)
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
detail={"limit": limit},
)
statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
asset_rows = await db.execute(
select(PrivatePortraitAsset.id)
.where(PrivatePortraitAsset.remote_delete_status.in_(statuses))
.order_by(PrivatePortraitAsset.updated_at.asc())
.limit(limit)
)
asset_ids = [row[0] for row in asset_rows.all()]
for asset_id in asset_ids:
await delete_asset_remote(db, asset_id=asset_id)
remaining = max(0, limit - len(asset_ids))
group_count = 0
if remaining > 0:
group_rows = await db.execute(
select(PrivatePortraitAssetGroup)
.where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses))
.order_by(PrivatePortraitAssetGroup.updated_at.asc())
.limit(remaining)
)
client = ArkPrivateAssetClient(for_celery=True)
groups = list(group_rows.scalars().all())
group_count = len(groups)
for group in groups:
await _delete_asset_group_remote(db, group=group, client=client)
result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
detail=result,
)
return result
@@ -0,0 +1,225 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
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,
PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
DOMAIN = "private_portrait"
def _safe_slug(value: str, *, max_length: int = 80) -> str:
value = (value or "").strip().lower()
# 先保留常见英文数字连字符;中文等字符统一转 _,避免火山 ProjectName 字符限制不明确导致失败。
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,
user_id=project.user_id if include_user else None,
name=project.name,
name_slug=project.name_slug,
remote_project_name=project.remote_project_name,
description=project.description,
status=project.status,
asset_group_count=project.asset_group_count or 0,
asset_count=project.asset_count or 0,
active_asset_count=project.active_asset_count or 0,
last_used_at=project.last_used_at,
created_at=project.created_at,
updated_at=project.updated_at,
)
async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
result = await db.execute(
select(PrivatePortraitProject).where(
PrivatePortraitProject.id == project_id,
PrivatePortraitProject.user_id == user_id,
PrivatePortraitProject.deleted_at.is_(None),
).limit(1)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="真人素材项目不存在")
return project
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)
project = PrivatePortraitProject(
id=generate_id(),
user_id=user_id,
name=payload.name.strip(),
name_slug=slug,
remote_project_name=remote_project_name,
description=payload.description,
status=PrivatePortraitProjectStatus.ACTIVE.value,
)
db.add(project)
await db.flush()
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_CREATE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
message="创建真人素材项目",
detail={"name": project.name, "remote_project_name": project.remote_project_name},
)
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 = {
"name": project.name,
"name_slug": project.name_slug,
"remote_project_name": project.remote_project_name,
"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},
)
if payload.description is not None:
project.description = payload.description
if payload.status is not None:
if payload.status not in {PrivatePortraitProjectStatus.ACTIVE.value}:
raise HTTPException(status_code=400, detail="项目状态不支持")
project.status = payload.status
await db.flush()
after = {
"name": project.name,
"name_slug": project.name_slug,
"remote_project_name": project.remote_project_name,
"description": project.description,
"status": project.status,
}
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_UPDATE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
message="更新真人素材项目",
detail={"before": before, "after": after, "remote_name_locked": remote_name_locked},
)
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]:
page = max(1, page)
page_size = min(max(1, page_size), 100)
filters = [PrivatePortraitProject.deleted_at.is_(None)]
if user_id:
filters.append(PrivatePortraitProject.user_id == user_id)
if keyword:
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
if status:
filters.append(PrivatePortraitProject.status == status)
total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one()
result = await db.execute(select(PrivatePortraitProject).where(*filters).order_by(PrivatePortraitProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
return list(result.scalars().all()), int(total or 0)
async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) -> None:
project_ids = list({pid for pid in project_ids if pid})
if not project_ids:
return
group_rows = await db.execute(
select(PrivatePortraitAssetGroup.project_id, func.count(PrivatePortraitAssetGroup.id))
.where(PrivatePortraitAssetGroup.project_id.in_(project_ids), PrivatePortraitAssetGroup.deleted_at.is_(None))
.group_by(PrivatePortraitAssetGroup.project_id)
)
asset_rows = await db.execute(
select(
PrivatePortraitAsset.project_id,
func.count(PrivatePortraitAsset.id),
func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)),
)
.where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None))
.group_by(PrivatePortraitAsset.project_id)
)
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
asset_count_map: dict[str, tuple[int, int]] = {}
for pid, total, active_total in asset_rows.all():
asset_count_map[pid] = (int(total or 0), int(active_total or 0))
for pid in project_ids:
total, active_total = asset_count_map.get(pid, (0, 0))
await db.execute(update(PrivatePortraitProject).where(PrivatePortraitProject.id == pid).values(asset_group_count=group_count_map.get(pid, 0), asset_count=total, active_asset_count=active_total))
async def soft_delete_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
project = await get_user_project(db, user_id=user_id, project_id=project_id)
now = datetime.now(timezone.utc)
project.deleted_at = now
project.status = PrivatePortraitProjectStatus.DELETED.value
await db.execute(update(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value))
await db.execute(update(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value))
await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="本地软删真人素材项目", detail={"remote_project_name": project.remote_project_name})
return project
@@ -0,0 +1,61 @@
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.enums.private_portrait import PRIVATE_PORTRAIT_ACTION_QPS_LIMITS, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType
from app.services.operation_log_service import log_operation_event
from app.utils.redis import get_redis
DOMAIN = "private_portrait"
class PrivatePortraitRateLimitExceeded(RuntimeError):
pass
async def acquire_private_portrait_action_token(
*,
action: str,
wait_timeout_seconds: float = 2.0,
for_celery: bool = False,
) -> bool:
"""Redis 分布式 QPS 限制。Redis 不可用时降级放行,避免影响主功能。"""
limit = int(PRIVATE_PORTRAIT_ACTION_QPS_LIMITS.get(action, 1))
if limit <= 0:
return True
deadline = time.monotonic() + max(0.0, wait_timeout_seconds)
while True:
ok = await _try_take(action, limit)
if ok:
return True
if time.monotonic() >= deadline:
event_type = PrivatePortraitEventType.ARK_API_RATE_LIMIT_WAIT.value if for_celery else PrivatePortraitEventType.ARK_API_RATE_LIMIT_REJECT.value
log_operation_event(
domain=DOMAIN,
event_type=event_type,
event_status=PrivatePortraitEventStatus.SKIPPED.value if for_celery else PrivatePortraitEventStatus.FAILED.value,
source=PrivatePortraitEventSource.CELERY.value if for_celery else PrivatePortraitEventSource.API.value,
remote_action=action,
detail={"limit": limit, "wait_timeout_seconds": wait_timeout_seconds},
message="火山私域真人素材 API 触发本地 QPS 限制",
)
if for_celery:
return False
raise PrivatePortraitRateLimitExceeded("请求过于频繁,请稍后再试")
await asyncio.sleep(0.05)
async def _try_take(action: str, limit: int) -> bool:
client = get_redis()
if client is None:
return True
key = f"private_portrait:qps:{action}:{int(time.time())}"
try:
count = await client.incr(key)
if count == 1:
await client.expire(key, 2)
return int(count) <= limit
except Exception:
return True
@@ -0,0 +1,59 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX, PrivatePortraitAssetStatus, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType, PrivatePortraitReferenceSource
from app.models.private_portrait import PrivatePortraitAsset
from app.services.operation_log_service import log_operation_event
DOMAIN = "private_portrait"
def _ref_get(ref: Any, key: str) -> Any:
if isinstance(ref, dict):
return ref.get(key)
return getattr(ref, key, None)
def _ref_set(ref: Any, key: str, value: Any) -> None:
if isinstance(ref, dict):
ref[key] = value
else:
setattr(ref, key, value)
async def resolve_private_portrait_references(db: AsyncSession, *, user_id: str, media_references: list[Any] | None) -> list[Any] | None:
if not media_references:
return media_references
refs = deepcopy(media_references)
ids = [str(_ref_get(ref, "private_asset_id")) for ref in refs if _ref_get(ref, "source") == PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value and _ref_get(ref, "private_asset_id")]
ids = list(dict.fromkeys(ids))
if not ids:
return refs
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.SERVICE.value, user_id=user_id, detail={"private_asset_ids": ids})
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id.in_(ids)))
asset_map = {asset.id: asset for asset in rows.scalars().all()}
for ref in refs:
if _ref_get(ref, "source") != PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value:
continue
asset_id = str(_ref_get(ref, "private_asset_id") or "")
asset = asset_map.get(asset_id)
if not asset:
raise HTTPException(status_code=400, detail="真人素材不存在")
if asset.user_id != user_id:
raise HTTPException(status_code=403, detail="真人素材不属于当前用户")
if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="真人素材已删除")
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status}Active 后才可用于生成")
if not asset.remote_asset_id:
raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId")
_ref_set(ref, "remote_asset_id", asset.remote_asset_id)
_ref_set(ref, "url", f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}")
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REFERENCE_RESOLVE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.SERVICE.value, user_id=user_id, detail={"count": len(ids)})
return refs
+2 -1
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from volcenginesdkarkruntime import AsyncArk
from app.config import settings
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
from app.models.video_engine import VideoEngine
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
from app.services.generation_provider_types import (
@@ -98,7 +99,7 @@ def _resolve_url(url: str) -> str:
# with open(file_path, "rb") as f:
# b64 = base64.b64encode(f.read()).decode()
# return f"data:{mime};base64,{b64}"
if url.startswith(("http://", "https://", "data:")):
if url.startswith(("http://", "https://", "data:", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
return url
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
+19 -4
View File
@@ -24,6 +24,7 @@ CELERY_TASK_IMPORTS = (
"app.tasks.module_async_recovery_tasks",
"app.tasks.user_oauth_tasks",
"app.tasks.cleanup",
"app.tasks.private_portrait_asset_tasks",
)
@@ -41,10 +42,9 @@ def _derive_redis_db(url: str, db_no: int) -> str:
def _beat_schedule() -> dict:
if not bool(getattr(settings, "POLL_DUE_DISPATCH_ENABLED", True)):
return {}
return {
"dispatch-due-poll-tasks-every-minute": {
schedule: dict = {}
if bool(getattr(settings, "POLL_DUE_DISPATCH_ENABLED", True)):
schedule["dispatch-due-poll-tasks-every-minute"] = {
"task": CeleryTaskName.DISPATCH_DUE_POLL.value,
"schedule": max(1, int(settings.POLL_DUE_DISPATCH_INTERVAL_SECONDS or 60)),
"options": {
@@ -52,7 +52,17 @@ def _beat_schedule() -> dict:
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
},
}
schedule["private-portrait-sync-due-assets-every-minute"] = {
"task": CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value,
"schedule": 60,
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
}
schedule["private-portrait-recover-remote-deletes-every-5-minutes"] = {
"task": CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value,
"schedule": 300,
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
}
return schedule
broker_url = settings.CELERY_BROKER_URL or (_derive_redis_db(settings.REDIS_URL, 1) if settings.REDIS_URL else "")
@@ -116,6 +126,11 @@ if broker_url:
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"queue": RECOVERY_QUEUE},
"user_oauth.update_oauth_accounts": {"queue": CeleryQueue.DEFAULT.value},
"app.tasks.cleanup.*": {"queue": CeleryQueue.DEFAULT.value},
CeleryTaskName.PRIVATE_PORTRAIT_POLL_ASSET.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_ASSET.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
},
)
else:
@@ -0,0 +1,156 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE,
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE,
PrivatePortraitEventSource,
PrivatePortraitEventType,
)
from app.models import async_session
from app.services.operation_log_service import log_operation_error
from app.services.private_portrait.asset_service import (
DOMAIN,
delete_asset_remote,
delete_project_remote,
poll_due_assets_once,
recover_remote_deletes_once,
sync_asset_status,
)
from app.tasks.celery_app import celery_app
logger = logging.getLogger(__name__)
def _run(coro):
return asyncio.run(coro)
def _retry_countdown(retries: int) -> int:
return min(300, 30 * (2 ** max(0, retries)))
async def _rollback_and_reraise(db, *, event_type: str, exc: BaseException, detail: dict[str, Any] | None = None, **kwargs: Any):
await db.rollback()
log_operation_error(
domain=DOMAIN,
event_type=event_type,
source=PrivatePortraitEventSource.CELERY.value,
exc=exc,
detail=detail,
**kwargs,
)
raise exc
@celery_app.task(name="private_portrait.poll_asset_status", queue="gen_private_portrait", bind=True, max_retries=5, default_retry_delay=30)
def poll_private_portrait_asset_status(self, asset_id: str) -> None:
async def _inner():
async with async_session() as db:
try:
await sync_asset_status(db, user_id=None, asset_id=asset_id)
await db.commit()
except Exception as exc:
logger.exception("poll private portrait asset failed: %s", asset_id)
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value,
exc=exc,
asset_id=asset_id,
detail={"celery_task": "private_portrait.poll_asset_status"},
)
try:
_run(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.sync_due_assets", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def sync_private_portrait_due_assets(self) -> int:
async def _inner() -> int:
async with async_session() as db:
try:
count = await poll_due_assets_once(db, limit=PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE)
await db.commit()
return count
except Exception as exc:
logger.exception("sync private portrait due assets failed")
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_FAILED.value,
exc=exc,
detail={"celery_task": "private_portrait.sync_due_assets"},
)
try:
return _run(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.delete_asset_remote", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def delete_private_portrait_asset_remote(self, asset_id: str) -> None:
async def _inner():
async with async_session() as db:
try:
await delete_asset_remote(db, asset_id=asset_id)
await db.commit()
except Exception as exc:
logger.exception("delete private portrait asset remote failed: %s", asset_id)
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
exc=exc,
asset_id=asset_id,
detail={"celery_task": "private_portrait.delete_asset_remote"},
)
try:
_run(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.delete_project_remote", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def delete_private_portrait_project_remote(self, project_id: str) -> None:
async def _inner():
async with async_session() as db:
try:
await delete_project_remote(db, project_id=project_id)
await db.commit()
except Exception as exc:
logger.exception("delete private portrait project remote failed: %s", project_id)
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value,
exc=exc,
project_id=project_id,
detail={"celery_task": "private_portrait.delete_project_remote"},
)
try:
_run(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(name="private_portrait.recover_remote_deletes", queue="gen_private_portrait", bind=True, max_retries=3, default_retry_delay=60)
def recover_private_portrait_remote_deletes(self) -> dict[str, int]:
async def _inner() -> dict[str, int]:
async with async_session() as db:
try:
result = await recover_remote_deletes_once(db, limit=PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE)
await db.commit()
return result
except Exception as exc:
logger.exception("recover private portrait remote deletes failed")
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_FAILED.value,
exc=exc,
detail={"celery_task": "private_portrait.recover_remote_deletes"},
)
try:
return _run(_inner())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))