This commit is contained in:
2026-07-07 15:18:00 +08:00
parent e94225995c
commit 0fb2c5f165
52 changed files with 4017 additions and 1531 deletions
+4
View File
@@ -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==
@@ -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")
+33 -101
View File
@@ -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)
+3 -1
View File
@@ -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)
+2 -2
View File
@@ -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,
+78 -75
View File
@@ -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="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroupGroupType=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)
+4
View File
@@ -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=消息组IDTemplateID=模板IDSign=短信签名内容。
VOLC_SMS_ACCESS_KEY_ID: str = ""
+37 -6
View File
@@ -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)
+2 -2
View File
@@ -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
)
+2 -2
View File
@@ -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):
+102 -22
View File
@@ -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 / VideoAudio 暂不开放。")
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/VideoAudio 暂未开放")
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],
)
+1 -1
View File
@@ -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/VideoAudio 暂未开放")
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 来自数据库默认值或 onupdateflush 后可能处于 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 来自数据库默认值或 onupdateflush 后可能处于 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)