1
This commit is contained in:
@@ -57,6 +57,10 @@ VIDEO_COVER_WIDTH=600
|
||||
VIDEO_COVER_TIMEOUT_SECONDS=15
|
||||
VIDEO_COVER_FORMAT=png
|
||||
|
||||
# VOLC
|
||||
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
# VOLC_SMS
|
||||
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
"""rename private portrait asset limit and add library type
|
||||
|
||||
Revision ID: cd7688999204
|
||||
Revises: e7038d02c355
|
||||
Create Date: 2026-07-07 13:21:49.612465
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "cd7688999204"
|
||||
down_revision: Union[str, None] = "e7038d02c355"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. users 字段改名:必须 rename,保留原有用户额度数据
|
||||
op.alter_column(
|
||||
"users",
|
||||
"private_portrait_image_limit",
|
||||
new_column_name="private_portrait_asset_limit",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("5"),
|
||||
comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
|
||||
existing_comment="私域真人图片素材数量限制,0表示关闭",
|
||||
)
|
||||
|
||||
# 2. private_portrait_asset_groups
|
||||
op.add_column(
|
||||
"private_portrait_asset_groups",
|
||||
sa.Column(
|
||||
"library_type",
|
||||
sa.String(length=32),
|
||||
server_default="real_person",
|
||||
nullable=False,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_asset_groups_library_type",
|
||||
"private_portrait_asset_groups",
|
||||
["library_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_asset_groups_user_library",
|
||||
"private_portrait_asset_groups",
|
||||
["user_id", "library_type"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# 3. private_portrait_assets
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column(
|
||||
"library_type",
|
||||
sa.String(length=32),
|
||||
server_default="real_person",
|
||||
nullable=False,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("video_duration", sa.Float(), nullable=True, comment="视频素材时长,秒"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("video_cover_url", sa.Text(), nullable=True, comment="视频素材封面预览地址"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("file_size", sa.Integer(), nullable=True, comment="素材文件大小,字节"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("mime_type", sa.String(length=128), nullable=True, comment="素材 MIME 类型"),
|
||||
)
|
||||
|
||||
# 如果 private_portrait_assets.asset_type 已经存在索引,这条要删除
|
||||
op.create_index(
|
||||
"idx_private_portrait_assets_asset_type",
|
||||
"private_portrait_assets",
|
||||
["asset_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_assets_library_type",
|
||||
"private_portrait_assets",
|
||||
["library_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_assets_user_library_status_created",
|
||||
"private_portrait_assets",
|
||||
["user_id", "library_type", "status", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# 4. private_portrait_projects
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column(
|
||||
"library_type",
|
||||
sa.String(length=32),
|
||||
server_default="real_person",
|
||||
nullable=False,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="图片素材数量"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="视频素材数量"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("active_image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用图片素材数量"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("active_video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用视频素材数量"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_projects_library_type",
|
||||
"private_portrait_projects",
|
||||
["library_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_projects_user_library_status_created",
|
||||
"private_portrait_projects",
|
||||
["user_id", "library_type", "status", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. users 字段反向改名:保留数据
|
||||
op.alter_column(
|
||||
"users",
|
||||
"private_portrait_asset_limit",
|
||||
new_column_name="private_portrait_image_limit",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("5"),
|
||||
comment="私域真人图片素材数量限制,0表示关闭",
|
||||
existing_comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
|
||||
)
|
||||
|
||||
# 2. private_portrait_projects
|
||||
op.drop_index(
|
||||
"idx_private_portrait_projects_user_library_status_created",
|
||||
table_name="private_portrait_projects",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_projects_library_type",
|
||||
table_name="private_portrait_projects",
|
||||
)
|
||||
op.drop_column("private_portrait_projects", "active_video_asset_count")
|
||||
op.drop_column("private_portrait_projects", "active_image_asset_count")
|
||||
op.drop_column("private_portrait_projects", "video_asset_count")
|
||||
op.drop_column("private_portrait_projects", "image_asset_count")
|
||||
op.drop_column("private_portrait_projects", "library_type")
|
||||
|
||||
# 3. private_portrait_assets
|
||||
op.drop_index(
|
||||
"idx_private_portrait_assets_user_library_status_created",
|
||||
table_name="private_portrait_assets",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_assets_library_type",
|
||||
table_name="private_portrait_assets",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_assets_asset_type",
|
||||
table_name="private_portrait_assets",
|
||||
)
|
||||
op.drop_column("private_portrait_assets", "mime_type")
|
||||
op.drop_column("private_portrait_assets", "file_size")
|
||||
op.drop_column("private_portrait_assets", "video_cover_url")
|
||||
op.drop_column("private_portrait_assets", "video_duration")
|
||||
op.drop_column("private_portrait_assets", "library_type")
|
||||
|
||||
# 4. private_portrait_asset_groups
|
||||
op.drop_index(
|
||||
"idx_private_portrait_asset_groups_user_library",
|
||||
table_name="private_portrait_asset_groups",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_asset_groups_library_type",
|
||||
table_name="private_portrait_asset_groups",
|
||||
)
|
||||
op.drop_column("private_portrait_asset_groups", "library_type")
|
||||
@@ -10,127 +10,59 @@ from app.dependencies import get_admin_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAdminConfigUpdate,
|
||||
PrivatePortraitAdminStatsOut,
|
||||
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
|
||||
from app.services.private_portrait.admin.asset_query_service import admin_list_assets
|
||||
from app.services.private_portrait.admin.project_query_service import admin_list_projects
|
||||
from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats
|
||||
from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit
|
||||
|
||||
router = APIRouter(prefix="/admin/private-portrait", tags=["admin-private-portrait"])
|
||||
router = APIRouter(prefix="/admin/private-portrait", tags=["管理后台-私域人像素材库"])
|
||||
|
||||
|
||||
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),
|
||||
):
|
||||
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度")
|
||||
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")}),
|
||||
)
|
||||
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),
|
||||
):
|
||||
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量")
|
||||
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)
|
||||
await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_asset_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"),
|
||||
}
|
||||
),
|
||||
)
|
||||
await log_operation(db, admin.id, admin.username, f"设置私域人像素材总量限制:{before.asset_limit} -> {after.asset_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")}))
|
||||
await db.commit()
|
||||
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("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计")
|
||||
async def admin_get_private_portrait_stats_api(user_id: str | None = Query(None), library_type: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type)
|
||||
await log_operation(db, admin.id, admin.username, "查看私域人像素材统计", "GET", "/admin/private-portrait/stats", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type}, "stats": stats.model_dump(mode="json")}))
|
||||
return stats
|
||||
|
||||
|
||||
@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)
|
||||
@router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表")
|
||||
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), library_type: 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 admin_list_projects(db, user_id=user_id, library_type=library_type, 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, "library_type": library_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
|
||||
await db.commit()
|
||||
return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表")
|
||||
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), library_type: str | None = Query(None), asset_type: 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 admin_list_assets(db, user_id=user_id, project_id=project_id, library_type=library_type, asset_type=asset_type, 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, "library_type": library_type, "asset_type": asset_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
|
||||
await db.commit()
|
||||
return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
@@ -33,6 +33,7 @@ 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
|
||||
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -67,4 +68,5 @@ api_router.include_router(team_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(private_portrait_router)
|
||||
api_router.include_router(private_portrait_router)
|
||||
api_router.include_router(private_portrait_virtual_router)
|
||||
|
||||
@@ -174,7 +174,7 @@ 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,
|
||||
private_portrait_asset_limit=req.private_portrait_asset_limit,
|
||||
)
|
||||
user.credits = round(user.credits, 2)
|
||||
db.add(user)
|
||||
@@ -192,7 +192,7 @@ async def create_user(
|
||||
"username": username,
|
||||
"user_type": req.user_type,
|
||||
"frontend_user_kind": user.frontend_user_kind,
|
||||
"private_portrait_image_limit": user.private_portrait_image_limit,
|
||||
"private_portrait_asset_limit": user.private_portrait_asset_limit,
|
||||
"credits": user.credits,
|
||||
"phone": user.phone,
|
||||
"email": user.email,
|
||||
|
||||
@@ -9,9 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
@@ -20,6 +22,7 @@ from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitAssetOut,
|
||||
PrivatePortraitDeleteOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitProjectCreate,
|
||||
@@ -30,13 +33,13 @@ from app.schemas.private_portrait import (
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitValidateSessionCreate,
|
||||
PrivatePortraitValidateSessionOut,
|
||||
PrivatePortraitEnumMetaOut,
|
||||
build_private_portrait_enum_meta,
|
||||
)
|
||||
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,
|
||||
@@ -47,100 +50,102 @@ from app.services.private_portrait.asset_service import (
|
||||
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,
|
||||
)
|
||||
from app.services.private_portrait.real_person.service import (
|
||||
create_real_person_asset,
|
||||
create_real_person_project,
|
||||
create_real_person_validate_session,
|
||||
update_real_person_project,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["private-portrait"])
|
||||
router = APIRouter(tags=["私域真人素材库"])
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
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},
|
||||
)
|
||||
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)
|
||||
@router.get(
|
||||
"/private-portrait/config",
|
||||
response_model=PrivatePortraitConfigOut,
|
||||
summary="获取当前用户私域人像素材额度配置",
|
||||
description="返回私域人像素材总量限制。额度由真人认证素材库与虚拟人像素材库共用,图片和视频共用,Audio 暂未开放。",
|
||||
)
|
||||
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=PrivatePortraitProjectCreateWithValidateOut)
|
||||
@router.get(
|
||||
"/private-portrait/meta/enums",
|
||||
response_model=PrivatePortraitEnumMetaOut,
|
||||
summary="获取私域人像素材库枚举说明",
|
||||
description="给前端展示状态、类型、素材库类型使用。Audio 仅作为火山支持项展示,当前业务不开放上传。",
|
||||
)
|
||||
async def get_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/projects",
|
||||
response_model=PrivatePortraitProjectCreateWithValidateOut,
|
||||
summary="创建真人认证素材项目并生成认证会话",
|
||||
description="创建本地真人素材项目,随后调用火山 CreateVisualValidateSession 返回 H5Link。用户完成认证后,回调会创建本地 Asset Group 映射。",
|
||||
)
|
||||
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)
|
||||
session = await create_validate_session(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
project_id=project.id,
|
||||
callback_redirect_url=payload.callback_redirect_url,
|
||||
)
|
||||
out = PrivatePortraitProjectCreateWithValidateOut(
|
||||
project=project_to_out(project),
|
||||
validate_session=validate_session_to_out(session),
|
||||
poll_interval_ms=2000,
|
||||
)
|
||||
project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
|
||||
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project.id, callback_redirect_url=payload.callback_redirect_url)
|
||||
out = PrivatePortraitProjectCreateWithValidateOut(project=project_to_out(project), validate_session=validate_session_to_out(session), poll_interval_ms=2000)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/projects", response_model=PrivatePortraitProjectListOut)
|
||||
@router.get(
|
||||
"/private-portrait/projects",
|
||||
response_model=PrivatePortraitProjectListOut,
|
||||
summary="查询当前用户真人认证素材项目列表",
|
||||
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可用 status 覆盖。",
|
||||
)
|
||||
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),
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始。"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100。"),
|
||||
keyword: str | None = Query(None, description="项目名称模糊搜索。"),
|
||||
status: str | None = Query(None, description="项目状态,不传默认 active。"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status)
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
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=query_status)
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
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)
|
||||
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取真人认证素材项目详情")
|
||||
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))
|
||||
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value))
|
||||
|
||||
|
||||
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut)
|
||||
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新真人认证素材项目")
|
||||
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)
|
||||
project = await update_real_person_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)
|
||||
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材项目")
|
||||
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 = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
project_id_snapshot = project.id
|
||||
await db.commit()
|
||||
try:
|
||||
@@ -153,30 +158,26 @@ async def delete_private_portrait_project(project_id: str, current_user: User =
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut)
|
||||
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut, summary="重新创建真人认证会话")
|
||||
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)
|
||||
session = await create_real_person_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)
|
||||
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut, summary="查询真人认证会话状态")
|
||||
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")
|
||||
@router.get("/private-portrait/validate-callback", summary="火山真人认证回调入口")
|
||||
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_params = {
|
||||
"session_id": session.id,
|
||||
"status": session.status,
|
||||
"resultCode": session.result_code or "",
|
||||
}
|
||||
redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""}
|
||||
if session.remote_group_id:
|
||||
redirect_params["remote_group_id"] = session.remote_group_id
|
||||
response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id}
|
||||
@@ -188,9 +189,9 @@ async def private_portrait_validate_callback(session_id: str, request: Request,
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/private-portrait/projects/{project_id}/assets")
|
||||
@router.post("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传真人认证素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
|
||||
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 = await create_real_person_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)
|
||||
@@ -205,32 +206,34 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
|
||||
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)
|
||||
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询真人认证素材列表")
|
||||
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), asset_type: 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, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
|
||||
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}")
|
||||
@router.get("/private-portrait/assets/{asset_id}", summary="获取真人认证素材详情")
|
||||
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()
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.REAL_PERSON.value).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
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")
|
||||
@router.post("/private-portrait/assets/{asset_id}/sync", summary="同步真人认证素材状态")
|
||||
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)
|
||||
if asset.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
out = asset_to_out(asset)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut)
|
||||
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材")
|
||||
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 = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
asset_id_snapshot = asset.id
|
||||
project_id_snapshot = asset.project_id
|
||||
await db.commit()
|
||||
@@ -244,7 +247,7 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
|
||||
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)
|
||||
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的真人认证素材")
|
||||
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), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), 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, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
|
||||
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitAssetOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitDeleteOut,
|
||||
PrivatePortraitEnumMetaOut,
|
||||
PrivatePortraitProjectListOut,
|
||||
PrivatePortraitProjectOut,
|
||||
PrivatePortraitProjectUpdate,
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitVirtualProjectCreate,
|
||||
build_private_portrait_enum_meta,
|
||||
)
|
||||
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,
|
||||
get_user_private_portrait_config,
|
||||
list_assets,
|
||||
list_selectable_assets,
|
||||
soft_delete_asset,
|
||||
sync_asset_status,
|
||||
)
|
||||
from app.services.private_portrait.project_service import (
|
||||
get_user_project,
|
||||
list_projects,
|
||||
project_to_out,
|
||||
refresh_project_counters,
|
||||
soft_delete_project,
|
||||
)
|
||||
from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
|
||||
|
||||
router = APIRouter(tags=["私域虚拟人像素材库"])
|
||||
|
||||
|
||||
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/virtual/config", response_model=PrivatePortraitConfigOut, summary="获取虚拟人像素材库额度配置", description="额度与真人认证素材库共用;图片/视频共用;Audio 暂不开放。")
|
||||
async def get_my_virtual_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.get("/private-portrait/virtual-meta/enums", response_model=PrivatePortraitEnumMetaOut, summary="获取虚拟人像素材库枚举说明")
|
||||
async def get_virtual_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
|
||||
|
||||
@router.post("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectOut, summary="创建虚拟人像项目组", description="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroup,GroupType=AIGC。ProjectName 必须与后续生成 API Key 所属项目一致,默认使用 default。")
|
||||
async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtualProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await create_virtual_project(db, user_id=current_user.id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectListOut, summary="查询当前用户虚拟人像项目列表")
|
||||
async def list_private_portrait_virtual_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)):
|
||||
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
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=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取虚拟人像项目详情")
|
||||
async def get_private_portrait_virtual_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, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value))
|
||||
|
||||
|
||||
@router.put("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新虚拟人像项目")
|
||||
async def update_private_portrait_virtual_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await update_virtual_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/virtual-projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像项目")
|
||||
async def delete_private_portrait_virtual_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, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
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/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传虚拟人像素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
|
||||
async def create_private_portrait_virtual_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await create_virtual_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/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询虚拟人像素材列表")
|
||||
async def list_private_portrait_virtual_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), asset_type: 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, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
|
||||
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/virtual-assets/{asset_id}", response_model=PrivatePortraitAssetOut, summary="获取虚拟人像素材详情")
|
||||
async def get_private_portrait_virtual_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, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value).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/virtual-assets/{asset_id}/sync", response_model=PrivatePortraitAssetOut, summary="同步虚拟人像素材状态")
|
||||
async def sync_private_portrait_virtual_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)
|
||||
if asset.library_type != PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
raise HTTPException(status_code=404, detail="虚拟人像素材不存在")
|
||||
out = asset_to_out(asset)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像素材")
|
||||
async def delete_private_portrait_virtual_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, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
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/virtual-selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的虚拟人像素材")
|
||||
async def list_private_portrait_virtual_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), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), 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, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
|
||||
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
@@ -37,6 +37,10 @@ class Settings(BaseSettings):
|
||||
SMS_TEMPLATE_CODE: str = "SMS_001"
|
||||
SMS_MOCK: bool = True
|
||||
|
||||
# 火山引擎配置。
|
||||
VOLC_ACCESS_KEY_ID: str = ""
|
||||
VOLC_SECRET_ACCESS_KEY: str = ""
|
||||
|
||||
# 火山引擎短信配置。
|
||||
# SmsAccount=消息组ID,TemplateID=模板ID,Sign=短信签名内容。
|
||||
VOLC_SMS_ACCESS_KEY_ID: str = ""
|
||||
|
||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。
|
||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5
|
||||
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
||||
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
|
||||
|
||||
# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default"
|
||||
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE = "LivenessFace"
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE = "LivenessFace"
|
||||
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE = "AIGC"
|
||||
# 兼容旧代码导入,默认代表真人认证素材组类型。
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE = PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE
|
||||
PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time"
|
||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://"
|
||||
@@ -20,7 +24,9 @@ ARK_PRIVATE_PORTRAIT_REGION = "cn-beijing"
|
||||
ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com"
|
||||
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS = 30
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT = 120
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
|
||||
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
|
||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
|
||||
@@ -29,6 +35,7 @@ PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
|
||||
PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
|
||||
"CreateVisualValidateSession": 3,
|
||||
"GetVisualValidateResult": 3,
|
||||
"CreateAssetGroup": 10,
|
||||
"CreateAsset": 1,
|
||||
"ListAssetGroups": 10,
|
||||
"ListAssets": 10,
|
||||
@@ -44,6 +51,7 @@ PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
|
||||
class ArkPrivatePortraitAction(str, Enum):
|
||||
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
|
||||
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
|
||||
CREATE_ASSET_GROUP = "CreateAssetGroup"
|
||||
CREATE_ASSET = "CreateAsset"
|
||||
GET_ASSET = "GetAsset"
|
||||
LIST_ASSETS = "ListAssets"
|
||||
@@ -55,10 +63,17 @@ class ArkPrivatePortraitAction(str, Enum):
|
||||
DELETE_ASSET_GROUP = "DeleteAssetGroup"
|
||||
|
||||
|
||||
class PrivatePortraitLibraryType(str, Enum):
|
||||
REAL_PERSON = "real_person"
|
||||
AIGC_VIRTUAL = "aigc_virtual"
|
||||
|
||||
|
||||
class PrivatePortraitProjectStatus(str, Enum):
|
||||
VALIDATING = "validating"
|
||||
ACTIVE = "active"
|
||||
VALIDATE_FAILED = "validate_failed"
|
||||
CREATING_REMOTE_GROUP = "creating_remote_group"
|
||||
CREATE_GROUP_FAILED = "create_group_failed"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
@@ -72,6 +87,7 @@ class PrivatePortraitValidateSessionStatus(str, Enum):
|
||||
|
||||
|
||||
class PrivatePortraitAssetGroupStatus(str, Enum):
|
||||
CREATING = "creating"
|
||||
ACTIVE = "active"
|
||||
LOCAL_DELETED = "local_deleted"
|
||||
REMOTE_DELETED = "remote_deleted"
|
||||
@@ -92,7 +108,13 @@ class PrivatePortraitAssetStatus(str, Enum):
|
||||
class PrivatePortraitAssetType(str, Enum):
|
||||
IMAGE = "Image"
|
||||
VIDEO = "Video"
|
||||
AUDIO = "Audio"
|
||||
AUDIO = "Audio" # 火山支持,但当前业务暂不开放。
|
||||
|
||||
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES = {
|
||||
PrivatePortraitAssetType.IMAGE.value,
|
||||
PrivatePortraitAssetType.VIDEO.value,
|
||||
}
|
||||
|
||||
|
||||
class PrivatePortraitRemoteDeleteStatus(str, Enum):
|
||||
@@ -125,9 +147,19 @@ class PrivatePortraitEventSource(str, Enum):
|
||||
|
||||
|
||||
class PrivatePortraitEventType(str, Enum):
|
||||
QUOTA_CHECK_START = "QUOTA_CHECK_START"
|
||||
QUOTA_CHECK_PASS = "QUOTA_CHECK_PASS"
|
||||
QUOTA_CHECK_DENY = "QUOTA_CHECK_DENY"
|
||||
|
||||
PROJECT_CREATE = "PROJECT_CREATE"
|
||||
PROJECT_UPDATE = "PROJECT_UPDATE"
|
||||
PROJECT_DELETE = "PROJECT_DELETE"
|
||||
|
||||
VIRTUAL_PROJECT_CREATE_START = "VIRTUAL_PROJECT_CREATE_START"
|
||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START"
|
||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS"
|
||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED"
|
||||
|
||||
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
|
||||
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
||||
VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED"
|
||||
@@ -163,7 +195,6 @@ class PrivatePortraitEventType(str, Enum):
|
||||
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"
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
@@ -19,10 +20,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__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_library_status_created", "user_id", "library_type", "status", "created_at"),
|
||||
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_library_type", "library_type"),
|
||||
Index("idx_private_portrait_assets_asset_type", "asset_type"),
|
||||
Index(
|
||||
"idx_private_portrait_assets_next_poll_status",
|
||||
"next_poll_at",
|
||||
@@ -36,6 +40,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
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)
|
||||
library_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
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)
|
||||
@@ -45,6 +56,10 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
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)
|
||||
video_duration: Mapped[float | None] = mapped_column(Float, nullable=True, comment="视频素材时长,秒")
|
||||
video_cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频素材封面预览地址")
|
||||
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材文件大小,字节")
|
||||
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -6,8 +6,9 @@ from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
@@ -20,9 +21,11 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__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_user_library", "user_id", "library_type"),
|
||||
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"),
|
||||
Index("idx_private_portrait_asset_groups_library_type", "library_type"),
|
||||
Index(
|
||||
"uq_private_portrait_asset_groups_one_active_project",
|
||||
"project_id",
|
||||
@@ -34,10 +37,17 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
|
||||
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)
|
||||
library_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
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)
|
||||
group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -5,15 +5,16 @@ 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.enums.private_portrait import PrivatePortraitLibraryType, PrivatePortraitProjectStatus
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
||||
"""用户本地私域人像素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
||||
|
||||
__tablename__ = "private_portrait_projects"
|
||||
__table_args__ = (
|
||||
Index("idx_private_portrait_projects_user_library_status_created", "user_id", "library_type", "status", "created_at"),
|
||||
Index("idx_private_portrait_projects_user_status_created", "user_id", "status", "created_at"),
|
||||
Index(
|
||||
"idx_private_portrait_projects_user_deleted",
|
||||
@@ -22,10 +23,19 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"),
|
||||
Index("idx_private_portrait_projects_library_type", "library_type"),
|
||||
)
|
||||
|
||||
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)
|
||||
library_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
index=True,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
)
|
||||
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 快照")
|
||||
@@ -39,5 +49,9 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
)
|
||||
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")
|
||||
image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
video_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")
|
||||
active_image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
active_video_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)
|
||||
|
||||
@@ -39,8 +39,8 @@ class User(Base, TimestampMixin):
|
||||
)
|
||||
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。
|
||||
private_portrait_image_limit: Mapped[int] = mapped_column(
|
||||
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
|
||||
private_portrait_asset_limit: Mapped[int] = mapped_column(
|
||||
Integer, default=5, server_default="5", nullable=False
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +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
|
||||
private_portrait_asset_limit: int = 5
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -72,7 +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)
|
||||
private_portrait_asset_limit: int = Field(default=5, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭")
|
||||
|
||||
|
||||
class UpdateFrontendUserKindRequest(BaseModel):
|
||||
|
||||
@@ -4,36 +4,67 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitAssetType
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
)
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
|
||||
class PrivatePortraitEnumItem(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PrivatePortraitEnumMetaOut(BaseModel):
|
||||
library_types: list[PrivatePortraitEnumItem]
|
||||
asset_types: list[PrivatePortraitEnumItem]
|
||||
project_statuses: list[PrivatePortraitEnumItem]
|
||||
asset_statuses: list[PrivatePortraitEnumItem]
|
||||
|
||||
|
||||
class PrivatePortraitConfigOut(BaseModel):
|
||||
enabled: bool
|
||||
image_limit: int
|
||||
used_image_count: int
|
||||
remaining_image_count: int
|
||||
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
|
||||
asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
|
||||
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
|
||||
remaining_asset_count: int = Field(..., description="剩余可上传素材数量。")
|
||||
supported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value], description="当前业务开放的素材类型。")
|
||||
unsupported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value], description="火山支持但当前业务暂不开放的素材类型。")
|
||||
# 兼容旧前端,后续确认无引用后可移除。
|
||||
image_limit: int | None = Field(None, description="兼容旧字段:请改用 asset_limit。")
|
||||
used_image_count: int | None = Field(None, description="兼容旧字段:请改用 used_asset_count。")
|
||||
remaining_image_count: int | None = Field(None, description="兼容旧字段:请改用 remaining_asset_count。")
|
||||
|
||||
|
||||
class PrivatePortraitAdminConfigUpdate(BaseModel):
|
||||
private_portrait_image_limit: int = Field(..., ge=0, le=9999, description="0 表示关闭真人素材模块;>0 表示启用并限制图片总量")
|
||||
private_portrait_asset_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)
|
||||
callback_redirect_url: str | None = Field(None, description="项目创建时真人认证完成后的手机端提示页地址")
|
||||
name: str = Field(..., min_length=1, max_length=128, description="项目组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||
callback_redirect_url: str | None = Field(None, description="仅真人认证使用:认证完成后的手机端提示页地址。")
|
||||
|
||||
|
||||
class PrivatePortraitVirtualProjectCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步创建火山 Asset Group。")
|
||||
description: str | None = Field(None, max_length=2000, description="虚拟人像素材项目组描述,会同步到火山 Asset Group。")
|
||||
|
||||
|
||||
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
|
||||
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||
status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
|
||||
|
||||
|
||||
class PrivatePortraitProjectOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
name: str
|
||||
name_slug: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
@@ -41,7 +72,11 @@ class PrivatePortraitProjectOut(BaseModel):
|
||||
status: str
|
||||
asset_group_count: int = 0
|
||||
asset_count: int = 0
|
||||
image_asset_count: int = 0
|
||||
video_asset_count: int = 0
|
||||
active_asset_count: int = 0
|
||||
active_image_asset_count: int = 0
|
||||
active_video_asset_count: int = 0
|
||||
last_used_at: NaiveDatetimeOptional = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
@@ -81,18 +116,17 @@ class PrivatePortraitValidateSessionOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
|
||||
|
||||
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
|
||||
project: PrivatePortraitProjectOut
|
||||
validate_session: PrivatePortraitValidateSessionOut
|
||||
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒")
|
||||
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。")
|
||||
|
||||
|
||||
class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
project_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_group_name: str | None = None
|
||||
remote_project_name: str
|
||||
@@ -108,16 +142,22 @@ class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
|
||||
|
||||
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)
|
||||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
|
||||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / Video,Audio 暂不开放。")
|
||||
name: str | None = Field(None, max_length=256, description="素材名称,仅用于检索和管理。")
|
||||
video_duration: float | None = Field(None, ge=0, description="视频素材时长,单位秒。图片可为空。")
|
||||
video_cover_url: str | None = Field(None, description="视频封面预览地址。图片可为空。")
|
||||
file_size: int | None = Field(None, ge=0, description="文件大小,字节。")
|
||||
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型。")
|
||||
|
||||
@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")
|
||||
raise ValueError("asset_type 仅支持 Image/Video,Audio 暂未开放")
|
||||
if value not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
|
||||
return value
|
||||
|
||||
|
||||
@@ -127,6 +167,7 @@ class PrivatePortraitAssetOut(BaseModel):
|
||||
project_id: str
|
||||
project_name: str | None = None
|
||||
group_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_asset_id: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
@@ -134,10 +175,16 @@ class PrivatePortraitAssetOut(BaseModel):
|
||||
name: str | None = None
|
||||
source_url: str
|
||||
preview_url: str | None = None
|
||||
display_url: str | None = None
|
||||
provider_url: str | None = None
|
||||
remote_url: str | None = None
|
||||
remote_url_expired_at: NaiveDatetimeOptional = None
|
||||
video_duration: float | None = None
|
||||
video_cover_url: str | None = None
|
||||
file_size: int | None = None
|
||||
mime_type: str | None = None
|
||||
status: str
|
||||
moderation: dict[str, Any] | None = None
|
||||
moderation: Any = None
|
||||
last_poll_at: NaiveDatetimeOptional = None
|
||||
next_poll_at: NaiveDatetimeOptional = None
|
||||
poll_count: int = 0
|
||||
@@ -162,10 +209,15 @@ class PrivatePortraitSelectableAssetOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
project_name: str
|
||||
library_type: str
|
||||
name: str | None = None
|
||||
asset_type: str
|
||||
preview_url: str | None = None
|
||||
status: str
|
||||
display_url: str | None = None
|
||||
provider_url: str | None = None
|
||||
video_duration: float | None = None
|
||||
video_cover_url: str | None = None
|
||||
status: str = PrivatePortraitAssetStatus.ACTIVE.value
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
|
||||
|
||||
@@ -178,4 +230,32 @@ class PrivatePortraitSelectableAssetListOut(BaseModel):
|
||||
|
||||
class PrivatePortraitDeleteOut(BaseModel):
|
||||
success: bool = True
|
||||
remote_delete_status: str | None = None
|
||||
remote_delete_status: str
|
||||
|
||||
|
||||
class PrivatePortraitAdminStatsOut(BaseModel):
|
||||
total_projects: int = 0
|
||||
total_assets: int = 0
|
||||
image_assets: int = 0
|
||||
video_assets: int = 0
|
||||
active_assets: int = 0
|
||||
processing_assets: int = 0
|
||||
failed_assets: int = 0
|
||||
real_person_assets: int = 0
|
||||
virtual_assets: int = 0
|
||||
|
||||
|
||||
def build_private_portrait_enum_meta() -> PrivatePortraitEnumMetaOut:
|
||||
return PrivatePortraitEnumMetaOut(
|
||||
library_types=[
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.REAL_PERSON.value, label="真人认证素材库", description="需要用户扫码完成真人授权认证后才能上传素材。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, label="私域虚拟人像素材库", description="通过火山 CreateAssetGroup/CreateAsset 入库的虚拟人像素材。"),
|
||||
],
|
||||
asset_types=[
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.IMAGE.value, label="图片", description="当前开放。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.VIDEO.value, label="视频", description="当前开放,处理时间通常比图片更长。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.AUDIO.value, label="音频", description="火山支持但当前业务暂不开放。"),
|
||||
],
|
||||
project_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitProjectStatus],
|
||||
asset_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitAssetStatus],
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
|
||||
allowed_menus: list | None = None
|
||||
must_set_password: bool = False
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_image_limit: int = 5
|
||||
private_portrait_asset_limit: int = 5
|
||||
team_id: str | None = None
|
||||
team_name: str | None = None
|
||||
is_team_manager: bool = False
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.private_portrait.asset_service import asset_to_out, list_assets
|
||||
|
||||
|
||||
async def admin_list_assets(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
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, library_type=library_type, asset_type=asset_type)
|
||||
return [asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.private_portrait import PrivatePortraitProject
|
||||
from app.services.private_portrait.project_service import list_projects, project_to_out, refresh_project_counters
|
||||
|
||||
|
||||
async def _reload_projects_by_ids(db: AsyncSession, project_ids: list[str]) -> list[PrivatePortraitProject]:
|
||||
"""Reload projects after counter refresh to avoid async expired-attribute lazy load.
|
||||
|
||||
refresh_project_counters() uses bulk UPDATE. In SQLAlchemy async ORM, previously
|
||||
loaded ORM instances may become expired after a bulk update. Accessing expired
|
||||
scalar attributes outside greenlet context triggers MissingGreenlet. Reloading
|
||||
with populate_existing=True refreshes the identity-map objects during the awaited
|
||||
execute call, and keeps the original pagination order.
|
||||
"""
|
||||
if not project_ids:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitProject)
|
||||
.where(PrivatePortraitProject.id.in_(project_ids))
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
project_map = {project.id: project for project in result.scalars().all()}
|
||||
return [project_map[project_id] for project_id in project_ids if project_id in project_map]
|
||||
|
||||
|
||||
async def admin_list_projects(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
items, total = await list_projects(
|
||||
db,
|
||||
user_id=user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
library_type=library_type,
|
||||
)
|
||||
project_ids = [item.id for item in items]
|
||||
await refresh_project_counters(db, project_ids)
|
||||
|
||||
# Bulk UPDATE may expire loaded ORM instances. Re-query before DTO conversion.
|
||||
items = await _reload_projects_by_ids(db, project_ids)
|
||||
return [project_to_out(item, include_user=True) for item in items], total
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitLibraryType
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
from app.schemas.private_portrait import PrivatePortraitAdminStatsOut
|
||||
|
||||
|
||||
async def admin_get_private_portrait_stats(db: AsyncSession, *, user_id: str | None = None, library_type: str | None = None) -> PrivatePortraitAdminStatsOut:
|
||||
project_filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
||||
asset_filters = [PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
if user_id:
|
||||
project_filters.append(PrivatePortraitProject.user_id == user_id)
|
||||
asset_filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
if library_type:
|
||||
project_filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
asset_filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
|
||||
total_projects = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*project_filters))).scalar_one()
|
||||
rows = await db.execute(
|
||||
select(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status, func.count(PrivatePortraitAsset.id))
|
||||
.where(*asset_filters)
|
||||
.group_by(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status)
|
||||
)
|
||||
stats = PrivatePortraitAdminStatsOut(total_projects=int(total_projects or 0))
|
||||
for lib, asset_type, status, count in rows.all():
|
||||
n = int(count or 0)
|
||||
stats.total_assets += n
|
||||
if asset_type == PrivatePortraitAssetType.IMAGE.value:
|
||||
stats.image_assets += n
|
||||
elif asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
stats.video_assets += n
|
||||
if status == PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
stats.active_assets += n
|
||||
elif status == PrivatePortraitAssetStatus.PROCESSING.value:
|
||||
stats.processing_assets += n
|
||||
elif status == PrivatePortraitAssetStatus.FAILED.value:
|
||||
stats.failed_assets += n
|
||||
if lib == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
stats.real_person_assets += n
|
||||
elif lib == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
stats.virtual_assets += n
|
||||
return stats
|
||||
@@ -51,11 +51,11 @@ def _remote_error_http_status(code: str) -> int:
|
||||
|
||||
|
||||
class ArkPrivateAssetClient:
|
||||
"""火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。"""
|
||||
"""火山 Ark 私域可信素材 Asset 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
|
||||
def __init__(self, *, for_celery: bool = False):
|
||||
self.ak = settings.VOLC_ACCESS_KEY_ID
|
||||
self.sk = settings.VOLC_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")
|
||||
@@ -66,6 +66,12 @@ class ArkPrivateAssetClient:
|
||||
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_group(self, *, project_name: str, name: str, description: str | None = None, group_type: str = "AIGC") -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"Name": name, "GroupType": group_type, "ProjectName": project_name}
|
||||
if description:
|
||||
payload["Description"] = description
|
||||
return await self._call(ArkPrivatePortraitAction.CREATE_ASSET_GROUP, payload)
|
||||
|
||||
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:
|
||||
|
||||
@@ -6,33 +6,42 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy import func, select
|
||||
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_ASSET_URI_PREFIX,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
|
||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
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.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, 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.services.private_portrait.quota_service import (
|
||||
count_user_counting_assets,
|
||||
ensure_private_portrait_asset_quota_available,
|
||||
get_user_private_portrait_config,
|
||||
set_user_private_portrait_limit,
|
||||
)
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
@@ -53,7 +62,6 @@ def _loads(data: str | None) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _exception_message(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
@@ -65,7 +73,7 @@ def _exception_message(exc: Exception) -> str:
|
||||
|
||||
|
||||
def _public_url(url: str) -> str:
|
||||
if url.startswith(("http://", "https://")):
|
||||
if url.startswith(("http://", "https://", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
|
||||
return url
|
||||
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
||||
|
||||
@@ -83,36 +91,31 @@ def _remote_group_name(user_id: str, project_name: str) -> str:
|
||||
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)
|
||||
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return asset.preview_url or asset.remote_url or asset.source_url or None
|
||||
|
||||
|
||||
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
|
||||
def _provider_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None
|
||||
|
||||
|
||||
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)
|
||||
def _poll_interval_seconds(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS
|
||||
|
||||
|
||||
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 _poll_max_count(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT
|
||||
|
||||
|
||||
def _assert_enabled_asset_type(asset_type: str) -> None:
|
||||
if asset_type not in {item.value for item in PrivatePortraitAssetType}:
|
||||
raise HTTPException(status_code=400, detail="asset_type 仅支持 Image/Video,Audio 暂未开放")
|
||||
if asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video")
|
||||
|
||||
|
||||
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||||
@@ -137,12 +140,14 @@ def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_
|
||||
|
||||
|
||||
def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
|
||||
display_url = _asset_display_url(asset)
|
||||
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,
|
||||
library_type=asset.library_type,
|
||||
remote_group_id=asset.remote_group_id,
|
||||
remote_asset_id=asset.remote_asset_id,
|
||||
remote_project_name=asset.remote_project_name,
|
||||
@@ -150,8 +155,14 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
|
||||
name=asset.name,
|
||||
source_url=asset.source_url,
|
||||
preview_url=asset.preview_url,
|
||||
display_url=display_url,
|
||||
provider_url=_provider_url(asset),
|
||||
remote_url=asset.remote_url,
|
||||
remote_url_expired_at=asset.remote_url_expired_at,
|
||||
video_duration=asset.video_duration,
|
||||
video_cover_url=asset.video_cover_url,
|
||||
file_size=asset.file_size,
|
||||
mime_type=asset.mime_type,
|
||||
status=asset.status,
|
||||
moderation=_loads(asset.moderation_json),
|
||||
last_poll_at=asset.last_poll_at,
|
||||
@@ -166,15 +177,18 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
|
||||
)
|
||||
|
||||
|
||||
async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> PrivatePortraitAssetGroup | None:
|
||||
async def _get_existing_active_group(db: AsyncSession, *, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup | None:
|
||||
filters = [
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
||||
]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAssetGroup.library_type == library_type)
|
||||
return (
|
||||
await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
||||
)
|
||||
.where(*filters)
|
||||
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -182,7 +196,9 @@ async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> Pr
|
||||
|
||||
|
||||
async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePortraitProject) -> PrivatePortraitValidateSession | None:
|
||||
active_group = await _get_existing_active_group(db, project_id=project.id)
|
||||
if project.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
raise HTTPException(status_code=400, detail="虚拟人像项目不支持真人认证")
|
||||
active_group = await _get_existing_active_group(db, project_id=project.id, library_type=project.library_type)
|
||||
if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
|
||||
|
||||
@@ -206,12 +222,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
|
||||
select(PrivatePortraitValidateSession)
|
||||
.where(
|
||||
PrivatePortraitValidateSession.project_id == project.id,
|
||||
PrivatePortraitValidateSession.status.in_(
|
||||
[
|
||||
PrivatePortraitValidateSessionStatus.CREATED.value,
|
||||
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value,
|
||||
]
|
||||
),
|
||||
PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]),
|
||||
PrivatePortraitValidateSession.expired_at.is_not(None),
|
||||
PrivatePortraitValidateSession.expired_at > now,
|
||||
)
|
||||
@@ -223,7 +234,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
|
||||
|
||||
|
||||
async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
reusable_session = await _ensure_project_can_validate(db, project=project)
|
||||
if reusable_session:
|
||||
return reusable_session
|
||||
@@ -246,10 +257,8 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
|
||||
session.h5_link = resp.get("H5Link") or resp.get("h5Link")
|
||||
session.raw_response_json = _json(resp)
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(session)
|
||||
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})
|
||||
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, "library_type": project.library_type})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
@@ -259,6 +268,7 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc)
|
||||
raise
|
||||
|
||||
|
||||
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:
|
||||
@@ -304,7 +314,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
raise HTTPException(status_code=400, detail=session.error_message)
|
||||
|
||||
try:
|
||||
existing_group = await _get_existing_active_group(db, project_id=session.project_id)
|
||||
existing_group = await _get_existing_active_group(db, project_id=session.project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
if existing_group:
|
||||
session.remote_group_id = existing_group.remote_group_id
|
||||
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
|
||||
@@ -330,10 +340,11 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
id=generate_id(),
|
||||
user_id=session.user_id,
|
||||
project_id=session.project_id,
|
||||
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
remote_group_id=group_id,
|
||||
remote_group_name=remote_group_name,
|
||||
remote_project_name=session.remote_project_name,
|
||||
group_type=PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
group_type=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
raw_response_json=_json(resp),
|
||||
)
|
||||
@@ -347,7 +358,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
await refresh_project_counters(db, [session.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(session)
|
||||
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})
|
||||
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, "library_type": PrivatePortraitLibraryType.REAL_PERSON.value})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
@@ -358,51 +369,67 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
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:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
|
||||
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
|
||||
result = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.user_id == user_id, PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, PrivatePortraitAssetGroup.deleted_at.is_(None)).order_by(PrivatePortraitAssetGroup.created_at.desc()).limit(1))
|
||||
detail = "请先完成真人授权认证,再上传素材" if project.library_type == PrivatePortraitLibraryType.REAL_PERSON.value else "虚拟人像素材组尚未创建成功,不能上传素材"
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(
|
||||
PrivatePortraitAssetGroup.user_id == user_id,
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.library_type == project.library_type,
|
||||
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="请先完成真人授权认证,再上传素材")
|
||||
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)
|
||||
async def create_asset(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
payload: PrivatePortraitAssetCreate,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitAsset:
|
||||
_assert_enabled_asset_type(payload.asset_type)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="项目正在真人认证或认证未通过,不能上传素材")
|
||||
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} 张图片,请删除已有素材后再上传")
|
||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
||||
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project.id)
|
||||
limit, current_count = await ensure_private_portrait_asset_quota_available(db, user_id=user_id, project_id=project_id, library_type=project.library_type, asset_type=payload.asset_type)
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type)
|
||||
public_url = _public_url(payload.url)
|
||||
asset = PrivatePortraitAsset(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
library_type=project.library_type,
|
||||
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,
|
||||
video_duration=payload.video_duration,
|
||||
video_cover_url=payload.video_cover_url,
|
||||
file_size=payload.file_size,
|
||||
mime_type=payload.mime_type,
|
||||
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})
|
||||
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={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "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")
|
||||
@@ -411,14 +438,12 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo
|
||||
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.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
asset.raw_response_json = _json(remote_resp)
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(asset)
|
||||
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})
|
||||
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, "library_type": project.library_type, "asset_type": asset.asset_type})
|
||||
return asset
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
@@ -434,11 +459,11 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
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="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材尚未创建远程 Asset")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材尚未创建远程 Asset")
|
||||
|
||||
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
|
||||
log_operation_event(
|
||||
@@ -449,12 +474,7 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
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,
|
||||
},
|
||||
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, "library_type": asset.library_type, "asset_type": asset.asset_type},
|
||||
)
|
||||
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)
|
||||
@@ -467,24 +487,15 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
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"))
|
||||
max_count = _poll_max_count(asset.asset_type)
|
||||
|
||||
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT:
|
||||
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= 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,
|
||||
)
|
||||
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": max_count, "remote_asset_id": asset.remote_asset_id, "library_type": asset.library_type, "asset_type": asset.asset_type}, error=asset.error_message)
|
||||
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
|
||||
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
|
||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
else:
|
||||
asset.next_poll_at = None
|
||||
|
||||
@@ -493,23 +504,25 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
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},
|
||||
)
|
||||
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, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
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]]:
|
||||
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,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
) -> 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)]
|
||||
@@ -517,6 +530,10 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
|
||||
filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
if project_id:
|
||||
filters.append(PrivatePortraitAsset.project_id == project_id)
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
if asset_type:
|
||||
filters.append(PrivatePortraitAsset.asset_type == asset_type)
|
||||
if status:
|
||||
filters.append(PrivatePortraitAsset.status == status)
|
||||
if keyword:
|
||||
@@ -532,15 +549,35 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
|
||||
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 list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20, library_type: str | None = None, asset_type: str | None = None) -> 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, library_type=library_type, asset_type=asset_type)
|
||||
return [
|
||||
PrivatePortraitSelectableAssetOut(
|
||||
id=asset.id,
|
||||
project_id=asset.project_id,
|
||||
project_name=project_name_map.get(asset.project_id, ""),
|
||||
library_type=asset.library_type,
|
||||
name=asset.name,
|
||||
asset_type=asset.asset_type,
|
||||
preview_url=asset.preview_url or asset.remote_url,
|
||||
display_url=_asset_display_url(asset),
|
||||
provider_url=_provider_url(asset),
|
||||
video_duration=asset.video_duration,
|
||||
video_cover_url=asset.video_cover_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()
|
||||
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> PrivatePortraitAsset:
|
||||
filters = [PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
now = datetime.now(timezone.utc)
|
||||
asset.deleted_at = now
|
||||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
||||
@@ -548,64 +585,30 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) ->
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
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})
|
||||
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, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
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="远程删除跳过:本地素材不存在",
|
||||
)
|
||||
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",
|
||||
)
|
||||
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},
|
||||
)
|
||||
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, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
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},
|
||||
)
|
||||
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, "library_type": asset.library_type})
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||||
@@ -619,45 +622,18 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
|
||||
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",
|
||||
)
|
||||
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},
|
||||
)
|
||||
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, "library_type": group.library_type})
|
||||
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},
|
||||
)
|
||||
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, "library_type": group.library_type})
|
||||
except Exception as exc:
|
||||
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||||
@@ -667,14 +643,7 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
|
||||
|
||||
|
||||
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="开始远程删除真人素材项目资源",
|
||||
)
|
||||
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)
|
||||
@@ -682,14 +651,7 @@ async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
|
||||
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="远程删除真人素材项目资源完成",
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -707,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
.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)},
|
||||
)
|
||||
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:
|
||||
@@ -722,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
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},
|
||||
)
|
||||
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},
|
||||
)
|
||||
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_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)
|
||||
@@ -761,12 +694,7 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
|
||||
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)
|
||||
)
|
||||
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)
|
||||
@@ -774,12 +702,5 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
@@ -27,16 +29,24 @@ DOMAIN = "private_portrait"
|
||||
|
||||
def _safe_slug(value: str, *, max_length: int = 80) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
|
||||
value = re.sub(r"[^a-z0-9_-]+", "_", value)
|
||||
value = re.sub(r"_+", "_", value).strip("_-")
|
||||
return (value[:max_length] or "project")
|
||||
|
||||
|
||||
def _status_for_created_project(library_type: str) -> str:
|
||||
if library_type == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
return PrivatePortraitProjectStatus.VALIDATING.value
|
||||
if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
return PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value
|
||||
raise HTTPException(status_code=400, detail="library_type 不支持")
|
||||
|
||||
|
||||
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,
|
||||
library_type=project.library_type,
|
||||
name=project.name,
|
||||
name_slug=project.name_slug,
|
||||
remote_project_name=project.remote_project_name,
|
||||
@@ -44,37 +54,57 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals
|
||||
status=project.status,
|
||||
asset_group_count=project.asset_group_count or 0,
|
||||
asset_count=project.asset_count or 0,
|
||||
image_asset_count=getattr(project, "image_asset_count", 0) or 0,
|
||||
video_asset_count=getattr(project, "video_asset_count", 0) or 0,
|
||||
active_asset_count=project.active_asset_count or 0,
|
||||
active_image_asset_count=getattr(project, "active_image_asset_count", 0) or 0,
|
||||
active_video_asset_count=getattr(project, "active_video_asset_count", 0) 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)
|
||||
)
|
||||
async def get_user_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
filters = [
|
||||
PrivatePortraitProject.id == project_id,
|
||||
PrivatePortraitProject.user_id == user_id,
|
||||
PrivatePortraitProject.deleted_at.is_(None),
|
||||
]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
result = await db.execute(select(PrivatePortraitProject).where(*filters).limit(1))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="真人素材项目不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材项目不存在")
|
||||
return project
|
||||
|
||||
|
||||
async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject:
|
||||
async def create_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
payload: PrivatePortraitProjectCreate,
|
||||
library_type: str = PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
status: str | None = None,
|
||||
remote_project_name: str = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
) -> PrivatePortraitProject:
|
||||
slug = _safe_slug(payload.name)
|
||||
project = PrivatePortraitProject(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
library_type=library_type,
|
||||
name=payload.name.strip(),
|
||||
name_slug=slug,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
remote_project_name=remote_project_name,
|
||||
description=payload.description,
|
||||
status=PrivatePortraitProjectStatus.VALIDATING.value,
|
||||
status=status or _status_for_created_project(library_type),
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
@@ -85,35 +115,38 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="创建待认证真人素材项目",
|
||||
detail={"name": project.name, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||
message="创建私域人像素材项目",
|
||||
detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
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)
|
||||
async def update_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
payload: PrivatePortraitProjectUpdate,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
before = {
|
||||
"name": project.name,
|
||||
"name_slug": project.name_slug,
|
||||
"remote_project_name": project.remote_project_name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
if payload.name is not None:
|
||||
new_name = payload.name.strip()
|
||||
if new_name and new_name != project.name:
|
||||
project.name = new_name
|
||||
project.name_slug = _safe_slug(new_name)
|
||||
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
if payload.status is not None:
|
||||
allowed_statuses = {
|
||||
PrivatePortraitProjectStatus.VALIDATING.value,
|
||||
PrivatePortraitProjectStatus.ACTIVE.value,
|
||||
PrivatePortraitProjectStatus.VALIDATE_FAILED.value,
|
||||
}
|
||||
allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED}
|
||||
if payload.status not in allowed_statuses:
|
||||
raise HTTPException(status_code=400, detail="项目状态不支持")
|
||||
project.status = payload.status
|
||||
@@ -124,6 +157,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
"remote_project_name": project.remote_project_name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
@@ -132,7 +166,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="更新真人素材项目",
|
||||
message="更新私域人像素材项目",
|
||||
detail={"before": before, "after": after},
|
||||
)
|
||||
return project
|
||||
@@ -146,18 +180,27 @@ async def list_projects(
|
||||
page_size: int = 20,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
library_type: 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 library_type:
|
||||
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
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))
|
||||
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)
|
||||
|
||||
|
||||
@@ -174,27 +217,74 @@ async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) ->
|
||||
select(
|
||||
PrivatePortraitAsset.project_id,
|
||||
func.count(PrivatePortraitAsset.id),
|
||||
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
|
||||
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
|
||||
func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)),
|
||||
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value), 1), else_=0)),
|
||||
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.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))
|
||||
asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {}
|
||||
for pid, total, image_total, video_total, active_total, active_image_total, active_video_total in asset_rows.all():
|
||||
asset_count_map[pid] = (
|
||||
int(total or 0),
|
||||
int(image_total or 0),
|
||||
int(video_total or 0),
|
||||
int(active_total or 0),
|
||||
int(active_image_total or 0),
|
||||
int(active_video_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))
|
||||
total, image_total, video_total, active_total, active_image_total, active_video_total = asset_count_map.get(pid, (0, 0, 0, 0, 0, 0))
|
||||
await db.execute(
|
||||
update(PrivatePortraitProject)
|
||||
.where(PrivatePortraitProject.id == pid)
|
||||
.values(
|
||||
asset_group_count=group_count_map.get(pid, 0),
|
||||
asset_count=total,
|
||||
image_asset_count=image_total,
|
||||
video_asset_count=video_total,
|
||||
active_asset_count=active_total,
|
||||
active_image_asset_count=active_image_total,
|
||||
active_video_asset_count=active_video_total,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
async def soft_delete_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
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.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})
|
||||
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={"library_type": project.library_type, "remote_project_name": project.remote_project_name},
|
||||
)
|
||||
return project
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitConfigOut
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
|
||||
_COUNTING_STATUSES = {
|
||||
PrivatePortraitAssetStatus.CREATING.value,
|
||||
PrivatePortraitAssetStatus.PROCESSING.value,
|
||||
PrivatePortraitAssetStatus.ACTIVE.value,
|
||||
}
|
||||
_COUNTING_LIBRARY_TYPES = {
|
||||
PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def get_user_or_404(db: AsyncSession, *, user_id: str, for_update: bool = False) -> User:
|
||||
stmt = select(User).where(User.id == user_id).limit(1)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def get_user_asset_limit_value(user: User) -> int:
|
||||
return int(getattr(user, "private_portrait_asset_limit", PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT) or 0)
|
||||
|
||||
|
||||
async def count_user_counting_assets(db: AsyncSession, *, user_id: str) -> int:
|
||||
total = (
|
||||
await db.execute(
|
||||
select(func.count(PrivatePortraitAsset.id)).where(
|
||||
PrivatePortraitAsset.user_id == user_id,
|
||||
PrivatePortraitAsset.library_type.in_(_COUNTING_LIBRARY_TYPES),
|
||||
PrivatePortraitAsset.asset_type.in_(PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES),
|
||||
PrivatePortraitAsset.deleted_at.is_(None),
|
||||
PrivatePortraitAsset.status.in_(_COUNTING_STATUSES),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
|
||||
user = await get_user_or_404(db, user_id=user_id)
|
||||
limit = get_user_asset_limit_value(user)
|
||||
used = await count_user_counting_assets(db, user_id=user_id)
|
||||
remaining = max(0, limit - used) if limit > 0 else 0
|
||||
return PrivatePortraitConfigOut(
|
||||
enabled=limit > 0,
|
||||
asset_limit=limit,
|
||||
used_asset_count=used,
|
||||
remaining_asset_count=remaining,
|
||||
image_limit=limit,
|
||||
used_image_count=used,
|
||||
remaining_image_count=remaining,
|
||||
)
|
||||
|
||||
|
||||
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
|
||||
user = await get_user_or_404(db, user_id=user_id)
|
||||
user.private_portrait_asset_limit = max(0, int(limit))
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def ensure_private_portrait_asset_quota_available(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
user = await get_user_or_404(db, user_id=user_id, for_update=True)
|
||||
limit = get_user_asset_limit_value(user)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type},
|
||||
)
|
||||
if limit <= 0:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type, "reason": "disabled"},
|
||||
message="用户私域人像素材库未启用",
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="私域人像素材库未启用")
|
||||
|
||||
used = await count_user_counting_assets(db, user_id=user_id)
|
||||
if used >= limit:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "used_asset_count": used, "library_type": library_type, "asset_type": asset_type, "reason": "max_limit"},
|
||||
message="用户私域人像素材总量已达上限",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"你的私域人像素材库最多可上传 {limit} 个素材,请删除已有素材后再上传")
|
||||
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_PASS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "used_asset_count": used, "remaining_asset_count": max(0, limit - used), "library_type": library_type, "asset_type": asset_type},
|
||||
)
|
||||
return limit, used
|
||||
@@ -0,0 +1 @@
|
||||
from app.services.private_portrait.real_person.service import *
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitLibraryType
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectCreate, PrivatePortraitProjectUpdate
|
||||
from app.services.private_portrait.asset_service import create_asset, create_validate_session
|
||||
from app.services.private_portrait.project_service import create_project, update_project
|
||||
|
||||
|
||||
async def create_real_person_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate):
|
||||
return await create_project(db, user_id=user_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
|
||||
|
||||
async def update_real_person_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
|
||||
return await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
|
||||
|
||||
async def create_real_person_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None):
|
||||
return await create_validate_session(db, user_id=user_id, project_id=project_id, callback_redirect_url=callback_redirect_url)
|
||||
|
||||
|
||||
async def create_real_person_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
|
||||
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PrivatePortraitAssetStatus,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
@@ -25,7 +26,6 @@ DOMAIN = "private_portrait"
|
||||
_ASSET_TYPE_TO_REFERENCE_TYPE = {
|
||||
PrivatePortraitAssetType.IMAGE.value: "image",
|
||||
PrivatePortraitAssetType.VIDEO.value: "video",
|
||||
PrivatePortraitAssetType.AUDIO.value: "audio",
|
||||
}
|
||||
|
||||
|
||||
@@ -260,24 +260,26 @@ async def resolve_private_portrait_references(
|
||||
|
||||
asset_id = str(_ref_get(ref, "private_asset_id") or "")
|
||||
if not asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材引用缺少 private_asset_id")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材引用缺少 private_asset_id")
|
||||
|
||||
asset = asset_map.get(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=400, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材不存在")
|
||||
if asset.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="真人素材不属于当前用户")
|
||||
raise HTTPException(status_code=403, detail="私域人像素材不属于当前用户")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||||
if asset.asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video 私域素材")
|
||||
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status},Active 后才可用于生成")
|
||||
raise HTTPException(status_code=400, detail=f"私域人像素材状态为 {asset.status},Active 后才可用于生成")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材缺少远程 AssetId")
|
||||
|
||||
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
|
||||
ref_type = _normalize_ref_type(_ref_get(ref, "type"))
|
||||
if expected_ref_type and ref_type and ref_type != expected_ref_type:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
|
||||
raise HTTPException(status_code=400, detail=f"私域人像素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
|
||||
|
||||
provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}"
|
||||
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from app.services.private_portrait.virtual.service import *
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAssetGroup
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectUpdate, PrivatePortraitVirtualProjectCreate
|
||||
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.asset_service import create_asset
|
||||
from app.services.private_portrait.project_service import create_project, refresh_project_counters, update_project
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
|
||||
|
||||
def _json(data) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
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"virtual-{user_id}-{safe_name}"[:128]
|
||||
|
||||
|
||||
async def create_virtual_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitVirtualProjectCreate):
|
||||
project = await create_project(
|
||||
db,
|
||||
user_id=user_id,
|
||||
payload=payload, # type: ignore[arg-type]
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
status=PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
)
|
||||
remote_group_name = _remote_group_name(user_id, project.name)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
detail={"remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name, "group_type": PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE},
|
||||
)
|
||||
try:
|
||||
resp = await ArkPrivateAssetClient().create_asset_group(
|
||||
project_name=project.remote_project_name,
|
||||
name=remote_group_name,
|
||||
description=project.description,
|
||||
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
)
|
||||
remote_group_id = resp.get("Id") or resp.get("GroupId") or resp.get("groupId")
|
||||
if not remote_group_id:
|
||||
raise RuntimeError("CreateAssetGroup 未返回素材组 ID")
|
||||
group = PrivatePortraitAssetGroup(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
remote_group_id=remote_group_id,
|
||||
remote_group_name=remote_group_name,
|
||||
remote_project_name=project.remote_project_name,
|
||||
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
raw_response_json=_json(resp),
|
||||
)
|
||||
db.add(group)
|
||||
project.status = PrivatePortraitProjectStatus.ACTIVE.value
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
detail={"remote_group_id": remote_group_id, "remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name},
|
||||
)
|
||||
return project
|
||||
except Exception as exc:
|
||||
project.status = PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value
|
||||
await db.flush()
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"创建火山虚拟人像素材组失败:{exc}") from exc
|
||||
|
||||
|
||||
async def update_virtual_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
|
||||
project = await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
# 远程同步失败不影响本地更新,记录日志便于排查。
|
||||
try:
|
||||
# 只同步当前激活组。
|
||||
from app.services.private_portrait.asset_service import get_project_active_group
|
||||
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
await ArkPrivateAssetClient().update_asset_group(project_name=project.remote_project_name, group_id=group.remote_group_id, name=group.remote_group_name, title=project.name, description=project.description)
|
||||
except Exception as exc:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, exc=exc)
|
||||
return project
|
||||
|
||||
|
||||
async def create_virtual_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
|
||||
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
Reference in New Issue
Block a user