merge main

This commit is contained in:
2026-08-11 10:16:38 +08:00
156 changed files with 22362 additions and 1211 deletions
@@ -0,0 +1,3 @@
from app.admin_api.api_keys.routes import router
__all__ = ["router"]
@@ -0,0 +1,435 @@
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.api.api_key import ApiKey
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_usage_log import ApiUsageLog
from app.models.user import User
from app.schemas.admin_api.api_key import (
ApiKeyCallableModel,
ApiKeyCreateRequest,
ApiKeyCreateResponse,
ApiKeyListItem,
ApiKeyListOut,
ApiKeyQuotaAdjustRequest,
ApiKeyRevealResponse,
ApiKeyResponse,
ApiKeyUpdateRequest,
)
from app.schemas.admin_api.api_upscale import (
ApiUpscaleConfigData,
ApiUpscaleConfigResponse,
ApiUpscaleConfigSaveRequest,
)
from app.schemas.admin_api.api_usage import ApiUsageLogResponse, ApiUsageSummaryResponse
from app.services.api_v3 import (
key_service,
upscale_service,
usage_log_service,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/admin/api-keys", tags=["admin-api-keys"])
def _key_to_list_item(key: ApiKey) -> ApiKeyListItem:
"""将 ApiKey 模型转为列表项 Schema。"""
try:
callable_models = json.loads(key.callable_models) if key.callable_models else []
except (json.JSONDecodeError, TypeError):
callable_models = []
return ApiKeyListItem(
id=key.id,
company_name=key.company_name,
api_key_prefix=f"{key.api_key_prefix}****",
description=key.description,
callable_models=[ApiKeyCallableModel(**m) for m in callable_models],
quota_limit=key.quota_limit,
quota_cycle=key.quota_cycle,
quota_used=key.quota_used,
is_active=key.is_active,
valid_from=key.valid_from,
valid_until=key.valid_until,
max_concurrent_video_tasks=key.max_concurrent_video_tasks,
last_used_at=key.last_used_at,
created_at=key.created_at,
)
# === API Key CRUD ===
@router.get("", response_model=ApiKeyListOut, summary="列出 API Key")
async def list_keys(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
company_name: str | None = None,
is_active: bool | None = None,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListOut:
"""列出所有 API Key(分页+筛选)。"""
total, keys = await key_service.list_api_keys(
db, skip=skip, limit=limit,
company_name=company_name, is_active=is_active,
)
return ApiKeyListOut(
total=total,
items=[_key_to_list_item(k) for k in keys],
)
@router.post(
"",
response_model=ApiKeyCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="创建 API Key",
)
async def create_key(
req: ApiKeyCreateRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyCreateResponse:
"""创建新的 API Key。
返回包含完整明文 api_key,仅此一次。
"""
callable_models = [m.model_dump() for m in req.callable_models] if req.callable_models else []
key, raw_key = await key_service.create_api_key(
db=db,
company_name=req.company_name,
callable_models=callable_models,
quota_limit=req.quota_limit,
quota_cycle=req.quota_cycle,
valid_from=req.valid_from,
valid_until=req.valid_until,
max_concurrent_video_tasks=req.max_concurrent_video_tasks,
description=req.description,
)
await db.commit()
return ApiKeyCreateResponse(
id=key.id,
company_name=key.company_name,
api_key=raw_key,
api_key_prefix=key.api_key_prefix,
valid_until=key.valid_until,
created_at=key.created_at,
)
@router.get("/{key_id}/reveal", response_model=ApiKeyRevealResponse, summary="揭秘 API Key")
async def reveal_key(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyRevealResponse:
"""揭秘 API Key(随时可获取完整明文 Key)。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
plaintext = key.decrypt_api_key()
if not plaintext:
raise HTTPException(
status_code=400,
detail="该 API Key 创建时未启用加密存储,无法揭秘。请重新创建 Key。",
)
return ApiKeyRevealResponse(
id=key.id,
company_name=key.company_name,
api_key=plaintext,
api_key_prefix=key.api_key_prefix,
)
@router.get("/{key_id}", response_model=ApiKeyListItem, summary="获取 API Key 详情")
async def get_key(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListItem:
"""获取单个 API Key 详情。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
return _key_to_list_item(key)
@router.put("/{key_id}", response_model=ApiKeyListItem, summary="更新 API Key")
async def update_key(
req: ApiKeyUpdateRequest,
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyResponse:
"""更新 API Key 配置。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
# model_dump 默认输出 snake_case 字段名,直接传给 service
update_data = req.model_dump(exclude_none=True)
if "callable_models" in update_data and update_data["callable_models"] is not None:
update_data["callable_models"] = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["callable_models"]
]
key = await key_service.update_api_key(db, key, **update_data)
await db.commit()
return _key_to_list_item(key)
@router.delete("/{key_id}", summary="删除 API Key")
async def delete_key(
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""软删除 API Key。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
await key_service.delete_api_key(db, key)
await db.commit()
return {"status": "deleted", "id": key_id}
# === 超分配置 ===
@router.get("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="获取 API Key 超分配置")
async def get_upscale_config(
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUpscaleConfigResponse:
"""获取 API Key 的超分配置。"""
config = await upscale_service.get_or_create_upscale_config(db, key_id)
try:
rules = json.loads(config.rules_json) if config.rules_json else []
except (json.JSONDecodeError, TypeError):
rules = []
return ApiUpscaleConfigResponse(
data=ApiUpscaleConfigData(
enabled=config.enabled,
delete_source_after_success=config.delete_source_after_success,
rules=rules,
),
)
@router.put("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="保存 API Key 超分配置")
async def save_upscale_config(
req: ApiUpscaleConfigSaveRequest,
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUpscaleConfigResponse:
"""保存 API Key 的超分配置。"""
config = await upscale_service.save_upscale_config(
db=db,
api_key_id=key_id,
enabled=req.data.enabled,
delete_source_after_success=req.data.delete_source_after_success,
rules=[r.model_dump() for r in req.data.rules],
)
await db.commit()
return ApiUpscaleConfigResponse(
data=ApiUpscaleConfigData(
enabled=config.enabled,
delete_source_after_success=config.delete_source_after_success,
rules=json.loads(config.rules_json) if config.rules_json else [],
),
)
# === 使用日志 ===
@router.get("/{key_id}/usage", response_model=ApiUsageSummaryResponse, summary="获取 API Key 使用统计")
async def get_usage(
key_id: str = Path(...),
days: int = Query(30, ge=1, le=365),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUsageSummaryResponse:
"""获取 API Key 的使用统计和明细。"""
# 验证 key 存在
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
summary = await usage_log_service.get_usage_summary(db, api_key_id=key_id, days=days)
total, logs = await usage_log_service.list_usage_logs(db, api_key_id=key_id, limit=page_size, skip=(page - 1) * page_size)
return ApiUsageSummaryResponse(
total_requests=summary["total_requests"],
total_credits_cost=summary["total_credits_cost"],
total_tokens_used=summary["total_tokens_used"],
success_count=summary["success_count"],
failed_count=summary["failed_count"],
avg_duration_ms=summary["avg_duration_ms"],
total=total,
page=page,
page_size=page_size,
items=[
ApiUsageLogResponse(
id=log.id,
api_key_id=log.api_key_id,
api_generation_task_id=log.api_generation_task_id,
request_type=log.request_type,
model_name=log.model_name,
gen_type=log.gen_type,
credits_cost=log.credits_cost,
tokens_used=log.tokens_used,
request_duration_ms=log.request_duration_ms,
status=log.status,
error_message=log.error_message,
error_code=log.error_code,
created_at=log.created_at,
)
for log in logs
],
)
# === 整体消耗列表 ===
@router.get("/usage/all", response_model=dict, summary="获取整体 API 消耗列表")
async def list_all_usage(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
api_key_id: str | None = None,
gen_type: str | None = None,
status_filter: str | None = Query(None, alias="status"),
start_date: datetime | None = None,
end_date: datetime | None = None,
search: str | None = None,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""获取整体 API 消耗列表(跨所有 Key,支持筛选和分页)。"""
# 构建查询
query = select(ApiUsageLog, ApiKey.company_name, ApiKey.api_key_prefix).join(
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
)
count_query = select(func.count(ApiUsageLog.id)).join(
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
)
# 筛选条件
filters = []
if api_key_id:
filters.append(ApiUsageLog.api_key_id == api_key_id)
if gen_type:
filters.append(ApiUsageLog.gen_type == gen_type)
if status_filter:
filters.append(ApiUsageLog.status == status_filter)
if start_date:
filters.append(ApiUsageLog.created_at >= start_date)
if end_date:
filters.append(ApiUsageLog.created_at <= end_date)
if search:
search_pattern = f"%{search}%"
filters.append(
(ApiKey.company_name.ilike(search_pattern))
| (ApiKey.api_key_prefix.ilike(search_pattern))
)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
# 总数
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# 分页查询
query = query.order_by(ApiUsageLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
rows = result.all()
items = []
for log, company_name, key_prefix in rows:
items.append({
"id": log.id,
"apiKeyId": log.api_key_id,
"companyName": company_name,
"apiKeyPrefix": f"{key_prefix}****" if key_prefix else None,
"taskId": log.api_generation_task_id,
"requestType": log.request_type,
"modelName": log.model_name,
"genType": log.gen_type,
"creditsCost": log.credits_cost,
"tokensUsed": log.tokens_used,
"requestDurationMs": log.request_duration_ms,
"duration": log.duration,
"resolution": log.resolution,
"status": log.status,
"errorMessage": log.error_message,
"errorCode": log.error_code,
"createdAt": log.created_at.isoformat() if log.created_at else None,
})
return {
"total": total,
"items": items,
}
@router.post("/{key_id}/quota-adjust", response_model=ApiKeyListItem, summary="调整 API Key 配额")
async def quota_adjust(
req: ApiKeyQuotaAdjustRequest,
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListItem:
"""调整 API Key 配额(增加总额/重置已用/设置限额/修改周期)。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
key, changes = await key_service.adjust_quota(
db,
key,
action=req.action,
quota_limit_delta=req.quota_limit_delta,
quota_limit=req.quota_limit,
quota_cycle=req.quota_cycle,
)
# 审计日志
try:
from app.services.operation_log import log_operation
await log_operation(
db=db,
user_id=str(admin.id),
username=str(admin.username),
action=f"quota_adjust:{req.action}",
method="POST",
path=f"/admin/api-keys/{key_id}/quota-adjust",
detail=json.dumps(
{**changes, "reason": req.reason},
ensure_ascii=False,
default=str,
),
)
except Exception as log_exc:
logger.warning("配额调整审计日志记录失败: %s", log_exc)
await db.commit()
return _key_to_list_item(key)
@@ -0,0 +1,3 @@
from app.admin_api.api_model_pricings.routes import router
__all__ = ["router"]
@@ -0,0 +1,131 @@
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.api.api_model_pricing import ApiModelPricing
from app.models.image_engine import ImageEngine
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.admin_api.api_model_pricing import ApiModelPricingCreate, ApiModelPricingOut
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/admin/api-model-pricings", tags=["admin-api-model-pricings"])
async def _validate_pricing_engine(db: AsyncSession, req: ApiModelPricingCreate) -> None:
"""校验定价规则绑定的引擎是否存在。"""
gen_type = (req.gen_type or "").lower().strip()
engine_id = (req.model_config_id or "").strip()
if gen_type not in ("image", "video"):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
if not engine_id:
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
model = ImageEngine if gen_type == "image" else VideoEngine
result = await db.execute(
select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
detail = "图片定价规则绑定的图片引擎不存在" if gen_type == "image" else "视频定价规则绑定的视频引擎不存在"
raise HTTPException(status_code=400, detail=detail)
@router.get("", response_model=list[ApiModelPricingOut])
async def list_pricings(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).order_by(
ApiModelPricing.gen_type.desc(),
ApiModelPricing.model_config_id.desc(),
)
)
return result.scalars().all()
@router.post("", response_model=ApiModelPricingOut)
async def create_pricing(
req: ApiModelPricingCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""创建 API 模型价格。"""
await _validate_pricing_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
pricing = ApiModelPricing(id=generate_id(), **data)
db.add(pricing)
await db.commit()
await db.refresh(pricing)
return pricing
@router.put("/{pricing_id}", response_model=ApiModelPricingOut)
async def update_pricing(
pricing_id: str,
req: ApiModelPricingCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
)
pricing = result.scalar_one_or_none()
if not pricing:
raise HTTPException(status_code=404, detail="定价规则不存在")
await _validate_pricing_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
for k, v in data.items():
setattr(pricing, k, v)
await db.commit()
await db.refresh(pricing)
return pricing
@router.delete("/{pricing_id}")
async def delete_pricing(
pricing_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
)
pricing = result.scalar_one_or_none()
if not pricing:
raise HTTPException(status_code=404, detail="定价规则不存在")
await db.delete(pricing)
await db.commit()
return {"message": "ok"}
@router.get("/grouped", response_model=dict)
async def list_pricings_grouped(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""按 gen_type 分组列出价格。"""
result = await db.execute(select(ApiModelPricing))
pricings = result.scalars().all()
grouped = {}
for pricing in pricings:
if pricing.gen_type not in grouped:
grouped[pricing.gen_type] = []
grouped[pricing.gen_type].append(ApiModelPricingOut.model_validate(pricing))
return grouped
@@ -0,0 +1,3 @@
from app.admin_api.vp_v3_quota.routes import router
__all__ = ["router"]
@@ -0,0 +1,80 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.models.virtual_portrait_v3.api_key_quota import VpV3ApiKeyQuota
from app.schemas.admin_api.vp_v3_quota import (
VpV3QuotaConfigData,
VpV3QuotaConfigResponse,
)
from app.services.api_v3 import key_service
from app.services.virtual_portrait_v3.quota_service import get_quota
router = APIRouter(prefix="/admin/api-keys", tags=["admin-vp-v3-quota"])
def _to_response(quota: VpV3ApiKeyQuota) -> VpV3QuotaConfigResponse:
enabled = any([
(quota.project_limit or 0) > 0,
(quota.asset_limit or 0) > 0,
(quota.storage_mb_limit or 0) > 0,
])
return VpV3QuotaConfigResponse(
api_key_id=quota.api_key_id,
project_limit=int(quota.project_limit or 0),
asset_limit=int(quota.asset_limit or 0),
storage_mb_limit=int(quota.storage_mb_limit or 0),
remark=quota.remark,
project_used=int(quota.project_used or 0),
asset_used=int(quota.asset_used or 0),
storage_mb_used=float(quota.storage_mb_used or 0),
enabled=enabled,
)
@router.get(
"/{key_id}/vp-v3-quota",
response_model=VpV3QuotaConfigResponse,
summary="获取 API Key 的虚拟素材库配额配置",
description="返回指定 API Key 的虚拟素材库配额上限及当前使用量。不存在配额记录时自动创建默认 0 值。",
)
async def get_vp_v3_quota(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> VpV3QuotaConfigResponse:
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
quota = await get_quota(db, api_key_id=key_id, refresh=True)
await db.commit()
return _to_response(quota)
@router.post(
"/{key_id}/vp-v3-quota",
response_model=VpV3QuotaConfigResponse,
summary="保存 API Key 的虚拟素材库配额配置",
description="保存虚拟素材库配额(项目数/素材数/存储 MB),默认 0=不可使用该功能。保存后自动刷新已使用量。",
)
async def save_vp_v3_quota(
payload: VpV3QuotaConfigData,
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> VpV3QuotaConfigResponse:
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
quota = await get_quota(db, api_key_id=key_id, refresh=True)
quota.project_limit = int(payload.project_limit or 0)
quota.asset_limit = int(payload.asset_limit or 0)
quota.storage_mb_limit = int(payload.storage_mb_limit or 0)
quota.remark = payload.remark if payload.remark is not None else quota.remark
await db.flush()
await db.refresh(quota)
await db.commit()
return _to_response(quota)
+6
View File
@@ -12,6 +12,9 @@ from app.api.admin.llm_billing import router as llm_billing_router
from app.api.admin.menu_config import router as menu_config_router
from app.api.admin.upload import router as admin_upload_router
from app.api.admin.contact import router as admin_contact_router
from app.admin_api.api_keys import router as api_keys_admin_router
from app.admin_api.api_model_pricings import router as api_model_pricings_admin_router
from app.admin_api.vp_v3_quota import router as vp_v3_quota_admin_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
@@ -26,3 +29,6 @@ router.include_router(llm_billing_router)
router.include_router(menu_config_router)
router.include_router(admin_upload_router)
router.include_router(admin_contact_router)
router.include_router(api_keys_admin_router)
router.include_router(api_model_pricings_admin_router)
router.include_router(vp_v3_quota_admin_router)
+4
View File
@@ -36,6 +36,8 @@ 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
from app.api.v1.upload_resource import router as upload_resource_router
from app.api.v1.invoices import router as invoices_router
from app.api.v1.invoice_headers import router as invoice_headers_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -74,3 +76,5 @@ api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router)
api_router.include_router(private_portrait_virtual_router)
api_router.include_router(upload_resource_router)
api_router.include_router(invoices_router)
api_router.include_router(invoice_headers_router)
+253 -23
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy import and_, case, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user
@@ -63,6 +63,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.schemas.invoice import InvoiceStatusUpdateRequest
from app.utils.id_gen import generate_id
@@ -1721,17 +1722,62 @@ async def update_system_config(
return config
@router.post("/system-configs/banner/reset", summary="重置活动横幅展示")
async def reset_banner(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""递增 site_banner_version,使所有用户再次看到横幅。"""
from app.utils.id_gen import generate_id
result = await db.execute(select(SystemConfig).where(SystemConfig.key == "site_banner_version").limit(1))
config = result.scalar_one_or_none()
new_version = 1
if config:
try:
new_version = int(config.value or 0) + 1
except ValueError:
new_version = 1
config.value = str(new_version)
else:
config = SystemConfig(
id=generate_id(),
key="site_banner_version",
value=str(new_version),
description="活动横幅版本号,递增后所有用户重新看到横幅",
)
db.add(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"重置活动横幅 (版本 → {new_version})",
"POST",
"/admin/system-configs/banner/reset",
detail=json.dumps({"new_version": new_version}),
)
await db.commit()
await invalidate_system_config_cache(["site_banner_version"])
return {"site_banner_version": new_version}
# ── Operation Logs ──────────────────────────────────────
@router.get("/operation-logs")
async def list_operation_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
action: str | None = Query(None, description="按 action 过滤(前缀匹配)"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(OperationLog).order_by(OperationLog.created_at.desc())
count_query = select(func.count(OperationLog.id))
if action:
query = query.where(OperationLog.action.like(f"{action}%"))
count_query = count_query.where(OperationLog.action.like(f"{action}%"))
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all()
@@ -1774,17 +1820,26 @@ async def get_stats(
):
today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
date_start: datetime
date_end: datetime
try:
if start_date:
date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
else:
date_start = today_start
if end_date:
date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
# 先构造完整的 naive 日期时刻,再一次性 attach tzinfo(避免分步 replace 丢 tzinfo
naive_end = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, microsecond=999999,
)
date_end = naive_end.replace(tzinfo=CST)
else:
date_end = datetime.now(CST)
except:
# 合法性:end >= start
if date_end < date_start:
date_end = date_start.replace(hour=23, minute=59, second=59, microsecond=999999)
except (ValueError, TypeError):
# 只拦截日期解析错误,不吞掉 SQL/运行时异常(原裸 except 会吞所有错误导致用户看不到报错)
date_start = today_start
date_end = datetime.now(CST)
@@ -1827,20 +1882,45 @@ async def get_stats(
)
)).scalar() or 0
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
# 消费类(真实扣费 + 预扣占用):charge_action 为空时仍按真实扣费兼容hold 为预扣占用
credit_charge_action_filter = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
CreditRecord.charge_action == "hold",
)
# 「仅真实扣费」filter 用于图表、模型使用次数等需要按实际产出(非预扣)统计的场景。
real_credit_charge_filter = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
)
credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
# 核心数据「消耗积分」= 净消耗 = 真实消费 + 预扣占用 - 真实退款 - 预扣释放。
# 说明:
# hold(预扣占用):type=consumecharge_action='hold'amount<0
# hold_release(预扣释放退回):type=refundcharge_action='hold_release'amount>0
# (账本 L256 强校验:hold_release.type 必须是 'refund',不是 consume
# charge(真实扣费):type=consumecharge_action='charge' 或 NULL(历史)amount<0
# refund(真实退款):type=refundcharge_action='refund' 或 NULL(历史兼容)amount>0
# 因此 type=refund 天然包含「真实退款 + 预扣释放退回」两类子流水。
_stats_real_and_hold = case(
(and_(CreditRecord.type == "consume", credit_charge_action_filter), func.abs(CreditRecord.amount)),
else_=0,
)
_stats_refund_and_release = case(
(CreditRecord.type == "refund", func.abs(CreditRecord.amount)),
else_=0,
)
_net_row = (await db.execute(
select(
func.coalesce(func.sum(_stats_real_and_hold), 0),
func.coalesce(func.sum(_stats_refund_and_release), 0),
).where(
CreditRecord.type.in_(["consume", "refund"]),
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
)).scalar() or 0
)).one()
credits_consumed = round(max(float(_net_row[0] or 0) - float(_net_row[1] or 0), 0.0), 2)
alipay_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
@@ -1904,21 +1984,31 @@ async def get_stats(
)
)).scalar() or 0
last_period_credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
last_period_net_row = (await db.execute(
select(
func.coalesce(func.sum(_stats_real_and_hold), 0),
func.coalesce(func.sum(_stats_refund_and_release), 0),
).where(
CreditRecord.type.in_(["consume", "refund"]),
CreditRecord.created_at >= last_period_start,
CreditRecord.created_at <= last_period_end,
)
)).scalar() or 0
)).one()
last_period_credits_consumed = round(
max(float(last_period_net_row[0] or 0) - float(last_period_net_row[1] or 0), 0.0), 2,
)
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
# created_at 为 timestamptz,数据库 session 时区已是东八区(CST),
# 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时。
from sqlalchemy import Date, cast as sa_cast
_day_expr = sa_cast(CreditRecord.created_at, Date)
# 图表固定展示 [date_end - 6天, date_end] 共7天
_chart_end_dt = date_end
_chart_start_dt = _chart_end_dt - timedelta(days=6)
_chart_start_dt = datetime(
_chart_end_dt.year, _chart_end_dt.month, _chart_end_dt.day, 0, 0, 0, 0, tzinfo=CST,
) - timedelta(days=6)
_chart_end_dt_inclusive = _chart_end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
_inner = (
select(
_day_expr.label('date'),
@@ -1929,7 +2019,7 @@ async def get_stats(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= _chart_start_dt,
CreditRecord.created_at <= _chart_end_dt,
CreditRecord.created_at <= _chart_end_dt_inclusive,
)
.group_by(_day_expr, CreditRecord.source_module)
.subquery()
@@ -1973,23 +2063,41 @@ async def get_stats(
]
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
# 净消耗 = (真实消费 charge + 预扣占用 hold) - (真实退款 refund + 预扣释放 hold_release)
# 注意:
# hold(预扣占用):type=consumecharge_action='hold'amount<0 → 加项
# hold_release(预扣释放):type=refundcharge_action='hold_release'amount>0 → 减项(type=refund 天然包含)
# charge(真实扣费):type=consumecharge/NULL → 加项
# refund(真实退款):type=refundrefund/NULL → 减项
_charge_hold_filter = and_(
CreditRecord.type == "consume",
credit_charge_action_filter, # charge / hold / NULL(历史 charge)
)
_charge_hold_expr = case((_charge_hold_filter, func.abs(CreditRecord.amount)), else_=0)
# type=refund = 真实退款 + 预扣释放退回(账本强制 hold_release.type=refund
_refund_release_expr = case((CreditRecord.type == "refund", func.abs(CreditRecord.amount)), else_=0)
team_credit_rows = (await db.execute(
select(
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
CreditRecord.team_id_snapshot.label('team_id'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
func.coalesce(func.sum(_charge_hold_expr), 0).label("total_charge_hold"),
func.coalesce(func.sum(_refund_release_expr), 0).label("total_refund_release"),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.type.in_(["consume", "refund"]),
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot)
.order_by(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).desc())
# 按"净消耗 = 真实+预扣 - 退款+释放"倒序排序(排行榜)
.order_by((func.coalesce(func.sum(_charge_hold_expr), 0) - func.coalesce(func.sum(_refund_release_expr), 0)).desc())
)).all()
credits_by_team = [
TeamCreditOut(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0))
TeamCreditOut(
team_name=row.team_name,
team_id=row.team_id,
credits=round(max(float(row.total_charge_hold or 0) - float(row.total_refund_release or 0), 0.0), 2),
)
for row in team_credit_rows
]
@@ -2127,6 +2235,8 @@ async def admin_list_generation_records(
status: str | None = Query(None),
engine_id: str | None = Query(None),
include_media_references: bool | None = Query(None),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user),
@@ -2149,6 +2259,10 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
query = query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
query = query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
# Count total
count_query = (
@@ -2164,6 +2278,10 @@ async def admin_list_generation_records(
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
count_query = count_query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
count_query = count_query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
@@ -2404,6 +2522,118 @@ async def upload_login_video(
return {"url": url}
# ── Payment Stats ────────────────────────────────────────
# ── Invoice Management ───────────────────────────────────
@router.get("/invoices")
async def admin_list_invoices(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""后台发票列表(分页+筛选)。"""
from app.services.invoice import get_admin_invoices
items, total = await get_admin_invoices(
db, page, page_size,
status_filter=status,
phone=phone,
start_date=start_date,
end_date=end_date,
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.get("/invoices/{invoice_id}")
async def admin_get_invoice(
invoice_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""后台获取发票详情(含关联订单)。"""
from app.services.invoice import get_invoice_with_orders
detail = await get_invoice_with_orders(db, invoice_id)
if not detail:
raise HTTPException(status_code=404, detail="发票不存在")
invoice = detail["invoice"]
orders = detail["orders"]
return {
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"userId": invoice.user_id,
"headerType": invoice.header_type,
"headerName": invoice.header_name,
"headerTaxNo": invoice.header_tax_no,
"headerRegisterAddress": invoice.header_register_address,
"headerRegisterPhone": invoice.header_register_phone,
"headerBankName": invoice.header_bank_name,
"headerBankAccount": invoice.header_bank_account,
"email": invoice.email,
"totalAmount": round(float(invoice.total_amount), 2),
"totalCredits": round(float(invoice.total_credits), 2),
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
"updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
"orders": [
{
"id": o.id,
"orderNo": o.order_no,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
}
for o in orders
],
}
@router.put("/invoices/{invoice_id}/status")
async def admin_update_invoice_status(
invoice_id: str,
req: InvoiceStatusUpdateRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票状态(success/failed)。"""
from app.services.invoice import update_invoice_status
invoice, old_status = await update_invoice_status(db, invoice_id, req, admin.id)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"发票状态变更: {invoice.invoice_no} {old_status}{req.status}",
"PUT",
f"/admin/invoices/{invoice_id}/status",
detail=json.dumps(
{
"invoice_id": invoice_id,
"invoice_no": invoice.invoice_no,
"old_status": old_status,
"new_status": req.status,
"failure_reason": req.failure_reason,
},
ensure_ascii=False,
),
)
await db.commit()
return {
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
}
+4 -1
View File
@@ -351,7 +351,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video"
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits", "site_banner", "site_banner_version"
]))
)
configs = result.scalars().all()
@@ -375,6 +375,9 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"),
"operation_manual": info.get("operation_manual", ""),
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
"optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
"site_banner": info.get("site_banner", ""),
"site_banner_version": int(info.get("site_banner_version") or 0),
}
@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderOut, InvoiceHeaderUpdate
from app.services.invoice_header import (
create_header,
delete_header,
get_user_headers,
set_default_header,
update_header,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoice-headers", tags=["invoice-headers"])
def _header_to_out(header) -> dict:
return {
"id": header.id,
"user_id": header.user_id,
"type": header.type,
"name": header.name,
"tax_no": header.tax_no,
"register_address": header.register_address,
"register_phone": header.register_phone,
"bank_name": header.bank_name,
"bank_account": header.bank_account,
"email": header.email,
"is_default": header.is_default,
"created_at": header.created_at.isoformat() if header.created_at else None,
"updated_at": header.updated_at.isoformat() if header.updated_at else None,
}
@router.post("", response_model=InvoiceHeaderOut)
async def create_invoice_header(
req: InvoiceHeaderCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票抬头。"""
header = await create_header(db, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.get("")
async def list_invoice_headers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的所有发票抬头。"""
headers = await get_user_headers(db, current_user.id)
return {"items": [_header_to_out(h) for h in headers]}
@router.put("/{header_id}", response_model=InvoiceHeaderOut)
async def update_invoice_header(
header_id: str,
req: InvoiceHeaderUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票抬头。"""
header = await update_header(db, header_id, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.delete("/{header_id}")
async def delete_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除发票抬头。"""
await delete_header(db, header_id, current_user.id)
await db.commit()
return {"success": True}
@router.put("/{header_id}/set-default", response_model=InvoiceHeaderOut)
async def set_default_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""设置默认发票抬头。"""
header = await set_default_header(db, header_id, current_user.id)
await db.commit()
return _header_to_out(header)
+110
View File
@@ -0,0 +1,110 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.invoice import Invoice, InvoiceOrder
from app.models.user import User
from app.schemas.invoice import InvoiceCreateRequest, InvoiceOut, InvoiceOrderOut
from app.services.invoice import (
create_invoice,
get_user_invoices,
get_invoice_by_id,
get_invoice_with_orders,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoices", tags=["invoices"])
@router.post("", response_model=InvoiceOut)
async def create_invoice_endpoint(
req: InvoiceCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票申请。"""
invoice = await create_invoice(db, current_user.id, req)
await db.commit()
# 重新查询以获取关联订单
detail = await get_invoice_with_orders(db, invoice.id)
return _invoice_to_out(detail["invoice"], detail["orders"])
@router.get("")
async def list_invoices(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的发票列表(分页)。"""
invoices, total = await get_user_invoices(db, current_user.id, page, page_size)
# 加载每个发票的关联订单
items = []
for inv in invoices:
result = await db.execute(
select(InvoiceOrder).where(InvoiceOrder.invoice_id == inv.id)
)
orders = result.scalars().all()
items.append(_invoice_to_out(inv, list(orders)))
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.get("/{invoice_id}")
async def get_invoice(
invoice_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取发票详情(含关联订单)。"""
detail = await get_invoice_with_orders(db, invoice_id)
if not detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在")
invoice = detail["invoice"]
if invoice.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该发票")
return _invoice_to_out(invoice, detail["orders"])
def _invoice_to_out(invoice: Invoice, orders: list[InvoiceOrder]) -> dict:
"""将 Invoice ORM 对象转换为响应 dict。"""
return {
"id": invoice.id,
"user_id": invoice.user_id,
"invoice_no": invoice.invoice_no,
"header_type": invoice.header_type,
"header_name": invoice.header_name,
"header_tax_no": invoice.header_tax_no,
"header_register_address": invoice.header_register_address,
"header_register_phone": invoice.header_register_phone,
"header_bank_name": invoice.header_bank_name,
"header_bank_account": invoice.header_bank_account,
"email": invoice.email,
"total_amount": round(float(invoice.total_amount), 2),
"total_credits": round(float(invoice.total_credits), 2),
"status": invoice.status,
"failure_reason": invoice.failure_reason,
"issued_at": invoice.issued_at.isoformat() if invoice.issued_at else None,
"created_at": invoice.created_at.isoformat() if invoice.created_at else None,
"updated_at": invoice.updated_at.isoformat() if invoice.updated_at else None,
"orders": [
{
"id": o.id,
"invoice_id": o.invoice_id,
"order_id": o.order_id,
"order_no": o.order_no,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
}
for o in orders
],
}
+52 -3
View File
@@ -289,17 +289,36 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def list_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status_filter: str | None = Query(None, description="按状态筛选: pending/paid/refunded/failed/cancelled"),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
invoice_mode: bool = Query(False, description="开票模式:仅返回已支付订单"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.services.payment import _check_and_expire_order
from datetime import datetime, timezone, timedelta
count_query = select(func.count(PaymentOrder.id)).where(PaymentOrder.user_id == current_user.id)
# 构建筛选条件
conditions = [PaymentOrder.user_id == current_user.id]
if status_filter:
conditions.append(PaymentOrder.status == status_filter)
if invoice_mode:
conditions.append(PaymentOrder.status == "paid")
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at >= start_dt)
if end_date:
end_dt = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at < end_dt)
# 统计总数
count_query = select(func.count(PaymentOrder.id)).where(*conditions)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id)
.where(*conditions)
.order_by(PaymentOrder.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
@@ -308,7 +327,37 @@ async def list_orders(
for o in orders:
await _check_and_expire_order(db, o)
return {"items": [PaymentOrderOut.model_validate(o) for o in orders], "total": total}
# 开票模式:附带订单占用状态
items = []
if invoice_mode:
# 收集当前页订单ID
order_ids = [o.id for o in orders]
# 查询这些订单是否已被占用
from app.models.invoice import Invoice, InvoiceOrder
occupied_map: dict[str, str] = {}
if order_ids:
occ_result = await db.execute(
select(InvoiceOrder.order_id, Invoice.invoice_no)
.join(Invoice, InvoiceOrder.invoice_id == Invoice.id)
.where(
InvoiceOrder.order_id.in_(order_ids),
Invoice.status.in_(["processing", "success"]),
)
)
for row in occ_result.all():
occupied_map[row.order_id] = row.invoice_no
for o in orders:
item = PaymentOrderOut.model_validate(o)
item_dict = item.model_dump()
item_dict["is_occupied"] = o.id in occupied_map
item_dict["occupied_by"] = occupied_map.get(o.id)
items.append(item_dict)
else:
for o in orders:
item = PaymentOrderOut.model_validate(o)
items.append(item.model_dump())
return {"items": items, "total": total}
@router.get("/orders/{order_no}", response_model=PaymentOrderOut)
+29 -3
View File
@@ -364,15 +364,41 @@ async def export_team_credit_records(
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开)
# 生成 CSV(兼容 Excel 打开UTF-8 BOM
import csv
import io
from datetime import datetime as _dt
def _format_dt(val):
if val is None:
return "-"
return str(datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S"))
try:
# 情况 1:已经是 datetime
if isinstance(val, _dt):
dt = val
elif isinstance(val, (int, float)):
# 情况 2:Unix 时间戳(极少,兼容旧代码)
dt = _dt.fromtimestamp(val)
elif isinstance(val, str):
# 情况 3ISO 字符串(admin_credit_record_service._iso 返回的格式)
s = val.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = _dt.fromisoformat(s)
except ValueError:
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
else:
return str(val)
# 统一转东八区展示
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=CST)
else:
dt = dt.astimezone(CST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception: # noqa: BLE001
return str(val) if val else "-"
output = io.StringIO()
writer = csv.writer(output)
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.v3.videos import router as videos_router
from app.api.v3.images import router as images_router
from app.api.v3.models import router as models_router
from app.api.v3.virtual_portrait import router as virtual_portrait_router
api_router_v3 = APIRouter()
api_router_v3.include_router(models_router)
api_router_v3.include_router(videos_router)
api_router_v3.include_router(images_router)
api_router_v3.include_router(virtual_portrait_router)
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class ApiError(BaseModel):
"""API 错误详情。"""
code: str
message: str
class ApiErrorResponse(BaseModel):
"""API 错误响应(旧格式,保留兼容)。"""
error: ApiError
+55
View File
@@ -0,0 +1,55 @@
import logging
import time
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.schemas.api_v3.image import (
ApiImageGenerateRequest,
ApiImageGenerateResponse,
)
from app.services.api_v3 import auth_service, generation_service
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/images", tags=["api-v3-images"])
@router.post(
"",
summary="生成图片",
description="同步生成图片,等待完成后直接返回结果",
)
async def generate_image(
req: ApiImageGenerateRequest,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
) -> JSONResponse:
"""同步生成图片。"""
start_time = time.perf_counter()
try:
result = await generation_service.generate_image_sync(
db=db,
key=key_context.api_key,
callable_models=key_context.callable_models,
req=req,
start_time=start_time,
)
data = result.model_dump()
# 处理 datetime 序列化
if data.get("created"):
data["created"] = data["created"] if isinstance(data["created"], int) else int(data["created"])
return JSONResponse(
content={"code": 0, "data": data, "message": "ok"},
status_code=200,
)
except HTTPException:
raise
except Exception as exc:
logger.exception("API image generation failed")
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"图片生成失败: {str(exc)[:200]}",
)
+119
View File
@@ -0,0 +1,119 @@
import json
import logging
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
from app.schemas.api_v3.model import ApiModelInfo, ApiModelsResponse
from app.services.api_v3 import auth_service
from app.services.api_v3.pricing_service import get_priced_models
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/models", tags=["api-v3-models"])
@router.get(
"",
summary="获取可用模型列表",
description="获取当前 API Key 可调用的所有视频和图片模型(仅返回已配置价格的模型)",
)
async def list_models(
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
) -> JSONResponse:
"""获取当前 API Key 可用的模型列表。"""
models: list[ApiModelInfo] = []
# 获取所有已配置价格的引擎 ID 集合
priced_engine_ids = await get_priced_models(db)
# 获取 API Key 的白名单引擎 ID 集合
allowed_engine_ids = {m.get("engine_id", "") for m in key_context.callable_models} if key_context.callable_models else set()
# 确定要返回的引擎 ID 列表
target_engine_ids = priced_engine_ids if not allowed_engine_ids else (allowed_engine_ids & priced_engine_ids)
# 构建引擎信息映射
engine_info_map = {m.get("engine_id", ""): m for m in key_context.callable_models}
for engine_id in target_engine_ids:
engine_type = engine_info_map.get(engine_id, {}).get("engine_type", "")
model_name = engine_info_map.get(engine_id, {}).get("model_name", "")
# 如果没有从白名单获取到类型,尝试从数据库加载
if not engine_type:
video_result = await db.execute(
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
)
if video_result.scalar_one_or_none():
engine_type = "video"
else:
image_result = await db.execute(
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
)
if image_result.scalar_one_or_none():
engine_type = "image"
# 加载引擎详情
supported_ratios = None
supported_resolutions = None
supported_durations = None
supported_sizes = None
try:
if engine_type == "video":
result = await db.execute(
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if engine:
if not model_name:
model_name = engine.model_name
supported_ratios = _parse_json_list(engine.supported_ratios)
supported_resolutions = _parse_json_list(engine.supported_resolutions)
supported_durations = _parse_json_list(engine.supported_durations)
elif engine_type == "image":
result = await db.execute(
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if engine:
if not model_name:
model_name = engine.model_name
supported_sizes = _parse_json_list(engine.supported_sizes)
except Exception:
pass
info = ApiModelInfo(
model=model_name,
engine_type=engine_type,
engine_id=engine_id,
supported_ratios=supported_ratios,
supported_resolutions=supported_resolutions,
supported_durations=supported_durations,
supported_sizes=supported_sizes,
)
models.append(info)
return JSONResponse(
content={"code": 0, "data": {"models": [m.model_dump() for m in models]}, "message": "ok"},
status_code=200,
)
def _parse_json_list(value: str | None) -> list[str | int] | None:
"""解析 JSON 列表字段。"""
if not value:
return None
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else None
except (json.JSONDecodeError, TypeError):
return None
+167
View File
@@ -0,0 +1,167 @@
import logging
import time
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.models.api.api_generation_task import ApiGenerationTask
from app.schemas.api_v3.video import (
ApiVideoCreateRequest,
ApiVideoCreateResponse,
ApiVideoStatusResponse,
)
from app.services.api_v3 import auth_service, generation_service, task_service
from app.services.resource_signed_url_service import build_resource_signed_url
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/videos", tags=["api-v3-videos"])
async def _validate_request(
db: AsyncSession,
key_context: auth_service.ApiKeyContext,
req: ApiVideoCreateRequest,
) -> ApiGenerationTask | None:
"""请求层校验:参数、权限、幂等性。
Returns:
None = 校验通过,继续创建
ApiGenerationTask = 幂等请求,返回已有任务
"""
# 模型权限校验
allowed_model_names = {m.get("model_name", "") for m in key_context.callable_models}
if allowed_model_names and req.model not in allowed_model_names:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"无权使用模型 {req.model}",
)
# 幂等性检查
if req.idempotency_key:
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.api_key_id == key_context.api_key.id,
ApiGenerationTask.external_idempotency_key == req.idempotency_key,
ApiGenerationTask.deleted_at.is_(None),
).limit(1)
)
existing_task = result.scalar_one_or_none()
if existing_task:
logger.info(
"Idempotent request: returning existing task %s for key=%s",
existing_task.id, req.idempotency_key,
)
return existing_task
return None
def _map_status(internal_status: str) -> str:
"""将内部状态映射为 API 状态。"""
status_map = {
"pending": "queued",
"queued": "queued",
"generating": "running",
"processing": "running",
"completed": "succeeded",
"failed": "failed",
"timeout": "expired",
}
return status_map.get(internal_status, internal_status)
@router.post(
"",
response_model=ApiVideoCreateResponse,
summary="创建视频生成任务",
)
async def create_video(
req: ApiVideoCreateRequest,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
) -> ApiVideoCreateResponse:
"""创建视频生成任务(异步)。
幂等性说明:如果 idempotency_key 已存在,直接返回已有任务 ID(不会重复创建)。
"""
try:
# 路由层校验:权限、幂等性
existing_task = await _validate_request(db, key_context, req)
if existing_task:
logger.info(
"Idempotent request: returning existing task %s for key=%s",
existing_task.id, req.idempotency_key,
)
return ApiVideoCreateResponse(id=f"zc-{existing_task.id}")
# 调用服务层创建任务
result = await generation_service.submit_video_generation(
db=db,
key=key_context.api_key,
callable_models=key_context.callable_models,
req=req,
)
return ApiVideoCreateResponse(id=f"zc-{result.id}")
except HTTPException:
raise
except Exception as exc:
logger.exception("API video creation failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建视频任务失败: {str(exc)[:200]}",
)
@router.get(
"/{task_id}",
response_model=ApiVideoStatusResponse,
summary="查询视频任务状态",
)
async def get_video_status(
task_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
) -> ApiVideoStatusResponse:
"""查询视频任务状态。"""
# 去掉 zc- 前缀
if task_id.startswith("zc-"):
task_id = task_id[3:]
task = await task_service.get_task(db, task_id, key_context.api_key.id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"任务 {task_id} 不存在或不属于当前 API Key",
)
now = int(time.time())
# 构建 content(成功时返回完整视频URL,包含 BASE_URL
content = None
if task.status == "completed" and task.video_url:
from app.schemas.api_v3.video import ApiVideoContent
from app.config import settings
# 拼接完整 URL
video_url = build_resource_signed_url(task.video_url)
if video_url and not video_url.startswith(("http://", "https://")):
base = settings.BASE_URL.rstrip("/")
if video_url.startswith("/"):
video_url = f"{base}{video_url}"
else:
video_url = f"{base}/{video_url}"
content = ApiVideoContent(video_url=video_url)
return ApiVideoStatusResponse(
id=f"zc-{task.id}",
model=task.model_name,
status=_map_status(task.status),
created_at=int(task.created_at.timestamp()) if task.created_at else now,
updated_at=int(task.updated_at.timestamp()) if task.updated_at else now,
content=content,
duration=task.duration,
ratio=task.aspect_ratio,
resolution=task.resolution,
error=task.error_message if task.status in ("failed", "timeout") else None,
)
@@ -0,0 +1,485 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Form, HTTPException, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.enums.upload_resource import UploadResourceTypeEnum # noqa: F401 (内部引用保留)
from app.enums.private_portrait import (
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.schemas.virtual_portrait_v3 import (
VpV3AssetCreate,
VpV3AssetDeleteOut,
VpV3AssetListOut,
VpV3EnumMeta,
VpV3IdOut,
VpV3ProjectCreate,
VpV3ProjectDeleteOut,
VpV3ProjectListOut,
VpV3ProjectOut,
VpV3ProjectUpdate,
VpV3QuotaConfigOut,
VpV3SelectableAssetListOut,
)
from app.services import virtual_portrait_v3 as vp_v3
from app.services.api_v3 import auth_service
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/virtual-portrait", tags=["api-v3-virtual-portrait"])
API_PREFIX_INFO = """
> **虚拟素材库(V3 中转 API**
>
> - 数据与前台用户私域素材库完全隔离(独立 `vp_v3_*` 表),归属按 API Key 管理
> - 所有接口需要在 Header 中携带 `Authorization: Bearer <API Key>`(或通过 `X-API-Key`,详见鉴权说明)
> - 配额:每个 API Key 需要管理员在后台配置虚拟素材额度(项目数/素材数/存储 MB),默认 0=不可使用
> - 生命周期:上传文件 → 创建素材(异步审核,会自动轮询)→ 状态 Active 后可用于 AI 创作
> - 远端删除遵循「先本地软删 → commit 后投递 Celery 异步任务删火山」模式,API 返回 `remote_delete_status=pending` 表示处理中
""" # noqa: E501
# ---------------------------------------------------------------------------
# 基础 & 配置
# ---------------------------------------------------------------------------
@router.get(
"/config",
response_model=VpV3QuotaConfigOut,
summary="获取虚拟素材库配额配置",
description=(
"返回当前 API Key 的虚拟素材配额上限(项目/素材/存储)和已使用量。"
"任一上限大于 0 表示启用虚拟素材库功能。"
+ API_PREFIX_INFO
),
)
async def get_virtual_portrait_config(
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
quota = await vp_v3.quota_service.get_quota(db, api_key_id=key_context.api_key_id, refresh=True)
enabled = any([
(quota.project_limit or 0) > 0,
(quota.asset_limit or 0) > 0,
(quota.storage_mb_limit or 0) > 0,
])
return VpV3QuotaConfigOut(
project_limit=int(quota.project_limit or 0),
asset_limit=int(quota.asset_limit or 0),
storage_mb_limit=int(quota.storage_mb_limit or 0),
project_used=int(quota.project_used or 0),
asset_used=int(quota.asset_used or 0),
storage_mb_used=float(quota.storage_mb_used or 0),
enabled=bool(enabled),
)
@router.get(
"/enums",
response_model=VpV3EnumMeta,
summary="获取虚拟素材库枚举元数据",
description="返回素材类型、素材状态、项目状态、远端删除状态等枚举说明。",
)
async def get_virtual_portrait_enums(
_: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
):
return VpV3EnumMeta(
asset_type={
PrivatePortraitAssetType.IMAGE.value: "图片素材",
PrivatePortraitAssetType.VIDEO.value: "视频素材",
},
asset_status={
PrivatePortraitAssetStatus.CREATING.value: "创建中/审核中",
PrivatePortraitAssetStatus.ACTIVE.value: "已就绪/可用",
PrivatePortraitAssetStatus.FAILED.value: "失败",
PrivatePortraitAssetStatus.DELETING.value: "删除中",
},
project_status={
PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value: "远端组创建中",
PrivatePortraitProjectStatus.ACTIVE.value: "就绪",
PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value: "远端组创建失败",
PrivatePortraitProjectStatus.DELETING.value: "删除中",
},
remote_delete_status={
PrivatePortraitRemoteDeleteStatus.NONE.value: "未删除",
PrivatePortraitRemoteDeleteStatus.PENDING.value: "待异步删除",
PrivatePortraitRemoteDeleteStatus.PROCESSING.value: "远端删除中",
PrivatePortraitRemoteDeleteStatus.DELETED.value: "远端已删除",
PrivatePortraitRemoteDeleteStatus.FAILED.value: "远端删除失败",
},
)
# ---------------------------------------------------------------------------
# 项目 CRUD
# ---------------------------------------------------------------------------
@router.post(
"/projects",
response_model=VpV3IdOut,
summary="创建虚拟素材项目",
description=(
"在当前 API Key 下创建一个虚拟素材项目(同步调用火山创建远端 AssetGroup)。"
"项目名称 1-100 字符;描述最多 500 字符。"
"创建项目会占用 1 个项目配额,超出上限将返回 403。"
),
)
async def create_virtual_portrait_project(
payload: VpV3ProjectCreate,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
try:
project = await vp_v3.project_service.create_project(
db, api_key_id=key_context.api_key_id, payload=payload
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"创建项目失败:{exc}") from exc
return VpV3IdOut(Id=project.remote_group_id)
@router.get(
"/projects",
response_model=VpV3ProjectListOut,
summary="查询虚拟素材项目列表",
description="按 API Key 分页查询虚拟素材项目。支持项目名称模糊搜索、状态筛选。默认按创建时间倒序。",
)
async def list_virtual_portrait_projects(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量 1-100"),
keyword: str | None = Query(None, description="项目名称模糊搜索"),
status: str | None = Query(None, description="项目状态筛选(不传查全部)"),
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
items, total = await vp_v3.project_service.list_projects(
db,
api_key_id=key_context.api_key_id,
page=page,
page_size=page_size,
keyword=keyword,
status=status,
)
return VpV3ProjectListOut(
items=[vp_v3.project_service.project_to_out(it) for it in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/projects/{project_id}",
response_model=VpV3ProjectOut,
summary="获取虚拟素材项目详情",
)
async def get_virtual_portrait_project(
project_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
project = await vp_v3.project_service.get_project(
db, api_key_id=key_context.api_key_id, project_id=project_id
)
return vp_v3.project_service.project_to_out(project)
@router.put(
"/projects/{project_id}",
response_model=VpV3ProjectOut,
summary="更新虚拟素材项目",
description="更新虚拟素材项目本地展示信息(名称/描述),不会重新创建火山远端 Group。",
)
async def update_virtual_portrait_project(
project_id: str,
payload: VpV3ProjectUpdate,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
try:
project = await vp_v3.project_service.update_project(
db, api_key_id=key_context.api_key_id, project_id=project_id, payload=payload
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"更新项目失败:{exc}") from exc
return vp_v3.project_service.project_to_out(project)
@router.delete(
"/projects/{project_id}",
response_model=VpV3ProjectDeleteOut,
summary="删除虚拟素材项目",
description=(
"软删虚拟素材项目及其下所有素材。本地 commit 后会投递 Celery 异步任务去删除火山远端 AssetGroup/Asset。"
"返回的 remote_delete_status=pending 表示远端删除处理中(可通过项目详情接口轮询)。"
),
)
async def delete_virtual_portrait_project(
project_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
project = await vp_v3.project_service.soft_delete_project(
db, api_key_id=key_context.api_key_id, project_id=project_id
)
project_id_snapshot = project.id
try:
await db.commit()
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"删除项目失败:{exc}") from exc
# commit 后投递 V3 专属的异步删除任务
try:
from app.tasks.vp_v3_asset_tasks import delete_v3_project_remote_task # type: ignore
delete_v3_project_remote_task.delay(project_id_snapshot)
logger.info("vp_v3 project %s 已投递远端删除任务", project_id_snapshot)
except Exception as exc: # noqa: BLE001
logger.warning("vp_v3 项目删除任务投递失败:project_id=%s err=%s", project_id_snapshot, exc)
return VpV3ProjectDeleteOut(
success=True,
remote_delete_status=project.remote_delete_status or PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
# ---------------------------------------------------------------------------
# 素材 CRUD
# ---------------------------------------------------------------------------
@router.post(
"/projects/{project_id}/assets",
response_model=VpV3IdOut,
summary="创建虚拟素材(提交审核)",
description=(
"在指定项目下创建虚拟素材,提交到火山进行异步审核。\n"
"- source_url:必填,必须是 POST /uploads/image 或 /uploads/video 返回的 url(或 /uploads/* 路径)\n"
"- asset_typeImage/VideoVideo 必须提供 video_duration(秒),最多 60 秒\n"
"- 创建成功后 status=Creating;建议调用方自行轮询 /assets/{id}/sync 或详情接口直到 status=Active\n"
"- 同时会占用 1 份素材配额和文件大小对应的存储配额"
),
)
async def create_virtual_portrait_asset(
project_id: str,
payload: VpV3AssetCreate,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
try:
project = await vp_v3.project_service.get_project(
db, api_key_id=key_context.api_key_id, project_id=project_id
)
asset = await vp_v3.asset_service.create_asset(
db, api_key_id=key_context.api_key_id, project=project, payload=payload
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"创建素材失败:{exc}") from exc
asset_id_snapshot = asset.remote_asset_id
# commit 成功后投递 V3 专属轮询任务
try:
from app.tasks.vp_v3_asset_tasks import poll_v3_asset_status # type: ignore
async_result = poll_v3_asset_status.delay(asset_id_snapshot)
logger.info(
"vp_v3 素材轮询任务投递成功:asset_id=%s celery_task_id=%s",
asset_id_snapshot,
getattr(async_result, "id", None),
)
except Exception as exc: # noqa: BLE001
logger.warning("vp_v3 素材轮询任务投递失败:asset_id=%s err=%s", asset_id_snapshot, exc)
return VpV3IdOut(Id=asset.remote_asset_id)
@router.get(
"/projects/{project_id}/assets",
response_model=VpV3AssetListOut,
summary="查询指定项目下的虚拟素材列表",
description="按项目分页查询素材。可按 status/asset_type 筛选,按素材名称 keyword 模糊搜索。",
)
async def list_virtual_portrait_project_assets(
project_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = Query(None, description="素材状态筛选(Creating/Active/Failed/Deleting"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image/Video"),
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
# 先校验项目归属
await vp_v3.project_service.get_project(db, api_key_id=key_context.api_key_id, project_id=project_id)
items, total = await vp_v3.asset_service.list_assets(
db,
api_key_id=key_context.api_key_id,
project_id=project_id,
status=status,
keyword=keyword,
asset_type=asset_type,
page=page,
page_size=page_size,
)
return VpV3AssetListOut(
items=[vp_v3.asset_service.asset_to_out(it) for it in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/assets/{asset_id}",
summary="获取虚拟素材审核详情",
description=(
"返回素材的 moderation_json(火山审核 JSON)。\n"
"- 若素材状态为 Creating(审核中)且 next_poll_at 已到期,内部会自动调火山 GetAsset 同步最新状态。\n"
"- 返回内容为解析后的 JSON 对象。"
),
)
async def get_virtual_portrait_asset(
asset_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
# 北京时间(UTC+8)统一基准
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
asset = await vp_v3.asset_service.get_asset(db, api_key_id=key_context.api_key_id, asset_id=asset_id)
# 统一为 naive 北京时间比较
def _naive(dt: datetime | None) -> datetime | None:
if dt is None:
return None
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
need_sync = (
asset.status == PrivatePortraitAssetStatus.CREATING.value
and asset.remote_asset_id
and (_naive(asset.next_poll_at) is None or _naive(asset.next_poll_at) <= _bj_now())
)
if need_sync:
try:
asset = await vp_v3.asset_service.sync_asset_status(
db, api_key_id=key_context.api_key_id, asset_id=asset_id,
)
await db.commit()
await db.refresh(asset)
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"同步素材状态失败:{exc}") from exc
# 只返回 moderation_json 解析后的内容
moderation = None
if asset.moderation_json:
try:
moderation = json.loads(asset.moderation_json)
except (json.JSONDecodeError, TypeError):
moderation = asset.moderation_json
return JSONResponse(content=moderation)
@router.delete(
"/assets/{asset_id}",
response_model=VpV3AssetDeleteOut,
summary="删除虚拟素材",
description=(
"软删虚拟素材。本地 commit 后会投递 Celery 异步任务去删除火山远端 Asset。"
"返回 remote_delete_status=pending 表示处理中(可通过素材详情接口轮询)。"
),
)
async def delete_virtual_portrait_asset(
asset_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
asset = await vp_v3.asset_service.soft_delete_asset(
db, api_key_id=key_context.api_key_id, asset_id=asset_id
)
asset_id_snapshot = asset.remote_asset_id
try:
await db.commit()
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"删除素材失败:{exc}") from exc
# commit 后投递 V3 专属的异步删除任务
try:
from app.tasks.vp_v3_asset_tasks import delete_v3_asset_remote_task # type: ignore
delete_v3_asset_remote_task.delay(asset_id_snapshot)
except Exception as exc: # noqa: BLE001
logger.warning("vp_v3 素材远端删除任务投递失败:asset_id=%s err=%s", asset_id_snapshot, exc)
return VpV3AssetDeleteOut(
success=True,
remote_delete_status=asset.remote_delete_status or PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
# ---------------------------------------------------------------------------
# AI 创作选择器用
# ---------------------------------------------------------------------------
@router.get(
"/selectable-assets",
response_model=VpV3SelectableAssetListOut,
summary="查询可用于 AI 创作的虚拟素材",
description=(
"只返回当前 API Key 虚拟素材库中 status=Active 的图片/视频素材。"
"该接口提供给 AI 创作参考素材选择器使用。"
),
)
async def list_virtual_portrait_selectable_assets(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
project_id: str | None = Query(None, description="按项目筛选(可选)"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image/Video"),
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
items, total = await vp_v3.asset_service.list_selectable_assets(
db,
api_key_id=key_context.api_key_id,
project_id=project_id,
keyword=keyword,
asset_type=asset_type,
page=page,
page_size=page_size,
)
return VpV3SelectableAssetListOut(
items=[vp_v3.asset_service.asset_to_selectable(it) for it in items],
total=total,
page=page,
page_size=page_size,
)
+14
View File
@@ -346,6 +346,20 @@ class Settings(BaseSettings):
PRIVATE_PORTRAIT_DISPATCH_LOCK_KEY: str = "vg:celery:private_portrait:dispatch_lock"
PRIVATE_PORTRAIT_DELETE_RECOVERY_LOCK_KEY: str = "vg:celery:private_portrait:delete_recovery_lock"
# V3 虚拟素材库 Celery Runtime(与前台私域素材库独立隔离,避免任务集合 key 冲突和相互影响)
VP_V3_POLL_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:vp_v3_poll:active"
VP_V3_POLL_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:vp_v3_poll:active_index"
VP_V3_POLL_LOCK_KEY_PREFIX: str = "vg:lock:vp_v3:poll"
VP_V3_DELETE_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:vp_v3_delete:active"
VP_V3_DELETE_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:vp_v3_delete:active_index"
VP_V3_DELETE_LOCK_KEY_PREFIX: str = "vg:lock:vp_v3:delete"
VP_V3_RUNTIME_LOCK_TTL_SECONDS: int = 180
VP_V3_RUNTIME_HEARTBEAT_SECONDS: int = 30
VP_V3_DISPATCH_LOCK_KEY: str = "vg:celery:vp_v3:dispatch_lock"
VP_V3_DELETE_RECOVERY_LOCK_KEY: str = "vg:celery:vp_v3:delete_recovery_lock"
VP_V3_ASSET_POLL_BATCH_SIZE: int = 50
VP_V3_REMOTE_DELETE_RECOVERY_BATCH_SIZE: int = 50
SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4
SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16"
SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p"
+7
View File
@@ -12,6 +12,7 @@ class CeleryQueue(str, Enum):
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
GEN_SHOT_SPLIT = "gen_shot_split"
GEN_CREDIT_MAINTENANCE = "gen_credit_maintenance"
GEN_API_UPSCALE = "gen_api_upscale"
DEFAULT = "default"
@@ -28,6 +29,7 @@ class CeleryTaskName(str, Enum):
VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT = "video_upscale.download_remote_result"
VIDEO_UPSCALE_FINALIZE = "video_upscale.finalize"
VIDEO_UPSCALE_RECOVER = "video_upscale.recover_once"
API_GENERATION_RECOVER = "api_generation.recover_tasks_once"
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
STARTUP_RECOVERY = "recovery.startup_recovery_once"
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
@@ -45,3 +47,8 @@ class CeleryTaskName(str, Enum):
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
CREDIT_MAINTENANCE = "credit.maintenance_once"
VP_V3_POLL_ASSET = "vp_v3.asset.poll_status"
VP_V3_SYNC_DUE_ASSETS = "vp_v3.sync_due_assets"
VP_V3_DELETE_ASSET = "vp_v3.asset.delete_remote"
VP_V3_DELETE_PROJECT = "vp_v3.project.delete_remote"
VP_V3_RECOVER_REMOTE_DELETES = "vp_v3.recover_remote_deletes"
+1 -1
View File
@@ -45,7 +45,7 @@ class GenerationType(str, Enum):
# 生成配置常量
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
RESOLUTIONS = ["480p", "720p", "1080p"]
IMAGE_SIZES = ["1K", "2K", "4K"]
@@ -74,6 +74,7 @@ class PrivatePortraitProjectStatus(str, Enum):
VALIDATE_FAILED = "validate_failed"
CREATING_REMOTE_GROUP = "creating_remote_group"
CREATE_GROUP_FAILED = "create_group_failed"
DELETING = "deleting"
DELETED = "deleted"
@@ -101,6 +102,7 @@ class PrivatePortraitAssetStatus(str, Enum):
ACTIVE = "Active"
FAILED = "Failed"
LOCAL_DELETED = "local_deleted"
DELETING = "deleting"
REMOTE_DELETED = "remote_deleted"
DELETE_FAILED = "delete_failed"
@@ -159,6 +161,9 @@ class PrivatePortraitEventType(str, Enum):
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"
VIRTUAL_ASSET_CREATE_REMOTE_START = "VIRTUAL_ASSET_CREATE_REMOTE_START"
VIRTUAL_ASSET_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_CREATE_REMOTE_SUCCESS"
VIRTUAL_ASSET_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_CREATE_REMOTE_FAILED"
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
+3 -3
View File
@@ -18,9 +18,9 @@ class UploadResourceModuleEnum(StrEnum):
class UploadResourceTypeEnum(StrEnum):
"""上传资源类型。"""
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
IMAGE = "Image"
VIDEO = "Video"
AUDIO = "Audio"
SHOT_SEGMENT = "shot_segment"
PDF = "pdf"
FILE = "file"
+109 -1
View File
@@ -3,7 +3,7 @@ import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
@@ -518,12 +518,120 @@ def create_app() -> FastAPI:
# Routes
application.include_router(api_router, prefix="/api")
application.include_router(api_router_v2, prefix="/api/v2")
from app.api.v3 import api_router_v3
application.include_router(api_router_v3, prefix="/api/v3")
# === API v3 请求日志中间件 ===
import json as _json
import time as _time
from app.services.api_v3.logging_service import log_request, log_response, log_request_error
@application.middleware("http")
async def v3_request_logger(request: Request, call_next):
"""记录所有 /api/v3/ 请求和响应。"""
if not str(request.url.path).startswith("/api/v3"):
return await call_next(request)
start_time = _time.perf_counter()
# 提取 API Key ID
auth_header = request.headers.get("Authorization", "")
api_key_id = "unknown"
if auth_header.startswith("Bearer "):
api_key_id = auth_header[7:15] + "..."
# 读取请求体
body = None
if request.method in ("POST", "PUT", "PATCH"):
try:
body = await request.json()
except Exception:
pass
log_request(
method=request.method,
path=str(request.url.path),
api_key_id=api_key_id,
body=body,
)
try:
response = await call_next(request)
except Exception as exc:
duration_ms = int((_time.perf_counter() - start_time) * 1000)
log_request_error(
method=request.method,
path=str(request.url.path),
api_key_id=api_key_id,
error=str(exc),
)
return JSONResponse(
content={"code": 50000, "data": None, "message": f"服务器内部错误: {str(exc)[:200]}"},
status_code=200,
)
duration_ms = int((_time.perf_counter() - start_time) * 1000)
# 读取响应体
response_body = None
try:
response_body = _json.loads(response.body)
except Exception:
pass
log_response(
method=request.method,
path=str(request.url.path),
api_key_id=api_key_id,
status_code=response.status_code,
body=response_body,
duration_ms=duration_ms,
)
return response
# === API v3 统一异常处理 ===
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from app.services.api_v3.pricing_service import PricingNotConfiguredError
@application.exception_handler(HTTPException)
async def v3_http_exception_handler(request: Request, exc: HTTPException):
"""仅对 /api/v3/ 路径返回统一格式,HTTP 状态码固定 200。"""
if not str(request.url.path).startswith("/api/v3"):
# 非 v3 路径返回标准 HTTPException 响应,保持原始状态码
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
detail = exc.detail
message = detail.get("message", str(detail)) if isinstance(detail, dict) else str(detail)
code_map = {400: 40000, 401: 40100, 403: 40300, 404: 40400, 429: 42900, 422: 42200, 500: 50000, 504: 50400}
code = code_map.get(exc.status_code, exc.status_code * 100)
return JSONResponse(content={"code": code, "data": None, "message": message}, status_code=200)
@application.exception_handler(PricingNotConfiguredError)
async def v3_pricing_not_configured_handler(request: Request, exc: PricingNotConfiguredError):
if not str(request.url.path).startswith("/api/v3"):
raise exc
return JSONResponse(content={"code": 40001, "data": None, "message": str(exc)}, status_code=200)
@application.exception_handler(Exception)
async def v3_general_exception_handler(request: Request, exc: Exception):
if not str(request.url.path).startswith("/api/v3"):
raise exc
return JSONResponse(content={"code": 50000, "data": None, "message": f"服务器内部错误: {str(exc)[:200]}"}, status_code=200)
# Static files for uploads
upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH)
os.makedirs(upload_dir, exist_ok=True)
application.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
# 挂载 API v3 生成文件静态目录
generate_dir = os.path.join(os.path.dirname(upload_dir), "generate")
os.makedirs(generate_dir, exist_ok=True)
application.mount("/generate", StaticFiles(directory=generate_dir), name="generate")
@application.get("/internal/health")
async def health():
return {"status": "ok"}
@@ -3,7 +3,7 @@ import json
import logging
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import Request, Response
from fastapi import HTTPException, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.config import settings
+6
View File
@@ -41,7 +41,10 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.contact_request import ContactRequest
from app.models.invoice import Invoice, InvoiceOrder
from app.models.invoice_header import InvoiceHeader
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
__all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
@@ -62,4 +65,7 @@ __all__ = [
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
"PrivatePortraitProject", "PrivatePortraitValidateSession",
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing",
"Invoice", "InvoiceOrder", "InvoiceHeader",
]
+15
View File
@@ -0,0 +1,15 @@
from app.models.api.api_key import ApiKey
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.api.api_usage_log import ApiUsageLog
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_upscale_link import ApiUpscaleLink
from app.models.api.api_model_pricing import ApiModelPricing
__all__ = [
"ApiKey",
"ApiGenerationTask",
"ApiUsageLog",
"ApiKeyUpscaleConfig",
"ApiUpscaleLink",
"ApiModelPricing",
]
@@ -0,0 +1,125 @@
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class ApiGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
"""对外开放 API 的生成任务表。
该表设计满足 ProviderGenerationRecordLike 协议,
使现有的 Volcano Ark SDK 封装函数可以直接复用。
"""
__tablename__ = "api_generation_tasks"
__table_args__ = (
# 幂等键唯一索引
Index(
"uq_api_generation_tasks_key_idempotency",
"api_key_id",
"external_idempotency_key",
unique=True,
postgresql_where=text("deleted_at IS NULL AND external_idempotency_key IS NOT NULL"),
),
# 视频轮询调度索引
Index(
"idx_api_generation_tasks_next_poll_at",
"next_poll_at",
postgresql_where=text(
"deleted_at IS NULL "
"AND status = 'generating' "
"AND gen_type = 'video' "
"AND next_poll_at IS NOT NULL"
),
),
Index("idx_api_generation_tasks_api_key_created", "api_key_id", "created_at"),
Index("idx_api_generation_tasks_provider_task_id", "provider_task_id"),
Index("idx_api_generation_tasks_status", "status"),
CheckConstraint("generation_count BETWEEN 1 AND 5", name="ck_api_generation_tasks_generation_count"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True
)
external_idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
# === ProviderGenerationRecordLike 协议字段 ===
original_prompt: Mapped[str] = mapped_column(Text, nullable=False)
optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
gen_type: Mapped[str] = mapped_column(String(16), default="video", nullable=False)
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
provider_generation_resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
image_size: Mapped[str | None] = mapped_column(String(16), nullable=True)
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_px: Mapped[str | None] = mapped_column(String(16), nullable=True)
generation_count: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
model_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="模型名称")
media_references: Mapped[str | None] = mapped_column(Text, nullable=True, comment="用户原始上传的媒体URL")
local_media_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="下载到本地的媒体文件路径JSON")
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# === 请求参数快照 ===
request_params_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="完整原始请求参数")
# === 流水线状态(镜像 ChatGenerationTask ===
status: Mapped[str] = mapped_column(String(32), default="pending", nullable=False)
pipeline_stage: Mapped[str | None] = mapped_column(String(32), nullable=True)
generation_attempt_no: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
resource_generation_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === 供应商交互 ===
provider_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
remote_result_url: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# === 结果 ===
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === 超分 ===
video_upscale_enabled_snapshot: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
video_upscale_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# === 配额消耗 ===
credits_cost: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0")
video_tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
image_tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# === 轮询控制 ===
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=30, server_default="30")
poll_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === Celery 执行租约(镜像 ChatGenerationTask ===
provider_create_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_create_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
provider_create_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
poll_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_error_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
download_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
download_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
download_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
download_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
download_storage_date_dir: Mapped[str | None] = mapped_column(String(16), nullable=True)
# === 存储 ===
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
from app.utils.security import encrypt_text, decrypt_text
class ApiKey(Base, TimestampMixin, SoftDeleteMixin):
"""对外开放 API 的密钥管理表。
每个 api-key 对应一个外部调用方(公司/组织),
可配置可调用模型、配额、有效期、并发限制。
"""
__tablename__ = "api_keys"
__table_args__ = (
Index("idx_api_keys_active", "is_active", postgresql_where=text("deleted_at IS NULL")),
Index("idx_api_keys_company", "company_name"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
company_name: Mapped[str] = mapped_column(String(128), nullable=False)
api_key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
api_key_prefix: Mapped[str] = mapped_column(String(16), nullable=False)
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False, comment="AES-256-GCM 加密的完整 API Key")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
def decrypt_api_key(self) -> str | None:
"""解密并返回完整 API Key。"""
return decrypt_text(self.api_key_encrypted)
def set_plaintext_key(self, plaintext: str) -> None:
"""设置明文 API Key(自动加密存储)。"""
self.api_key_encrypted = encrypt_text(plaintext)
# === 可调用模型配置 ===
callable_models: Mapped[str] = mapped_column(Text, nullable=False, server_default="[]",
comment='JSON数组: [{"engine_type":"video","engine_id":"xxx","model_name":"doubao-seedance-2-0-260128"}]')
# === 配额配置(不设置=无限制) ===
quota_limit: Mapped[float | None] = mapped_column(Float, nullable=True, comment="配额总量,NULL=无限")
quota_cycle: Mapped[str | None] = mapped_column(String(16), nullable=True, comment="daily|monthly|one_time|NULL=无限")
quota_used: Mapped[float] = mapped_column(Float, nullable=False, default=0.0, server_default="0.0", comment="当前周期已使用量")
# === 有效期(不设置=永不过期) ===
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === 并发限制(不设置=无限制) ===
max_concurrent_video_tasks: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="最大并发视频任务数,NULL=无限")
# === 状态 ===
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,27 @@
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiKeyUpscaleConfig(Base, TimestampMixin):
"""API Key 级别的超分配置表。
每个 API Key 可独立配置超分规则,不依赖现有的 video_upscale 配置。
"""
__tablename__ = "api_key_upscale_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), unique=True, nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
delete_source_after_success: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
rules_json: Mapped[str] = mapped_column(
Text, nullable=False, server_default="[]",
comment='JSON数组: [{"target_resolution":"1080p","provider_generation_resolution":"720p","processor_key":"volc_standard_v1","enabled":true}]'
)
@@ -0,0 +1,40 @@
from sqlalchemy import Float, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiModelPricing(Base, TimestampMixin):
"""API 模型价格表(全局统一配置)。
完全镜像 credit_ratios 表结构,将积分字段替换为金额字段(元)。
所有 API Key 共用一套价格表。
model_config_id 兼容 credit_ratios 字段名约定:
- gen_type=image 时,该字段保存 image_engines.id
- gen_type=video 时,该字段保存 video_engines.id
"""
__tablename__ = "api_model_pricings"
__table_args__ = (
Index("ix_api_model_pricings_gen_type_engine_resolution", "gen_type", "model_config_id", "resolution"),
Index("ix_api_model_pricings_gen_type_resolution", "gen_type", "resolution"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
model_config_id: Mapped[str] = mapped_column(String(32), index=True)
gen_type: Mapped[str] = mapped_column(String(16), default="video", index=True)
resolution: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
# === 价格字段(元) ===
price_ratio: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, comment="价格系数(乘数)")
base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="基础价格(元)")
per_second_price: Mapped[float] = mapped_column(Float, default=0.0, comment="每秒价格(视频,元)")
# === 传入媒体附加费 ===
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0, comment="传入视频系数")
input_video_base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入视频基础价(元)")
input_video_per_second_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入视频每秒价(元)")
input_image_ratio: Mapped[float] = mapped_column(Float, default=1.0, comment="传入图片系数")
input_image_base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入图片基础价(元)")
input_image_per_image_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入图片每张价(元)")
@@ -0,0 +1,22 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiUpscaleLink(Base, TimestampMixin):
"""API 任务与超分任务的关联表。
由于不能修改现有的 video_upscale_tasks 表结构,
通过此关联表追踪 API 任务对应的超分子任务。
"""
__tablename__ = "api_upscale_links"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_generation_task_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_generation_tasks.id", ondelete="CASCADE"), nullable=False, index=True
)
video_upscale_task_id: Mapped[str] = mapped_column(
String(32), ForeignKey("video_upscale_tasks.id", ondelete="CASCADE"), nullable=False, index=True
)
@@ -0,0 +1,61 @@
from sqlalchemy import Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiUsageLog(Base, TimestampMixin):
"""API 调用详细消耗记录表。
记录每次 API 请求的完整消费信息,包括:
- 扣除金额和退回金额
- 模型详情(名称、分辨率、时长等)
- 对应的生成任务 ID
- 操作类型(扣除/退回)
"""
__tablename__ = "api_usage_logs"
__table_args__ = (
Index("idx_api_usage_logs_api_key_created", "api_key_id", "created_at"),
Index("idx_api_usage_logs_task_id", "api_generation_task_id"),
Index("idx_api_usage_logs_action", "price_action"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True
)
api_generation_task_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("api_generation_tasks.id", ondelete="SET NULL"), nullable=True
)
# === 操作类型 ===
price_action: Mapped[str] = mapped_column(String(16), nullable=False, comment="deduct=扣除, refund=退回")
# === 请求信息 ===
request_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="video_create|image_generate")
model_name: Mapped[str] = mapped_column(String(128), nullable=False)
gen_type: Mapped[str] = mapped_column(String(16), nullable=False)
resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
# === 金额信息 ===
credits_cost: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0", comment="实际扣除金额")
refund_amount: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0", comment="退回金额")
quota_before: Mapped[float | None] = mapped_column(Float, nullable=True, comment="操作前配额余额")
quota_after: Mapped[float | None] = mapped_column(Float, nullable=True, comment="操作后配额余额")
# === Token 用量 ===
tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
request_duration_ms: Mapped[int] = mapped_column(Integer, default=0, server_default="0", comment="端到端耗时")
# === 价格明细(JSON ===
price_detail_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="价格计算明细JSON")
# === 结果 ===
status: Mapped[str] = mapped_column(String(32), nullable=False, comment="success|failed")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
# === 调试 ===
request_payload_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始请求快照")
+3 -3
View File
@@ -49,16 +49,16 @@ class Base(AsyncAttrs, DeclarativeBase):
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="更新时间"
)
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
DateTime(timezone=True), nullable=True, index=True, comment="软删除时间,NULL表示未删除"
)
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class Invoice(Base, TimestampMixin):
__tablename__ = "invoices"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
invoice_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
header_type: Mapped[str] = mapped_column(String(16), nullable=False)
header_name: Mapped[str] = mapped_column(String(128), nullable=False)
header_tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_register_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
header_register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
header_bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True)
email: Mapped[str] = mapped_column(String(128), nullable=False)
total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
total_credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="processing")
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
issued_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index('idx_invoices_user_created', 'user_id', 'created_at'),
Index('idx_invoices_status_created', 'status', 'created_at'),
)
class InvoiceOrder(Base, TimestampMixin):
__tablename__ = "invoice_orders"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
invoice_id: Mapped[str] = mapped_column(
String(32), ForeignKey("invoices.id", ondelete="CASCADE"), index=True
)
order_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), nullable=False)
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
__table_args__ = (
UniqueConstraint('invoice_id', 'order_id', name='uq_invoice_orders'),
Index('idx_invoice_orders_invoice', 'invoice_id'),
Index('idx_invoice_orders_order', 'order_id'),
)
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class InvoiceHeader(Base):
"""发票抬头表"""
__tablename__ = "invoice_headers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="主键")
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID"
)
type: Mapped[str] = mapped_column(String(16), nullable=False, comment="抬头类型: personal/company")
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="抬头名称")
tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="税号")
register_address: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="注册地址")
register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="注册电话")
bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="开户行")
bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="银行账号")
email: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="接收邮箱")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="更新时间"
)
__table_args__ = (
Index('idx_invoice_headers_user', 'user_id'),
)
@@ -34,6 +34,12 @@ class VideoUpscaleTask(Base, TimestampMixin):
ForeignKey("generation_records.id", ondelete="CASCADE"),
nullable=True,
)
api_generation_task_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("api_generation_tasks.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", server_default="pending")
stage: Mapped[str] = mapped_column(String(48), nullable=False, default="upscale_queued", server_default="upscale_queued")
@@ -0,0 +1,9 @@
from app.models.virtual_portrait_v3.api_key_quota import VpV3ApiKeyQuota
from app.models.virtual_portrait_v3.project import VpV3Project
from app.models.virtual_portrait_v3.asset import VpV3Asset
__all__ = [
"VpV3ApiKeyQuota",
"VpV3Project",
"VpV3Asset",
]
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class VpV3ApiKeyQuota(Base, TimestampMixin):
"""API V3 虚拟素材库配额(每个 ApiKey 一份,默认 0=不可用)。
配额在创建/删除项目、上传/删除素材时实时统计(直接 COUNT/SUM),
避免缓存不准;配额字段默认 0,后台管理配置后才可用。
"""
__tablename__ = "vp_v3_api_key_quotas"
__table_args__ = (
Index("uq_vp_v3_api_key_quotas_key_id", "api_key_id", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
comment="所属 API Key,唯一:一个 API Key 只有一份虚拟素材配额",
)
# 配额上限(默认 0 = 不可使用该功能)
project_limit: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="虚拟项目上限,默认 0 不可创建",
)
asset_limit: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="虚拟素材总数上限(图片+视频),默认 0 不可上传",
)
storage_mb_limit: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="上传存储上限 MB,默认 0 不可上传文件",
)
# 已使用量(冗余字段提升性能,每次增删同步,和真实 COUNT 不一致时以 COUNT 为准)
project_used: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="已创建项目数(未删除)",
)
asset_used: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="已上传素材数(未删除,图片+视频)",
)
storage_mb_used: Mapped[float] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="已占用存储 MB(未删除文件大小合计,1MB=1024*1024",
)
remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment="后台备注")
@@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import datetime
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,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class VpV3Asset(Base, TimestampMixin, SoftDeleteMixin):
"""API V3 虚拟素材(图片/视频),归属某个 Project=火山 1 个 AssetGroup)。
字段语义和 private_portrait.PrivatePortraitAsset 保持一致,便于 service 层复用逻辑。
"""
__tablename__ = "vp_v3_assets"
__table_args__ = (
Index("uq_vp_v3_assets_remote_asset_id", "remote_asset_id", unique=True),
Index("idx_vp_v3_assets_key_status_created", "api_key_id", "status", "created_at"),
Index("idx_vp_v3_assets_project_status_created", "project_id", "status", "created_at"),
Index(
"idx_vp_v3_assets_next_poll_status",
"next_poll_at",
"status",
postgresql_where=text("deleted_at IS NULL AND next_poll_at IS NOT NULL"),
),
Index("idx_vp_v3_assets_remote_delete_status", "remote_delete_status"),
Index("idx_vp_v3_assets_asset_type", "asset_type"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True,
)
project_id: Mapped[str] = mapped_column(
String(32), ForeignKey("vp_v3_projects.id", ondelete="CASCADE"), nullable=False, index=True,
)
# 火山远端映射
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
# 素材元信息
asset_type: Mapped[str] = mapped_column(
String(16), nullable=False, default=PrivatePortraitAssetType.IMAGE.value, index=True,
comment="素材类型:Image=图片 / Video=视频",
)
name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
# 资源 URL
source_url: Mapped[str] = mapped_column(Text, nullable=False, comment="本地上传后的访问 URLUploadResource 返回的)")
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="给前端预览/显示用的 URL(签名 URL 可能过期)")
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山返回的资源访问 URL(可能带签名和过期)")
remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
upload_resource_id: Mapped[str | None] = mapped_column(
String(32), nullable=True, index=True, comment="本地 UploadResource 账本 resource_id(容量释放用)",
)
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_bytes: 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,
default=PrivatePortraitAssetStatus.CREATING.value,
server_default=PrivatePortraitAssetStatus.CREATING.value,
index=True,
comment="素材状态:creating/审核中 active/可用 failed/失败 deleting/删除中",
)
moderation_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山审核结果 JSON")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因")
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山原始响应 JSON")
# 轮询控制(异步审核)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
poll_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
# 远端删除
remote_delete_status: Mapped[str] = mapped_column(
String(32), nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,86 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import (
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class VpV3Project(Base, TimestampMixin, SoftDeleteMixin):
"""API V3 虚拟素材项目(按 API Key 隔离)。
一个 VpV3Project 对应火山远端的 1 个 AssetGroup(一对一:这里不做 nested group)。
"""
__tablename__ = "vp_v3_projects"
__table_args__ = (
Index("idx_vp_v3_projects_key_status_created", "api_key_id", "status", "created_at"),
Index(
"idx_vp_v3_projects_key_deleted",
"api_key_id",
"deleted_at",
postgresql_where=text("deleted_at IS NULL"),
),
Index("idx_vp_v3_projects_remote_project_name", "remote_project_name"),
Index("idx_vp_v3_projects_remote_group_id", "remote_group_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True,
comment="所属 API KeyV3 调用方)",
)
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(构建远端 GroupName 用)")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# 火山远端映射
remote_project_name: Mapped[str] = mapped_column(
String(256), nullable=False, index=True, comment="火山 ProjectName(快照)",
)
remote_group_id: Mapped[str] = mapped_column(
String(128), nullable=False, index=True, comment="火山 AssetGroup Id",
)
remote_group_name: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="火山 AssetGroup Name 快照")
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitProjectStatus.ACTIVE.value,
server_default=PrivatePortraitProjectStatus.ACTIVE.value,
index=True,
comment="项目状态:active/creating_remote_group/create_group_failed/deleting",
)
# 计数
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")
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_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")
storage_mb_used: Mapped[float] = mapped_column(Integer, nullable=False, default=0, server_default="0",
comment="项目占用存储 MB(未删除素材文件大小合计)")
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 远端删除状态(沿用 private_portrait 枚举)
remote_delete_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="创建失败等错误信息")
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山原始响应")
+12
View File
@@ -162,6 +162,18 @@ class AdminCreditRecordSummaryOut(BaseModel):
total_recharge: float = 0.0
total_consume: float = 0.0
total_refund: float = 0.0
# 消费类分解(仅 type=consume,不含 team_internal 团队内部转账;真实扣费 + 预扣占用 = total_consume
# - total_charge : 真实扣费 charge(含历史 NULL),对应"筛选明细类型=消费 且 action=charge/NULL"求和
# - total_hold : 预扣占用 hold
total_charge: float = 0.0
total_hold: float = 0.0
# 回退类分解(仅 type=refund;真实退款 + 预扣释放 = total_refund
# - total_refund_real : 真实退款 refund(含历史 NULL)
# - total_hold_release: 预扣释放 hold_release
total_refund_real: float = 0.0
total_hold_release: float = 0.0
# 净消耗 = max(total_consume - total_refund, 0) = 实际"用掉了"的积分
net_consume: float = 0.0
transaction_count: int = 0
generation_count: int = 0
generation_attempt_count: int = 0
@@ -0,0 +1,31 @@
from app.schemas.admin_api.api_key import (
ApiKeyCreateRequest,
ApiKeyUpdateRequest,
ApiKeyResponse,
ApiKeyCreateResponse,
ApiKeyListItem,
ApiKeyListOut,
)
from app.schemas.admin_api.api_upscale import (
ApiUpscaleConfigData,
ApiUpscaleConfigSaveRequest,
ApiUpscaleConfigResponse,
)
from app.schemas.admin_api.api_usage import (
ApiUsageLogResponse,
ApiUsageSummaryResponse,
)
__all__ = [
"ApiKeyCreateRequest",
"ApiKeyUpdateRequest",
"ApiKeyResponse",
"ApiKeyCreateResponse",
"ApiKeyListItem",
"ApiKeyListOut",
"ApiUpscaleConfigData",
"ApiUpscaleConfigSaveRequest",
"ApiUpscaleConfigResponse",
"ApiUsageLogResponse",
"ApiUsageSummaryResponse",
]
@@ -0,0 +1,156 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
# 保留供其他地方使用
def _empty_to_null(value):
"""将空字符串转为 None,避免 Pydantic 校验失败。"""
if value == "" or value == "null":
return None
return value
class ApiKeyCallableModel(BaseModel):
"""API Key 可调用模型配置。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
engine_type: str = Field(..., description="video | image", alias="engineType")
engine_id: str = Field(..., description="引擎ID", alias="engineId")
model_name: str = Field(..., description="模型名称", alias="modelName")
class ApiKeyCreateRequest(BaseModel):
"""创建 API Key 请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
company_name: str = Field(..., max_length=128, description="公司名称", alias="companyName")
description: str | None = Field(None, description="备注")
callable_models: list[ApiKeyCallableModel] = Field(
default_factory=list, description="可调用模型列表", alias="callableModels",
)
quota_limit: float | None = Field(None, description="配额总量,NULL=无限", alias="quotaLimit")
quota_cycle: str | None = Field(None, description="daily | monthly | one_time | NULL=无限", alias="quotaCycle")
valid_from: datetime | None = Field(None, description="生效时间", alias="validFrom")
valid_until: datetime | None = Field(None, description="过期时间", alias="validUntil")
max_concurrent_video_tasks: int | None = Field(
None, description="最大并发视频任务数", alias="maxConcurrentVideoTasks",
)
@field_validator("valid_from", "valid_until", mode="before")
@classmethod
def empty_str_to_none(cls, v):
if v == "" or v == "null" or v == 0:
return None
return v
class ApiKeyUpdateRequest(BaseModel):
"""更新 API Key 请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
company_name: str | None = Field(None, max_length=128, alias="companyName")
description: str | None = None
callable_models: list[ApiKeyCallableModel] | None = Field(None, alias="callableModels")
quota_limit: float | None = Field(None, alias="quotaLimit")
quota_cycle: str | None = Field(None, alias="quotaCycle")
valid_from: datetime | None = Field(None, alias="validFrom")
valid_until: datetime | None = Field(None, alias="validUntil")
max_concurrent_video_tasks: int | None = Field(None, alias="maxConcurrentVideoTasks")
is_active: bool | None = Field(None, alias="isActive")
@field_validator("valid_from", "valid_until", mode="before")
@classmethod
def empty_str_to_none(cls, v):
if v == "" or v == "null" or v == 0:
return None
return v
class ApiKeyResponse(BaseModel):
"""API Key 详情响应。"""
id: str
company_name: str
api_key_prefix: str = Field(..., description="Key 前缀,如 vk_xxxx****")
description: str | None
callable_models: list[ApiKeyCallableModel]
quota_limit: float | None
quota_cycle: str | None
quota_used: float
valid_from: datetime | None
valid_until: datetime | None
max_concurrent_video_tasks: int | None
is_active: bool
last_used_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ApiKeyCreateResponse(BaseModel):
"""创建 API Key 响应(包含完整明文 Key,仅此一次)。"""
id: str
company_name: str
api_key: str = Field(..., description="完整 API Key,仅创建时返回一次")
api_key_prefix: str
valid_until: datetime | None
created_at: datetime
class ApiKeyRevealResponse(BaseModel):
"""揭秘 API Key 响应(随时可获取明文)。"""
id: str
company_name: str
api_key: str = Field(..., description="完整 API Key")
api_key_prefix: str
class ApiKeyListItem(BaseModel):
"""API Key 列表项。"""
id: str
company_name: str
api_key_prefix: str
description: str | None
callable_models: list[ApiKeyCallableModel] = []
quota_limit: float | None
quota_cycle: str | None
quota_used: float
is_active: bool
valid_from: datetime | None
valid_until: datetime | None
max_concurrent_video_tasks: int | None
last_used_at: datetime | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ApiKeyQuotaAdjustRequest(BaseModel):
"""配额调整请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
action: str = Field(
...,
pattern=r"^(adjust|reset_usage|set_limit|change_cycle)$",
description="adjust=增加总额 | reset_usage=重置已用 | set_limit=设置限额 | change_cycle=修改周期",
)
quota_limit_delta: float | None = Field(None, ge=0, description="增加总额时的增量", alias="quotaLimitDelta")
quota_limit: float | None = Field(None, description="设置新限额时的值(NULL=无限)", alias="quotaLimit")
quota_cycle: str | None = Field(None, description="修改周期时的值", alias="quotaCycle")
reason: str | None = Field(None, max_length=500, description="调整原因/备注")
class ApiKeyListOut(BaseModel):
"""API Key 列表响应。"""
total: int
items: list[ApiKeyListItem]
@@ -0,0 +1,33 @@
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import NaiveDatetime
class ApiModelPricingCreate(BaseModel):
"""创建 API 模型价格请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
model_config_id: str = Field(
..., max_length=32, description="引擎ID", alias="modelConfigId",
)
gen_type: str = Field(default="video", max_length=16, description="image | video", alias="genType")
resolution: str = Field(..., max_length=16, description="分辨率")
price_ratio: float = Field(default=1.0, gt=0, description="价格系数", alias="priceRatio")
base_price: float = Field(default=0.0, ge=0, description="基础价格(元)", alias="basePrice")
per_second_price: float = Field(default=0.0, ge=0, description="每秒价格(元)", alias="perSecondPrice")
input_video_ratio: float = Field(default=1.0, ge=0, description="传入视频系数", alias="inputVideoRatio")
input_video_base_price: float = Field(default=0.0, ge=0, description="传入视频基础价(元)", alias="inputVideoBasePrice")
input_video_per_second_price: float = Field(default=0.0, ge=0, description="传入视频每秒价(元)", alias="inputVideoPerSecondPrice")
input_image_ratio: float = Field(default=1.0, ge=0, description="传入图片系数", alias="inputImageRatio")
input_image_base_price: float = Field(default=0.0, ge=0, description="传入图片基础价(元)", alias="inputImageBasePrice")
input_image_per_image_price: float = Field(default=0.0, ge=0, description="传入图片每张价(元)", alias="inputImagePerImagePrice")
class ApiModelPricingOut(ApiModelPricingCreate):
"""API 模型价格响应。"""
id: str
created_at: NaiveDatetime
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
@@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict, Field
class ApiUpscaleRule(BaseModel):
"""API 超分规则。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
target_resolution: str = Field(..., description="目标分辨率: 480p | 720p | 1080p | 2K | 4K", alias="targetResolution")
provider_generation_resolution: str = Field(..., description="供应商生成分辨率", alias="providerGenerationResolution")
processor_key: str = Field(
...,
description="处理器: local_ffmpeg_crop_v1 | volc_standard_v1 | volc_professional_v1 | volc_large_model_v1",
alias="processorKey",
)
enabled: bool = True
class ApiUpscaleConfigData(BaseModel):
"""API 超分配置数据。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
enabled: bool = False
delete_source_after_success: bool = Field(True, alias="deleteSourceAfterSuccess")
rules: list[ApiUpscaleRule] = Field(default_factory=list)
class ApiUpscaleConfigSaveRequest(BaseModel):
"""保存 API 超分配置请求。"""
data: ApiUpscaleConfigData
class ApiUpscaleConfigResponse(BaseModel):
"""API 超分配置响应。"""
data: ApiUpscaleConfigData
@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel
class ApiUsageLogResponse(BaseModel):
"""API 使用日志响应。"""
id: str
api_key_id: str
api_generation_task_id: str | None
request_type: str
model_name: str
gen_type: str
credits_cost: float
tokens_used: int
request_duration_ms: int
status: str
error_message: str | None
error_code: str | None
created_at: datetime
class Config:
from_attributes = True
class ApiUsageSummaryResponse(BaseModel):
"""API 使用汇总响应。"""
total_requests: int
total_credits_cost: float
total_tokens_used: int
success_count: int
failed_count: int
avg_duration_ms: int
total: int = 0
page: int = 1
page_size: int = 20
items: list[ApiUsageLogResponse]
@@ -0,0 +1,33 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3QuotaConfigData(BaseModel):
"""后台保存虚拟素材库配额。"""
project_limit: int = Field(0, ge=0, description="虚拟项目上限,0=不可创建")
asset_limit: int = Field(0, ge=0, description="虚拟素材总数上限,0=不可上传")
storage_mb_limit: int = Field(0, ge=0, description="存储上限 MB0=不可上传文件")
remark: str | None = Field(None, max_length=500, description="后台备注")
class VpV3QuotaConfigResponse(BaseModel):
"""虚拟素材库配额响应。"""
model_config = ConfigDict(extra="ignore")
api_key_id: str = Field(description="API Key ID")
# 上限
project_limit: int = Field(0, description="虚拟项目上限")
asset_limit: int = Field(0, description="虚拟素材上限")
storage_mb_limit: int = Field(0, description="存储上限 MB")
remark: str | None = Field(None, description="备注")
# 已使用
project_used: int = Field(0, description="已创建项目数")
asset_used: int = Field(0, description="已上传素材数")
storage_mb_used: float = Field(0.0, description="已使用存储 MB")
enabled: bool = Field(False, description="是否启用(任一上限 > 0")
@@ -0,0 +1,31 @@
from app.schemas.api_v3.video import (
ApiVideoContentPart,
ApiVideoCreateRequest,
ApiVideoCreateResponse,
ApiVideoStatusResponse,
)
from app.schemas.api_v3.image import (
ApiImageGenerateRequest,
ApiImageGenerateResponse,
)
from app.schemas.api_v3.model import (
ApiModelInfo,
ApiModelsResponse,
)
from app.schemas.api_v3.common import (
ApiError,
ApiErrorResponse,
)
__all__ = [
"ApiVideoContentPart",
"ApiVideoCreateRequest",
"ApiVideoCreateResponse",
"ApiVideoStatusResponse",
"ApiImageGenerateRequest",
"ApiImageGenerateResponse",
"ApiModelInfo",
"ApiModelsResponse",
"ApiError",
"ApiErrorResponse",
]
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class ApiError(BaseModel):
"""API 错误详情。"""
code: str
message: str
class ApiErrorResponse(BaseModel):
"""API 错误响应。"""
error: ApiError
+34
View File
@@ -0,0 +1,34 @@
from pydantic import BaseModel, Field
class ApiImageGenerateRequest(BaseModel):
"""图片生成请求。支持全量 Volcano Ark SDK 参数。"""
model: str = Field(..., description="模型名称, 如 doubao-seedream-5-0-260128")
prompt: str = Field(..., description="图片描述提示词")
size: str | None = Field("2K", description="图片尺寸: 2K | 4K 或 2048x2048")
response_format: str | None = Field("url", description="返回格式: url | b64_json")
watermark: bool | None = Field(False, description="是否添加水印")
image: list[str] | None = Field(None, description="参考图片URL列表")
output_format: str | None = Field(None, description="输出格式: jpeg | png | webp")
sequential_image_generation: str | None = Field(
None, description="组图模式: auto 开启"
)
generation_count: int | None = Field(1, ge=1, le=5, description="生成数量: 1-5")
class ApiImageGenerateDataItem(BaseModel):
"""单张图片结果。"""
url: str | None = None
b64_json: str | None = None
size: str | None = None
output_format: str | None = None
class ApiImageGenerateResponse(BaseModel):
"""图片生成响应(同步返回)。"""
created: int
data: list[ApiImageGenerateDataItem]
model: str
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel, Field
class ApiModelInfo(BaseModel):
"""可用模型信息。"""
model: str = Field(..., description="模型名称")
engine_type: str = Field(..., description="引擎类型: video | image")
engine_id: str = Field(..., description="引擎ID")
supported_ratios: list[str] | None = None
supported_resolutions: list[str] | None = None
supported_durations: list[int] | None = None
supported_sizes: list[str] | None = None
class ApiModelsResponse(BaseModel):
"""可用模型列表响应。"""
models: list[ApiModelInfo]
+135
View File
@@ -0,0 +1,135 @@
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class ApiVideoContentPart(BaseModel):
"""视频生成内容部分:文本/图片/视频/音频参考。"""
type: str = Field(..., description="内容类型: text | image_url | video_url | audio_url")
text: str | None = None
image_url: dict | None = Field(None, description="图片URL对象: {\"url\": \"...\"}")
video_url: dict | None = Field(None, description="视频URL对象: {\"url\": \"...\"}")
audio_url: dict | None = Field(None, description="音频URL对象: {\"url\": \"...\"}")
role: str | None = Field(
None,
description="参考角色: first_frame | last_frame | reference_image | reference_video | reference_audio",
)
@field_validator("type")
@classmethod
def validate_type(cls, v):
allowed = {"text", "image_url", "video_url", "audio_url"}
if v not in allowed:
raise ValueError(f"type 必须是 {allowed} 之一,当前值: {v}")
return v
@field_validator("role")
@classmethod
def validate_role(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
role_map = {
"image_url": {"first_frame", "last_frame", "reference_image"},
"video_url": {"reference_video"},
"audio_url": {"reference_audio"},
}
allowed_roles = role_map.get(type_value, set())
if v not in allowed_roles:
raise ValueError(
f"type={type_value} 时 role 必须是 {allowed_roles} 之一,当前值: {v}"
)
return v
@field_validator("image_url")
@classmethod
def validate_image_url(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
if type_value == "image_url" and (not v or not v.get("url")):
raise ValueError("type=image_url 时 image_url.url 不能为空")
return v
@field_validator("video_url")
@classmethod
def validate_video_url(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
if type_value == "video_url" and (not v or not v.get("url")):
raise ValueError("type=video_url 时 video_url.url 不能为空")
return v
@field_validator("audio_url")
@classmethod
def validate_audio_url(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
if type_value == "audio_url" and (not v or not v.get("url")):
raise ValueError("type=audio_url 时 audio_url.url 不能为空")
return v
class ApiVideoCreateRequest(BaseModel):
"""视频生成请求。支持全量 Volcano Ark SDK 参数。"""
model: str = Field(..., description="模型名称, 如 doubao-seedance-2-0-260128")
content: list[ApiVideoContentPart] = Field(
..., min_length=1, description="生成内容: 文本提示词 + 可选的图片/视频/音频参考"
)
ratio: str | None = Field("16:9", description="视频比例: 16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9")
duration: int | None = Field(5, ge=3, le=30, description="视频时长(秒): 3-30")
resolution: str | None = Field("480p", description="分辨率: 480p | 720p | 1080p")
generate_audio: bool | None = Field(True, description="是否生成音频")
watermark: bool | None = Field(False, description="是否添加水印")
idempotency_key: str | None = Field(None, description="幂等键,防止重复创建")
@field_validator("ratio")
@classmethod
def validate_ratio(cls, v):
if v is None:
return v
allowed = {"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}
if v not in allowed:
raise ValueError(f"ratio 必须是 {allowed} 之一,当前值: {v}")
return v
@field_validator("resolution")
@classmethod
def validate_resolution(cls, v):
if v is None:
return v
allowed = {"480p", "720p", "1080p"}
if v not in allowed:
raise ValueError(f"resolution 必须是 {allowed} 之一,当前值: {v}")
return v
class ApiVideoCreateResponse(BaseModel):
"""视频任务创建响应。"""
id: str = Field(..., description="任务ID")
class ApiVideoContent(BaseModel):
"""视频内容(成功时返回)。"""
video_url: str = Field(..., description="视频URL")
class ApiVideoStatusResponse(BaseModel):
"""视频任务状态查询响应。"""
id: str = Field(..., description="任务ID")
model: str = Field(..., description="模型名称")
status: str = Field(..., description="任务状态: queued | running | succeeded | failed | expired")
created_at: int = Field(..., description="创建时间戳(Unix)")
updated_at: int = Field(..., description="更新时间戳(Unix)")
content: ApiVideoContent | None = Field(None, description="视频内容(成功时返回)")
duration: int | None = Field(None, description="视频时长(秒)")
ratio: str | None = Field(None, description="视频比例")
resolution: str | None = Field(None, description="分辨率")
error: str | None = Field(None, description="错误信息(失败时返回)")
+135
View File
@@ -0,0 +1,135 @@
import re
from typing import Any
from pydantic import BaseModel, Field, model_validator
from app.schemas.common import NaiveDatetimeOptional
_EMAIL_REGEX = re.compile(r"^[\w.\-]+@[\w.\-]+\.\w+$")
class InvoiceCreateRequest(BaseModel):
"""创建发票请求。"""
header_type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
header_name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
header_tax_no: str | None = Field(None, max_length=32, description="税号")
header_register_address: str | None = Field(None, max_length=256, description="注册地址")
header_register_phone: str | None = Field(None, max_length=32, description="注册电话")
header_bank_name: str | None = Field(None, max_length=128, description="开户行")
header_bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str = Field(..., max_length=128, description="电子邮箱(必填)")
order_ids: list[str] = Field(..., min_length=1, description="订单ID列表")
@model_validator(mode="after")
def validate_email(self) -> "InvoiceCreateRequest":
if not _EMAIL_REGEX.match(self.email):
raise ValueError("邮箱格式不正确")
return self
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceCreateRequest":
if self.header_type == "company" and not self.header_tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceStatusUpdateRequest(BaseModel):
"""更新发票状态请求。"""
status: str = Field(..., pattern="^(success|failed)$", description="目标状态")
failure_reason: str | None = Field(None, max_length=500, description="失败原因")
@model_validator(mode="after")
def validate_failure_reason(self) -> "InvoiceStatusUpdateRequest":
if self.status == "failed" and not self.failure_reason:
raise ValueError("开具失败时必须填写失败原因")
return self
class InvoiceOrderOut(BaseModel):
"""发票关联订单响应。"""
model_config = {"from_attributes": True}
id: str
invoice_id: str
order_id: str
order_no: str
amount: float
credits: float
class InvoiceOut(BaseModel):
"""发票响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
invoice_no: str
header_type: str
header_name: str
header_tax_no: str | None = None
header_register_address: str | None = None
header_register_phone: str | None = None
header_bank_name: str | None = None
header_bank_account: str | None = None
email: str
total_amount: float
total_credits: float
status: str
failure_reason: str | None = None
issued_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
orders: list[InvoiceOrderOut] = []
# ── 发票抬头 ──────────────────────────────────────────────
class InvoiceHeaderCreate(BaseModel):
"""创建发票抬头请求。"""
type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool = Field(False, description="是否设为默认")
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceHeaderCreate":
if self.type == "company" and not self.tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceHeaderUpdate(BaseModel):
"""更新发票抬头请求。"""
name: str | None = Field(None, min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool | None = Field(None, description="是否设为默认")
class InvoiceHeaderOut(BaseModel):
"""发票抬头响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
type: str
name: str
tax_no: str | None = None
register_address: str | None = None
register_phone: str | None = None
bank_name: str | None = None
bank_account: str | None = None
email: str | None = None
is_default: bool = False
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
+2 -2
View File
@@ -11,11 +11,11 @@ class VideoEngineCreate(BaseModel):
model_name: str = Field(default="", max_length=128)
supported_ratios: str = Field(default='["16:9","4:3","1:1","3:4","9:16","21:9"]')
supported_resolutions: str = Field(default='["480p","720p","1080p"]')
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15]')
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]')
max_duration: int = Field(default=15)
max_image_count: int = Field(default=2)
max_video_count: int = Field(default=0)
max_audio_count: int = Field(default=0, ge=0, le=3, description="最大参考音频数量,0 表示不支持音频参考")
max_audio_count: int = Field(default=0, description="最大参考音频数量,0 表示不支持音频参考")
multi_generation_enabled: bool = Field(
default=False,
description="是否允许客户端选择生成多个视频;关闭时客户端只能选择 1 份",
@@ -0,0 +1,37 @@
from app.schemas.virtual_portrait_v3.common import VpV3EnumMeta
from app.schemas.virtual_portrait_v3.quota import VpV3QuotaConfigOut
from app.schemas.virtual_portrait_v3.project import (
VpV3IdOut,
VpV3ProjectCreate,
VpV3ProjectDeleteOut,
VpV3ProjectListOut,
VpV3ProjectOut,
VpV3ProjectUpdate,
)
from app.schemas.virtual_portrait_v3.asset import (
VpV3AssetCreate,
VpV3AssetDeleteOut,
VpV3AssetListOut,
VpV3AssetOut,
VpV3SelectableAssetListOut,
VpV3SelectableAssetOut,
)
from app.schemas.virtual_portrait_v3.upload import VpV3UploadOut
__all__ = [
"VpV3EnumMeta",
"VpV3QuotaConfigOut",
"VpV3IdOut",
"VpV3ProjectCreate",
"VpV3ProjectUpdate",
"VpV3ProjectOut",
"VpV3ProjectListOut",
"VpV3ProjectDeleteOut",
"VpV3AssetCreate",
"VpV3AssetOut",
"VpV3AssetListOut",
"VpV3AssetDeleteOut",
"VpV3SelectableAssetOut",
"VpV3SelectableAssetListOut",
"VpV3UploadOut",
]
@@ -0,0 +1,106 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class VpV3AssetCreate(BaseModel):
"""在项目下创建虚拟素材请求(一步到位:接收远程 URL 先下载到本地,再同步火山)。"""
source_url: str = Field(
...,
min_length=8,
max_length=2000,
description=(
"素材源 URL(必须 http/https 公网可访问的图片/视频直链,"
"系统先将其下载保存到本地存储并占用存储配额,再同步到火山)"
),
)
name: str | None = Field(
None, max_length=100, description="素材名称(可选;不传则自动从 URL 文件名 / Content-Disposition 推断)",
)
asset_type: str = Field(
..., pattern=r"^(Image|Video)$", description="素材类型:Image=图片 / Video=视频",
)
video_duration: float | None = Field(
None, description="视频时长,秒(Video 可选;不传时系统自动用 ffprobe 探测;最大 60 秒)",
)
video_cover_url: str | None = Field(
None, description="视频封面图 URL(可选,仅 Video 用,建议 16:9)",
)
class VpV3AssetOut(BaseModel):
"""虚拟素材详情响应。"""
model_config = ConfigDict(extra="ignore")
asset_id: str = Field(description="素材 ID")
project_id: str = Field(description="所属项目 ID")
name: str | None = Field(None, description="素材名称")
asset_type: str = Field(description="素材类型:Image/Video")
status: str = Field(description="素材状态")
# 显示 URL
source_url: str = Field(description="原始上传 URL")
preview_url: str | None = Field(None, description="显示用预览 URL(可能带签名过期)")
remote_url: str | None = Field(None, description="火山返回的资源访问 URL(可能带签名过期)")
remote_url_expired_at: datetime | None = Field(None, description="remote_url 过期时间")
video_duration: float | None = Field(None, description="视频时长秒")
video_cover_url: str | None = Field(None, description="视频封面")
file_size_bytes: int | None = Field(None, description="文件大小字节")
mime_type: str | None = Field(None, description="MIME 类型")
moderation_json: dict | None = Field(None, description="火山审核 JSON(失败时可查看原因)")
error_message: str | None = Field(None, description="失败原因")
remote_delete_status: str = Field("none", description="远端删除状态")
created_at: datetime = Field(description="创建时间")
updated_at: datetime = Field(description="最后更新时间")
class VpV3AssetListOut(BaseModel):
"""虚拟素材列表响应。"""
items: list[VpV3AssetOut] = Field(default_factory=list)
total: int = Field(0, description="总数")
page: int = Field(1, description="当前页码")
page_size: int = Field(20, description="每页数量")
class VpV3AssetDeleteOut(BaseModel):
"""删除响应。"""
success: bool = Field(True)
remote_delete_status: str = Field(description="远端删除状态")
class VpV3SelectableAssetOut(BaseModel):
"""AI 创作选择器使用的素材条目。"""
model_config = ConfigDict(extra="ignore")
asset_id: str = Field(description="素材 ID(带入生成用 source=vp_v3_asset + asset_id")
project_id: str = Field(description="项目 ID")
name: str | None = Field(None)
asset_type: str = Field(description="Image/Video")
status: str = Field(description="状态=Active")
source_url: str = Field(description="原始上传 URL")
preview_url: str | None = Field(None, description="预览 URL(直接显示用)")
video_duration: float | None = Field(None)
video_cover_url: str | None = Field(None)
file_size_bytes: int | None = Field(None)
created_at: datetime = Field(description="创建时间")
class VpV3SelectableAssetListOut(BaseModel):
"""AI 创作选择器素材列表。"""
items: list[VpV3SelectableAssetOut] = Field(default_factory=list)
total: int = Field(0)
page: int = Field(1)
page_size: int = Field(20)
@@ -0,0 +1,14 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3EnumMeta(BaseModel):
"""虚拟素材库枚举元数据。"""
model_config = ConfigDict(extra="ignore")
asset_type: dict[str, str] = Field(description="素材类型:Image=图片 / Video=视频")
asset_status: dict[str, str] = Field(description="素材状态:creating/审核中 active/可用 failed/失败")
project_status: dict[str, str] = Field(description="项目状态:active/creating_remote_group/create_group_failed/deleting")
remote_delete_status: dict[str, str] = Field(description="远端删除状态:none/pending/processing/deleted/failed")
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class VpV3ProjectCreate(BaseModel):
"""创建虚拟素材项目请求。"""
name: str = Field(..., min_length=1, max_length=100, description="项目名称,1-100 字符")
description: str | None = Field(None, max_length=500, description="项目描述,最多 500 字符")
class VpV3ProjectUpdate(BaseModel):
"""更新虚拟素材项目请求。"""
name: str | None = Field(None, min_length=1, max_length=100, description="项目名称,1-100 字符")
description: str | None = Field(None, max_length=500, description="项目描述,最多 500 字符")
class VpV3ProjectOut(BaseModel):
"""虚拟素材项目详情响应。"""
model_config = ConfigDict(extra="ignore")
project_id: str = Field(description="项目 ID")
name: str = Field(description="项目名称")
description: str | None = Field(None, description="项目描述")
status: str = Field(description="项目状态")
# 计数
asset_count: int = Field(0, description="素材总数(含失败、删除中)")
active_asset_count: int = Field(0, description="可用素材数(status=active")
image_asset_count: int = Field(0, description="图片素材数")
video_asset_count: int = Field(0, description="视频素材数")
storage_mb_used: float = Field(0, description="已占用存储 MB")
remote_delete_status: str = Field("none", description="远端删除状态")
error_message: str | None = Field(None, description="最近一次错误信息")
created_at: datetime = Field(description="创建时间")
updated_at: datetime = Field(description="最后更新时间")
class VpV3ProjectListOut(BaseModel):
"""虚拟素材项目列表响应。"""
items: list[VpV3ProjectOut] = Field(default_factory=list)
total: int = Field(0, description="总数")
page: int = Field(1, ge=1, description="当前页码")
page_size: int = Field(20, ge=1, description="每页数量")
class VpV3ProjectDeleteOut(BaseModel):
"""删除响应。"""
success: bool = Field(True)
remote_delete_status: str = Field(description="远端删除状态:none/pending/...")
class VpV3IdOut(BaseModel):
"""创建接口的简单 ID 响应。"""
Id: str = Field(description="资源 ID")
@@ -0,0 +1,21 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3QuotaConfigOut(BaseModel):
"""当前 API Key 的虚拟素材配额&已使用量。"""
model_config = ConfigDict(extra="ignore")
# 上限
project_limit: int = Field(0, description="虚拟项目上限,0=不可创建")
asset_limit: int = Field(0, description="虚拟素材总数上限,0=不可上传")
storage_mb_limit: int = Field(0, description="上传存储上限 MB0=不可上传文件")
# 已使用
project_used: int = Field(0, description="已创建项目数(未删除)")
asset_used: int = Field(0, description="已上传素材数(未删除,图片+视频)")
storage_mb_used: float = Field(0, description="已占用存储 MB(未删除文件大小合计)")
enabled: bool = Field(False, description="该 API Key 是否可使用虚拟素材库功能(任一上限 > 0 即可)")
@@ -0,0 +1,16 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3UploadOut(BaseModel):
"""上传文件响应(写入 UploadResource 账本后返回)。"""
model_config = ConfigDict(extra="ignore")
url: str = Field(description="上传后的访问 URL,创建素材时作为 source_url 传入")
filename: str = Field(description="文件名")
type: str = Field(description="资源类型:Image/Video")
resource_id: str = Field(description="UploadResource 的 resource_id,创建素材时请回传 upload_resource_id")
file_size_bytes: int = Field(0, description="文件大小字节")
duration_seconds: float | None = Field(None, description="视频时长秒(Video 上传返回)")
@@ -60,7 +60,9 @@ def _as_date_start(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d")
# 构造东八区 00:00:00 与 DB timezone-aware created_at 比较,避免 8 小时偏移
naive = datetime.strptime(value, "%Y-%m-%d")
return naive.replace(tzinfo=CST)
except Exception:
return None
@@ -69,7 +71,11 @@ def _as_date_end(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999)
# 构造东八区 23:59:59.999999
naive = datetime.strptime(value, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, microsecond=999999,
)
return naive.replace(tzinfo=CST)
except Exception:
return None
@@ -264,7 +270,9 @@ async def list_admin_credit_records(
end_date: str | None = None,
) -> dict[str, Any]:
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 1000)
# 列表页默认最多 1000 条;导出接口可传较大值(最多 100000 条),避免月度导出被截断
max_page_size = 100000 if page_size is not None and int(page_size) > 1000 else 1000
page_size = min(max(int(page_size or 20), 1), max_page_size)
filters = _build_filters(
user_id=user_id,
user_name=user_name,
@@ -326,30 +334,103 @@ async def list_admin_credit_records(
})
items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows]
# 说明:
# - consume 类型:amount 是负数(扣减积分),统计用 abs() 保证为正值
# team_internal(团队内部积分流转/管理员分配)不参与消费/扣费统计——它不是真实消费
# - refund 类型:amount 是正数(退回积分),为兼容旧数据/边缘场景也用 abs() 保证统计值恒正
# 子分类:真实退款 refund(action='refund'/NULL) + 预扣释放 hold_release(action='hold_release')
# - recharge 类型:amount 是正数(充值增加),金额直接求和,不需要 abs
#
# 口径更新(Bug 修复 · 第二次修正):
# 1. 消费类统计仅看 type=consume(排除 team_internal 团队内部转账)
# 2. 真实扣费 / 预扣占用 / 真实退款 / 预扣释放 全部改为"独立统计列",不再用差值推导
# (避免任何一类范围不同导致推导失真)
#
# 消费类(type=consume):
# - total_charge :真实扣费 charge_action in (NULL, 'charge') abs 求和
# - total_hold :预扣占用 charge_action = 'hold' abs 求和
# - total_consume total_charge + total_hold = charge_action in (NULL, charge, hold) abs 求和
# 回退类(type=refund):
# - total_refund_real :真实退款 charge_action in (NULL, 'refund') abs 求和
# - total_hold_release :预扣释放 charge_action = 'hold_release' abs 求和
# - total_refund total_refund_real + total_hold_release = type=refund 全部 abs 求和
# 净消耗 net_consume = max(total_consume - total_refund, 0)
#
# 按积分 subject 分类的子项(图片/视频/提词/分析)仍保持「仅真实扣费 charge」口径不变:
# 预扣是按任务预估的冻结,不是按图/视频实际产出,会让子分类统计失真。
_real_charge_action = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
)
_charge_or_hold_action = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
CreditRecord.charge_action == "hold",
)
_real_refund_action = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "refund",
)
# 仅统计 type=consume 的消费类(排除 team_internal 团队内部转账)
_consume_type = CreditRecord.type == "consume"
# 预扣释放 / 真实退款 filter(都是 type=refund,账本 L256 强校验 hold_release.type=refund
_hold_release_filter = and_(
CreditRecord.type == "refund",
CreditRecord.charge_action == "hold_release",
)
_refund_type = CreditRecord.type == "refund"
summary_query = select(
# 0: 充值
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.type.in_(["consume", "team_internal"]), (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
# 1: 总消费 = total_charge + total_hold(真实扣费 + 预扣占用)
func.coalesce(func.sum(case((and_(_consume_type, _charge_or_hold_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 2: 总回退 = 真实退款 + 预扣释放(type=refund 全部流水)
func.coalesce(func.sum(case((_refund_type, func.abs(CreditRecord.amount)), else_=0)), 0),
# 3: 交易笔数
func.count(CreditRecord.id),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), 1), else_=None)),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0),
# 4-11: 生成条数 / 尝试次数 / 图片视频条数 / 图片视频提词分析消费(仍按 charge 口径)
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), 1), else_=None)),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 12-14: Token
func.coalesce(func.sum(CreditRecord.total_tokens), 0),
func.coalesce(func.sum(CreditRecord.input_tokens), 0),
func.coalesce(func.sum(CreditRecord.output_tokens), 0),
# 15: 真实扣费 total_charge(独立列:type=consume AND charge_action in (NULL, 'charge')
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 16: 预扣占用 total_hold(独立列:type=consume AND charge_action='hold'
func.coalesce(func.sum(case((and_(_consume_type, CreditRecord.charge_action == "hold"), func.abs(CreditRecord.amount)), else_=0)), 0),
# 17: 真实退款 total_refund_real(独立列:type=refund AND charge_action in (NULL, 'refund')
func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 18: 预扣释放 total_hold_release(独立列:type=refund AND charge_action='hold_release'
func.coalesce(func.sum(case((_hold_release_filter, func.abs(CreditRecord.amount)), else_=0)), 0),
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
if where_clause is not None:
summary_query = summary_query.where(where_clause)
s = (await db.execute(summary_query)).one()
_total_recharge = _round2(s[0])
_total_consume = _round2(s[1])
_total_refund = _round2(s[2])
_total_charge = _round2(s[15])
_total_hold = _round2(s[16])
_total_refund_real = _round2(s[17])
_total_hold_release = _round2(s[18])
# 净消耗 = 总消费 − 总回退;若回退跨周期导致负数,按 0 兜底
_net_consume = _round2(max(_total_consume - _total_refund, 0.0))
summary = {
"total_recharge": _round2(s[0]),
"total_consume": _round2(s[1]),
"total_refund": _round2(s[2]),
"total_recharge": _total_recharge,
"total_consume": _total_consume,
"total_refund": _total_refund,
"total_charge": _total_charge,
"total_hold": _total_hold,
"total_refund_real": _total_refund_real,
"total_hold_release": _total_hold_release,
"net_consume": _net_consume,
"transaction_count": int(s[3] or 0),
"generation_count": int(s[4] or 0),
"generation_attempt_count": int(s[5] or 0),
@@ -0,0 +1,59 @@
from app.services.api_v3.auth_service import ApiKeyContext, get_api_key_dependency
from app.services.api_v3.key_service import (
create_api_key,
list_api_keys,
get_api_key,
update_api_key,
delete_api_key,
reset_quota_if_needed,
)
from app.services.api_v3.quota_service import check_quota, can_start_video_task, get_active_video_tasks_count, get_queued_video_tasks
from app.services.api_v3.usage_log_service import record_usage, get_usage_summary, list_usage_logs
from app.services.api_v3.generation_service import submit_video_generation, generate_image_sync
from app.services.api_v3.task_service import create_video_task, create_image_task, get_task, map_task_to_status_response
from app.services.api_v3.upscale_service import (
get_or_create_upscale_config,
save_upscale_config,
build_api_upscale_snapshot,
prepare_api_upscale_task,
)
from app.services.api_v3.engine_service import resolve_video_engine, resolve_image_engine, build_engine_snapshot
from app.services.api_v3.pricing_service import (
calc_api_video_price,
calc_api_image_price,
get_priced_models,
)
__all__ = [
"ApiKeyContext",
"get_api_key_dependency",
"create_api_key",
"list_api_keys",
"get_api_key",
"update_api_key",
"delete_api_key",
"reset_quota_if_needed",
"check_quota",
"can_start_video_task",
"get_active_video_tasks_count",
"get_queued_video_tasks",
"record_usage",
"get_usage_summary",
"list_usage_logs",
"submit_video_generation",
"generate_image_sync",
"create_video_task",
"create_image_task",
"get_task",
"map_task_to_status_response",
"get_or_create_upscale_config",
"save_upscale_config",
"build_api_upscale_snapshot",
"prepare_api_upscale_task",
"resolve_video_engine",
"resolve_image_engine",
"build_engine_snapshot",
"calc_api_video_price",
"calc_api_image_price",
"get_priced_models",
]
@@ -0,0 +1,99 @@
import hashlib
import json
import logging
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.models.api.api_key import ApiKey
logger = logging.getLogger("videogen")
security = HTTPBearer(auto_error=False)
class ApiKeyContext:
"""API Key 验证上下文,携带解析后的可调用模型列表。"""
def __init__(self, api_key: ApiKey, callable_models: list[dict]):
self.api_key = api_key
self.api_key_id = api_key.id
self.callable_models = callable_models
async def get_api_key_dependency(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_db),
) -> ApiKeyContext:
"""FastAPI Dependency: 验证 API Key 并返回上下文。
验证流程:
1. 提取 Bearer <REDACTED>
2. SHA-256 哈希后查询数据库
3. 检查 is_active、deleted_at
4. 检查有效期 (valid_from, valid_until)
5. 检查配额 (quota_limit, quota_used)
6. 重置过期周期的配额
"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="缺少 Authorization 头,请提供 Bearer <REDACTED>",
)
token_hash = hashlib.sha256(credentials.credentials.encode()).hexdigest()
result = await db.execute(
select(ApiKey).where(
ApiKey.api_key_hash == token_hash,
ApiKey.is_active == True,
ApiKey.deleted_at.is_(None),
).limit(1)
)
key = result.scalar_one_or_none()
if not key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 API Key",
)
now = datetime.now(timezone.utc)
# 检查有效期
if key.valid_from and now < key.valid_from:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API Key 尚未生效",
)
if key.valid_until and now >= key.valid_until:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API Key 已过期",
)
# 配额周期重置
from app.services.api_v3.key_service import reset_quota_if_needed
key = await reset_quota_if_needed(db, key)
# 检查配额
if key.quota_limit is not None and key.quota_used >= key.quota_limit:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"API Key 配额已用尽 (已用 {key.quota_used:.2f} / 限额 {key.quota_limit:.2f})",
)
# 解析可调用模型
try:
callable_models = json.loads(key.callable_models) if key.callable_models else []
except (json.JSONDecodeError, TypeError):
callable_models = []
# 更新最后使用时间
key.last_used_at = now
return ApiKeyContext(api_key=key, callable_models=callable_models)
@@ -0,0 +1,132 @@
import json
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
logger = logging.getLogger("videogen")
async def resolve_video_engine(
db: AsyncSession,
engine_id: str,
callable_models: list[dict],
) -> VideoEngine:
"""根据 engine_id 解析视频引擎,并验证是否在 api-key 的可调用列表中。"""
# 验证授权
allowed = {m["engine_id"] for m in callable_models if m.get("engine_type") == "video"}
if engine_id not in allowed:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"该 API Key 无权使用引擎 {engine_id}",
)
result = await db.execute(
select(VideoEngine).where(
VideoEngine.id == engine_id,
VideoEngine.is_active == True,
VideoEngine.deleted_at.is_(None),
).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"视频引擎 {engine_id} 不存在或未启用",
)
return engine
async def resolve_image_engine(
db: AsyncSession,
engine_id: str,
callable_models: list[dict],
) -> ImageEngine:
"""根据 engine_id 解析图片引擎,并验证是否在 api-key 的可调用列表中。"""
allowed = {m["engine_id"] for m in callable_models if m.get("engine_type") == "image"}
if engine_id not in allowed:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"该 API Key 无权使用引擎 {engine_id}",
)
result = await db.execute(
select(ImageEngine).where(
ImageEngine.id == engine_id,
ImageEngine.is_active == True,
ImageEngine.deleted_at.is_(None),
).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"图片引擎 {engine_id} 不存在或未启用",
)
return engine
def build_engine_snapshot(engine: VideoEngine | ImageEngine) -> dict:
"""构建引擎配置快照。"""
snapshot = {
"id": str(engine.id),
"name": str(engine.name),
"provider": str(getattr(engine, "provider", "")),
"api_base": str(engine.api_base),
"model_name": str(engine.model_name),
}
# 可选字段
for field in [
"supported_ratios", "supported_resolutions", "supported_durations",
"default_size", "multi_generation_enabled", "max_generation_count",
]:
val = getattr(engine, field, None)
if val is not None:
if isinstance(val, str):
try:
val = json.loads(val)
except (json.JSONDecodeError, TypeError):
pass
snapshot[field] = val
return snapshot
async def resolve_engine_by_model_name(
db: AsyncSession,
model_name: str,
callable_models: list[dict],
engine_type: str,
) -> tuple[str, VideoEngine | ImageEngine]:
"""根据模型名称查找对应的引擎。
Returns:
(engine_id, engine 对象)
"""
# 在 callable_models 中查找
target = None
for m in callable_models:
if m.get("model_name") == model_name and m.get("engine_type") == engine_type:
target = m
break
if not target:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"该 API Key 无权使用模型 {model_name}",
)
engine_id = target["engine_id"]
if engine_type == "video":
engine = await resolve_video_engine(db, engine_id, callable_models)
else:
engine = await resolve_image_engine(db, engine_id, callable_models)
return engine_id, engine
@@ -0,0 +1,148 @@
"""API v3 文件下载服务。
下载用户提供的图片/视频/音频到本地存储。
"""
import base64
import logging
import os
import re
import uuid
from datetime import datetime
from urllib.parse import urlparse
import httpx
from app.config import settings
logger = logging.getLogger("videogen")
def _get_date_str() -> str:
"""获取当前日期字符串。"""
return datetime.now().strftime("%Y%m%d")
def _get_uploads_dir() -> str:
"""获取上传文件存储目录。"""
upload_dir = os.path.join(os.path.dirname(settings.STORAGE_LOCAL_PATH), "uploads", "api")
os.makedirs(upload_dir, exist_ok=True)
return upload_dir
async def download_file_from_url(url: str, sub_dir: str = "") -> str:
"""从 URL 下载文件到本地。
Args:
url: 文件 URL
sub_dir: 子目录(如 images/videos/audios
Returns:
相对路径: /uploads/api/{sub_dir}/{date}/{filename}
"""
upload_dir = _get_uploads_dir()
date_str = _get_date_str()
# 创建目标目录
dest_dir = os.path.join(upload_dir, sub_dir, date_str)
os.makedirs(dest_dir, exist_ok=True)
# 从 URL 提取扩展名
parsed = urlparse(url)
path = parsed.path
ext = os.path.splitext(path)[1].lower()
if not ext or len(ext) > 10:
ext = ".bin" # 默认扩展名
# 生成唯一文件名
filename = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(dest_dir, filename)
# 下载文件
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
with open(dest_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
f.write(chunk)
# 返回相对路径
rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}"
logger.info("Downloaded file: %s -> %s", url[:80], rel_path)
return rel_path
def save_base64_file(data: str, sub_dir: str = "") -> str:
"""保存 Base64 编码的文件到本地。
Args:
data: Base64 编码的数据(可包含 data:...;base64, 前缀)
sub_dir: 子目录
Returns:
相对路径: /uploads/api/{sub_dir}/{date}/{filename}
"""
upload_dir = _get_uploads_dir()
date_str = _get_date_str()
# 创建目标目录
dest_dir = os.path.join(upload_dir, sub_dir, date_str)
os.makedirs(dest_dir, exist_ok=True)
# 解析 Base64 数据
if "," in data:
header, b64_data = data.split(",", 1)
# 从 header 提取 MIME 类型
mime_match = re.search(r"data:([^;]+)", header)
mime_type = mime_match.group(1) if mime_match else "application/octet-stream"
# 根据 MIME 类型确定扩展名
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"video/mp4": ".mp4",
"video/webm": ".webm",
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
"audio/ogg": ".ogg",
}
ext = ext_map.get(mime_type, ".bin")
else:
b64_data = data
ext = ".bin"
# 解码并保存
try:
file_data = base64.b64decode(b64_data)
except Exception as exc:
raise ValueError(f"Base64 解码失败: {exc}")
filename = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(dest_dir, filename)
with open(dest_path, "wb") as f:
f.write(file_data)
rel_path = f"/uploads/api/{sub_dir}/{date_str}/{filename}"
logger.info("Saved base64 file: %s (%d bytes)", rel_path, len(file_data))
return rel_path
async def process_media_url(url: str, media_type: str) -> str:
"""处理媒体 URL:下载到本地或保存 Base64。
Args:
url: URL 或 Base64 数据
media_type: image / video / audio
Returns:
相对路径: /uploads/api/{type}/{date}/{filename}
"""
sub_dir = {"image": "images", "video": "videos", "audio": "audios"}.get(media_type, "files")
# 判断是 Base64 还是 URL
if url.startswith("data:"):
return save_base64_file(url, sub_dir)
else:
return await download_file_from_url(url, sub_dir)
@@ -0,0 +1,542 @@
import asyncio
import json
import logging
import os
import time
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_key import ApiKey
from app.schemas.api_v3.image import ApiImageGenerateRequest, ApiImageGenerateResponse, ApiImageGenerateDataItem
from app.schemas.api_v3.video import ApiVideoCreateRequest, ApiVideoCreateResponse
from app.services.api_v3 import task_service, engine_service, upscale_service
from app.services.api_v3.quota_service import can_start_video_task
from app.services.api_v3.pricing_service import calc_api_video_price, calc_api_image_price, PricingNotConfiguredError
from app.services.api_v3.logging_service import log_model_request
logger = logging.getLogger("videogen")
def _make_image_url(local_path: str) -> str:
"""将本地图片路径转为完整可访问 URL。"""
if not local_path:
return local_path
# 如果已经是完整 URL,直接返回
if local_path.startswith(("http://", "https://")):
return local_path
from app.config import settings
from app.services.resource_signed_url_service import build_resource_signed_url
# 生成签名 URL
signed = build_resource_signed_url(local_path)
if signed and not signed.startswith(("http://", "https://")):
base = settings.BASE_URL.rstrip("/")
if signed.startswith("/"):
signed = f"{base}{signed}"
else:
signed = f"{base}/{signed}"
return signed or local_path
async def submit_video_generation(
db: AsyncSession,
key: ApiKey,
callable_models: list[dict],
req: ApiVideoCreateRequest,
) -> ApiVideoCreateResponse:
"""提交视频生成任务(异步)。
流程:
1. 检查并发视频任务数
2. 解析引擎
3. 构建超分快照
4. 创建任务记录
5. 入队 Celery 任务
6. 返回 task_id
"""
# 1. 检查是否可以立即启动(并发限制)
can_start = await can_start_video_task(key, db)
# 2. 解析引擎
engine_id, engine = await engine_service.resolve_engine_by_model_name(
db, req.model, callable_models, "video"
)
engine_snapshot = engine_service.build_engine_snapshot(engine)
# 3. 构建超分快照
provider_resolution, upscale_enabled, upscale_snapshot_json = await upscale_service.build_api_upscale_snapshot(
db, key.id, req.resolution or "480p", req.ratio
)
# 如果超分要求不同的生成分辨率,使用超分的
final_provider_resolution = provider_resolution or req.resolution or "480p"
def _get_max_supported_duration(engine) -> int | None:
"""从引擎 supported_durations 获取最大时长。"""
try:
durations = json.loads(engine.supported_durations) if engine.supported_durations else []
return max(durations) if durations else engine.max_duration
except (json.JSONDecodeError, TypeError, ValueError):
return engine.max_duration
# 3.5 验证传入的媒体文件是否符合引擎配置要求
from app.services.api_v3.file_service import process_media_url
from app.services.video_upscale.media_service import probe_video
from fastapi import HTTPException, status # noqa: F401
input_image_count = 0
input_video_count = 0
input_audio_count = 0
input_video_duration = 0.0
local_media_refs = [] # 存储本地路径
# 统计各类媒体数量
for p in req.content:
ptype = p.type
if ptype == "image_url":
input_image_count += 1
elif ptype == "video_url":
input_video_count += 1
elif ptype == "audio_url":
input_audio_count += 1
# 视频引擎校验(本函数仅处理视频生成)
# 校验图片数量限制
if input_image_count > (engine.max_image_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_image_count} 张参考图片,当前传入 {input_image_count}",
)
# 校验视频数量限制
if input_video_count > (engine.max_video_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_video_count} 个参考视频,当前传入 {input_video_count}",
)
# 校验音频数量限制
if input_audio_count > (engine.max_audio_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_audio_count} 个参考音频,当前传入 {input_audio_count}",
)
for p in req.content:
ptype = p.type
if ptype == "text":
local_media_refs.append(p.model_dump(exclude_none=True))
continue
original_url = ""
if ptype == "image_url" and p.image_url:
original_url = p.image_url.get("url", "")
elif ptype == "video_url" and p.video_url:
original_url = p.video_url.get("url", "")
elif ptype == "audio_url" and p.audio_url:
original_url = p.audio_url.get("url", "")
# 下载文件到本地
try:
local_path = await process_media_url(original_url, ptype.replace("_url", ""))
except Exception as exc:
logger.warning("Failed to download media %s: %s", original_url[:80], exc)
local_path = original_url
# 如果是视频/音频,探测实际时长并校验
if ptype in ("video_url", "audio_url") and local_path:
try:
media_info = await probe_video(local_path)
if media_info and media_info.duration_seconds:
duration = media_info.duration_seconds
# 校验最低时长(2秒)
if duration < 2.0:
raise HTTPException(
status_code=400,
detail=f"上传的{ptype.replace('_url', '')}时长不能低于2秒,当前时长: {duration:.1f}",
)
# 校验最高时长(根据引擎 supported_durations 最大值)
max_duration = _get_max_supported_duration(engine)
if max_duration and duration > max_duration:
raise HTTPException(
status_code=400,
detail=f"上传的{ptype.replace('_url', '')}时长不能超过{max_duration}秒,当前时长: {duration:.1f}",
)
if ptype == "video_url":
input_video_duration += duration
except HTTPException:
raise
except Exception as exc:
logger.warning("Failed to probe media duration: %s", exc)
local_media_refs.append({
"type": ptype,
ptype: {"url": local_path},
"role": p.role,
})
# 3.6 计算价格(基于实际探测的视频时长)
try:
estimated_price = await calc_api_video_price(
db,
duration=req.duration or 5,
resolution=req.resolution or "480p",
engine_id=engine_id,
input_video_duration=input_video_duration,
input_image_count=input_image_count,
)
except PricingNotConfiguredError as exc:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# 预检配额
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
)
# 预扣配额
if estimated_price > 0:
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
# 4. 创建任务记录(幂等性已在路由层检查)
content_dicts = [p.model_dump(exclude_none=True) for p in req.content]
task = await task_service.create_video_task(
db=db,
api_key_id=key.id,
model_name=req.model,
engine_id=engine_id,
engine_snapshot=engine_snapshot,
content=content_dicts,
ratio=req.ratio,
duration=req.duration,
resolution=req.resolution,
provider_generation_resolution=final_provider_resolution,
upscale_enabled=upscale_enabled,
upscale_snapshot_json=upscale_snapshot_json,
idempotency_key=req.idempotency_key,
local_media_refs=local_media_refs,
)
task.credits_cost = estimated_price # 记录预扣金额
# 根据并发限制决定立即执行还是排队
if can_start:
# 立即执行
task.status = "pending"
task.pipeline_stage = "queued"
else:
# 排队等待
task.status = "queued"
task.pipeline_stage = "waiting_concurrency"
await db.commit()
# 提交时即记录使用日志(配额已预扣)
try:
from app.services.api_v3.usage_log_service import record_usage
quota_before = key.quota_used - estimated_price # 扣减前的余额
quota_after = key.quota_used # 扣减后的余额
price_detail = {
"base_price": getattr(locals(), "base_price", 0),
"per_second_price": getattr(locals(), "per_second_price", 0),
"duration": req.duration,
"resolution": req.resolution,
"ratio": req.ratio,
"total": estimated_price,
}
await record_usage(
db=db,
api_key_id=key.id,
request_type="video_create",
model_name=req.model,
gen_type="video",
status="success",
task_id=task.id,
credits_cost=estimated_price,
price_action="deduct",
resolution=req.resolution,
duration=req.duration,
quota_before=quota_before,
quota_after=quota_after,
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
)
except Exception as log_exc:
logger.error("Failed to record usage on submit: %s", log_exc)
# 记录模型调用日志
log_model_request(
engine_id=engine_id,
model_name=req.model,
task_id=task.id,
params={
"ratio": req.ratio,
"duration": req.duration,
"resolution": req.resolution,
"generate_audio": req.generate_audio,
"watermark": req.watermark,
"content_count": len(req.content),
"queued": not can_start,
},
)
# 5. 只有立即执行的才入队 Celery
if can_start:
from app.tasks.api_generation_tasks import api_create_generation_task
api_create_generation_task.apply_async(
args=[task.id],
queue="gen_api_create",
)
status_str = "queued" if can_start else "pending_queue"
logger.info("API video task created: task_id=%s model=%s key=%s price=%.2f status=%s", task.id, req.model, key.id, estimated_price, status_str)
return ApiVideoCreateResponse(id=task.id)
async def generate_image_sync(
db: AsyncSession,
key: ApiKey,
callable_models: list[dict],
req: "ApiImageGenerateRequest",
start_time: float,
) -> ApiImageGenerateResponse:
"""同步生成图片。
流程:
1. 解析引擎
2. 创建任务记录
3. 调用 Volcano Ark SDK(同步)
4. 下载图片
5. 更新任务状态
6. 记录使用日志
7. 返回结果
"""
from app.services.api_v3.usage_log_service import record_usage
# 1. 解析引擎
engine_id, engine = await engine_service.resolve_engine_by_model_name(
db, req.model, callable_models, "image"
)
engine_snapshot = engine_service.build_engine_snapshot(engine)
# 2. 创建任务记录
task = await task_service.create_image_task(
db=db,
api_key_id=key.id,
model_name=req.model,
engine_id=engine_id,
engine_snapshot=engine_snapshot,
prompt=req.prompt,
size=req.size,
)
# 2.5 验证传入的媒体文件是否符合引擎配置要求
# 校验参考图片数量限制
input_image_count = len(req.image) if req.image else 0
if input_image_count > (engine.max_reference_image_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_reference_image_count} 张参考图片,当前传入 {input_image_count}",
)
# 校验组图数量限制
generation_count = req.generation_count or 1
if generation_count > (engine.multi_image_max_images or 1):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持生成 {engine.multi_image_max_images} 张图片,当前请求 {generation_count}",
)
try:
estimated_price = await calc_api_image_price(
db,
image_size=req.size or "2K",
engine_id=engine_id,
input_image_count=input_image_count,
)
except PricingNotConfiguredError as exc:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# 预检配额
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
)
# 预扣配额
if estimated_price > 0:
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
task.credits_cost = estimated_price
await db.commit()
# 提交时即记录使用日志(配额已预扣)
try:
quota_before = key.quota_used - estimated_price
quota_after = key.quota_used
price_detail = {
"base_price": getattr(locals(), "base_price", 0),
"size": req.size,
"generation_count": req.generation_count or 1,
"total": estimated_price,
}
await record_usage(
db=db,
api_key_id=key.id,
request_type="image_generate",
model_name=req.model,
gen_type="image",
status="success",
task_id=task.id,
credits_cost=estimated_price,
price_action="deduct",
resolution=req.size,
quota_before=quota_before,
quota_after=quota_after,
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
)
except Exception as log_exc:
logger.error("Failed to record usage on image submit: %s", log_exc)
try:
# 设置总体超时(120秒,防止同步请求长时间挂起)
_IMAGE_GEN_TIMEOUT = 120
# 3. 调用 Volcano Ark SDK(同步函数,在线程中执行)
from app.services.image_gen import submit_image_task, download_image
from app.config import settings
# 构建 media_references,下载图片到本地
from app.services.api_v3.file_service import process_media_url
image_refs = []
if req.image:
for url in req.image:
try:
local_path = await process_media_url(url, "image")
except Exception as exc:
logger.warning("Failed to download image %s: %s", url[:80], exc)
local_path = url
image_refs.append({"type": "image", "url": local_path})
# 临时设置 media_references
task.media_references = json.dumps(image_refs, ensure_ascii=False) if image_refs else None
task.image_size = req.size or "2K"
await db.flush()
# 在线程中执行同步 SDK 调用(带超时保护)
result = await asyncio.wait_for(
asyncio.to_thread(
submit_image_task,
db,
engine,
task,
True, # include_media_references
req.generation_count or 1,
),
timeout=_IMAGE_GEN_TIMEOUT,
)
# 4. 下载图片
items = result.get("items", [])
downloaded_items: list[ApiImageGenerateDataItem] = []
for item in items:
url = item.get("remote_result_url")
if url:
# 下载到本地
date_dir = datetime.now().strftime("%Y%m%d")
dest_dir = f"./storage/generate/api/images/{date_dir}"
os.makedirs(dest_dir, exist_ok=True)
dest_path = os.path.join(dest_dir, f"{task.id}_{item.get('generation_index', 1)}.png")
try:
await asyncio.wait_for(
download_image(url, dest_path),
timeout=30,
)
except asyncio.TimeoutError:
logger.warning("Image download timeout: %s", url[:80])
except Exception as dl_err:
logger.warning("Image download failed: %s", dl_err)
# 将本地路径转为完整 URL
image_url = _make_image_url(dest_path)
downloaded_items.append(ApiImageGenerateDataItem(
url=image_url,
size=item.get("size"),
output_format=item.get("output_format"),
))
elif item.get("error_message"):
downloaded_items.append(ApiImageGenerateDataItem(
url=None,
))
# 5. 使用预扣金额(不再重复扣减)
task.credits_cost = estimated_price
# 6. 更新任务状态
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = datetime.now(timezone.utc)
if downloaded_items and downloaded_items[0].url:
task.image_url = downloaded_items[0].url
await db.commit()
# 提交时已记录使用日志,成功时无需重复记录
duration_ms = int((time.perf_counter() - start_time) * 1000)
return ApiImageGenerateResponse(
created=result.get("created", int(time.time())),
data=downloaded_items,
model=result.get("model", req.model),
)
except asyncio.TimeoutError:
from fastapi import HTTPException, status
logger.exception("API image generation timed out (task_id=%s)", task.id)
# 超时:退回预扣配额
if estimated_price > 0:
key.quota_used = round(max(0, (key.quota_used or 0.0) - estimated_price), 2)
task.status = "failed"
task.error_message = "图片生成超时(超过120秒)"
task.credits_cost = 0
await db.commit()
raise HTTPException(
status_code=504,
detail="图片生成超时,请稍后重试",
)
except Exception as exc:
# 失败:退回预扣配额
if estimated_price > 0:
key.quota_used = round(max(0, (key.quota_used or 0.0) - estimated_price), 2)
task.status = "failed"
task.error_message = str(exc)
task.credits_cost = 0 # 实际消耗为0(已退回)
await db.commit()
duration_ms = int((time.perf_counter() - start_time) * 1000)
quota_after_refund = key.quota_used # 退回后的余额
await record_usage(
db=db,
api_key_id=key.id,
request_type="image_generate",
model_name=req.model,
gen_type="image",
status="failed",
task_id=task.id,
credits_cost=estimated_price,
refund_amount=estimated_price,
request_duration_ms=duration_ms,
error_message=str(exc),
error_code="generation_failed",
price_action="refund",
resolution=req.size,
generation_count=req.generation_count or 1,
quota_before=quota_after_refund,
quota_after=quota_after_refund + estimated_price,
)
await db.commit()
raise
@@ -0,0 +1,195 @@
import hashlib
import json
import logging
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_key import ApiKey
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
API_KEY_PREFIX = "vk_"
async def create_api_key(
db: AsyncSession,
company_name: str,
callable_models: list[dict] | None = None,
quota_limit: float | None = None,
quota_cycle: str | None = None,
valid_from: datetime | None = None,
valid_until: datetime | None = None,
max_concurrent_video_tasks: int | None = None,
description: str | None = None,
) -> tuple[ApiKey, str]:
"""创建新的 API Key。
Returns:
(ApiKey 对象, 明文 API Key) — 明文仅返回这一次。
"""
# 生成密钥: vk_ + 32字节随机hex
raw_key = API_KEY_PREFIX + secrets.token_hex(24) # vk_ + 48位hex = 51字符
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_prefix = raw_key[:8] # 前8位用于展示: vk_xxxxx
api_key = ApiKey(
id=generate_id(),
company_name=company_name,
api_key_hash=key_hash,
api_key_prefix=key_prefix,
description=description,
callable_models=json.dumps(callable_models or [], ensure_ascii=False),
quota_limit=quota_limit,
quota_cycle=quota_cycle,
quota_used=0.0,
valid_from=valid_from,
valid_until=valid_until,
max_concurrent_video_tasks=max_concurrent_video_tasks,
is_active=True,
)
api_key.set_plaintext_key(raw_key) # 加密存储完整 Key
db.add(api_key)
await db.flush()
logger.info("API Key created: id=%s company=%s prefix=%s", api_key.id, company_name, key_prefix)
return api_key, raw_key
async def list_api_keys(
db: AsyncSession,
skip: int = 0,
limit: int = 50,
company_name: str | None = None,
is_active: bool | None = None,
) -> tuple[int, list[ApiKey]]:
"""列出 API Key(分页+筛选)。"""
from sqlalchemy import func
query = select(ApiKey).where(ApiKey.deleted_at.is_(None))
count_query = select(func.count(ApiKey.id)).where(ApiKey.deleted_at.is_(None))
if company_name:
query = query.where(ApiKey.company_name.ilike(f"%{company_name}%"))
count_query = count_query.where(ApiKey.company_name.ilike(f"%{company_name}%"))
if is_active is not None:
query = query.where(ApiKey.is_active == is_active)
count_query = count_query.where(ApiKey.is_active == is_active)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(ApiKey.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
keys = list(result.scalars().all())
return total, keys
async def get_api_key(db: AsyncSession, key_id: str) -> ApiKey | None:
"""获取单个 API Key 详情。"""
result = await db.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.deleted_at.is_(None)).limit(1)
)
return result.scalar_one_or_none()
async def update_api_key(db: AsyncSession, key: ApiKey, **kwargs) -> ApiKey:
"""更新 API Key 配置。"""
updatable_fields = {
"company_name", "description", "callable_models",
"quota_limit", "quota_cycle", "valid_from", "valid_until",
"max_concurrent_video_tasks", "is_active",
}
for field, value in kwargs.items():
if field in updatable_fields and value is not None:
if field == "callable_models" and isinstance(value, list):
value = json.dumps(value, ensure_ascii=False)
setattr(key, field, value)
await db.flush()
return key
async def adjust_quota(
db: AsyncSession,
key: ApiKey,
action: str,
quota_limit_delta: float | None = None,
quota_limit: float | None = None,
quota_cycle: str | None = None,
) -> tuple[ApiKey, dict]:
"""调整 API Key 配额。
返回 (更新后的 key, 变更详情 dict)。
action:
- adjust: 增加总额,quota_limit_delta 累加到当前 quota_limit
- reset_usage: 重置 quota_used 为 0
- set_limit: 直接设置 quota_limit
- change_cycle: 修改 quota_cycle
"""
old_limit = key.quota_limit
old_used = key.quota_used
old_cycle = key.quota_cycle
if action == "adjust":
delta = quota_limit_delta or 0
key.quota_limit = round((key.quota_limit or 0) + delta, 2)
elif action == "reset_usage":
key.quota_used = 0.0
elif action == "set_limit":
key.quota_limit = quota_limit # 允许设为 None(无限)
elif action == "change_cycle":
key.quota_cycle = quota_cycle # 允许设为 None(无限)
else:
raise ValueError(f"未知的调整操作: {action}")
await db.flush()
changes = {
"old_limit": old_limit, "new_limit": key.quota_limit,
"old_used": old_used, "new_used": key.quota_used,
"old_cycle": old_cycle, "new_cycle": key.quota_cycle,
}
return key, changes
async def delete_api_key(db: AsyncSession, key: ApiKey) -> None:
"""软删除 API Key。"""
key.deleted_at = datetime.now(timezone.utc)
key.is_active = False
await db.flush()
async def reset_quota_if_needed(db: AsyncSession, key: ApiKey) -> ApiKey:
"""检查并重置过期周期的配额。
- daily: 如果上次重置不是今天,重置 quota_used=0
- monthly: 如果上次重置不是本月,重置 quota_used=0
"""
if key.quota_limit is None or key.quota_cycle is None:
return key
now = datetime.now(timezone.utc)
# 使用 quota_used 的 updated_at 作为周期判断依据
last_reset = key.updated_at or key.created_at
if last_reset is None:
return key
should_reset = False
if key.quota_cycle == "daily":
should_reset = last_reset.date() < now.date()
elif key.quota_cycle == "monthly":
should_reset = (last_reset.year, last_reset.month) < (now.year, now.month)
if should_reset and key.quota_used > 0:
key.quota_used = 0.0
await db.flush()
logger.info("Quota reset for API Key %s (cycle=%s)", key.id, key.quota_cycle)
return key
@@ -0,0 +1,187 @@
"""外部 API v3 日志服务。
按天分类存储在 log/api/ 目录下:
- log/api/requests/YYYY-MM-DD.log — 所有外部请求和响应
- log/api/models/YYYY-MM-DD.log — 模型调用(Volcano Ark SDK
- log/api/upscale/YYYY-MM-DD.log — 超分轮询
- log/api/errors/YYYY-MM-DD.log — 错误日志
"""
import json
import logging
import os
from datetime import datetime, timezone
# === 日志目录 ===
# video-gen-api/log/api/
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
# 上溯3级: services/api_v3 -> services -> app -> video-gen-api (即项目根目录)
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(_THIS_DIR)))
BASE_LOG_DIR = os.path.join(_BASE_DIR, "log", "api")
os.makedirs(BASE_LOG_DIR, exist_ok=True)
# 子目录
REQUESTS_LOG_DIR = os.path.join(BASE_LOG_DIR, "requests")
MODELS_LOG_DIR = os.path.join(BASE_LOG_DIR, "models")
UPSCALE_LOG_DIR = os.path.join(BASE_LOG_DIR, "upscale")
ERRORS_LOG_DIR = os.path.join(BASE_LOG_DIR, "errors")
for d in [REQUESTS_LOG_DIR, MODELS_LOG_DIR, UPSCALE_LOG_DIR, ERRORS_LOG_DIR]:
os.makedirs(d, exist_ok=True)
def _get_date_str() -> str:
"""获取当前日期字符串。"""
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
class _DailyFileHandler(logging.Handler):
"""按天写入的日志处理器。"""
def __init__(self, log_dir: str):
super().__init__()
self.log_dir = log_dir
self._current_date = None
self._file_handler = None
self._open_file()
def _open_file(self):
"""打开当天的日志文件。"""
date_str = _get_date_str()
if date_str == self._current_date and self._file_handler:
return
if self._file_handler:
self._file_handler.close()
self._current_date = date_str
filepath = os.path.join(self.log_dir, f"{date_str}.log")
self._file_handler = logging.FileHandler(filepath, encoding="utf-8")
self._file_handler.setFormatter(
logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
)
def emit(self, record):
try:
self._open_file()
self._file_handler.emit(record)
except Exception:
self.handleError(record)
def close(self):
if self._file_handler:
self._file_handler.close()
super().close()
def _create_logger(name: str, log_dir: str) -> logging.Logger:
"""创建按天写入的 Logger。"""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# 避免重复添加 handler
if not logger.handlers:
handler = _DailyFileHandler(log_dir)
logger.addHandler(handler)
return logger
# === Logger 实例 ===
requests_logger = _create_logger("api_v3.requests", REQUESTS_LOG_DIR)
models_logger = _create_logger("api_v3.models", MODELS_LOG_DIR)
upscale_logger = _create_logger("api_v3.upscale", UPSCALE_LOG_DIR)
errors_logger = _create_logger("api_v3.errors", ERRORS_LOG_DIR)
def _safe_json(obj) -> str:
"""安全地序列化为 JSON。"""
try:
return json.dumps(obj, ensure_ascii=False, default=str)
except Exception:
return str(obj)
# === 请求/响应日志 ===
def log_request(method: str, path: str, api_key_id: str, body: dict | None = None):
"""记录外部请求。"""
requests_logger.info(
f"REQUEST | {method} {path} | key={api_key_id} | body={_safe_json(body)}"
)
def log_response(method: str, path: str, api_key_id: str, status_code: int, body=None, duration_ms: int = 0):
"""记录外部响应。"""
requests_logger.info(
f"RESPONSE | {method} {path} | key={api_key_id} | status={status_code} | duration={duration_ms}ms | body={_safe_json(body)}"
)
def log_request_error(method: str, path: str, api_key_id: str, error: str, status_code: int = 500):
"""记录请求错误。"""
errors_logger.error(
f"REQUEST_ERROR | {method} {path} | key={api_key_id} | status={status_code} | error={error}"
)
# === 模型调用日志 ===
def log_model_request(engine_id: str, model_name: str, task_id: str, params: dict):
"""记录模型调用请求。"""
models_logger.info(
f"MODEL_REQUEST | engine={engine_id} | model={model_name} | task={task_id} | params={_safe_json(params)}"
)
def log_model_response(engine_id: str, model_name: str, task_id: str, success: bool, result: dict | None = None, error: str | None = None):
"""记录模型调用响应。"""
if success:
models_logger.info(
f"MODEL_RESPONSE | engine={engine_id} | model={model_name} | task={task_id} | success | result={_safe_json(result)}"
)
else:
models_logger.error(
f"MODEL_RESPONSE | engine={engine_id} | model={model_name} | task={task_id} | failed | error={error}"
)
errors_logger.error(
f"MODEL_ERROR | engine={engine_id} | model={model_name} | task={task_id} | error={error}"
)
# === 超分轮询日志 ===
def log_upscale_poll_start(task_id: str, api_task_id: str):
"""记录超分轮询开始。"""
upscale_logger.info(f"UPSCALE_POLL_START | task={task_id} | api_task={api_task_id}")
def log_upscale_poll(task_id: str, api_task_id: str, status: str, attempt: int, result: dict | None = None):
"""记录超分轮询状态。"""
upscale_logger.info(
f"UPSCALE_POLL | task={task_id} | api_task={api_task_id} | status={status} | attempt={attempt} | result={_safe_json(result)}"
)
def log_upscale_poll_end(task_id: str, api_task_id: str, success: bool, final_status: str, total_attempts: int):
"""记录超分轮询结束。"""
if success:
upscale_logger.info(
f"UPSCALE_POLL_END | task={task_id} | api_task={api_task_id} | success | status={final_status} | attempts={total_attempts}"
)
else:
upscale_logger.error(
f"UPSCALE_POLL_END | task={task_id} | api_task={api_task_id} | failed | status={final_status} | attempts={total_attempts}"
)
errors_logger.error(
f"UPSCALE_ERROR | task={task_id} | api_task={api_task_id} | status={final_status} | attempts={total_attempts}"
)
# === 通用错误日志 ===
def log_error(category: str, message: str, details: dict | None = None):
"""记录通用错误。"""
errors_logger.error(
f"{category} | {message} | details={_safe_json(details)}"
)
@@ -0,0 +1,173 @@
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_model_pricing import ApiModelPricing
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
logger = logging.getLogger("videogen")
class PricingNotConfiguredError(Exception):
"""模型+分辨率组合未配置价格。"""
def __init__(self, *, model_name: str, resolution: str):
self.model_name = model_name
self.resolution = resolution
super().__init__(
f"模型或引擎 '{self.model_name}' 在分辨率 '{self.resolution}' 下未配置,无法生成"
)
async def resolve_engine_display_name(db: AsyncSession, engine_id: str) -> str:
"""根据引擎 ID 解析展示名称(找不到时原样返回 ID)。"""
if not engine_id:
return engine_id or "unknown"
result = await db.execute(
select(VideoEngine.name).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
)
name = result.scalar_one_or_none()
if name:
return name
result = await db.execute(
select(ImageEngine.name).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
)
name = result.scalar_one_or_none()
return name or engine_id
async def _get_api_pricing(
db: AsyncSession,
*,
gen_type: str,
resolution: str,
engine_id: str | None = None,
) -> ApiModelPricing | None:
"""按引擎精确规则优先获取定价;找不到时回退到同类型同分辨率。
查询优先级:
1. gen_type + engine_id + resolution 精确规则
2. gen_type + resolution 下 base_price 最高规则
"""
gen_type = (gen_type or "").lower().strip()
resolution = (resolution or "").strip()
engine_id = (engine_id or "").strip() or None
if engine_id:
result = await db.execute(
select(ApiModelPricing)
.where(ApiModelPricing.gen_type == gen_type)
.where(ApiModelPricing.model_config_id == engine_id)
.where(ApiModelPricing.resolution == resolution)
.order_by(ApiModelPricing.base_price.desc(), ApiModelPricing.per_second_price.desc())
.limit(1)
)
pricing = result.scalar_one_or_none()
if pricing:
return pricing
result = await db.execute(
select(ApiModelPricing)
.where(ApiModelPricing.gen_type == gen_type)
.where(ApiModelPricing.resolution == resolution)
.order_by(ApiModelPricing.base_price.desc(), ApiModelPricing.per_second_price.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def calc_api_video_price(
db: AsyncSession,
duration: int,
resolution: str,
engine_id: str | None = None,
input_video_duration: float = 0,
input_image_count: int = 0,
) -> float:
"""计算 API 视频生成价格(元)。
未配置价格时抛出 PricingNotConfiguredError。
公式(与 credit_ratios 一致):
base_cost = (base_price + per_second_price × duration) × price_ratio
if 传入视频: += (input_video_base_price + input_video_per_second_price × input_video_duration) × input_video_ratio
if 传入图片: += (input_image_base_price + input_image_per_image_price × input_image_count) × input_image_ratio
"""
if not engine_id:
result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc())
.limit(1)
)
engine_id = result.scalar_one_or_none()
pricing = await _get_api_pricing(db, gen_type="video", resolution=resolution, engine_id=engine_id)
if not pricing:
raise PricingNotConfiguredError(
model_name=await resolve_engine_display_name(db, engine_id),
resolution=resolution,
)
# 基础价格
base_cost = (pricing.base_price + pricing.per_second_price * duration) * pricing.price_ratio
# 传入视频附加费(每秒 × 倍率)
if input_video_duration > 0:
base_cost += (pricing.input_video_base_price + pricing.input_video_per_second_price * input_video_duration) * pricing.input_video_ratio
# 传入图片附加费(每张 × 倍率)
if input_image_count > 0:
base_cost += (pricing.input_image_base_price + pricing.input_image_per_image_price * input_image_count) * pricing.input_image_ratio
return round(base_cost, 2)
async def calc_api_image_price(
db: AsyncSession,
image_size: str,
engine_id: str | None = None,
input_image_count: int = 0,
) -> float:
"""计算 API 图片生成价格(元)。
未配置价格时抛出 PricingNotConfiguredError。
公式(与 credit_ratios 一致):
base_cost = base_price × price_ratio
if 传入图片: += (input_image_base_price + input_image_per_image_price × input_image_count) × input_image_ratio
"""
if not engine_id:
result = await db.execute(
select(ImageEngine.id)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
.limit(1)
)
engine_id = result.scalar_one_or_none()
pricing = await _get_api_pricing(db, gen_type="image", resolution=image_size, engine_id=engine_id)
if not pricing:
raise PricingNotConfiguredError(
model_name=await resolve_engine_display_name(db, engine_id),
resolution=image_size,
)
# 基础价格
base_cost = pricing.base_price * pricing.price_ratio
# 传入图片附加费(每张 × 倍率)
if input_image_count > 0:
base_cost += (pricing.input_image_base_price + pricing.input_image_per_image_price * input_image_count) * pricing.input_image_ratio
return round(base_cost, 2)
async def get_priced_models(db: AsyncSession) -> set[str]:
"""获取所有已配置价格的引擎 ID 集合。
用于过滤 /api/v3/models 接口,仅返回已定价的模型。
"""
result = await db.execute(
select(ApiModelPricing.model_config_id).distinct()
)
return {row[0] for row in result.all()}
@@ -0,0 +1,68 @@
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.api.api_key import ApiKey
logger = logging.getLogger("videogen")
async def check_quota(key: ApiKey) -> bool:
"""检查 API Key 配额是否充足。
Returns:
True = 配额充足或无限额, False = 已超限。
"""
if key.quota_limit is None:
return True
return key.quota_used < key.quota_limit
async def get_active_video_tasks_count(api_key_id: str, db: AsyncSession) -> int:
"""统计 API Key 当前活跃的视频任务数。
活跃 = status IN ('pending', 'generating', 'processing') AND gen_type='video'
"""
result = await db.execute(
select(func.count(ApiGenerationTask.id)).where(
ApiGenerationTask.api_key_id == api_key_id,
ApiGenerationTask.gen_type == "video",
ApiGenerationTask.status.in_(["pending", "generating", "processing"]),
ApiGenerationTask.deleted_at.is_(None),
)
)
return result.scalar_one() or 0
async def can_start_video_task(key: ApiKey, db: AsyncSession) -> bool:
"""检查是否可以立即启动新的视频任务。
Returns:
True = 可以立即启动, False = 需要排队。
"""
if key.max_concurrent_video_tasks is None:
return True # 无限制
current = await get_active_video_tasks_count(key.id, db)
return current < key.max_concurrent_video_tasks
async def get_queued_video_tasks(key: ApiKey, db: AsyncSession, limit: int = 10) -> list[ApiGenerationTask]:
"""获取排队的视频任务列表(按创建时间排序)。"""
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.api_key_id == key.id,
ApiGenerationTask.gen_type == "video",
ApiGenerationTask.status == "queued",
ApiGenerationTask.deleted_at.is_(None),
).order_by(ApiGenerationTask.created_at.asc()).limit(limit)
)
return list(result.scalars().all())
async def increment_quota(db: AsyncSession, key: ApiKey, credits_cost: float) -> None:
"""原子性增加配额使用量。"""
key.quota_used = round((key.quota_used or 0.0) + credits_cost, 2)
await db.flush()
@@ -0,0 +1,233 @@
import json
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_generation_task import ApiGenerationTask
from app.schemas.api_v3.video import ApiVideoStatusResponse
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
async def create_video_task(
db: AsyncSession,
api_key_id: str,
model_name: str,
engine_id: str,
engine_snapshot: dict,
content: list[dict],
ratio: str | None,
duration: int | None,
resolution: str | None,
provider_generation_resolution: str | None,
upscale_enabled: bool,
upscale_snapshot_json: str | None,
idempotency_key: str | None = None,
local_media_refs: list[dict] | None = None,
) -> ApiGenerationTask:
"""创建视频生成任务记录。
如果调用方已下载好媒体文件(local_media_refs),则直接复用,避免重复下载。
"""
# 提取文本提示词
text_parts = [p.get("text", "") for p in content if p.get("type") == "text"]
original_prompt = " ".join(text_parts) if text_parts else content[0].get("text", "") if content else ""
# 构建 media_references(扁平格式,便于外部读取)
# 构建 local_media_json(嵌套格式,与 Volcano SDK 兼容)
media_refs = [] # 扁平格式: {"type": "image", "url": "...", "role": "..."}
for p in content:
ptype = p.get("type", "")
if ptype == "text":
continue
# 提取原始 URL(从嵌套格式中提取)
original_url = ""
media_type = ptype.replace("_url", "") # image_url -> image
if ptype == "image_url" and p.get("image_url"):
original_url = p["image_url"].get("url", "")
elif ptype == "video_url" and p.get("video_url"):
original_url = p["video_url"].get("url", "")
elif ptype == "audio_url" and p.get("audio_url"):
original_url = p["audio_url"].get("url", "")
# 存储扁平格式到 media_references
media_refs.append({
"type": media_type,
"url": original_url,
"role": p.get("role"),
})
# 如果调用方已传入 local_media_refs(已下载),直接使用,不再重复下载
if local_media_refs is None:
from app.services.api_v3.file_service import process_media_url
local_media_refs = [] # 本地下载路径(嵌套格式)
for p in content:
ptype = p.get("type", "")
if ptype == "text":
continue
original_url = ""
if ptype == "image_url" and p.get("image_url"):
original_url = p["image_url"].get("url", "")
elif ptype == "video_url" and p.get("video_url"):
original_url = p["video_url"].get("url", "")
elif ptype == "audio_url" and p.get("audio_url"):
original_url = p["audio_url"].get("url", "")
# 下载文件到本地
try:
local_path = await process_media_url(original_url, ptype.replace("_url", ""))
except Exception as exc:
logger.warning("Failed to download media %s: %s", original_url[:80], exc)
local_path = original_url
# 本地路径使用嵌套格式(与 Volcano SDK 兼容)
local_media_refs.append({
"type": ptype,
ptype: {"url": local_path},
"role": p.get("role"),
})
media_references_json = json.dumps(media_refs, ensure_ascii=False) if media_refs else None
local_media_json = json.dumps(local_media_refs, ensure_ascii=False) if local_media_refs else None
now = datetime.now(timezone.utc)
deadline = now + timedelta(hours=24)
task = ApiGenerationTask(
id=generate_id(),
api_key_id=api_key_id,
external_idempotency_key=idempotency_key,
original_prompt=original_prompt,
gen_type="video",
model_name=model_name,
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
generation_count=1,
engine_id=engine_id,
media_references=media_references_json,
local_media_json=local_media_json,
engine_snapshot_json=json.dumps(engine_snapshot, ensure_ascii=False),
status="pending",
pipeline_stage="queued",
deadline_at=deadline,
video_upscale_enabled_snapshot=upscale_enabled,
video_upscale_snapshot_json=upscale_snapshot_json,
)
db.add(task)
await db.flush()
return task
async def create_image_task(
db: AsyncSession,
api_key_id: str,
model_name: str,
engine_id: str,
engine_snapshot: dict,
prompt: str,
size: str | None,
idempotency_key: str | None = None,
) -> ApiGenerationTask:
"""创建图片生成任务记录。"""
task = ApiGenerationTask(
id=generate_id(),
api_key_id=api_key_id,
external_idempotency_key=idempotency_key,
original_prompt=prompt,
gen_type="image",
image_size=size,
generation_count=1,
engine_id=engine_id,
engine_snapshot_json=json.dumps(engine_snapshot, ensure_ascii=False),
status="processing",
pipeline_stage="creating_provider_task",
)
db.add(task)
await db.flush()
return task
async def get_task(db: AsyncSession, task_id: str, api_key_id: str) -> ApiGenerationTask | None:
"""获取任务(带所有权验证)。"""
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.id == task_id,
ApiGenerationTask.api_key_id == api_key_id,
ApiGenerationTask.deleted_at.is_(None),
).limit(1)
)
return result.scalar_one_or_none()
async def find_by_idempotency_key(db: AsyncSession, api_key_id: str, idempotency_key: str) -> ApiGenerationTask | None:
"""根据幂等键查找已存在的任务。"""
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.api_key_id == api_key_id,
ApiGenerationTask.external_idempotency_key == idempotency_key,
ApiGenerationTask.deleted_at.is_(None),
).limit(1)
)
return result.scalar_one_or_none()
def map_task_to_status_response(task: ApiGenerationTask) -> ApiVideoStatusResponse:
"""将任务对象映射为状态查询响应。"""
from app.config import settings
# 返回完整 URL(包含 BASE_URL
video_url = _make_full_url(task.video_url)
video_cover_url = _make_full_url(task.video_cover_url)
return ApiVideoStatusResponse(
task_id=task.id,
status=_map_status(task.status),
video_url=video_url,
video_cover_url=video_cover_url,
duration=task.duration,
ratio=task.aspect_ratio,
resolution=task.resolution,
error=task.error_message,
created_at=task.created_at,
completed_at=task.generated_at,
)
def _make_full_url(path: str | None) -> str | None:
"""将本地路径转换为完整 URL。"""
if not path:
return None
from app.config import settings
# 如果已经是完整 URL,直接返回
if path.startswith(("http://", "https://")):
return path
# 处理 ./storage/generate/... 格式 → /generate/...
if path.startswith("./storage"):
url_path = path[len("./storage"):]
elif path.startswith("/"):
url_path = path
else:
url_path = f"/{path}"
# 拼接 BASE_URL
base = settings.BASE_URL.rstrip("/")
return f"{base}{url_path}"
def _map_status(status: str) -> str:
"""将内部状态映射为 API 状态。"""
status_map = {
"pending": "queued",
"queued": "pending_queue",
"generating": "generating",
"processing": "generating",
"completed": "completed",
"failed": "failed",
}
return status_map.get(status, status)
@@ -0,0 +1,213 @@
import json
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_upscale_link import ApiUpscaleLink
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
async def get_or_create_upscale_config(
db: AsyncSession,
api_key_id: str,
) -> ApiKeyUpscaleConfig:
"""获取或创建 API Key 的超分配置。"""
result = await db.execute(
select(ApiKeyUpscaleConfig).where(
ApiKeyUpscaleConfig.api_key_id == api_key_id
).limit(1)
)
config = result.scalar_one_or_none()
if not config:
config = ApiKeyUpscaleConfig(
id=generate_id(),
api_key_id=api_key_id,
enabled=False,
delete_source_after_success=True,
rules_json="[]",
)
db.add(config)
await db.flush()
return config
async def save_upscale_config(
db: AsyncSession,
api_key_id: str,
enabled: bool,
delete_source_after_success: bool,
rules: list[dict],
) -> ApiKeyUpscaleConfig:
"""保存 API Key 的超分配置。"""
config = await get_or_create_upscale_config(db, api_key_id)
config.enabled = enabled
config.delete_source_after_success = delete_source_after_success
config.rules_json = json.dumps(rules, ensure_ascii=False)
await db.flush()
return config
async def build_api_upscale_snapshot(
db: AsyncSession,
api_key_id: str,
target_resolution: str,
aspect_ratio: str | None = None,
) -> tuple[str | None, bool, str | None]:
"""构建 API 超分快照。
读取 api_key_upscale_configs(而非 system_configs),
匹配目标分辨率对应的超分规则。
Returns:
(provider_generation_resolution, enabled, snapshot_json)
"""
config = await get_or_create_upscale_config(db, api_key_id)
if not config.enabled:
return None, False, None
try:
rules = json.loads(config.rules_json) if config.rules_json else []
except (json.JSONDecodeError, TypeError):
return None, False, None
# 匹配规则
matched_rule = None
for rule in rules:
if rule.get("enabled") and rule.get("target_resolution") == target_resolution:
matched_rule = rule
break
if not matched_rule:
return None, False, None
snapshot = {
"enabled": True,
"delete_source_after_success": config.delete_source_after_success,
"rule": matched_rule,
"matched_at": datetime.now(timezone.utc).isoformat(),
# 兼容现有超分流水线的 processor 字段
"processor": {
"max_attempts": 3,
"processor_key": matched_rule.get("processor_key", "volc_large_model_v1"),
},
"target_resolution": target_resolution,
"provider_generation_resolution": matched_rule.get("provider_generation_resolution", target_resolution),
"aspect_ratio": aspect_ratio,
}
provider_resolution = matched_rule.get("provider_generation_resolution", target_resolution)
snapshot_json = json.dumps(snapshot, ensure_ascii=False)
return provider_resolution, True, snapshot_json
async def prepare_api_upscale_task(
db: AsyncSession,
api_task: ApiGenerationTask,
source_local_path: str,
source_width: int = 0,
source_height: int = 0,
source_duration: float = 0.0,
source_file_size_bytes: int = 0
) -> "VideoUpscaleTask | None":
"""为 API 任务创建超分子任务。
复用现有的 VideoUpscaleTask 表和 upscale 执行流水线。
如果已存在超分任务则返回 None(避免重复创建)。
"""
from app.models.video_upscale_task import VideoUpscaleTask
from sqlalchemy import select
# 检查是否已存在超分任务(避免重复创建)
existing = await db.execute(
select(VideoUpscaleTask).where(
VideoUpscaleTask.api_generation_task_id == api_task.id
).limit(1)
)
if existing.scalar_one_or_none():
logger.info("Upscale task already exists for API task %s, skipping", api_task.id)
return None
# 解析快照获取处理器配置
try:
snapshot = json.loads(api_task.video_upscale_snapshot_json) if api_task.video_upscale_snapshot_json else {}
except (json.JSONDecodeError, TypeError):
snapshot = {}
rule = snapshot.get("rule", {})
processor_key = rule.get("processor_key", "volc_large_model_v1")
target_resolution = rule.get("target_resolution", api_task.resolution or "1080p")
# 计算目标尺寸
target_width, target_height = _resolution_to_dimensions(target_resolution, api_task.aspect_ratio)
upscale_task = VideoUpscaleTask(
id=generate_id(),
chat_generation_task_id=None,
generation_record_id=None,
api_generation_task_id=api_task.id, # 关联 API v3 任务
processor_key=processor_key,
target_width=target_width,
target_height=target_height,
effective_target_width=target_width,
effective_target_height=target_height,
source_local_path=api_task.local_path or source_local_path, # 优先使用已下载的本地文件
source_remote_url=api_task.remote_result_url, # 火山 MediaKit 需要远程 URL
input_source_type="provider_remote",
source_file_size_bytes=source_file_size_bytes,
source_width=source_width,
source_height=source_height,
source_duration_seconds=source_duration,
status="pending",
stage="upscale_queued",
)
db.add(upscale_task)
await db.flush()
# 创建关联记录
link = ApiUpscaleLink(
id=generate_id(),
api_generation_task_id=api_task.id,
video_upscale_task_id=upscale_task.id,
)
db.add(link)
await db.flush()
logger.info(
"API upscale task prepared: api_task=%s upscale_task=%s processor=%s",
api_task.id, upscale_task.id, processor_key,
)
return upscale_task
def _resolution_to_dimensions(resolution: str, aspect_ratio: str | None) -> tuple[int, int]:
"""将分辨率名称转换为像素尺寸。"""
# 标准分辨率映射
resolution_map = {
"480p": (852, 480),
"720p": (1280, 720),
"1080p": (1920, 1080),
"2K": (2560, 1440),
"4K": (3840, 2160),
}
base = resolution_map.get(resolution, (1920, 1080))
# 根据宽高比调整
if aspect_ratio == "9:16":
return (base[1], base[0]) # 竖屏
elif aspect_ratio == "1:1":
return (base[0], base[0]) # 正方形
elif aspect_ratio == "4:3":
return (base[0], int(base[0] * 3 / 4))
return base
@@ -0,0 +1,150 @@
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_usage_log import ApiUsageLog
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
async def record_usage(
db: AsyncSession,
api_key_id: str,
request_type: str,
model_name: str,
gen_type: str,
status: str,
task_id: str | None = None,
credits_cost: float = 0.0,
tokens_used: int = 0,
request_duration_ms: int = 0,
error_message: str | None = None,
error_code: str | None = None,
request_payload_json: str | None = None,
price_action: str | None = None,
resolution: str | None = None,
duration: int | None = None,
refund_amount: float | None = None,
quota_before: float | None = None,
quota_after: float | None = None,
price_detail_json: str | None = None,
) -> ApiUsageLog:
"""记录一次 API 调用日志。"""
# 确定 price_action
if price_action:
action = price_action
elif status == "failed":
action = "refund"
else:
action = "deduct"
log = ApiUsageLog(
id=generate_id(),
api_key_id=api_key_id,
api_generation_task_id=task_id,
price_action=action,
request_type=request_type,
model_name=model_name,
gen_type=gen_type,
resolution=resolution,
duration=duration,
credits_cost=credits_cost,
refund_amount=refund_amount or 0.0,
quota_before=quota_before,
quota_after=quota_after,
tokens_used=tokens_used,
request_duration_ms=request_duration_ms,
price_detail_json=price_detail_json,
status=status,
error_message=error_message,
error_code=error_code,
request_payload_json=request_payload_json,
)
db.add(log)
await db.flush()
return log
async def list_usage_logs(
db: AsyncSession,
api_key_id: str | None = None,
skip: int = 0,
limit: int = 50,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> tuple[int, list[ApiUsageLog]]:
"""查询使用日志(分页+筛选)。"""
query = select(ApiUsageLog)
count_query = select(func.count(ApiUsageLog.id))
filters = []
if api_key_id:
filters.append(ApiUsageLog.api_key_id == api_key_id)
if start_date:
filters.append(ApiUsageLog.created_at >= start_date)
if end_date:
filters.append(ApiUsageLog.created_at <= end_date)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(ApiUsageLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
logs = list(result.scalars().all())
return total, logs
async def get_usage_summary(
db: AsyncSession,
api_key_id: str | None = None,
days: int = 30,
) -> dict:
"""获取使用汇总统计。"""
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
query = select(
func.count(ApiUsageLog.id).label("total_requests"),
func.coalesce(func.sum(ApiUsageLog.credits_cost), 0).label("total_credits"),
func.coalesce(func.sum(ApiUsageLog.tokens_used), 0).label("total_tokens"),
func.coalesce(func.avg(ApiUsageLog.request_duration_ms), 0).label("avg_duration"),
).where(ApiUsageLog.created_at >= start)
if api_key_id:
query = query.where(ApiUsageLog.api_key_id == api_key_id)
result = await db.execute(query)
row = result.one()
# 成功/失败统计
success_query = select(func.count(ApiUsageLog.id)).where(
ApiUsageLog.created_at >= start,
ApiUsageLog.status == "success",
)
failed_query = select(func.count(ApiUsageLog.id)).where(
ApiUsageLog.created_at >= start,
ApiUsageLog.status == "failed",
)
if api_key_id:
success_query = success_query.where(ApiUsageLog.api_key_id == api_key_id)
failed_query = failed_query.where(ApiUsageLog.api_key_id == api_key_id)
success_result = await db.execute(success_query)
failed_result = await db.execute(failed_query)
return {
"total_requests": row.total_requests or 0,
"total_credits_cost": float(row.total_credits or 0),
"total_tokens_used": int(row.total_tokens or 0),
"avg_duration_ms": int(row.avg_duration or 0),
"success_count": success_result.scalar_one() or 0,
"failed_count": failed_result.scalar_one() or 0,
}
+292
View File
@@ -0,0 +1,292 @@
import logging
import random
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.invoice import Invoice, InvoiceOrder
from app.models.payment_order import PaymentOrder
from app.schemas.invoice import InvoiceCreateRequest, InvoiceStatusUpdateRequest
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
CST = timezone(timedelta(hours=8))
def _generate_invoice_no() -> str:
"""生成发票编号:FP + YYYYMMDD + 5位随机数。"""
now = datetime.now(CST)
date_str = now.strftime("%Y%m%d")
random_part = str(random.randint(10000, 99999))
return f"FP{date_str}{random_part}"
async def check_orders_available(
db: AsyncSession,
order_ids: list[str],
exclude_invoice_id: str | None = None,
) -> list[dict]:
"""检查订单是否已被其他 processing/success 发票占用。
返回被占用的订单列表,每项包含 order_id、order_no、invoice_no。
"""
stmt = (
select(InvoiceOrder.order_id, InvoiceOrder.order_no, Invoice.invoice_no)
.join(Invoice, InvoiceOrder.invoice_id == Invoice.id)
.where(
InvoiceOrder.order_id.in_(order_ids),
Invoice.status.in_(["processing", "success"]),
)
)
if exclude_invoice_id:
stmt = stmt.where(Invoice.id != exclude_invoice_id)
result = await db.execute(stmt)
rows = result.all()
return [
{"order_id": row.order_id, "order_no": row.order_no, "invoice_no": row.invoice_no}
for row in rows
]
async def create_invoice(
db: AsyncSession,
user_id: str,
data: InvoiceCreateRequest,
) -> Invoice:
"""创建发票。校验订单归属、订单唯一性,创建主表+关联表。"""
# 1. 查询订单并校验归属
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.id.in_(data.order_ids))
)
orders = result.scalars().all()
if len(orders) != len(data.order_ids):
found_ids = {o.id for o in orders}
missing = [oid for oid in data.order_ids if oid not in found_ids]
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单不存在: {', '.join(missing)}",
)
for order in orders:
if order.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 不属于当前用户",
)
if order.status != "paid":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 未支付,无法开票",
)
# 2. 检查订单唯一性
occupied = await check_orders_available(db, data.order_ids)
if occupied:
details = "; ".join(
f"订单 {o['order_no']} 已被发票 {o['invoice_no']} 占用"
for o in occupied
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=details,
)
# 3. 创建发票
total_amount = sum(float(o.amount) for o in orders)
total_credits = sum(float(o.credits) for o in orders)
invoice = Invoice(
id=generate_id(),
user_id=user_id,
invoice_no=_generate_invoice_no(),
header_type=data.header_type,
header_name=data.header_name,
header_tax_no=data.header_tax_no,
header_register_address=data.header_register_address,
header_register_phone=data.header_register_phone,
header_bank_name=data.header_bank_name,
header_bank_account=data.header_bank_account,
email=data.email,
total_amount=round(total_amount, 2),
total_credits=round(total_credits, 2),
status="processing",
)
db.add(invoice)
await db.flush()
# 4. 创建关联表
for order in orders:
io = InvoiceOrder(
id=generate_id(),
invoice_id=invoice.id,
order_id=order.id,
order_no=order.order_no,
amount=round(float(order.amount), 2),
credits=round(float(order.credits), 2),
)
db.add(io)
await db.flush()
return invoice
async def get_user_invoices(
db: AsyncSession,
user_id: str,
page: int = 1,
page_size: int = 20,
) -> tuple[list[Invoice], int]:
"""获取用户发票列表。"""
count_query = select(func.count(Invoice.id)).where(Invoice.user_id == user_id)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
select(Invoice)
.where(Invoice.user_id == user_id)
.order_by(Invoice.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
invoices = result.scalars().all()
return list(invoices), total
async def get_invoice_by_id(
db: AsyncSession,
invoice_id: str,
) -> Invoice | None:
"""获取发票详情。"""
result = await db.execute(
select(Invoice).where(Invoice.id == invoice_id).limit(1)
)
return result.scalar_one_or_none()
async def get_invoice_with_orders(
db: AsyncSession,
invoice_id: str,
) -> dict | None:
"""获取发票+关联订单详情。"""
invoice = await get_invoice_by_id(db, invoice_id)
if not invoice:
return None
result = await db.execute(
select(InvoiceOrder)
.where(InvoiceOrder.invoice_id == invoice_id)
.order_by(InvoiceOrder.created_at.asc())
)
orders = result.scalars().all()
return {
"invoice": invoice,
"orders": list(orders),
}
async def update_invoice_status(
db: AsyncSession,
invoice_id: str,
data: InvoiceStatusUpdateRequest,
admin_id: str,
) -> Invoice:
"""更新发票状态,记录审计日志。"""
invoice = await get_invoice_by_id(db, invoice_id)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="发票不存在",
)
# 终态校验
if invoice.status in ("success", "failed"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"发票已终结({invoice.status}),无法变更",
)
old_status = invoice.status
invoice.status = data.status
if data.status == "success":
invoice.issued_at = datetime.now(CST)
invoice.failure_reason = None
elif data.status == "failed":
invoice.failure_reason = data.failure_reason
invoice.issued_at = None
await db.flush()
return invoice, old_status
async def get_admin_invoices(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
status_filter: str | None = None,
phone: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> tuple[list[dict], int]:
"""后台获取发票列表(含用户信息)。"""
from app.models.user import User
query = select(Invoice, User.username, User.phone).join(User, Invoice.user_id == User.id)
count_query = select(func.count(Invoice.id))
filters = []
if status_filter:
filters.append(Invoice.status == status_filter)
if phone:
filters.append(User.phone.ilike(f"%{phone.strip()}%"))
if start_date:
filters.append(Invoice.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(
Invoice.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(Invoice.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = []
for invoice, username, user_phone in rows:
# 获取关联订单数
order_count_result = await db.execute(
select(func.count(InvoiceOrder.id)).where(InvoiceOrder.invoice_id == invoice.id)
)
order_count = order_count_result.scalar() or 0
items.append({
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"userId": invoice.user_id,
"username": username,
"phone": user_phone,
"headerType": invoice.header_type,
"headerName": invoice.header_name,
"email": invoice.email,
"totalAmount": round(float(invoice.total_amount), 2),
"totalCredits": round(float(invoice.total_credits), 2),
"orderCount": order_count,
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
})
return items, total
@@ -0,0 +1,128 @@
import logging
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.invoice_header import InvoiceHeader
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderUpdate
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
async def create_header(db: AsyncSession, user_id: str, data: InvoiceHeaderCreate) -> InvoiceHeader:
"""创建发票抬头。"""
now = datetime.now(timezone.utc)
header = InvoiceHeader(
id=generate_id(),
user_id=user_id,
type=data.type,
name=data.name,
tax_no=data.tax_no,
register_address=data.register_address,
register_phone=data.register_phone,
bank_name=data.bank_name,
bank_account=data.bank_account,
email=data.email,
is_default=data.is_default,
created_at=now,
updated_at=now,
)
# 如果设为默认,先将其他抬头取消默认
if data.is_default:
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id)
.values(is_default=False, updated_at=now)
)
db.add(header)
await db.flush()
return header
async def get_user_headers(db: AsyncSession, user_id: str) -> list[InvoiceHeader]:
"""获取用户的所有发票抬头。"""
result = await db.execute(
select(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id)
.order_by(InvoiceHeader.is_default.desc(), InvoiceHeader.created_at.desc())
)
return list(result.scalars().all())
async def get_header_by_id(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader | None:
"""获取指定发票抬头(仅限本人)。"""
result = await db.execute(
select(InvoiceHeader).where(
InvoiceHeader.id == header_id,
InvoiceHeader.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def update_header(
db: AsyncSession, header_id: str, user_id: str, data: InvoiceHeaderUpdate
) -> InvoiceHeader:
"""更新发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
update_data = {}
for field, value in data.model_dump(exclude_unset=True).items():
update_data[field] = value
if update_data:
update_data["updated_at"] = datetime.now(timezone.utc)
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.id == header_id)
.values(**update_data)
)
# 如果设为默认,先将其他抬头取消默认
if data.is_default:
now = datetime.now(timezone.utc)
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
.values(is_default=False, updated_at=now)
)
await db.refresh(header)
return header
async def delete_header(db: AsyncSession, header_id: str, user_id: str) -> None:
"""删除发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
await db.delete(header)
await db.flush()
async def set_default_header(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader:
"""设置默认发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
now = datetime.now(timezone.utc)
# 先取消其他默认
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
.values(is_default=False, updated_at=now)
)
# 设置当前为默认
header.is_default = True
header.updated_at = now
await db.flush()
return header
+6 -6
View File
@@ -59,15 +59,15 @@ AI_LOG_ENABLED: bool = True # Set True to enable logging, or use env var AI_LOG
# ── Log output settings ────────────────────────────────────
LOG_DIR = os.path.join(
BASE_LOG_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"log", "AiModel",
"log",
)
LOG_DIR = os.path.join(BASE_LOG_DIR, "AiModel")
# 请求响应日志目录
LOG_R_Q_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"log", "RequestResponse",
)
LOG_R_Q_DIR = os.path.join(BASE_LOG_DIR, "RequestResponse")
# VP V3 虚拟素材库专用日志目录
VP_V3_LOG_DIR = os.path.join(BASE_LOG_DIR, "virtual_portrait_v3")
LOG_FILENAME_FORMAT = "{date}.log" # e.g. 2026-05-12.log
LOG_DATE_FORMAT = "%Y-%m-%d"
@@ -298,6 +298,7 @@ def _base_entry(
step_id: str | None = None,
remote_action: str | None = None,
remote_request_id: str | None = None,
api_key_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
@@ -321,6 +322,7 @@ def _base_entry(
"step_id": step_id,
"remote_action": remote_action,
"remote_request_id": remote_request_id,
"api_key_id": api_key_id,
"message": message,
"detail": detail or {},
"error": error,
@@ -345,6 +347,7 @@ def log_operation_event(
step_id: str | None = None,
remote_action: str | None = None,
remote_request_id: str | None = None,
api_key_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
@@ -369,6 +372,7 @@ def log_operation_event(
step_id=step_id,
remote_action=remote_action,
remote_request_id=remote_request_id,
api_key_id=api_key_id,
message=message,
detail=detail,
error=error,
@@ -390,6 +394,7 @@ def log_module_generation_event(
step_id: str | None = None,
remote_action: str | None = None,
remote_request_id: str | None = None,
api_key_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
@@ -413,6 +418,7 @@ def log_module_generation_event(
step_id=step_id,
remote_action=remote_action,
remote_request_id=remote_request_id,
api_key_id=api_key_id,
message=message,
detail=detail,
error=error,
@@ -30,23 +30,36 @@ def owner_id(owner: VideoUpscaleOwner | None) -> str | None:
def owner_is_generating(owner: VideoUpscaleOwner) -> bool:
if isinstance(owner, ChatGenerationTask):
return owner.status == ChatGenerationTaskStatus.GENERATING.value
if hasattr(owner, "api_key_id"):
# ApiGenerationTask
return owner.status in ("generating", "processing", "pending")
return owner.status == GenerationStatus.generating.value
def owner_is_completed(owner: VideoUpscaleOwner) -> bool:
if isinstance(owner, ChatGenerationTask):
return owner.status == ChatGenerationTaskStatus.COMPLETED.value
if hasattr(owner, "api_key_id"):
# ApiGenerationTask
return owner.status == "completed"
return owner.status == GenerationStatus.completed.value
def set_owner_stage(owner: VideoUpscaleOwner, stage: str) -> None:
owner.pipeline_stage = stage
# ApiGenerationTask 没有 pipeline_stage 字段,使用 stage 字段
if hasattr(owner, "pipeline_stage"):
owner.pipeline_stage = stage
elif hasattr(owner, "stage"):
owner.stage = stage
def upscale_stage_value(owner: VideoUpscaleOwner, chat_stage: ChatGenerationPipelineStage | str) -> str:
value = chat_stage.value if hasattr(chat_stage, "value") else str(chat_stage)
if isinstance(owner, ChatGenerationTask):
return value
if hasattr(owner, "api_key_id"):
# ApiGenerationTask - 直接返回 stage 值
return value
try:
return GenerationRecordPipelineStage(value).value
except ValueError:
@@ -69,6 +82,13 @@ async def load_upscale_owner(
GenerationRecord.id == upscale.generation_record_id,
GenerationRecord.deleted_at.is_(None),
)
elif upscale.api_generation_task_id:
# API v3 任务
from app.models.api.api_generation_task import ApiGenerationTask
query = select(ApiGenerationTask).where(
ApiGenerationTask.id == upscale.api_generation_task_id,
ApiGenerationTask.deleted_at.is_(None),
)
else:
return None
if for_update:
@@ -379,7 +379,8 @@ async def _claim(
return None
if upscale.status in {VideoUpscaleTaskStatus.COMPLETED.value, VideoUpscaleTaskStatus.FAILED.value}:
return None
if not owner_is_generating(task):
# 对于 API v3 任务(有 api_key_id 属性),即使所有者已完成也允许超分继续
if not hasattr(task, "api_key_id") and not owner_is_generating(task):
return None
lease_until = _aware(upscale.lease_until)
if lease_until and lease_until > _now() and upscale.status == VideoUpscaleTaskStatus.PROCESSING.value:
@@ -0,0 +1,13 @@
from app.services.virtual_portrait_v3 import (
quota_service,
project_service,
asset_service,
upload_service,
)
__all__ = [
"quota_service",
"project_service",
"asset_service",
"upload_service",
]
@@ -0,0 +1,674 @@
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.virtual_portrait_v3 import VpV3Asset, VpV3Project
from app.schemas.virtual_portrait_v3.asset import (
VpV3AssetCreate,
VpV3AssetListOut,
VpV3AssetOut,
VpV3SelectableAssetListOut,
VpV3SelectableAssetOut,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.ark_client import (
ArkPrivateAssetClient,
ArkPrivateAssetClientError,
)
from app.services.virtual_portrait_v3.project_service import (
refresh_project_counters,
)
from app.services.virtual_portrait_v3.quota_service import (
_bytes_to_mb,
_refresh_quota_used,
check_asset_quota,
get_quota,
remote_project_name,
)
from app.services.virtual_portrait_v3.upload_service import (
delete_local_file_by_url,
download_url_to_local,
)
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
DOMAIN = "virtual_portrait_v3"
URL_RE_REMOTE_URL_EXPR = re.compile(r"^https?://", re.IGNORECASE)
URL_LOCAL_UPLOAD_EXPR = re.compile(r"^/uploads/|^https?://[^/]+/uploads/", re.IGNORECASE)
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
def _json(data) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def asset_to_out(a: VpV3Asset) -> VpV3AssetOut:
return VpV3AssetOut(
asset_id=a.id,
project_id=a.project_id,
name=a.name,
asset_type=a.asset_type,
status=a.status,
source_url=a.source_url,
preview_url=a.preview_url,
remote_url=a.remote_url,
remote_url_expired_at=a.remote_url_expired_at,
video_duration=a.video_duration,
video_cover_url=a.video_cover_url,
file_size_bytes=a.file_size_bytes,
mime_type=a.mime_type,
moderation_json=a.moderation_json,
error_message=a.error_message,
remote_delete_status=a.remote_delete_status or PrivatePortraitRemoteDeleteStatus.NONE.value,
created_at=a.created_at,
updated_at=a.updated_at,
)
def asset_to_selectable(a: VpV3Asset) -> VpV3SelectableAssetOut:
return VpV3SelectableAssetOut(
asset_id=a.id,
project_id=a.project_id,
name=a.name,
asset_type=a.asset_type,
status=a.status,
source_url=a.source_url,
preview_url=a.preview_url or a.remote_url or a.source_url,
video_duration=a.video_duration,
video_cover_url=a.video_cover_url,
file_size_bytes=a.file_size_bytes,
created_at=a.created_at,
)
def _validate_source_url(url: str, asset_type: str) -> None:
"""创建素材时的 source_url 现在只允许 http(s) 的外部 URL。
旧的 /uploads/* 本地 URL 已不再推荐(直接让系统自己下载保存)。"""
if not url or not url.strip():
raise HTTPException(status_code=400, detail="source_url 不能为空")
stripped = url.strip()
if not stripped.lower().startswith("http://") and not stripped.lower().startswith("https://"):
raise HTTPException(
status_code=400,
detail="source_url 必须是公网可访问的 http(s) URL;本服务会自动下载并保存到本地",
)
if len(stripped) > 2000:
raise HTTPException(status_code=400, detail="source_url 过长(最多 2000 字符)")
# ---------------------------------------------------------------------------
# Asset CRUD
# ---------------------------------------------------------------------------
async def create_asset(
db: AsyncSession,
*,
api_key_id: str,
project: VpV3Project,
payload: VpV3AssetCreate,
) -> VpV3Asset:
"""在项目下创建素材:
**新流程(一步到位)**
1. project 状态校验
2. source_url 格式校验
3. 将 source_url 下载保存到本地 vp_v3 上传目录(占用磁盘,校验 MIME/大小/网络)
- 失败:抛 HTTPException400/413/415/502/500),不留临时文件
4. 配额校验(素材数 + 存储 MB,用下载后的实际 file_size_bytes
- 失败:**立刻删除本地已下载的文件**,避免占用磁盘;再抛 403
5. Video 时长校验(payload.video_duration 优先,否则用 ffprobe 探测到的值;>60s 报错)
- 失败:删本地文件 → 抛 400
6. 写 VpV3AssetCreating 状态,带 next_poll_at
- 失败:删本地文件 → 抛 500
7. 调 Ark CreateAsset(url=本地公网 URL),异步审核
- 异常:status 置为 FAILED,保留本地文件(因为已占配额和素材数,走删除接口会清理)
8. 刷新项目计数 + 配额 used,返回素材
"""
# 1. project 状态校验
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail=f"项目状态 {project.status} 不可创建素材,仅 active 项目可操作")
# 2. source_url 校验(只允许公网 http(s)
_validate_source_url(payload.source_url, payload.asset_type)
downloaded: "DownloadedAsset | None" = None
try:
# 3. URL → 本地下载保存(此处负责 URL 合法性/网络/MIME/大小的校验及抛错)
downloaded = await download_url_to_local(
api_key_id=api_key_id,
asset_type=payload.asset_type,
source_url=payload.source_url,
requested_filename=payload.name,
)
file_size_bytes = downloaded.file_size_bytes
# 4. 配额校验(素材数 + 存储),这里已经拿到真实 file_size_bytes
try:
await check_asset_quota(
db,
api_key_id=api_key_id,
asset_count_delta=1,
file_size_bytes=file_size_bytes,
)
except HTTPException:
# 配额不足 → 立刻清理刚下载好的本地文件,再抛
_safe_delete_local_file(downloaded.url)
raise
# 5. Video 时长:优先用 payload.video_duration,否则用探测值
effective_video_duration: float | None = None
if payload.asset_type == PrivatePortraitAssetType.VIDEO.value:
if payload.video_duration is not None and payload.video_duration > 0:
effective_video_duration = float(payload.video_duration)
elif downloaded.duration_seconds is not None and downloaded.duration_seconds > 0:
effective_video_duration = float(downloaded.duration_seconds)
else:
_safe_delete_local_file(downloaded.url)
raise HTTPException(
status_code=400,
detail="Video 素材无法获取时长:请显式传 video_duration(秒),或确保 URL 指向合法的视频文件",
)
if effective_video_duration > 60:
_safe_delete_local_file(downloaded.url)
raise HTTPException(status_code=400, detail="视频素材时长不能超过 60 秒")
# 素材展示名:payload.name → downloaded.suggested_name → filename 去扩展名
final_name: str | None = (payload.name or "").strip()[:128] or None
if not final_name and downloaded.suggested_name:
final_name = (downloaded.suggested_name or "").strip()[:128] or None
asset = VpV3Asset(
id=generate_id(),
api_key_id=api_key_id,
project_id=project.id,
remote_project_name=project.remote_project_name,
remote_group_id=project.remote_group_id,
remote_asset_id=None,
asset_type=payload.asset_type,
name=final_name,
source_url=payload.source_url, # 本地存储后的 URL
preview_url=downloaded.url, # 初始 preview = 本地 URL
remote_url=None,
remote_url_expired_at=None,
upload_resource_id=None, # 不再使用(旧接口兼容保留字段)
video_duration=effective_video_duration,
video_cover_url=payload.video_cover_url,
file_size_bytes=file_size_bytes,
mime_type=downloaded.mime_type,
status=PrivatePortraitAssetStatus.CREATING.value,
poll_count=0,
next_poll_at=_bj_now() + timedelta(seconds=2),
)
db.add(asset)
await db.flush()
await db.refresh(asset)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
# 任何 DB 写入前的异常 → 能清理就清理本地文件
if downloaded:
_safe_delete_local_file(downloaded.url)
logger.exception("vp_v3 创建素材(下载/写库阶段)异常:%s", exc)
raise HTTPException(status_code=500, detail=f"创建素材失败:{exc}") from exc
# 6. 调 Ark CreateAsset(到这里 DB 已经 flush 成功了)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=project.id,
asset_id=asset.id,
detail={
"remote_project_name": asset.remote_project_name,
"remote_group_id": asset.remote_group_id,
"source_url": asset.source_url,
"asset_type": asset.asset_type,
"original_source_url": payload.source_url.strip()[:500],
},
)
try:
resp = await ArkPrivateAssetClient().create_asset(
project_name=asset.remote_project_name,
group_id=asset.remote_group_id,
url=asset.source_url,
asset_type=asset.asset_type,
name=asset.name,
)
remote_asset_id = resp.get("Id") or resp.get("AssetId") or resp.get("assetId") or resp.get("id")
if not remote_asset_id:
raise RuntimeError("CreateAsset 未返回素材 Id")
asset.remote_asset_id = str(remote_asset_id)
asset.raw_response_json = _json(resp)
asset.remote_url = resp.get("URL") or resp.get("url") or resp.get("Url") or asset.remote_url
if asset.remote_url:
asset.preview_url = asset.remote_url
asset.next_poll_at = _bj_now() + timedelta(seconds=3)
asset.status = PrivatePortraitAssetStatus.CREATING.value
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=project.id,
asset_id=asset.id,
detail={"remote_asset_id": remote_asset_id},
)
await refresh_project_counters(db, [project.id])
_ = await get_quota(db, api_key_id=api_key_id, refresh=True)
return asset
except Exception as exc: # noqa: BLE001
# 火山调用失败 → 保留本地文件(DB 已写好,走删除接口清理),状态 FAILED,带错误
asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = str(exc)
asset.raw_response_json = _json({"error": str(exc)})
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=project.id,
asset_id=asset.id,
exc=exc,
)
raise HTTPException(status_code=502, detail=f"提交火山素材创建失败:{exc}") from exc
def _safe_delete_local_file(local_url: str | None) -> None:
if not local_url:
return
try:
delete_local_file_by_url(local_url)
except Exception: # noqa: BLE001
logger.warning("vp_v3 清理本地文件失败(不抛):%s", local_url)
async def list_assets(
db: AsyncSession,
*,
api_key_id: str,
project_id: str | None = None,
status: str | None = None,
keyword: str | None = None,
asset_type: str | None = None,
page: int,
page_size: int,
) -> tuple[list[VpV3Asset], int]:
"""分页查询素材列表。"""
conds = [VpV3Asset.api_key_id == api_key_id, VpV3Asset.deleted_at.is_(None)]
if project_id:
conds.append(VpV3Asset.remote_group_id == project_id)
if status:
conds.append(VpV3Asset.status == status)
if keyword:
conds.append((VpV3Asset.name.is_not(None)) & (VpV3Asset.name.ilike(f"%{keyword}%")))
if asset_type:
conds.append(VpV3Asset.asset_type == asset_type)
count_result = await db.execute(select(func.count(VpV3Asset.id)).where(*conds))
total = int(count_result.scalar() or 0)
q = (
select(VpV3Asset)
.where(*conds)
.order_by(VpV3Asset.created_at.desc())
.limit(page_size)
.offset((page - 1) * page_size)
)
items = list((await db.execute(q)).scalars().all())
return items, total
async def list_selectable_assets(
db: AsyncSession,
*,
api_key_id: str,
project_id: str | None = None,
keyword: str | None = None,
asset_type: str | None = None,
page: int,
page_size: int,
) -> tuple[list[VpV3Asset], int]:
"""AI 创作选择器素材列表:只返回 status=Active 的。"""
items, total = await list_assets(
db,
api_key_id=api_key_id,
project_id=project_id,
status=PrivatePortraitAssetStatus.ACTIVE.value,
keyword=keyword,
asset_type=asset_type,
page=page,
page_size=page_size,
)
return items, total
async def get_asset(db: AsyncSession, *, api_key_id: str, asset_id: str) -> VpV3Asset:
"""素材详情(权限校验)。"""
row = (await db.execute(
select(VpV3Asset).where(
VpV3Asset.remote_asset_id == asset_id,
VpV3Asset.api_key_id == api_key_id,
VpV3Asset.deleted_at.is_(None),
).limit(1)
)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="虚拟素材不存在")
return row
async def sync_asset_status(
db: AsyncSession,
*,
api_key_id: str,
asset_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> VpV3Asset:
"""主动同步素材状态(调 Ark GetAsset)。
注意:如果素材没有 remote_asset_id(远端还未 CreateAsset 返回),直接跳过并返回当前本地快照。
"""
asset = await get_asset(db, api_key_id=api_key_id, asset_id=asset_id)
if not asset.remote_asset_id:
return asset
try:
resp = await ArkPrivateAssetClient().get_asset(
project_name=asset.remote_project_name, asset_id=asset.remote_asset_id,
)
if execution_guard is not None:
await execution_guard()
_apply_get_asset_response(asset, resp)
except Exception as exc: # noqa: BLE001
# 异常分支也必须推进 poll 计数 + 重算下次轮询时间,避免无限调度且数据库无变化
asset.last_poll_at = _bj_now()
asset.poll_count = int(asset.poll_count or 0) + 1
asset.error_message = f"同步状态失败:{exc}"
logger.warning("vp_v3 同步素材状态失败:asset_id=%s err=%s", asset_id, exc)
# 异常情况仍然保持 CREATING,按指数退避重算 next_poll_at
delays = [3, 6, 12, 30, 60]
idx = min(asset.poll_count, len(delays) - 1)
asset.next_poll_at = _bj_now() + timedelta(seconds=delays[idx])
finally:
await db.flush()
await refresh_project_counters(db, [asset.project_id])
return asset
def _apply_get_asset_response(a: VpV3Asset, resp: dict) -> None:
"""把 Ark GetAsset 响应应用到本地记录(状态、URL、审核信息)。"""
# 先推进公共轮询字段(无论状态映射结果如何,只要调了一次 GetAsset 都必须记录)
a.last_poll_at = _bj_now()
a.poll_count = int(a.poll_count or 0) + 1
a.moderation_json = _json(resp)
a.raw_response_json = _json(resp)
# Status 映射:火山 Status 字段 → 本地枚举
status_raw = str(resp.get("Status") or resp.get("status") or "").lower()
if status_raw in {"active", "success", "done", "available"}:
a.status = PrivatePortraitAssetStatus.ACTIVE.value
elif status_raw in {"creating", "pending", "processing", "auditing"}:
a.status = PrivatePortraitAssetStatus.CREATING.value
elif status_raw in {"failed", "error", "rejected", "invalid"}:
a.status = PrivatePortraitAssetStatus.FAILED.value
msg = resp.get("Message") or resp.get("message") or resp.get("Error") or resp.get("error")
if msg:
a.error_message = str(msg)
else:
# 未知状态保持原
pass
# URL 续期
url = resp.get("URL") or resp.get("url") or resp.get("Url")
if url:
a.remote_url = url
a.preview_url = url
a.remote_url_expired_at = None # 无法解析过期时间就不填
# 视频时长
if not a.video_duration:
dur = resp.get("Duration") or resp.get("duration")
if dur is not None:
try:
a.video_duration = float(dur)
except Exception: # noqa: BLE001
pass
# 大小
if not a.file_size_bytes:
size = resp.get("FileSize") or resp.get("fileSize") or resp.get("size")
if size is not None:
try:
a.file_size_bytes = int(size)
except Exception: # noqa: BLE001
pass
# 状态判断下次轮询时间
if a.status == PrivatePortraitAssetStatus.CREATING.value:
# 指数退避:3s → 6s → 12s → 30s → 60s,最多 60s
delays = [3, 6, 12, 30, 60]
idx = min(a.poll_count, len(delays) - 1)
a.next_poll_at = _bj_now() + timedelta(seconds=delays[idx])
elif a.status == PrivatePortraitAssetStatus.FAILED.value:
a.next_poll_at = None # 失败不再轮询
elif a.status == PrivatePortraitAssetStatus.ACTIVE.value:
a.next_poll_at = None # 成功不再轮询
async def soft_delete_asset(db: AsyncSession, *, api_key_id: str, asset_id: str) -> VpV3Asset:
"""软删素材(本地先标记为删除中,同步删除本地落盘文件,重新计算项目计数和配额 used,然后 commit 后再投递异步远端删除任务)。"""
asset = await get_asset(db, api_key_id=api_key_id, asset_id=asset_id)
pid = asset.project_id
now = _bj_now()
asset.deleted_at = now
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
asset.status = PrivatePortraitAssetStatus.DELETING.value
# 本地落盘文件:立刻删(成功失败都不影响状态,避免占磁盘;失败仅 log)
if asset.source_url:
_safe_delete_local_file(asset.source_url)
await db.flush()
await refresh_project_counters(db, [pid])
q = await get_quota(db, api_key_id=api_key_id, refresh=True)
return asset
# V3 专属的远端删除服务
V3_DOMAIN = "virtual_portrait_v3"
async def _load_v3_asset_delete_snapshot(db: AsyncSession, *, asset_id: str) -> dict | None:
"""加载 V3 素材删除快照。"""
asset = (
await db.execute(
select(VpV3Asset).where(VpV3Asset.remote_asset_id == asset_id).limit(1)
)
).scalar_one_or_none()
if not asset:
return None
return {
"owner_id": str(asset.id),
"owner_type": "asset",
"api_key_id": str(asset.api_key_id),
"project_id": str(asset.project_id),
"remote_id": str(asset.remote_asset_id) if asset.remote_asset_id else None,
"remote_project_name": str(asset.remote_project_name or ""),
"asset_type": str(asset.asset_type or ""),
"remote_delete_status": str(asset.remote_delete_status or ""),
}
async def _apply_v3_asset_delete_result(
db: AsyncSession,
*,
asset_id: str,
remote_id: str | None,
succeeded: bool,
skipped: bool = False,
error: BaseException | None = None,
) -> None:
"""应用 V3 素材远端删除结果到数据库。"""
asset = (
await db.execute(
select(VpV3Asset).where(VpV3Asset.id == asset_id).with_for_update().limit(1)
)
).scalar_one_or_none()
if not asset:
return
if asset.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return
if remote_id and str(asset.remote_asset_id or "") != remote_id:
raise RuntimeError("V3 素材远程 Asset 已变化,旧删除结果已丢弃")
now = _bj_now()
if skipped:
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
asset.remote_delete_error = None
elif succeeded:
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = now
asset.remote_delete_error = None
else:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
asset.remote_delete_error = str(error or "远程删除失败")
await db.flush()
async def delete_v3_asset_remote(
db: AsyncSession,
*,
asset_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> None:
"""V3 素材远端删除(异步 Celery 任务调用)。"""
snapshot = await _load_v3_asset_delete_snapshot(db, asset_id=asset_id)
if snapshot is None:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
asset_id=asset_id,
message="远程删除跳过:本地素材不存在",
)
await db.rollback()
return
if snapshot["remote_delete_status"] in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
await db.rollback()
return
remote_id = snapshot["remote_id"]
if not remote_id:
await _apply_v3_asset_delete_result(
db,
asset_id=asset_id,
remote_id=None,
succeeded=False,
skipped=True,
)
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
message="远程删除跳过:素材没有 remote_asset_id",
)
return
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
detail={
"remote_asset_id": remote_id,
"remote_project_name": snapshot["remote_project_name"],
"asset_type": snapshot["asset_type"],
},
)
await db.rollback()
remote_error: BaseException | None = None
succeeded = False
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(
project_name=snapshot["remote_project_name"],
asset_id=remote_id,
)
succeeded = True
except Exception as exc:
remote_error = exc
# 404 视为幂等成功
if "not found" in str(exc).lower() or "404" in str(exc):
succeeded = True
if execution_guard is not None:
await execution_guard()
await _apply_v3_asset_delete_result(
db,
asset_id=asset_id,
remote_id=remote_id,
succeeded=succeeded,
error=remote_error,
)
if succeeded:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
message="远程资源不存在,按幂等删除成功处理" if remote_error is not None else None,
detail={
"remote_asset_id": remote_id,
"remote_project_name": snapshot["remote_project_name"],
},
)
else:
assert remote_error is not None
log_operation_error(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=snapshot["project_id"],
asset_id=asset_id,
exc=remote_error,
)
@@ -0,0 +1,184 @@
"""VP V3 虚拟素材库专用日志服务。
统一记录所有 VP V3 相关操作日志到 logs/virtual_portrait_v3/ 目录。
按天分文件,便于管理和排查问题。
"""
import json
import logging
import os
from datetime import datetime, timezone
from app.config import settings
# === 日志目录 ===
BASE_LOG_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"log", "virtual_portrait_v3",
)
os.makedirs(BASE_LOG_DIR, exist_ok=True)
class _DailyFileHandler(logging.Handler):
"""按天写入不同日志文件的处理器。"""
def __init__(self, log_dir: str):
super().__init__()
self.log_dir = log_dir
self._current_date = None
self._file_handler = None
self._open_file()
def _open_file(self):
"""打开当天的日志文件。"""
now = datetime.now(timezone.utc)
date_str = now.strftime("%Y-%m-%d")
if date_str == self._current_date and self._file_handler:
return
if self._file_handler:
self._file_handler.close()
self._current_date = date_str
filepath = os.path.join(self.log_dir, f"{date_str}.log")
self._file_handler = open(filepath, "a", encoding="utf-8")
def emit(self, record):
try:
self._open_file()
msg = self.format(record)
self._file_handler.write(msg + "\n")
self._file_handler.flush()
except Exception:
self.handleError(record)
def close(self):
if self._file_handler:
self._file_handler.close()
super().close()
def _create_logger(name: str, filename: str | None = None) -> logging.Logger:
"""创建专用 Logger。"""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# 避免重复添加 handler
if logger.handlers:
return logger
# 按天写入文件
handler = _DailyFileHandler(BASE_LOG_DIR)
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler.setFormatter(formatter)
logger.addHandler(handler)
# 不向上传播到 root logger(避免重复输出到控制台)
logger.propagate = False
return logger
# === 专用 Logger 实例 ===
asset_logger = _create_logger("vp_v3.asset")
project_logger = _create_logger("vp_v3.project")
quota_logger = _create_logger("vp_v3.quota")
api_logger = _create_logger("vp_v3.api")
def log_asset_event(
event_type: str,
api_key_id: str,
asset_id: str | None = None,
project_id: str | None = None,
status: str | None = None,
detail: dict | None = None,
error: str | None = None,
):
"""记录素材相关事件。"""
log_data = {
"event_type": event_type,
"api_key_id": api_key_id,
"asset_id": asset_id,
"project_id": project_id,
"status": status,
"detail": detail or {},
}
if error:
log_data["error"] = error
asset_logger.error(json.dumps(log_data, ensure_ascii=False, default=str))
else:
asset_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
def log_project_event(
event_type: str,
api_key_id: str,
project_id: str | None = None,
status: str | None = None,
detail: dict | None = None,
error: str | None = None,
):
"""记录项目相关事件。"""
log_data = {
"event_type": event_type,
"api_key_id": api_key_id,
"project_id": project_id,
"status": status,
"detail": detail or {},
}
if error:
log_data["error"] = error
project_logger.error(json.dumps(log_data, ensure_ascii=False, default=str))
else:
project_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
def log_quota_event(
event_type: str,
api_key_id: str,
quota_type: str,
amount: float,
quota_before: float | None = None,
quota_after: float | None = None,
detail: dict | None = None,
):
"""记录配额相关事件。"""
log_data = {
"event_type": event_type,
"api_key_id": api_key_id,
"quota_type": quota_type,
"amount": amount,
"quota_before": quota_before,
"quota_after": quota_after,
"detail": detail or {},
}
quota_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
def log_api_request(
method: str,
path: str,
api_key_id: str,
status_code: int,
duration_ms: int,
error: str | None = None,
):
"""记录 API 请求。"""
log_data = {
"method": method,
"path": path,
"api_key_id": api_key_id,
"status_code": status_code,
"duration_ms": duration_ms,
}
if error:
log_data["error"] = error
api_logger.error(json.dumps(log_data, ensure_ascii=False, default=str))
else:
api_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
@@ -0,0 +1,565 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.virtual_portrait_v3 import VpV3Asset, VpV3Project
from app.schemas.virtual_portrait_v3.project import (
VpV3ProjectCreate,
VpV3ProjectListOut,
VpV3ProjectOut,
VpV3ProjectUpdate,
)
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.virtual_portrait_v3.quota_service import (
_bytes_to_mb,
_refresh_quota_used,
_slug,
check_project_quota,
get_quota,
remote_group_name,
remote_project_name,
)
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
DOMAIN = "virtual_portrait_v3"
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
def _json(data) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def project_to_out(p: VpV3Project) -> VpV3ProjectOut:
return VpV3ProjectOut(
project_id=p.remote_group_id,
name=p.name,
description=p.description,
status=p.status,
asset_count=int(p.asset_count or 0),
active_asset_count=int(p.active_asset_count or 0),
image_asset_count=int(p.image_asset_count or 0),
video_asset_count=int(p.video_asset_count or 0),
storage_mb_used=float(p.storage_mb_used or 0),
remote_delete_status=p.remote_delete_status or PrivatePortraitRemoteDeleteStatus.NONE.value,
error_message=p.error_message,
created_at=p.created_at,
updated_at=p.updated_at,
)
# ---------------------------------------------------------------------------
# Project CRUD
# ---------------------------------------------------------------------------
async def create_project(
db: AsyncSession,
*,
api_key_id: str,
payload: VpV3ProjectCreate,
) -> VpV3Project:
"""创建虚拟素材项目(同步调用 Ark CreateAssetGroup)。
1. 配额校验
2. 本地落库 status=creating_remote_group
3. 调 Ark CreateAssetGroup 拿 remote_group_id
4. 本地更新为 active,返回
"""
await check_project_quota(db, api_key_id=api_key_id, delta=1)
# slug = _slug(payload.name)
proj = VpV3Project(
id=generate_id(),
api_key_id=api_key_id,
name=payload.name.strip()[:128],
name_slug=payload.name.strip()[:128],
description=payload.description,
remote_project_name=remote_project_name(),
remote_group_id="",
status=PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value,
asset_count=0,
active_asset_count=0,
image_asset_count=0,
video_asset_count=0,
storage_mb_used=0,
)
db.add(proj)
await db.flush()
await db.refresh(proj)
group_name = remote_group_name(api_key_id=api_key_id, project_slug=proj.name_slug,id=proj.id)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=proj.id,
detail={"remote_group_name": group_name, "remote_project_name": proj.remote_project_name},
)
try:
resp = await ArkPrivateAssetClient().create_asset_group(
project_name=proj.remote_project_name,
name=group_name,
description=payload.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")
proj.remote_group_id = str(remote_group_id)
proj.remote_group_name = group_name
proj.status = PrivatePortraitProjectStatus.ACTIVE.value
proj.raw_response_json = _json(resp)
quota = await get_quota(db, api_key_id=api_key_id, refresh=True)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=proj.id,
detail={"remote_group_id": remote_group_id, "group_name": group_name},
)
return proj
except Exception as exc: # noqa: BLE001
proj.status = PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value
proj.error_message = str(exc)
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=proj.id,
exc=exc,
)
raise HTTPException(status_code=502, detail=f"创建虚拟素材项目失败:{exc}") from exc
async def list_projects(
db: AsyncSession,
*,
api_key_id: str,
page: int,
page_size: int,
keyword: str | None = None,
status: str | None = None,
) -> tuple[list[VpV3Project], int]:
"""按 API Key 分页查询项目列表。"""
conds = [VpV3Project.api_key_id == api_key_id, VpV3Project.deleted_at.is_(None)]
if keyword:
conds.append(VpV3Project.name.ilike(f"%{keyword}%"))
if status:
conds.append(VpV3Project.status == status)
count_result = await db.execute(
select(func.count(VpV3Project.id)).where(*conds)
)
total = int(count_result.scalar() or 0)
q = (
select(VpV3Project)
.where(*conds)
.order_by(VpV3Project.created_at.desc())
.limit(page_size)
.offset((page - 1) * page_size)
)
items = list((await db.execute(q)).scalars().all())
return items, total
async def get_project(db: AsyncSession, *, api_key_id: str, project_id: str) -> VpV3Project:
"""获取项目详情(权限校验)。"""
row = (await db.execute(
select(VpV3Project).where(
VpV3Project.remote_group_id == project_id,
VpV3Project.api_key_id == api_key_id,
VpV3Project.deleted_at.is_(None),
).limit(1)
)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="虚拟素材项目不存在")
return row
async def update_project(
db: AsyncSession,
*,
api_key_id: str,
project_id: str,
payload: VpV3ProjectUpdate,
) -> VpV3Project:
"""更新项目展示信息(名称/描述,不会重新创建远端 Group)。"""
proj = await get_project(db, api_key_id=api_key_id, project_id=project_id)
changed = False
if payload.name is not None and payload.name != proj.name:
proj.name = payload.name.strip()[:128]
proj.name_slug = _slug(payload.name)
changed = True
if payload.description is not None and payload.description != proj.description:
proj.description = payload.description
changed = True
if changed:
await db.flush()
return proj
async def soft_delete_project(
db: AsyncSession,
*,
api_key_id: str,
project_id: str,
) -> VpV3Project:
"""软删项目和其下所有素材(本地先删,等 commit 后再投递异步远端删除任务)。
会把 quota used 重新刷新一次。
"""
proj = await get_project(db, api_key_id=api_key_id, project_id=project_id)
now = _bj_now()
proj.deleted_at = now
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
proj.status = PrivatePortraitProjectStatus.DELETING.value
# 级联软删其下所有素材
await db.execute(
VpV3Asset.__table__.update() # type: ignore[attr-defined]
.where(
VpV3Asset.project_id == proj.id,
VpV3Asset.deleted_at.is_(None),
)
.values(
deleted_at=now,
remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
)
quota = await get_quota(db, api_key_id=api_key_id, refresh=True)
return proj
# ---------------------------------------------------------------------------
# 项目计数刷新(增删素材后调用,用于项目列表快速显示)
# ---------------------------------------------------------------------------
async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) -> None:
"""按真实数据刷新项目 asset 计数和 storage。"""
if not project_ids:
return
for pid in project_ids:
row = (await db.execute(
select(
func.count(VpV3Asset.id),
func.sum(case((VpV3Asset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)),
func.sum(case((VpV3Asset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
func.sum(case((VpV3Asset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
func.sum(case(
(VpV3Asset.status == PrivatePortraitAssetStatus.ACTIVE.value,
case((VpV3Asset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
else_=0
)),
func.sum(case(
(VpV3Asset.status == PrivatePortraitAssetStatus.ACTIVE.value,
case((VpV3Asset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
else_=0
)),
func.coalesce(func.sum(VpV3Asset.file_size_bytes), 0),
).where(
VpV3Asset.project_id == pid,
VpV3Asset.deleted_at.is_(None),
)
)).one()
(total, active, img_cnt, vid_cnt, active_img, active_vid, storage_bytes) = row
proj = (await db.execute(
select(VpV3Project).where(VpV3Project.id == pid).limit(1)
)).scalar_one_or_none()
if proj:
proj.asset_count = int(total or 0)
proj.active_asset_count = int(active or 0)
proj.image_asset_count = int(img_cnt or 0)
proj.video_asset_count = int(vid_cnt or 0)
proj.active_image_asset_count = int(active_img or 0)
proj.active_video_asset_count = int(active_vid or 0)
proj.storage_mb_used = float(_bytes_to_mb(storage_bytes))
# V3 专属的项目远端删除服务
V3_DOMAIN = "virtual_portrait_v3"
async def _load_v3_project_delete_snapshot(db: AsyncSession, *, project_id: str) -> dict | None:
"""加载 V3 项目删除快照。"""
proj = (
await db.execute(
select(VpV3Project).where(VpV3Project.id == project_id).limit(1)
)
).scalar_one_or_none()
if not proj:
return None
return {
"owner_id": str(proj.id),
"owner_type": "project",
"api_key_id": str(proj.api_key_id),
"remote_id": str(proj.remote_group_id) if proj.remote_group_id else None,
"remote_project_name": str(proj.remote_project_name or ""),
"remote_delete_status": str(proj.remote_delete_status or ""),
}
async def _apply_v3_project_delete_result(
db: AsyncSession,
*,
project_id: str,
remote_id: str | None,
succeeded: bool,
skipped: bool = False,
error: BaseException | None = None,
) -> None:
"""应用 V3 项目远端删除结果到数据库。"""
proj = (
await db.execute(
select(VpV3Project)
.where(VpV3Project.id == project_id)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if not proj:
return
if proj.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return
if remote_id and str(proj.remote_group_id or "") != remote_id:
raise RuntimeError("V3 项目远程 Group 已变化,旧删除结果已丢弃")
now = _bj_now()
if skipped:
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
proj.remote_delete_error = None
elif succeeded:
proj.status = PrivatePortraitProjectStatus.DELETED.value
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
proj.remote_deleted_at = now
proj.remote_delete_error = None
else:
proj.status = PrivatePortraitProjectStatus.DELETED.value
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
proj.remote_delete_error = str(error or "远程删除失败")
await db.flush()
# 刷新配额
quota = await get_quota(db, api_key_id=proj.api_key_id, refresh=False)
await _refresh_quota_used(db, quota)
async def delete_v3_project_remote(
db: AsyncSession,
*,
project_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> None:
"""V3 项目远端删除(异步 Celery 任务调用)。
会先级联删除项目下所有素材的远端资源,再删除项目的远端 Group。
"""
# 先删除项目下所有素材的远端资源
# 使用 with_for_update(skip_locked=True) 避免与独立素材删除任务冲突
assets = (
await db.execute(
select(VpV3Asset).where(
VpV3Asset.project_id == project_id,
VpV3Asset.deleted_at.is_not(None),
VpV3Asset.remote_delete_status == PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
.with_for_update(skip_locked=True)
)
).scalars().all()
for asset in assets:
# 二次确认:如果独立素材删除任务已处理完该素材,跳过
if asset.remote_delete_status not in (
PrivatePortraitRemoteDeleteStatus.PENDING.value,
PrivatePortraitRemoteDeleteStatus.FAILED.value,
):
continue
if asset.remote_asset_id:
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(
project_name=asset.remote_project_name,
asset_id=asset.remote_asset_id,
)
await _apply_v3_asset_delete_result_for_project(
db,
asset_id=asset.id,
succeeded=True,
)
except Exception as exc:
if "not found" in str(exc).lower() or "404" in str(exc):
await _apply_v3_asset_delete_result_for_project(
db,
asset_id=asset.id,
succeeded=True,
)
else:
await _apply_v3_asset_delete_result_for_project(
db,
asset_id=asset.id,
succeeded=False,
error=exc,
)
# 再删除项目的远端 Group
snapshot = await _load_v3_project_delete_snapshot(db, project_id=project_id)
if snapshot is None:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="远程删除跳过:本地项目不存在",
)
await db.rollback()
return
if snapshot["remote_delete_status"] in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
await db.rollback()
return
remote_id = snapshot["remote_id"]
if not remote_id:
await _apply_v3_project_delete_result(
db,
project_id=project_id,
remote_id=None,
succeeded=False,
skipped=True,
)
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="远程删除跳过:项目没有 remote_group_id",
)
return
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
detail={
"remote_group_id": remote_id,
"remote_project_name": snapshot["remote_project_name"],
},
)
await db.rollback()
remote_error: BaseException | None = None
succeeded = False
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset_group(
project_name=snapshot["remote_project_name"],
group_id=remote_id,
)
succeeded = True
except Exception as exc:
remote_error = exc
# 404 视为幂等成功
if "not found" in str(exc).lower() or "404" in str(exc):
succeeded = True
if execution_guard is not None:
await execution_guard()
await _apply_v3_project_delete_result(
db,
project_id=project_id,
remote_id=remote_id,
succeeded=succeeded,
error=remote_error,
)
if succeeded:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="远程 Group 不存在,按幂等删除成功处理" if remote_error is not None else None,
detail={
"remote_group_id": remote_id,
"remote_project_name": snapshot["remote_project_name"],
},
)
else:
assert remote_error is not None
log_operation_error(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
exc=remote_error,
)
async def _apply_v3_asset_delete_result_for_project(
db: AsyncSession,
*,
asset_id: str,
succeeded: bool,
error: BaseException | None = None,
) -> None:
"""项目删除时级联应用素材删除结果。"""
asset = (
await db.execute(
select(VpV3Asset).where(VpV3Asset.id == asset_id).with_for_update().limit(1)
)
).scalar_one_or_none()
if not asset:
return
if asset.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return
if succeeded:
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = _bj_now()
asset.remote_delete_error = None
else:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
asset.remote_delete_error = str(error or "远程删除失败")
await db.flush()
@@ -0,0 +1,183 @@
from __future__ import annotations
import re
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
PrivatePortraitAssetType,
PrivatePortraitAssetStatus,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
)
from app.models.virtual_portrait_v3 import (
VpV3ApiKeyQuota,
VpV3Asset,
VpV3Project,
)
from app.utils.id_gen import generate_id
MB_BYTES = 1024 * 1024
_SAFE_SLUG = re.compile(r"[^a-zA-Z0-9_-]")
def _slug(name: str) -> str:
if not name:
return "unnamed"
return _SAFE_SLUG.sub("_", name.strip())[:80] or "unnamed"
def _bytes_to_mb(b: int | float | None) -> float:
if not b:
return 0.0
return round(b / MB_BYTES, 3)
# ---------------------------------------------------------------------------
# 配额读写(确保 VpV3ApiKeyQuota 记录存在)
# ---------------------------------------------------------------------------
async def _upsert_quota(db: AsyncSession, api_key_id: str) -> VpV3ApiKeyQuota:
"""获取配额记录;不存在则创建(默认全 0=不可用)。"""
from sqlalchemy.dialects.postgresql import insert
stmt = (
insert(VpV3ApiKeyQuota)
.values(
id=generate_id(),
api_key_id=api_key_id,
project_limit=0,
asset_limit=0,
storage_mb_limit=0,
project_used=0,
asset_used=0,
storage_mb_used=0,
)
.on_conflict_do_nothing(index_elements=["api_key_id"])
)
await db.execute(stmt)
row = (await db.execute(
select(VpV3ApiKeyQuota).where(VpV3ApiKeyQuota.api_key_id == api_key_id).limit(1)
)).scalar_one()
return row
async def _refresh_quota_used(db: AsyncSession, quota: VpV3ApiKeyQuota) -> None:
"""按真实数据重算已使用量(最终一致性)。"""
project_result = await db.execute(
select(func.count(VpV3Project.id)).where(
VpV3Project.api_key_id == quota.api_key_id,
VpV3Project.deleted_at.is_(None),
)
)
asset_result = await db.execute(
select(
func.count(VpV3Asset.id),
func.coalesce(func.sum(VpV3Asset.file_size_bytes), 0),
).where(
VpV3Asset.api_key_id == quota.api_key_id,
VpV3Asset.deleted_at.is_(None),
)
)
project_used = project_result.scalar() or 0
asset_row = asset_result.one()
asset_used = asset_row[0] or 0
storage_bytes = asset_row[1] or 0
quota.project_used = int(project_used)
quota.asset_used = int(asset_used)
quota.storage_mb_used = int(_bytes_to_mb(storage_bytes))
async def get_quota(db: AsyncSession, *, api_key_id: str, refresh: bool = True) -> VpV3ApiKeyQuota:
"""获取当前 API Key 的配额(含已使用量)。不存在则创建默认 0。"""
quota = await _upsert_quota(db, api_key_id)
if refresh:
await _refresh_quota_used(db, quota)
return quota
async def ensure_quota_enabled(db: AsyncSession, *, api_key_id: str) -> VpV3ApiKeyQuota:
"""校验是否已启用虚拟素材库功能,未启用直接 403。返回已刷新的配额。
判定口径(与后台设置保持一致):只要「项目数上限」或「素材数上限」任一 > 0 即视为启用;
存储上限已从配置中移除(不再作为启用条件,也不做硬性限制,仅保留数据库字段做统计展示)。
"""
quota = await get_quota(db, api_key_id=api_key_id, refresh=True)
if (quota.project_limit or 0) <= 0 and (quota.asset_limit or 0) <= 0:
raise HTTPException(status_code=403, detail="当前 API Key 未开启虚拟素材库功能,请联系管理员配置配额")
return quota
def _check(limit: int | None, used: int | float | None, delta: int | float, field: str) -> None:
"""通用配额上限校验。
约定:limit <= 0 视为该维度「未配置 / 不做限制」,此时直接跳过不报错;
只有 limit > 0 时才按「已用 + 本次 <= 上限」判断,避免影响已移除的维度(如存储上限)。
"""
if (limit or 0) <= 0:
return # 不限制,直接通过
if (used or 0) + delta > limit:
raise HTTPException(
status_code=403,
detail=f"虚拟素材库配额不足:{field} 上限 {limit},已使用 {used},本次需要 {delta},超出上限",
)
async def check_project_quota(db: AsyncSession, *, api_key_id: str, delta: int = 1) -> VpV3ApiKeyQuota:
"""创建项目前校验配额(带行锁,防止并发超配)。"""
quota = await ensure_quota_enabled(db, api_key_id=api_key_id)
# 用行锁重新读取,保证并发安全
quota = (
await db.execute(
select(VpV3ApiKeyQuota)
.where(VpV3ApiKeyQuota.api_key_id == api_key_id)
.with_for_update()
.limit(1)
)
).scalar_one()
await _refresh_quota_used(db, quota)
_check(quota.project_limit, quota.project_used, delta, "项目数")
return quota
async def check_asset_quota(
db: AsyncSession,
*,
api_key_id: str,
asset_count_delta: int = 1,
file_size_bytes: int | None = None,
) -> VpV3ApiKeyQuota:
"""上传素材前校验配额(带行锁,防止并发超配)。
注:「存储空间上限」已从业务约束中移除(不再做硬性配额限制),仅保留素材数量上限
与项目数量上限的校验;storage_mb_used 字段仍会在 get_quota 中刷新用于统计展示。
"""
del file_size_bytes # 不再用于配额校验(仅保留形参兼容现有调用点)
quota = await ensure_quota_enabled(db, api_key_id=api_key_id)
# 用行锁重新读取,保证并发安全
quota = (
await db.execute(
select(VpV3ApiKeyQuota)
.where(VpV3ApiKeyQuota.api_key_id == api_key_id)
.with_for_update()
.limit(1)
)
).scalar_one()
await _refresh_quota_used(db, quota)
_check(quota.asset_limit, quota.asset_used, asset_count_delta, "素材总数")
return quota
def remote_project_name() -> str:
"""火山 ProjectNameV3 中转统一共用这个 Project)。"""
return PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
def remote_group_name(*, api_key_id: str, project_slug: str, id: str) -> str:
"""火山 GroupNamevp-api-{api_key_id_short}-{id}-{slug} 最多 128 字符。"""
short_key = (api_key_id or "")
return f"vp-api-{short_key}-{id}-{project_slug}"[:128]
@@ -0,0 +1,547 @@
from __future__ import annotations
import hashlib
import logging
import mimetypes
import os
import shutil
import subprocess
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import NamedTuple
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.upload_resource import UploadResourceTypeEnum
from app.schemas.virtual_portrait_v3.upload import VpV3UploadOut
from app.services.video_cover_service import get_ffmpeg_bin
logger = logging.getLogger("videogen")
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
VP_V3_IMAGE_MAX_BYTES = 10 * 1024 * 1024
VP_V3_VIDEO_MAX_BYTES = 100 * 1024 * 1024
VP_V3_MODULE_NAME = "vp_v3_virtual"
IMAGE_EXT_ALLOWED = {"jpg", "jpeg", "png", "webp", "bmp"}
VIDEO_EXT_ALLOWED = {"mp4", "mov", "m4v", "webm"}
IMAGE_MIME_ALLOWED = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/bmp",
}
VIDEO_MIME_ALLOWED = {
"video/mp4", "video/quicktime", "video/x-m4v", "video/webm",
}
# URL 下载相关默认值
URL_DOWNLOAD_CONNECT_TIMEOUT_SEC = 15
URL_DOWNLOAD_READ_TIMEOUT_SEC = 300 # 大文件下载可以久一点,读的时候会按大小上限中断
URL_DOWNLOAD_MAX_REDIRECTS = 5
URL_DOWNLOAD_CHUNK_BYTES = 1024 * 1024 # 1MB
class DownloadedAsset(NamedTuple):
"""URL 下载到本地后的结果。"""
url: str # 对外访问 URL(最终要存到 VpV3Asset.source_url 的)
filename: str # 落盘后的文件名
file_size_bytes: int # 实际文件大小
mime_type: str | None # 从响应头/扩展名推断出的 MIME
duration_seconds: float | None # 视频:ffprobe 探测到的时长(Image 为 None)
suggested_name: str | None # 从 URL 或 Content-Disposition 推断的展示名(无扩展名)
def _max_bytes(asset_type: str) -> int:
return VP_V3_VIDEO_MAX_BYTES if asset_type == UploadResourceTypeEnum.VIDEO.value else VP_V3_IMAGE_MAX_BYTES
def _allowed_mime_set(asset_type: str) -> set[str]:
return VIDEO_MIME_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_MIME_ALLOWED
def _safe_ext(filename: str, asset_type: str) -> str:
ext = (os.path.splitext(filename or "")[1].lower().lstrip(".") or "").strip()
allowed = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_EXT_ALLOWED
if ext and ext in allowed:
return ext
# fallback
return "mp4" if asset_type == UploadResourceTypeEnum.VIDEO.value else "png"
def _get_ffprobe_bin() -> str:
ffmpeg = Path(get_ffmpeg_bin())
sibling = ffmpeg.with_name("ffprobe.exe" if ffmpeg.suffix.lower() == ".exe" else "ffprobe")
if sibling.exists():
return str(sibling)
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
if found:
return found
raise RuntimeError("未找到 ffprobe,请确保其与 FFMPEG_BIN 同目录或已加入 PATH")
def _probe_duration_optional(video_path: Path) -> float | None:
"""尝试 ffprobe 探测视频时长,失败不抛,返回 None 让调用方自己处理。"""
import json as _json
try:
ffprobe_bin = _get_ffprobe_bin()
# 检查 ffprobe 是否可用
if not shutil.which(ffprobe_bin) and ffprobe_bin == "ffprobe":
logger.warning("ffprobe 未在系统 PATH 中找到,无法探测视频时长。请安装 ffprobe 并添加到 PATH。")
return None
timeout = int(getattr(settings, "SHOT_FFPROBE_TIMEOUT_SECONDS", 20) or 20)
cmd = [
ffprobe_bin,
"-v", "error",
"-show_entries", "format=duration",
"-of", "json",
str(video_path),
]
completed = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout, check=False,
)
if completed.returncode != 0:
logger.warning(
"ffprobe 执行失败: returncode=%s stderr=%s",
completed.returncode, completed.stderr[:200],
)
return None
if not completed.stdout:
return None
data = _json.loads(completed.stdout or "{}")
dur_raw = (data.get("format") or {}).get("duration")
if dur_raw is None:
return None
dur = float(dur_raw)
if dur <= 0:
return None
return dur
except subprocess.TimeoutExpired:
logger.warning("ffprobe 超时: %s", str(video_path))
return None
except Exception as exc: # noqa: BLE001
logger.warning("ffprobe 探测视频时长失败: %s", exc)
return None
def _build_destination(*, api_key_id: str, asset_type: str, original_filename: str, duration_seconds: float | None) -> tuple[Path, str, str]:
"""构建 vp_v3 上传存储路径 + 对外访问 URL。
存储路径:UPLOAD_LOCAL_PATH/api/private_portrait_virtual/{asset_type}/{yyyy}/{mm}/{dd}/{uuid}.{ext}
"""
now = _bj_now()
ext = _safe_ext(original_filename, asset_type)
safe_uuid = uuid.uuid4().hex
year = f"{now.year:04d}"
month = f"{now.month:02d}"
day = f"{now.day:02d}"
sub_type = "videos" if asset_type == UploadResourceTypeEnum.VIDEO.value else "images"
rel_dir = Path("api") / "private_portrait_virtual" / sub_type / year / month / day
filename = f"vp_v3_{safe_uuid}.{ext}"
base_dir = Path(settings.UPLOAD_LOCAL_PATH) if settings.UPLOAD_LOCAL_PATH else Path("./storage/uploads")
final_path = base_dir / rel_dir / filename
# URL 前缀 /uploads/...
base_url = settings.UPLOAD_BASE_URL.rstrip("/") if settings and getattr(settings, "UPLOAD_BASE_URL", None) else "/uploads"
rel_url = f"/{rel_dir.as_posix()}/{filename}".replace("//", "/")
url = base_url + rel_url
return final_path, url, filename
def _guess_filename_from_url(url: str, cd_header: str | None) -> str:
"""优先从 Content-Disposition 拿文件名,其次从 URL path 拿,再 fallback 到 uuid 名。"""
# 1. Content-Disposition
if cd_header:
# filename="a.jpg" 或 filename*=UTF-8''a.jpg
import re as _re
m1 = _re.search(r"""filename\*\s*=\s*UTF-8''([^;]+)""", cd_header, flags=_re.IGNORECASE)
if m1:
from urllib.parse import unquote
try:
return unquote(m1.group(1).strip().strip('"').strip("'"))
except Exception: # noqa: BLE001
pass
m2 = _re.search(r"""filename\s*=\s*"([^"]+)""", cd_header, flags=_re.IGNORECASE)
if m2:
return m2.group(1)
m3 = _re.search(r"""filename\s*=\s*([^;]+)""", cd_header, flags=_re.IGNORECASE)
if m3:
return m3.group(1).strip().strip('"').strip("'")
# 2. URL path
try:
parsed = urlparse(url)
base = os.path.basename(parsed.path or "")
if base and "." in base:
return base
except Exception: # noqa: BLE001
pass
return f"vp_v3_{uuid.uuid4().hex[:12]}"
def _guess_ext_from_mime(mime: str | None, asset_type: str) -> str | None:
if not mime:
return None
# 按 asset_type 优先匹配
allowed_exts = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_EXT_ALLOWED
guesses = mimetypes.guess_all_extensions(mime.strip().lower()) or []
for g in guesses:
ext = g.lower().lstrip(".")
if ext in allowed_exts:
return ext
# 额外的手写映射
extra_map = {
"image/jpeg": "jpg", "image/jpg": "jpg",
"video/quicktime": "mov", "video/x-m4v": "m4v",
}
if mime.lower() in extra_map and extra_map[mime.lower()] in allowed_exts:
return extra_map[mime.lower()]
return None
# ---------------------------------------------------------------------------
# 1) 上传本地文件(保留旧 API 但走下载流程的也可以共用保存逻辑)
# ---------------------------------------------------------------------------
async def upload_asset_file(
db: AsyncSession,
*,
api_key_id: str,
file: UploadFile,
asset_type: str,
duration_seconds: float | None = None,
) -> VpV3UploadOut:
"""V3 虚拟素材上传(独立实现,不经过用户容量账本 UploadResource)。
- 校验 MIME/扩展名/大小
- 落盘到 /uploads/images|videos/vp_v3/{api_key_id_short}/{yyyy}/{mm}/{dd}/
- 返回 url + 虚拟 resource_idhash 形式)
"""
if asset_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材上传仅支持图片或视频")
max_size = _max_bytes(asset_type)
temp_path: str | None = None
try:
# 1. 落临时文件并限制大小
size_acc = 0
temp_dir = settings.UPLOAD_TEMP_DIR if settings and getattr(settings, "UPLOAD_TEMP_DIR", None) else "./uploads/_tmp_vp_v3"
Path(temp_dir).mkdir(parents=True, exist_ok=True)
temp_path = os.path.join(temp_dir, f"vp_v3_{uuid.uuid4().hex}")
with open(temp_path, "wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size_acc += len(chunk)
if size_acc > max_size:
raise HTTPException(
status_code=400,
detail=f"文件大小超出限制:{asset_type} 最大 {max_size // (1024*1024)} MB",
)
f.write(chunk)
file_size_bytes = size_acc
if file_size_bytes <= 0:
raise HTTPException(status_code=400, detail="空文件不允许上传")
# 2. 构建最终路径 + URL
final_path, url, safe_filename = _build_destination(
api_key_id=api_key_id,
asset_type=asset_type,
original_filename=file.filename or safe_filename,
duration_seconds=duration_seconds,
)
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(temp_path, final_path)
temp_path = None
# 3. 虚拟 resource_id(用于素材删除时的文件清理定位:hash(url))
resource_id = "vpv3_" + hashlib.sha256(url.encode()).hexdigest()[:24]
return VpV3UploadOut(
url=url,
filename=safe_filename,
type=asset_type,
resource_id=resource_id,
file_size_bytes=file_size_bytes,
duration_seconds=duration_seconds,
)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.exception("vp_v3 上传失败:%s", exc)
raise HTTPException(status_code=500, detail=f"上传失败:{exc}") from exc
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception: # noqa: BLE001
pass
# ---------------------------------------------------------------------------
# 2) URL 下载到本地(新流程:创建素材时一步到位,由 create_asset 内部调用)
# ---------------------------------------------------------------------------
async def download_url_to_local(
*,
api_key_id: str,
asset_type: str,
source_url: str,
requested_filename: str | None = None,
) -> DownloadedAsset:
"""把传入的远程 URLhttp/https)下载到本地 vp_v3 上传目录,返回本地 URL + 元信息。
完整的错误处理:
- 非法 URL → 400
- 连接/超时 → 502(外部资源不可达)
- HTTP 4xx/5xx → 502 带状态码
- Content-Type 不在允许列表 → 415
- 超出大小上限(读内容时逐 chunk 检查)→ 413
- 下载一半失败 → 清理临时文件,不留下半截
- 视频可选探测 ffprobe,失败不抛错(调用方自行用 payload.video_duration
"""
if asset_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材仅支持图片或视频")
# URL 合法性
if not source_url or not isinstance(source_url, str):
raise HTTPException(status_code=400, detail="source_url 不能为空")
parsed = urlparse(source_url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(status_code=400, detail="source_url 必须是合法的 http(s) URL")
# 拒绝私有/内网地址(SSRF 防御的最小集;生产环境可再严格)
import ipaddress
host_only = parsed.hostname or ""
try:
ip_obj = ipaddress.ip_address(host_only)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local:
raise HTTPException(status_code=400, detail="source_url 不允许指向内网/本机地址")
except ValueError:
# 不是 IP,是域名 → 放行
pass
max_bytes = _max_bytes(asset_type)
allowed_mimes = _allowed_mime_set(asset_type)
# 用户代理:标成我们服务的 UA,避免一些图片防盗链 403
user_agent = (
"Mozilla/5.0 (compatible; VideoGenVPV3/1.0; +https://minzhongzc.com/)"
if getattr(settings, "VP_V3_DOWNLOAD_UA", None) is None
else str(getattr(settings, "VP_V3_DOWNLOAD_UA"))
)
temp_path: str | None = None
final_path: Path | None = None
# 外层初始化,保证 client.stream 内部 raise 的情况下外层仍然可访问
inferred_filename: str = f"vp_v3_{uuid.uuid4().hex[:12]}"
mime: str | None = None
size_acc: int = 0
try:
# --- 第一步:下载到临时文件,限制大小 + 校验响应头 ---
temp_dir = settings.UPLOAD_TEMP_DIR if settings and getattr(settings, "UPLOAD_TEMP_DIR", None) else "./uploads/_tmp_vp_v3"
Path(temp_dir).mkdir(parents=True, exist_ok=True)
temp_path = os.path.join(temp_dir, f"vp_v3_url_{uuid.uuid4().hex}")
transport = httpx.AsyncHTTPTransport(retries=1)
timeout = httpx.Timeout(
connect=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
read=URL_DOWNLOAD_READ_TIMEOUT_SEC,
write=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
pool=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
)
headers = {"User-Agent": user_agent, "Accept": "*/*"}
async with httpx.AsyncClient(
timeout=timeout,
transport=transport,
follow_redirects=True,
max_redirects=URL_DOWNLOAD_MAX_REDIRECTS,
verify=bool(getattr(settings, "VP_V3_DOWNLOAD_VERIFY_SSL", True)),
) as client:
async with client.stream("GET", source_url.strip(), headers=headers) as resp:
# HTTP 状态码
if resp.status_code >= 400:
detail = f"远程资源返回状态码 {resp.status_code}"
try:
snippet = (await resp.aread())[:200]
if snippet:
detail += f",响应片段:{snippet.decode('utf-8', errors='ignore')}"
except Exception: # noqa: BLE001
pass
raise HTTPException(
status_code=502,
detail=f"source_url 下载失败(HTTP {resp.status_code}):" + detail,
)
# Content-Type 校验(没有就 fallback 到扩展名推断)
content_type_raw = resp.headers.get("Content-Type") or ""
mime = (content_type_raw.split(";")[0] or "").strip().lower() or None
if mime and mime not in allowed_mimes:
# 一些 CDN 会用 application/octet-stream,这种情况跳过 MIME 检查,用扩展名兜底
if mime != "application/octet-stream":
raise HTTPException(
status_code=415,
detail=(
f"不支持的 Content-Type{mime}"
f"{asset_type} 仅支持:{', '.join(sorted(allowed_mimes))}"
),
)
# Content-Length 预估检查(存在且超出就直接拒,不下载)
content_length = resp.headers.get("Content-Length")
if content_length:
try:
cl = int(content_length)
if cl > max_bytes:
raise HTTPException(
status_code=413,
detail=(
f"远程资源太大(Content-Length={cl}),超过 "
f"{asset_type} 上限 {max_bytes} 字节"
),
)
except ValueError:
pass
# filename 推断(用于扩展名 + 展示名)
cd = resp.headers.get("Content-Disposition")
inferred_filename = _guess_filename_from_url(source_url, cd)
if requested_filename:
# 若用户传了 name 就优先用它做展示名,但扩展名仍然以 mime/url 推断为准
try:
base_display = os.path.splitext(os.path.basename(requested_filename))[0]
old_ext = os.path.splitext(inferred_filename)[1] if inferred_filename else ""
inferred_filename = base_display + (old_ext or "")
except Exception: # noqa: BLE001
pass
# 扩展名再精化:如果 MIME 能得出扩展名,优先用
ext_from_mime = _guess_ext_from_mime(mime, asset_type)
if ext_from_mime:
try:
stem = os.path.splitext(inferred_filename)[0]
inferred_filename = f"{stem}.{ext_from_mime}"
except Exception: # noqa: BLE001
pass
# 流式下载到 temp_path,逐 chunk 检查大小
size_acc = 0
with open(temp_path, "wb") as f:
async for chunk in resp.aiter_bytes():
if not chunk:
continue
size_acc += len(chunk)
if size_acc > max_bytes:
raise HTTPException(
status_code=413,
detail=(
f"远程资源大小超过 {asset_type} 上限 "
f"{max_bytes // (1024*1024)} MB"
),
)
f.write(chunk)
# ======== 以下在 client.stream 退出后、但仍在 httpx.AsyncClient 上下文内执行 ========
# 空文件检查
file_size_bytes = size_acc
if file_size_bytes <= 0:
raise HTTPException(status_code=400, detail="远程 URL 返回空文件")
# --- 第二步:视频可选 ffprobe 探测时长 ---
duration: float | None = None
if asset_type == UploadResourceTypeEnum.VIDEO.value:
duration = _probe_duration_optional(Path(temp_path))
# --- 第三步:落到最终目录(与 _build_destination 一致的目录结构/权限) ---
final_path, url_out, final_filename = _build_destination(
api_key_id=api_key_id,
asset_type=asset_type,
original_filename=inferred_filename,
duration_seconds=duration,
)
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(temp_path, final_path)
temp_path = None
# 最终 MIME:按扩展名反推一个(如果之前没拿到)
if not mime:
mime, _ = mimetypes.guess_type(final_filename)
if not mime:
mime = "image/png" if asset_type == UploadResourceTypeEnum.IMAGE.value else "video/mp4"
suggested_name: str | None = None
try:
stem = os.path.splitext(inferred_filename or final_filename)[0]
if stem and not stem.startswith("vp_v3_"):
suggested_name = stem[:100] or None
except Exception: # noqa: BLE001
pass
return DownloadedAsset(
url=url_out,
filename=final_filename,
file_size_bytes=file_size_bytes,
mime_type=mime,
duration_seconds=duration,
suggested_name=suggested_name,
)
except HTTPException:
raise
except (httpx.TimeoutException, httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
raise HTTPException(status_code=502, detail=f"远程 URL 连接/读取超时:{exc}") from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"远程 URL 下载失败:{exc}") from exc
except Exception as exc: # noqa: BLE001
logger.exception("vp_v3 URL 下载异常:url=%s err=%s", source_url, exc)
raise HTTPException(status_code=500, detail=f"URL 下载保存失败:{exc}") from exc
finally:
# 任何失败都清掉半截临时文件;但最终文件已经 move 过去了的就不动
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception: # noqa: BLE001
pass
def delete_local_file_by_url(local_url: str) -> bool:
"""素材删除时根据 source_url 删除本地落盘文件(非强制,失败不抛)。"""
if not local_url:
return False
try:
base_url = settings.UPLOAD_BASE_URL.rstrip("/") if settings and getattr(settings, "UPLOAD_BASE_URL", None) else "/uploads"
rel_url = local_url
if rel_url.startswith(base_url):
rel_url = rel_url[len(base_url):]
if not rel_url.startswith("/"):
return False
# /uploads/api/private_portrait_virtual/... → /api/private_portrait_virtual/... → UPLOAD_LOCAL_PATH/api/private_portrait_virtual/...
sub_part = rel_url[len("/uploads"):] if rel_url.startswith("/uploads") else rel_url
base_dir = Path(settings.UPLOAD_LOCAL_PATH) if settings.UPLOAD_LOCAL_PATH else Path("./storage/uploads")
target = (base_dir / sub_part.lstrip("/")).resolve()
base_dir_resolved = base_dir.resolve()
# 仅允许删除 base_dir 下的文件(目录穿越防御)
if not str(target).startswith(str(base_dir_resolved)):
return False
if target.is_file():
target.unlink(missing_ok=True)
return True
except Exception: # noqa: BLE001
logger.exception("vp_v3 清理本地文件失败:%s", local_url)
return False
+10
View File
@@ -0,0 +1,10 @@
"""API 专用 Celery 任务注册模块。
复用共享的 celery_app 实例(同一个 broker、同一个 Redis),
使 API 任务注册到同一个 Celery 应用上。
Worker 启动时需要 --include=app.tasks.api_generation_tasks 来加载 API 任务。
"""
from app.tasks.celery_app import celery_app # noqa: F401
from app.tasks.celery_app import run_async # noqa: F401
@@ -0,0 +1,845 @@
"""API 对外开放接口的 Celery 任务。
处理视频和图片的异步生成流程:
- api_create_generation_task: 创建供应商任务(调用 Volcano Ark SDK
- api_poll_generation_task: 轮询视频任务状态
- api_download_generation_result_task: 下载生成结果
- api_upscale_finalize_task: 超分完成后更新 API 任务
Worker 启动命令示例:
celery -A app.tasks.celery_app worker \\
--include=app.tasks.api_generation_tasks \\
--queue=gen_api_create,gen_api_poll,gen_api_download \\
--concurrency=4
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.api.api_key import ApiKey
from app.models.base import async_session
from sqlalchemy import select
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
from app.services.api_v3 import upscale_service, usage_log_service
from app.services.api_v3.logging_service import log_model_response, log_upscale_poll, log_upscale_poll_start, log_upscale_poll_end, log_error
from app.services.api_v3.quota_service import get_queued_video_tasks, can_start_video_task
from app.services.redis_registry_service import redis_acquire_lock
from app.services.generation.poll_schedule_service import (
build_video_pending_poll_schedule,
ensure_video_poll_fields,
is_poll_not_due,
)
from app.services.video_gen import poll_task_status, submit_video_task
from app.tasks.api_celery_app import celery_app, run_async
logger = logging.getLogger("videogen")
# === 队列名称常量 ===
QUEUE_CREATE = "gen_api_create"
QUEUE_POLL = "gen_api_poll"
QUEUE_DOWNLOAD = "gen_api_download"
def _now() -> datetime:
return datetime.now(timezone.utc)
async def _get_quota_info(db, api_key_id: str) -> tuple[float | None, float | None]:
"""获取当前配额信息。
Returns:
(quota_before, quota_after) - 当前余额作为 beforeafter 需要计算
"""
from app.models.api.api_key import ApiKey
key = await db.get(ApiKey, api_key_id)
if key:
return key.quota_used, key.quota_used
return None, None
async def _refund_quota(db, task: ApiGenerationTask):
"""退回预扣配额。"""
from app.models.api.api_key import ApiKey
pre_deducted = task.credits_cost or 0.0
if pre_deducted <= 0:
return
key_result = await db.execute(
select(ApiKey).where(ApiKey.id == task.api_key_id).with_for_update()
)
key = key_result.scalar_one_or_none()
if key:
key.quota_used = round(max(0, (key.quota_used or 0.0) - pre_deducted), 2)
task.credits_cost = 0 # 标记已退回
async def _start_next_queued_task(db, api_key_id: str):
"""检查并启动下一个排队的视频任务。
当一个任务完成/失败时调用,检查是否有排队的任务可以启动。
"""
from app.models.api.api_key import ApiKey
# 加载 API Key
key = await db.get(ApiKey, api_key_id)
if not key:
return
# 检查是否可以启动新任务
if not await can_start_video_task(key, db):
return
# 获取最早的排队任务
queued_tasks = await get_queued_video_tasks(key, db, limit=1)
if not queued_tasks:
return
next_task = queued_tasks[0]
# 更新状态并启动
next_task.status = "pending"
next_task.pipeline_stage = "queued"
await db.commit()
# 入队 Celery 创建任务
api_create_generation_task.apply_async(
args=[next_task.id],
queue=QUEUE_CREATE,
)
logger.info("Started queued API task: %s (key=%s)", next_task.id, api_key_id)
def _build_optimized_prompt(task: ApiGenerationTask) -> str:
"""构建优化后的提示词(追加参数信息)。"""
base = (task.original_prompt or "").strip().rstrip(",。;; \n\t")
if not base:
return task.original_prompt or ""
parts = []
if task.gen_type == "video":
parts = [
f"时长:{task.duration or 4}",
f"画面比例:{task.aspect_ratio or '16:9'}",
f"分辨率:{task.provider_generation_resolution or task.resolution or '480p'}",
]
suffix = "".join(parts)
return f"{base}{suffix}" if base and suffix else base
# === 任务 1: 创建供应商任务 ===
@celery_app.task(
name="api.create_generation_task",
bind=True,
max_retries=3,
soft_time_limit=300,
time_limit=600,
acks_late=True,
)
def api_create_generation_task(self, task_id: str) -> dict[str, Any]:
"""创建视频生成任务并提交到 Volcano Ark SDK。"""
return run_async(_create_generation_task(self, task_id))
async def _create_generation_task(self, task_id: str) -> dict[str, Any]:
lock_key = f"vg:lock:api_generation:create:{task_id}:attempt:{1}"
token = await redis_acquire_lock(lock_key=lock_key, ttl_seconds=120)
if not token:
logger.warning("API create task lock not acquired: %s", task_id)
return {"status": "lock_not_acquired", "task_id": task_id}
try:
async with async_session() as db:
# 加载任务(带行锁)
task = await db.get(ApiGenerationTask, task_id, with_for_update=True)
if not task or task.deleted_at:
return {"status": "not_found", "task_id": task_id}
if task.status not in ("pending", "generating"):
return {"status": "skipped", "task_id": task_id, "current_status": task.status}
# 解析引擎配置
try:
engine_snapshot = json.loads(task.engine_snapshot_json) if task.engine_snapshot_json else {}
except (json.JSONDecodeError, TypeError):
engine_snapshot = {}
engine_id = task.engine_id or engine_snapshot.get("id")
if not engine_id:
# 失败:退回预扣配额
await _refund_quota(db, task)
task.status = "failed"
task.error_message = "无法解析引擎配置"
await db.commit()
return {"status": "failed", "task_id": task_id, "error": "no_engine"}
# 加载引擎
engine = await db.get(VideoEngine, engine_id) or await db.get(ImageEngine, engine_id)
if not engine:
# 失败:退回预扣配额
await _refund_quota(db, task)
task.status = "failed"
task.error_message = f"引擎 {engine_id} 不存在"
await db.commit()
return {"status": "failed", "task_id": task_id, "error": "engine_not_found"}
# 设置优化提示词
task.optimized_prompt = _build_optimized_prompt(task)
task.pipeline_stage = "creating_provider_task"
await db.flush()
try:
# 调用 Volcano Ark SDK
provider_task_id = await submit_video_task(
db=db,
engine=engine,
record=task,
include_media_references=True,
)
# 更新任务状态
task.provider_task_id = provider_task_id
task.provider_response_json = json.dumps({"task_id": provider_task_id}, ensure_ascii=False)
task.pipeline_stage = "waiting_remote"
task.status = "generating"
task.resource_generation_started_at = _now()
# 设置轮询字段
ensure_video_poll_fields(task)
task.poll_started_at = _now()
task.poll_interval_seconds = 30
task.next_poll_at = _now() + timedelta(seconds=30)
await db.commit()
logger.info("API video submitted: task_id=%s provider_task_id=%s", task_id, provider_task_id)
# 记录模型调用成功
log_model_response(
engine_id=engine_id,
model_name=task.model_name,
task_id=task_id,
success=True,
result={"provider_task_id": provider_task_id},
)
# 入队轮询任务
api_poll_generation_task.apply_async(
args=[task_id],
countdown=30,
queue=QUEUE_POLL,
)
return {"status": "submitted", "task_id": task_id, "provider_task_id": provider_task_id}
except Exception as exc:
logger.exception("API video submit failed: task_id=%s", task_id)
# 记录模型调用失败
log_model_response(
engine_id=engine_id or "unknown",
model_name=task.model_name,
task_id=task_id,
success=False,
error=str(exc)[:500],
)
# 失败:退回预扣配额
pre_deducted = task.credits_cost or 0.0
await _refund_quota(db, task)
task.status = "failed"
task.error_message = f"提交失败: {str(exc)[:500]}"
task.pipeline_stage = "failed"
await db.commit()
# 记录失败日志
try:
quota_before, _ = await _get_quota_info(db, task.api_key_id)
await usage_log_service.record_usage(
db=db,
api_key_id=task.api_key_id,
request_type="video_create",
model_name=task.model_name,
gen_type="video",
status="failed",
task_id=task.id,
credits_cost=pre_deducted,
refund_amount=pre_deducted,
price_action="refund",
error_message=str(exc)[:500],
error_code="submit_failed",
quota_before=quota_before,
quota_after=quota_before + pre_deducted if quota_before else None,
)
await db.commit()
except Exception as log_exc:
logger.error("Failed to record usage log: %s", log_exc)
# 失败释放并发槽位,检查排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "failed", "task_id": task_id, "error": str(exc)}
finally:
# 释放锁
from app.services.redis_registry_service import redis_release_lock
await redis_release_lock(lock_key=lock_key, token=token)
# === 任务 2: 轮询任务状态 ===
@celery_app.task(
name="api.poll_generation_task",
bind=True,
max_retries=3,
soft_time_limit=120,
time_limit=300,
acks_late=True,
)
def api_poll_generation_task(self, task_id: str) -> dict[str, Any]:
"""轮询视频任务状态。"""
return run_async(_poll_generation_task(self, task_id))
async def _poll_generation_task(self, task_id: str) -> dict[str, Any]:
lock_key = f"vg:lock:api_generation:poll:{task_id}"
token = await redis_acquire_lock(lock_key=lock_key, ttl_seconds=60)
if not token:
return {"status": "lock_not_acquired", "task_id": task_id}
try:
async with async_session() as db:
task = await db.get(ApiGenerationTask, task_id, with_for_update=True)
if not task or task.deleted_at:
return {"status": "not_found", "task_id": task_id}
if task.status != "generating" or not task.provider_task_id:
return {"status": "skipped", "task_id": task_id}
# 检查是否到轮询时间
if is_poll_not_due(task):
# 重新调度
schedule = build_video_pending_poll_schedule(task)
task.next_poll_at = schedule.next_poll_at
task.poll_interval_seconds = schedule.poll_interval_seconds
await db.commit()
api_poll_generation_task.apply_async(
args=[task_id],
countdown=schedule.delay_seconds,
queue=QUEUE_POLL,
)
return {"status": "rescheduled", "task_id": task_id, "delay": schedule.delay_seconds}
# 检查截止时间
if task.deadline_at and task.deadline_at <= _now():
# 超时:退回预扣配额
await _refund_quota(db, task)
task.status = "failed"
task.error_message = "任务超时(24小时)"
task.pipeline_stage = "timeout"
await db.commit()
return {"status": "timeout", "task_id": task_id}
# 解析引擎
try:
engine_snapshot = json.loads(task.engine_snapshot_json) if task.engine_snapshot_json else {}
except (json.JSONDecodeError, TypeError):
engine_snapshot = {}
engine_id = task.engine_id or engine_snapshot.get("id")
engine = await db.get(VideoEngine, engine_id) if engine_id else None
if not engine:
task.status = "failed"
task.error_message = f"引擎 {engine_id} 不存在"
await db.commit()
return {"status": "failed", "task_id": task_id, "error": "engine_not_found"}
# 轮询状态
task.last_poll_at = _now()
task.poll_count = (task.poll_count or 0) + 1
await db.flush()
try:
poll_result = await poll_task_status(engine, task.provider_task_id)
except Exception as exc:
logger.warning("API poll failed: task_id=%s error=%s", task_id, exc)
task.poll_error_count = (task.poll_error_count or 0) + 1
await db.commit()
# 重新调度
schedule = build_video_pending_poll_schedule(task)
task.next_poll_at = schedule.next_poll_at
task.poll_interval_seconds = schedule.poll_interval_seconds
await db.commit()
api_poll_generation_task.apply_async(
args=[task_id],
countdown=schedule.delay_seconds,
queue=QUEUE_POLL,
)
return {"status": "poll_error", "task_id": task_id}
status = poll_result.get("status")
if status == "succeeded":
# 成功:入队下载
task.remote_result_url = poll_result.get("video_url")
task.provider_response_json = poll_result.get("response_data", "")
task.pipeline_stage = "result_ready"
task.video_tokens_used = poll_result.get("video_tokens", 0)
await db.commit()
api_download_generation_result_task.apply_async(
args=[task_id],
queue=QUEUE_DOWNLOAD,
)
return {"status": "succeeded", "task_id": task_id}
elif status == "failed":
# 失败:退回预扣配额
await _refund_quota(db, task)
task.status = "failed"
task.error_message = poll_result.get("error", "视频生成失败")
task.pipeline_stage = "failed"
task.provider_response_json = poll_result.get("response_data", "")
await db.commit()
# 获取预扣金额(退回前)
pre_deducted = task.credits_cost or 0.0
await usage_log_service.record_usage(
db=db,
api_key_id=task.api_key_id,
request_type="video_create",
model_name=task.model_name,
gen_type="video",
status="failed",
task_id=task.id,
credits_cost=pre_deducted,
refund_amount=pre_deducted,
price_action="refund",
error_message=task.error_message,
error_code="generation_failed",
)
await db.commit()
# 失败释放并发槽位,检查排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "failed", "task_id": task_id}
else:
# 仍在处理中:重新调度
schedule = build_video_pending_poll_schedule(task)
task.next_poll_at = schedule.next_poll_at
task.poll_interval_seconds = schedule.poll_interval_seconds
await db.commit()
api_poll_generation_task.apply_async(
args=[task_id],
countdown=schedule.delay_seconds,
queue=QUEUE_POLL,
)
return {"status": "pending", "task_id": task_id, "delay": schedule.delay_seconds}
finally:
from app.services.redis_registry_service import redis_release_lock
await redis_release_lock(lock_key=lock_key, token=token)
# === 任务 3: 下载生成结果 ===
@celery_app.task(
name="api.download_generation_result_task",
bind=True,
max_retries=3,
soft_time_limit=600,
time_limit=900,
acks_late=True,
)
def api_download_generation_result_task(self, task_id: str) -> dict[str, Any]:
"""下载视频结果并触发超分(如启用)。"""
return run_async(_download_generation_result(self, task_id))
async def _download_generation_result(self, task_id: str) -> dict[str, Any]:
lock_key = f"vg:lock:api_generation:download:{task_id}"
token = await redis_acquire_lock(lock_key=lock_key, ttl_seconds=300)
if not token:
return {"status": "lock_not_acquired", "task_id": task_id}
try:
async with async_session() as db:
task = await db.get(ApiGenerationTask, task_id, with_for_update=True)
if not task or task.deleted_at:
return {"status": "not_found", "task_id": task_id}
if not task.remote_result_url:
task.status = "failed"
task.error_message = "无远程结果URL"
await db.commit()
return {"status": "failed", "task_id": task_id}
# 检查是否需要超分
use_upscale = bool(
task.gen_type == "video"
and task.video_upscale_enabled_snapshot
and task.video_upscale_snapshot_json
)
if use_upscale:
# 下载视频到 upscaled 目录(作为超分源)
import os
from app.services.video_gen import download_video
date_dir = datetime.now().strftime("%Y%m%d")
# 源文件存储路径
dest_path = f"./storage/generate/api/upscaled/{date_dir}/{task.id}_source.mp4"
abs_dest_path = os.path.abspath(dest_path)
os.makedirs(os.path.dirname(abs_dest_path), exist_ok=True)
logger.info("Downloading source video to: %s", abs_dest_path)
try:
await download_video(task.remote_result_url, abs_dest_path)
# 注意:超分时不设置 video_url,等超分完成后再设置
task.local_path = dest_path # 源文件路径
task.download_storage_date_dir = date_dir
logger.info("Source video downloaded successfully: %s", dest_path)
# 获取视频信息(尺寸、时长、文件大小)
try:
abs_path = os.path.abspath(dest_path)
if os.path.exists(abs_path):
source_file_size_bytes = os.path.getsize(abs_path)
# 使用 probe_video 探测实际视频尺寸和时长
from app.services.video_upscale.media_service import probe_video
source_info = await probe_video(abs_path)
if source_info:
source_width = source_info.width
source_height = source_info.height
source_duration = round(source_info.duration_seconds, 2)
except Exception as exc:
logger.warning("Failed to probe video info: %s", exc)
await db.flush()
# 创建超分任务(与状态更新在同一事务中)
try:
upscale_task = await upscale_service.prepare_api_upscale_task(
db=db,
api_task=task,
source_local_path=dest_path,
source_width=source_width,
source_height=source_height,
source_duration=source_duration,
source_file_size_bytes=source_file_size_bytes
)
# 如果已存在超分任务(重复调用),检查超分状态
if upscale_task is None:
logger.info("Upscale task already exists for %s, checking status", task_id)
# 重新查询超分任务状态
from app.models.video_upscale_task import VideoUpscaleTask
from sqlalchemy import select
upscale_result = await db.execute(
select(VideoUpscaleTask).where(
VideoUpscaleTask.api_generation_task_id == task.id
).limit(1)
)
existing_upscale = upscale_result.scalar_one_or_none()
if existing_upscale and existing_upscale.status == "completed":
# 超分已完成
task.video_url = existing_upscale.final_resource_url or task.remote_result_url
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = _now()
else:
# 超分仍在进行中,保持 generating 状态
task.status = "generating"
task.pipeline_stage = "upscale_processing"
await db.commit()
return {"status": task.status, "task_id": task_id, "note": "upscale_already_exists"}
# 记录超分开始
log_upscale_poll_start(task_id=upscale_task.id, api_task_id=task_id)
# 入队超分任务(使用简化版 API v3 专用任务)
from app.tasks.api_upscale_tasks import api_upscale_execute_local_simple, api_upscale_submit_remote_simple
processor_key = upscale_task.processor_key
if processor_key in ("local_ffmpeg_crop_v1",):
api_upscale_execute_local_simple.apply_async(
args=[upscale_task.id],
queue=CeleryQueue.GEN_API_UPSCALE.value,
)
else:
# 远程超分(火山 MediaKit
api_upscale_submit_remote_simple.apply_async(
args=[upscale_task.id],
queue=CeleryQueue.GEN_API_UPSCALE.value,
)
await db.commit()
return {"status": "upscale_queued", "task_id": task_id, "upscale_task_id": upscale_task.id}
except Exception as upscale_exc:
# 超分创建失败:记录错误,但视频已下载成功
# 将任务标记为 completed(有视频但无超分)
log_error(
"UPSCALE_CREATE_ERROR",
f"超分任务创建失败: {str(upscale_exc)[:500]}",
{"task_id": task_id, "video_url": task.remote_result_url}
)
task.video_url = task.remote_result_url
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = _now()
task.error_message = f"超分创建失败,返回原始视频: {str(upscale_exc)[:200]}"
await db.commit()
# 检查并启动下一个排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "completed_without_upscale", "task_id": task_id, "error": str(upscale_exc)[:500]}
except Exception as exc:
# 下载失败:退回预扣配额
from app.models.api.api_key import ApiKey
pre_deducted = task.credits_cost or 0.0
if pre_deducted > 0:
key_result = await db.execute(
select(ApiKey).where(ApiKey.id == task.api_key_id).with_for_update()
)
key = key_result.scalar_one_or_none()
if key:
key.quota_used = round(max(0, (key.quota_used or 0.0) - pre_deducted), 2)
log_error(
"DOWNLOAD_ERROR",
f"视频下载失败: {str(exc)[:500]}",
{"task_id": task_id}
)
logger.exception("API video download failed: task_id=%s", task_id)
task.status = "failed"
task.error_message = f"下载失败: {str(exc)[:500]}"
task.credits_cost = 0
await db.commit()
# 记录失败日志
await usage_log_service.record_usage(
db=db,
api_key_id=task.api_key_id,
request_type="video_create",
model_name=task.model_name,
gen_type="video",
status="failed",
task_id=task.id,
credits_cost=pre_deducted,
refund_amount=pre_deducted,
price_action="refund",
resolution=task.resolution,
duration=task.duration,
error_message=str(exc)[:500],
error_code="download_failed",
)
# 失败释放并发槽位,检查排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "failed", "task_id": task_id, "error": str(exc)}
else:
# 直接下载最终结果
import os
from app.services.video_gen import download_video
date_dir = datetime.now().strftime("%Y%m%d")
# 统一路径格式
dest_path = f"./storage/generate/api/videos/{date_dir}/{task.id}.mp4"
abs_dest_path = os.path.abspath(dest_path)
os.makedirs(os.path.dirname(abs_dest_path), exist_ok=True)
logger.info("Downloading final video to: %s", abs_dest_path)
try:
await download_video(task.remote_result_url, abs_dest_path)
logger.info("Final video downloaded successfully: %s", dest_path)
# 配额已在创建时预扣,此处不再重复扣减
task.video_url = dest_path # 使用相对路径
task.local_path = dest_path
task.download_storage_date_dir = date_dir
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = _now()
await db.commit()
# 记录成功日志(配额已在创建时预扣)
try:
await usage_log_service.record_usage(
db=db,
api_key_id=task.api_key_id,
request_type="video_create",
model_name=task.model_name,
gen_type="video",
status="success",
task_id=task.id,
credits_cost=task.credits_cost,
tokens_used=task.video_tokens_used,
price_action="deduct",
resolution=task.resolution,
duration=task.duration,
)
await db.commit()
except Exception as log_exc:
logger.error("Failed to record usage log: %s", log_exc)
# 日志记录失败不应影响任务完成
# 检查并启动下一个排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "completed", "task_id": task_id}
except Exception as exc:
# 下载失败:退回预扣配额
from app.models.api.api_key import ApiKey
pre_deducted = task.credits_cost or 0.0
if pre_deducted > 0:
key_result = await db.execute(
select(ApiKey).where(ApiKey.id == task.api_key_id).with_for_update()
)
key = key_result.scalar_one_or_none()
if key:
key.quota_used = round(max(0, (key.quota_used or 0.0) - pre_deducted), 2)
logger.exception("API video download failed: task_id=%s", task_id)
task.status = "failed"
task.error_message = f"下载失败: {str(exc)[:500]}"
task.credits_cost = 0
await db.commit()
# 失败释放并发槽位,检查排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "failed", "task_id": task_id, "error": str(exc)}
finally:
from app.services.redis_registry_service import redis_release_lock
await redis_release_lock(lock_key=lock_key, token=token)
# === 任务 4: 超分完成后更新 API 任务 ===
@celery_app.task(
name="api.upscale_finalize_task",
bind=True,
max_retries=3,
soft_time_limit=120,
time_limit=300,
acks_late=True,
)
def api_upscale_finalize_task(self, api_task_id: str, upscale_task_id: str) -> dict[str, Any]:
"""超分完成后更新 API 任务状态。"""
return run_async(_upscale_finalize(self, api_task_id, upscale_task_id))
async def _upscale_finalize(self, api_task_id: str, upscale_task_id: str) -> dict[str, Any]:
async with async_session() as db:
from app.models.video_upscale_task import VideoUpscaleTask
task = await db.get(ApiGenerationTask, api_task_id)
upscale_task = await db.get(VideoUpscaleTask, upscale_task_id)
if not task or task.deleted_at:
return {"status": "not_found", "api_task_id": api_task_id}
if upscale_task and upscale_task.status == "completed":
# 超分成功:更新视频URL(配额已在创建时预扣)
task.video_url = upscale_task.provider_output_url or task.remote_result_url
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = _now()
await db.commit()
# 记录超分完成日志
log_upscale_poll_end(
task_id=upscale_task_id,
api_task_id=api_task_id,
success=True,
final_status="completed",
total_attempts=upscale_task.attempt_count or 1,
)
await usage_log_service.record_usage(
db=db,
api_key_id=task.api_key_id,
request_type="video_create",
model_name=task.model_name,
gen_type="video",
status="success",
task_id=task.id,
credits_cost=task.credits_cost,
tokens_used=task.video_tokens_used,
)
await db.commit()
# 检查并启动下一个排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "completed", "api_task_id": api_task_id}
elif upscale_task and upscale_task.status == "failed":
# 超分失败:回退到原始视频
task.video_url = task.remote_result_url
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = _now()
task.error_message = "超分失败,返回原始视频"
await db.commit()
# 记录超分失败日志
log_upscale_poll_end(
task_id=upscale_task_id,
api_task_id=api_task_id,
success=False,
final_status="failed",
total_attempts=upscale_task.attempt_count or 1,
)
# 检查并启动下一个排队任务
await _start_next_queued_task(db, task.api_key_id)
return {"status": "completed_with_fallback", "api_task_id": api_task_id}
else:
# 超分仍在处理中:重新调度
log_upscale_poll(
task_id=upscale_task_id,
api_task_id=api_task_id,
status=upscale_task.status or "unknown",
attempt=upscale_task.attempt_count or 0,
)
api_upscale_finalize_task.apply_async(
args=[api_task_id, upscale_task_id],
countdown=60,
queue=QUEUE_DOWNLOAD,
)
return {"status": "waiting_upscale", "api_task_id": api_task_id}
@@ -0,0 +1,146 @@
"""API v3 容灾恢复任务。
处理服务重启后的任务恢复:
- 扫描处于中间状态的 ApiGenerationTask
- 重新入队未完成的 Celery 任务
- 处理租约过期的任务
Worker 启动时会自动触发恢复扫描。
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.base import async_session
from app.tasks.api_generation_tasks import (
QUEUE_CREATE,
QUEUE_DOWNLOAD,
QUEUE_POLL,
api_create_generation_task,
api_download_generation_result_task,
api_poll_generation_task,
)
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
logger = logging.getLogger("videogen")
async def recover_api_generation_tasks_once():
"""扫描并恢复未完成的 API v3 生成任务。
恢复场景:
1. status=pending 且未入队 -> 重新入队创建任务
2. status=generating 且 provider_task_id 为空 -> 重新入队创建任务
3. status=generating 且 provider_task_id 存在 -> 重新入队轮询任务
4. pipeline_stage=result_ready -> 重新入队下载任务
5. 租约过期但任务未完成 -> 重新入队对应阶段任务
"""
now = datetime.now(timezone.utc)
recovered = 0
async with async_session() as db:
# 1. 恢复 pending/generating 任务(未开始或中断)
result = await db.execute(
__import__("sqlalchemy").select(ApiGenerationTask).where(
ApiGenerationTask.status.in_(["pending", "generating"]),
ApiGenerationTask.deleted_at.is_(None),
ApiGenerationTask.created_at > now - timedelta(hours=48),
)
)
tasks = list(result.scalars().all())
for task in tasks:
try:
if task.status == "pending" or not task.provider_task_id:
# 重新入队创建任务
api_create_generation_task.apply_async(
args=[task.id],
queue=QUEUE_CREATE,
)
logger.info("API recovery: re-enqueued create task %s", task.id)
recovered += 1
elif task.status == "generating" and task.provider_task_id:
# 检查是否需要轮询
next_poll_at = task.next_poll_at
if next_poll_at is None or next_poll_at <= now:
# 重新入队轮询任务
api_poll_generation_task.apply_async(
args=[task.id],
queue=QUEUE_POLL,
)
logger.info("API recovery: re-enqueued poll task %s (provider_task_id=%s)", task.id, task.provider_task_id)
recovered += 1
# 检查下载阶段
if task.pipeline_stage == "result_ready" and not task.video_url and not task.image_url:
api_download_generation_result_task.apply_async(
args=[task.id],
queue=QUEUE_DOWNLOAD,
)
logger.info("API recovery: re-enqueued download task %s", task.id)
recovered += 1
except Exception as exc:
logger.warning("API recovery: failed to recover task %s: %s", task.id, exc)
# 2. 恢复排队任务(服务重启后,排队任务需要重新检查并发)
from app.services.api_v3.quota_service import can_start_video_task, get_queued_video_tasks
from app.models.api.api_key import ApiKey
# 获取所有有排队任务的 API Key
queued_result = await db.execute(
__import__("sqlalchemy").select(ApiGenerationTask.api_key_id).where(
ApiGenerationTask.status == "queued",
ApiGenerationTask.deleted_at.is_(None),
ApiGenerationTask.created_at > now - timedelta(hours=48),
).distinct()
)
api_key_ids = [row[0] for row in queued_result.all()]
for api_key_id in api_key_ids:
try:
key = await db.get(ApiKey, api_key_id)
if not key:
continue
# 检查是否可以启动排队任务
if await can_start_video_task(key, db):
queued_tasks = await get_queued_video_tasks(key, db, limit=1)
if queued_tasks:
next_task = queued_tasks[0]
next_task.status = "pending"
next_task.pipeline_stage = "queued"
await db.commit()
api_create_generation_task.apply_async(
args=[next_task.id],
queue=QUEUE_CREATE,
)
logger.info("API recovery: started queued task %s for key %s", next_task.id, api_key_id)
recovered += 1
except Exception as exc:
logger.warning("API recovery: failed to recover queued task for key %s: %s", api_key_id, exc)
if recovered:
logger.info("API recovery: recovered %d tasks", recovered)
return recovered
@celery_app.task(
name="api_generation.recover_tasks_once",
bind=True,
max_retries=0,
soft_time_limit=300,
time_limit=600,
)
def api_generation_recover_tasks_once(self):
"""API v3 任务恢复扫描(Celery Beat 定时触发)。"""
return run_async(recover_api_generation_tasks_once())
@@ -0,0 +1,372 @@
"""API v3 简化超分任务。
不使用复杂的 CeleryRuntimeLease 锁机制,直接执行超分流程。
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
import os
from typing import Any
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.services.video_upscale.volc_service import VolcSubmitResult, VolcQueryResult
from app.tasks.api_celery_app import celery_app
logger = logging.getLogger("videogen")
def _now() -> datetime:
"""获取当前时间(UTC)。"""
return datetime.now(timezone.utc)
def _now_str() -> str:
"""获取当前日期字符串。"""
return _now().strftime("%Y%m%d")
def _format_time(dt: datetime | None) -> str | None:
"""格式化时间为字符串(北京时间)。"""
if dt is None:
return None
from datetime import timedelta
beijing_time = dt + timedelta(hours=8)
return beijing_time.strftime("%Y-%m-%d %H:%M:%S")
async def _maybe_delete_source_file(db, upscale) -> None:
"""根据超分配置决定是否删除源文件。"""
if not upscale.source_local_path or not upscale.api_generation_task_id:
return
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_generation_task import ApiGenerationTask
from sqlalchemy import select
# 获取 API Key ID
api_task_result = await db.execute(
select(ApiGenerationTask.api_key_id).where(
ApiGenerationTask.id == upscale.api_generation_task_id
).limit(1)
)
api_key_id = api_task_result.scalar_one_or_none()
if not api_key_id:
return
# 查询超分配置
config_result = await db.execute(
select(ApiKeyUpscaleConfig).where(
ApiKeyUpscaleConfig.api_key_id == api_key_id
).limit(1)
)
upscale_config = config_result.scalar_one_or_none()
# 只有配置了"成功后删除源文件"才删除
if upscale_config and upscale_config.delete_source_after_success:
source_abs = os.path.abspath(upscale.source_local_path)
if os.path.exists(source_abs):
os.remove(source_abs)
logger.info("Deleted source file after upscale: %s", source_abs)
@celery_app.task(
name="api_upscale.execute_local_simple",
bind=True,
max_retries=2,
soft_time_limit=600,
time_limit=900,
)
def api_upscale_execute_local_simple(self, upscale_task_id: str) -> dict[str, Any]:
"""简化版本地超分执行(API v3 专用)。"""
from app.models.base import async_session
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.video_upscale.local_ffmpeg_service import execute_local_ffmpeg_crop
from sqlalchemy import select
async def _execute():
async with async_session() as db:
result = await db.execute(
select(VideoUpscaleTask).where(VideoUpscaleTask.id == upscale_task_id).limit(1)
)
upscale = result.scalar_one_or_none()
if not upscale:
return {"status": "not_found"}
if upscale.status == "completed":
return {"status": "already_completed"}
# 更新状态为处理中
upscale.status = "processing"
upscale.stage = "local_processing"
upscale.attempt_count = int(upscale.attempt_count or 0) + 1
upscale.started_at = upscale.started_at or __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
await db.commit()
try:
# 构建输出路径
date_dir = _now_str()
final_local_path = f"./storage/generate/api/videos/{date_dir}/{upscale.api_generation_task_id or 'unknown'}.mp4"
# 执行本地 FFmpeg 超分
output_path = await execute_local_ffmpeg_crop(
source_path=upscale.source_local_path,
final_path=final_local_path,
target_width=int(upscale.target_width or 0),
target_height=int(upscale.target_height or 0),
)
# 计算相对 URL 路径
url_path = f"/generate/api/videos/{date_dir}/{upscale.api_generation_task_id}.mp4"
# 更新成功状态
upscale.status = "completed"
upscale.stage = "upscale_completed"
upscale.final_local_path = final_local_path
upscale.final_resource_url = url_path # 相对 URL
upscale.completed_at = _now()
# 更新 API 任务的 video_url(使用相对 URL
if upscale.api_generation_task_id:
from app.models.api.api_generation_task import ApiGenerationTask
api_result = await db.execute(
select(ApiGenerationTask).where(ApiGenerationTask.id == upscale.api_generation_task_id).limit(1)
)
api_task = api_result.scalar_one_or_none()
if api_task:
api_task.video_url = url_path # 相对 URL
api_task.status = "completed"
api_task.pipeline_stage = "done"
api_task.generated_at = upscale.completed_at
await db.commit()
return {"status": "completed", "output_path": output_path}
except Exception as exc:
upscale.status = "failed"
upscale.stage = "failed"
upscale.failure_count = int(upscale.failure_count or 0) + 1
upscale.last_error = str(exc)[:500]
upscale.failed_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
await db.commit()
raise
from app.tasks.async_runner import run_async
return run_async(_execute())
@celery_app.task(
name="api_upscale.submit_remote_simple",
bind=True,
max_retries=2,
soft_time_limit=600,
time_limit=900,
)
def api_upscale_submit_remote_simple(self, upscale_task_id: str) -> dict[str, Any]:
"""简化版远程超分提交(API v3 专用)。"""
from app.models.base import async_session
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.video_upscale.volc_service import submit_video_enhance, VolcSubmitResult
from sqlalchemy import select
async def _execute():
async with async_session() as db:
result = await db.execute(
select(VideoUpscaleTask).where(VideoUpscaleTask.id == upscale_task_id).limit(1)
)
upscale = result.scalar_one_or_none()
if not upscale:
return {"status": "not_found"}
if upscale.status == "completed":
return {"status": "already_completed"}
# 更新状态
upscale.status = "processing"
upscale.stage = "remote_submitting"
upscale.attempt_count = int(upscale.attempt_count or 0) + 1
upscale.started_at = upscale.started_at or __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
await db.commit()
try:
# 提交到火山 MediaKit
from app.utils.id_gen import generate_id
# 从 API 任务获取超分快照
import json
api_snapshot = {}
if upscale.api_generation_task_id:
from app.models.api.api_generation_task import ApiGenerationTask
api_result = await db.execute(
__import__("sqlalchemy").select(ApiGenerationTask).where(ApiGenerationTask.id == upscale.api_generation_task_id).limit(1)
)
api_task = api_result.scalar_one_or_none()
if api_task and api_task.video_upscale_snapshot_json:
try:
api_snapshot = json.loads(api_task.video_upscale_snapshot_json)
except (json.JSONDecodeError, TypeError):
pass
submit_result: VolcSubmitResult = await submit_video_enhance(
processor_key=upscale.processor_key,
video_url=upscale.source_remote_url or "",
target_resolution=api_snapshot.get("target_resolution", "1080p"),
target_width=int(upscale.target_width or 0),
target_height=int(upscale.target_height or 0),
processor=api_snapshot.get("processor", {}),
client_token=generate_id(),
)
# 更新成功状态
upscale.provider_task_id = submit_result.task_id
upscale.provider_submitted_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
upscale.provider_request_json = json.dumps(submit_result.request_payload, ensure_ascii=False) if submit_result.request_payload else None
upscale.provider_response_json = json.dumps(submit_result.response_payload, ensure_ascii=False) if submit_result.response_payload else None
upscale.celery_task_id = self.request.id if hasattr(self, 'request') else None
upscale.status = "processing"
upscale.stage = "remote_polling"
await db.commit()
# 立即触发第一次轮询
api_upscale_poll_remote_simple.apply_async(
args=[upscale_task_id],
countdown=30,
queue=CeleryQueue.GEN_API_UPSCALE.value,
)
return {"status": "submitted", "provider_task_id": submit_result.task_id}
except Exception as exc:
upscale.status = "failed"
upscale.stage = "failed"
upscale.failure_count = int(upscale.failure_count or 0) + 1
upscale.last_error = str(exc)[:500]
upscale.failed_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
await db.commit()
raise
from app.tasks.async_runner import run_async
return run_async(_execute())
@celery_app.task(
name="api_upscale.poll_remote_simple",
bind=True,
max_retries=10,
soft_time_limit=120,
time_limit=300,
)
def api_upscale_poll_remote_simple(self, upscale_task_id: str) -> dict[str, Any]:
"""简化版远程超分轮询(API v3 专用)。"""
from app.models.base import async_session
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.video_upscale.volc_service import query_task
from sqlalchemy import select
async def _execute():
async with async_session() as db:
result = await db.execute(
select(VideoUpscaleTask).where(VideoUpscaleTask.id == upscale_task_id).limit(1)
)
upscale = result.scalar_one_or_none()
if not upscale or upscale.status == "completed":
return {"status": "not_found_or_completed"}
try:
# 查询火山 MediaKit 状态
query_result = await query_task(upscale.provider_task_id)
status = query_result.status
if status == "completed":
# 超分完成
output_url = query_result.result.get("video_url", "") if query_result.result else ""
upscale.provider_output_url = output_url
upscale.provider_output_url_expires_at = __import__("datetime").datetime.fromtimestamp(query_result.expires_at, tz=__import__("datetime").timezone.utc) if query_result.expires_at else None
import os
from app.services.video_gen import download_video
api_task_id = upscale.api_generation_task_id
final_path = None
try:
# 直接下载到最终路径
date_dir = _now_str()
# 相对 URL 路径
url_path = f"/generate/api/videos/{date_dir}/{api_task_id}.mp4"
# 绝对文件路径
z_url_path = f"./storage/generate/api/videos/{date_dir}/{api_task_id}.mp4"
abs_path = os.path.abspath(z_url_path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
await download_video(output_url, abs_path)
upscale.final_local_path = z_url_path
upscale.final_resource_url = url_path # 相对 URL
final_path = url_path
# 检查 API Key 超分配置中的"成功后删除源文件"设置
_maybe_delete_source_file(db, upscale)
except Exception:
upscale.final_local_path = output_url
upscale.final_resource_url = output_url
final_path = output_url
upscale.status = "completed"
upscale.stage = "upscale_completed"
upscale.completed_at = _now()
# 获取文件大小
try:
import os
abs_path = os.path.abspath(final_path) if final_path and final_path.startswith(".") else None
if abs_path and os.path.exists(abs_path):
upscale.final_file_size_bytes = os.path.getsize(abs_path)
except Exception:
pass
# 更新 API 任务
if api_task_id:
from app.models.api.api_generation_task import ApiGenerationTask
api_result = await db.execute(
select(ApiGenerationTask).where(ApiGenerationTask.id == api_task_id).limit(1)
)
api_task = api_result.scalar_one_or_none()
if api_task:
api_task.video_url = final_path or output_url
api_task.status = "completed"
api_task.pipeline_stage = "done"
api_task.generated_at = upscale.completed_at
await db.commit()
return {"status": "completed", "output_url": final_path or output_url}
elif status == "failed":
upscale.status = "failed"
upscale.stage = "failed"
upscale.failure_count = int(upscale.failure_count or 0) + 1
upscale.last_error = str(query_result.error) if query_result.error else "超分失败"
upscale.failed_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
await db.commit()
return {"status": "failed"}
else:
# 仍在处理中,继续轮询
upscale.stage = "remote_polling"
await db.commit()
# 重新调度下一次轮询
api_upscale_poll_remote_simple.apply_async(
args=[upscale_task_id],
countdown=30,
queue=CeleryQueue.GEN_API_UPSCALE.value,
)
return {"status": "polling"}
except Exception as exc:
upscale.failure_count = int(upscale.failure_count or 0) + 1
upscale.last_error = str(exc)[:500]
await db.commit()
raise
from app.tasks.async_runner import run_async
return run_async(_execute())
+35 -1
View File
@@ -33,8 +33,12 @@ CELERY_TASK_IMPORTS = (
"app.tasks.module_async_recovery_tasks",
"app.tasks.module_generation_v2_tasks",
"app.tasks.private_portrait_asset_tasks",
"app.tasks.vp_v3_asset_tasks",
"app.tasks.celery_runtime_tasks",
"app.tasks.credit_tasks",
"app.tasks.api_generation_tasks",
"app.tasks.api_recovery_tasks",
"app.tasks.api_upscale_tasks",
)
@@ -86,6 +90,14 @@ def _beat_schedule() -> dict:
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
},
}
schedule["api-generation-recovery-every-minute"] = {
"task": "api_generation.recover_tasks_once",
"schedule": 60,
"options": {
"queue": RECOVERY_QUEUE,
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
},
}
schedule["generation-download-recovery"] = {
"task": CeleryTaskName.RECOVER_DOWNLOAD.value,
"schedule": max(1, int(settings.DOWNLOAD_RECOVERY_INTERVAL_SECONDS or 60)),
@@ -126,6 +138,16 @@ def _beat_schedule() -> dict:
"schedule": 60,
"options": {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
}
schedule["vp-v3-sync-due-assets-every-minute"] = {
"task": CeleryTaskName.VP_V3_SYNC_DUE_ASSETS.value,
"schedule": 60,
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
}
schedule["vp-v3-recover-remote-deletes-every-5-minutes"] = {
"task": CeleryTaskName.VP_V3_RECOVER_REMOTE_DELETES.value,
"schedule": 300,
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
}
return schedule
@@ -248,6 +270,11 @@ if broker_url:
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.CREDIT_MAINTENANCE.value: {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
CeleryTaskName.VP_V3_POLL_ASSET.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.VP_V3_SYNC_DUE_ASSETS.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.VP_V3_DELETE_ASSET.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.VP_V3_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.VP_V3_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
},
)
else:
@@ -412,6 +439,7 @@ def on_worker_ready(sender=None, **kwargs):
try:
from app.services.celery_runtime.recovery_service import set_startup_barrier
from app.tasks.generation_recovery_tasks import startup_recovery_once
from app.tasks.api_recovery_tasks import api_generation_recover_tasks_once
run_async(set_startup_barrier())
countdown = max(0, int(settings.CELERY_STARTUP_RECOVERY_DELAY_SECONDS or 30))
@@ -420,8 +448,14 @@ def on_worker_ready(sender=None, **kwargs):
queue=RECOVERY_QUEUE,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
# API v3 任务恢复(延迟 35 秒执行,避免与其他恢复任务冲突)
api_generation_recover_tasks_once.apply_async(
countdown=countdown + 5,
queue=RECOVERY_QUEUE,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
logger.info(
"启动容灾恢复协调任务已投递。queue=%s countdown=%s",
"启动容灾恢复协调任务已投递(含 API v3。queue=%s countdown=%s",
RECOVERY_QUEUE,
countdown,
)
@@ -0,0 +1,449 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from app.config import settings
from app.enums.celery_queue import CeleryQueue, CeleryTaskName
from app.enums.celery_runtime import CeleryRuntimeDomain
from app.enums.private_portrait import (
PrivatePortraitAssetStatus,
PrivatePortraitEventSource,
PrivatePortraitEventType,
PrivatePortraitRemoteDeleteStatus,
)
from app.models import async_session
from app.models.virtual_portrait_v3 import VpV3Asset, VpV3Project
from app.services.celery_runtime.recovery_service import guard_periodic_recovery
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.redis_registry_service import RedisExecutionLockLease
from app.services.virtual_portrait_v3.asset_service import (
V3_DOMAIN,
delete_v3_asset_remote,
sync_asset_status,
)
from app.services.virtual_portrait_v3.project_service import (
V3_DOMAIN as V3_PROJECT_DOMAIN,
delete_v3_project_remote,
)
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
logger = logging.getLogger(__name__)
QUEUE = CeleryQueue.GEN_PRIVATE_PORTRAIT.value
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
def _now() -> datetime:
"""统一使用北京时间基准,与业务写入保持一致。"""
return _bj_now()
def _naive(dt: datetime | None) -> datetime | None:
"""把 datetime 统一成 naive 北京时间(去掉 tzinfo),避免 offset-aware vs naive 比较报错。
DB 列是 DateTime(timezone=True) 但业务写入都是北京时间(naive),
读回时根据方言可能变成 aware 或仍为 naive,比较前统一去掉 tzinfo。
"""
if dt is None:
return None
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
def _retry_countdown(retries: int) -> int:
return min(300, 30 * (2 ** max(0, retries)))
async def _rollback_and_reraise(
db,
*,
event_type: str,
exc: BaseException,
detail: dict[str, Any] | None = None,
**kwargs: Any,
):
await db.rollback()
log_operation_error(
domain=V3_DOMAIN,
event_type=event_type,
source=PrivatePortraitEventSource.CELERY.value,
exc=exc,
detail=detail,
**kwargs,
)
raise exc
async def _acquire_v3_runtime(
*,
domain: str,
owner_type: str,
owner_id: str,
task_name: str,
hash_key: str,
zset_key: str,
lock_prefix: str,
) -> CeleryRuntimeLease | None:
token = uuid.uuid4().hex
return await CeleryRuntimeLease.acquire(
identity=RuntimeIdentity(
domain=domain,
owner_type=owner_type,
owner_id=owner_id,
attempt_no=1,
task_name=task_name,
queue=QUEUE,
),
lock_key=f"{lock_prefix}:{owner_type}:{owner_id}:attempt:1",
hash_key=hash_key,
zset_key=zset_key,
token=token,
ttl_seconds=max(60, int(settings.VP_V3_RUNTIME_LOCK_TTL_SECONDS or 180)),
heartbeat_interval_seconds=max(10, int(settings.VP_V3_RUNTIME_HEARTBEAT_SECONDS or 30)),
pipeline_stage="processing",
)
# ---------------------------------------------------------------------------
# 轮询:单条素材
# ---------------------------------------------------------------------------
async def _run_poll_v3_asset(asset_id: str) -> None:
lease = await _acquire_v3_runtime(
domain=CeleryRuntimeDomain.PRIVATE_PORTRAIT_POLL.value,
owner_type="asset",
owner_id=asset_id,
task_name=CeleryTaskName.VP_V3_POLL_ASSET.value,
hash_key=settings.VP_V3_POLL_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.VP_V3_POLL_ACTIVE_REDIS_ZSET_KEY,
lock_prefix=settings.VP_V3_POLL_LOCK_KEY_PREFIX,
)
if lease is None:
logger.info("vp_v3 poll asset skip: runtime lease not acquired (asset_id=%s)", asset_id)
return
try:
async with async_session() as db:
try:
row = (await db.execute(
select(VpV3Asset.api_key_id).where(VpV3Asset.remote_asset_id == asset_id).limit(1)
)).scalar_one_or_none()
if not row:
return
asset = await sync_asset_status(
db,
api_key_id=str(row),
asset_id=asset_id,
execution_guard=lease.ensure_owned,
)
await lease.ensure_owned()
await db.commit()
logger.info(
"vp_v3 poll asset synced: asset_id=%s status=%s poll_count=%s",
asset_id, asset.status, asset.poll_count,
)
except Exception as exc:
logger.exception("vp_v3 poll asset failed: %s", asset_id)
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value,
exc=exc,
asset_id=asset_id,
detail={"celery_task": CeleryTaskName.VP_V3_POLL_ASSET.value},
)
finally:
await lease.close()
# ---------------------------------------------------------------------------
# 轮询:每分钟批量扫描到期素材并分发轮询任务
# ---------------------------------------------------------------------------
async def _dispatch_v3_due_assets() -> int:
barrier = await guard_periodic_recovery()
if barrier is not None:
logger.info("vp_v3 dispatch due assets skip: periodic recovery barrier active")
return 0
lock = await RedisExecutionLockLease.acquire(
lock_key=settings.VP_V3_DISPATCH_LOCK_KEY,
ttl_seconds=55,
renew_interval_seconds=20,
log_context="vp_v3_poll_dispatch",
)
if lock is None:
logger.info("vp_v3 dispatch due assets skip: dispatch lock not acquired")
return 0
async with lock:
async with async_session() as db:
now_naive = _naive(_now())
rows = await db.execute(
select(VpV3Asset)
.where(
VpV3Asset.deleted_at.is_(None),
VpV3Asset.status == PrivatePortraitAssetStatus.CREATING.value,
VpV3Asset.next_poll_at.is_not(None),
)
.order_by(VpV3Asset.next_poll_at.asc(), VpV3Asset.id.asc())
.limit(settings.VP_V3_ASSET_POLL_BATCH_SIZE or 50)
.with_for_update(skip_locked=True)
)
assets = list(rows.scalars().all())
# next_poll_at <= now 在内存里过滤(统一 naive 比较,避免 aware vs naive 报错)
assets = [a for a in assets if _naive(a.next_poll_at) is not None and _naive(a.next_poll_at) <= now_naive]
dispatches: list[tuple[str, int]] = []
queue_hold_until_naive = now_naive + timedelta(seconds=120)
for asset in assets:
poll_no = int(asset.poll_count or 0) + 1
dispatches.append((str(asset.remote_asset_id), poll_no))
asset.next_poll_at = queue_hold_until_naive
await db.commit()
for asset_id, poll_no in dispatches:
poll_v3_asset_status.apply_async(
args=[asset_id],
queue=QUEUE,
countdown=0,
task_id=f"vp-v3-poll:{asset_id}:attempt:{poll_no}",
)
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value,
event_status="success",
source=PrivatePortraitEventSource.CELERY.value,
detail={"matched_count": len(dispatches), "dispatched_count": len(dispatches)},
)
return len(dispatches)
# ---------------------------------------------------------------------------
# 删除:素材 / 项目远端删除(已存在)
# ---------------------------------------------------------------------------
async def _run_delete_v3_asset(asset_id: str) -> None:
"""执行 V3 素材远端删除。"""
lease = await _acquire_v3_runtime(
domain=CeleryRuntimeDomain.PRIVATE_PORTRAIT_DELETE.value,
owner_type="asset",
owner_id=asset_id,
task_name=CeleryTaskName.VP_V3_DELETE_ASSET.value,
hash_key=settings.VP_V3_DELETE_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.VP_V3_DELETE_ACTIVE_REDIS_ZSET_KEY,
lock_prefix=settings.VP_V3_DELETE_LOCK_KEY_PREFIX,
)
if lease is None:
logger.info("vp_v3 delete asset skip: runtime lease not acquired (asset_id=%s)", asset_id)
return
try:
async with async_session() as db:
try:
await delete_v3_asset_remote(
db,
asset_id=asset_id,
execution_guard=lease.ensure_owned,
)
await lease.ensure_owned()
await db.commit()
except Exception as exc:
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
exc=exc,
detail={"asset_id": asset_id},
)
finally:
await lease.close()
async def _run_delete_v3_project(project_id: str) -> int:
"""执行 V3 项目远端删除(级联删除素材 + 项目)。"""
lease = await _acquire_v3_runtime(
domain=CeleryRuntimeDomain.PRIVATE_PORTRAIT_DELETE.value,
owner_type="project",
owner_id=project_id,
task_name=CeleryTaskName.VP_V3_DELETE_PROJECT.value,
hash_key=settings.VP_V3_DELETE_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.VP_V3_DELETE_ACTIVE_REDIS_ZSET_KEY,
lock_prefix=settings.VP_V3_DELETE_LOCK_KEY_PREFIX,
)
if lease is None:
logger.info("vp_v3 delete project skip: runtime lease not acquired (project_id=%s)", project_id)
return 0
try:
async with async_session() as db:
try:
await delete_v3_project_remote(
db,
project_id=project_id,
)
await lease.ensure_owned()
await db.commit()
except Exception as exc:
await _rollback_and_reraise(
db,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value,
exc=exc,
detail={"project_id": project_id},
)
finally:
await lease.close()
return 0
# ---------------------------------------------------------------------------
# 删除恢复:每 5 分钟扫描 pending/failed 的 project/asset 再投递
# ---------------------------------------------------------------------------
async def _dispatch_v3_remote_delete_recovery() -> dict[str, int]:
barrier = await guard_periodic_recovery()
if barrier is not None:
logger.info("vp_v3 delete recovery skip: periodic recovery barrier active")
return {"asset_count": 0, "project_count": 0, "total_count": 0}
lock = await RedisExecutionLockLease.acquire(
lock_key=settings.VP_V3_DELETE_RECOVERY_LOCK_KEY,
ttl_seconds=240,
renew_interval_seconds=30,
log_context="vp_v3_delete_recovery",
)
if lock is None:
logger.info("vp_v3 delete recovery skip: recovery lock not acquired")
return {"asset_count": 0, "project_count": 0, "total_count": 0}
async with lock:
statuses = [
PrivatePortraitRemoteDeleteStatus.PENDING.value,
PrivatePortraitRemoteDeleteStatus.FAILED.value,
]
batch_size = max(1, int(settings.VP_V3_REMOTE_DELETE_RECOVERY_BATCH_SIZE or 50))
async with async_session() as db:
asset_rows = await db.execute(
select(VpV3Asset.id)
.where(VpV3Asset.remote_delete_status.in_(statuses))
.order_by(VpV3Asset.updated_at.asc(), VpV3Asset.id.asc())
.limit(batch_size)
)
asset_ids = [str(value) for value in asset_rows.scalars().all()]
remaining = max(0, batch_size - len(asset_ids))
project_ids: list[str] = []
if remaining:
project_rows = await db.execute(
select(VpV3Project.id)
.where(VpV3Project.remote_delete_status.in_(statuses))
.order_by(VpV3Project.updated_at.asc(), VpV3Project.id.asc())
.limit(remaining)
)
project_ids = [str(value) for value in project_rows.scalars().all()]
await db.rollback()
for asset_id in asset_ids:
delete_v3_asset_remote_task.apply_async(
args=[asset_id], queue=QUEUE, task_id=f"vp-v3-delete-asset:{asset_id}"
)
for project_id in project_ids:
delete_v3_project_remote_task.apply_async(
args=[project_id], queue=QUEUE, task_id=f"vp-v3-delete-project:{project_id}"
)
return {
"asset_count": len(asset_ids),
"project_count": len(project_ids),
"total_count": len(asset_ids) + len(project_ids),
}
# ---------------------------------------------------------------------------
# Celery 任务注册
# ---------------------------------------------------------------------------
@celery_app.task(
name=CeleryTaskName.VP_V3_POLL_ASSET.value,
queue=QUEUE,
bind=True,
max_retries=5,
default_retry_delay=30,
)
def poll_v3_asset_status(self, asset_id: str) -> None:
"""V3 素材单条状态轮询(Celery 任务)。"""
logger.info("vp_v3 poll task START: asset_id=%s task_id=%s", asset_id, self.request.id)
try:
return run_async(_run_poll_v3_asset(asset_id))
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(
name=CeleryTaskName.VP_V3_SYNC_DUE_ASSETS.value,
queue=QUEUE,
bind=True,
max_retries=3,
default_retry_delay=60,
)
def sync_v3_due_assets(self) -> int:
"""每分钟扫描 V3 到期素材并分发轮询任务(beat schedule)。"""
logger.info("vp_v3 sync_due_assets START: task_id=%s", self.request.id)
try:
return run_async(_dispatch_v3_due_assets())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(
name=CeleryTaskName.VP_V3_DELETE_ASSET.value,
queue=QUEUE,
bind=True,
max_retries=3,
default_retry_delay=60,
)
def delete_v3_asset_remote_task(self, asset_id: str) -> None:
"""V3 素材远端删除 Celery 任务。"""
logger.info("vp_v3 delete asset START: asset_id=%s task_id=%s", asset_id, self.request.id)
try:
return run_async(_run_delete_v3_asset(asset_id))
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(
name=CeleryTaskName.VP_V3_DELETE_PROJECT.value,
queue=QUEUE,
bind=True,
max_retries=3,
default_retry_delay=60,
)
def delete_v3_project_remote_task(self, project_id: str) -> int:
"""V3 项目远端删除 Celery 任务。"""
logger.info("vp_v3 delete project START: project_id=%s task_id=%s", project_id, self.request.id)
try:
return run_async(_run_delete_v3_project(project_id))
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
@celery_app.task(
name=CeleryTaskName.VP_V3_RECOVER_REMOTE_DELETES.value,
queue=QUEUE,
bind=True,
max_retries=3,
default_retry_delay=60,
)
def recover_v3_remote_deletes(self) -> dict[str, int]:
"""每 5 分钟扫描 V3 pending/failed 远端删除记录并重新投递(beat schedule)。"""
logger.info("vp_v3 recover_remote_deletes START: task_id=%s", self.request.id)
try:
return run_async(_dispatch_v3_remote_delete_recovery())
except Exception as exc:
raise self.retry(exc=exc, countdown=_retry_countdown(self.request.retries))
+21
View File
@@ -40,6 +40,27 @@ def decrypt_temp_token(token: str) -> str | None:
return None
def encrypt_text(plaintext: str) -> str:
"""使用 AES-256-GCM 加密字符串,返回 base64 编码的密文。"""
import os
aesgcm = AESGCM(get_aes_key())
nonce = os.urandom(12) # 96-bit nonce for GCM
ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
return base64.urlsafe_b64encode(nonce + ciphertext).decode()
def decrypt_text(token: str) -> str | None:
"""解密 AES-256-GCM 加密的字符串。失败返回 None。"""
try:
token_bytes = base64.urlsafe_b64decode(token)
nonce = token_bytes[:12]
ciphertext = token_bytes[12:]
aesgcm = AESGCM(get_aes_key())
return aesgcm.decrypt(nonce, ciphertext, None).decode()
except Exception:
return None
def hmac_sign(data: str) -> str:
"""Create HMAC-SHA256 signature."""
return hmac.new(