真人素材库本地完成
This commit is contained in:
@@ -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 ###
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -7,6 +7,8 @@ class Settings(BaseSettings):
|
||||
APP_NAME: str = "VideoGen API"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
# 运行环境,用于生成火山私域真人素材 ProjectName:local/test/online。
|
||||
APP_ENV: str = "local"
|
||||
SECRET_KEY: str = "change-me"
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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('/')}"
|
||||
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user