1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理 3、增加apikey单独的模型定价 4、增加apikey调用情况 5、完善所有数据的注释增加
This commit is contained in:
+2
-1
@@ -6,7 +6,7 @@ SECRET_KEY=local-dev-secret-key-not-for-production
|
||||
|
||||
# Database (PostgreSQL)
|
||||
#DATABASE_URL=postgresql+asyncpg://videogen_test:Yr7kM7kDj75izCiA@180.184.42.66:5432/videogen_test
|
||||
DATABASE_URL=postgresql+asyncpg://videogen:7k33pnXdPL62Yyb4@180.184.42.66:5432/videogen
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:123456@localhost:5432/videogen_cs
|
||||
|
||||
# Redis (leave empty to disable - rate limiting and captcha will use in-memory fallback)
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
@@ -44,6 +44,7 @@ CAPTCHA_ENABLED=true
|
||||
CORS_ORIGINS=["*"]
|
||||
|
||||
# Base URL (用于 favicon、回调地址等)
|
||||
#BASE_URL=http://localhost:8000
|
||||
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
|
||||
|
||||
# RESOURCE
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
"""2026073101_add_column_comments
|
||||
|
||||
Revision ID: 2026073101
|
||||
Revises: f7g8h9i0j1k2
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-31 的数据库迁移内容:
|
||||
给所有表字段添加 COMMENT 注释,便于数据库维护与排查。
|
||||
仅使用 COMMENT ON COLUMN/COMMENT ON TABLE 语句,不修改列类型与约束。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = '2026073101'
|
||||
down_revision: Union[str, None] = 'f7g8h9i0j1k2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ============================================================
|
||||
# users 表
|
||||
# ============================================================
|
||||
_comment_table("users", "用户表")
|
||||
_comment_column("users", "id", "主键ID")
|
||||
_comment_column("users", "username", "用户名,唯一")
|
||||
_comment_column("users", "email", "邮箱,唯一")
|
||||
_comment_column("users", "phone", "手机号,唯一")
|
||||
_comment_column("users", "hashed_password", "加密后的密码")
|
||||
_comment_column("users", "avatar", "头像URL")
|
||||
_comment_column("users", "credits", "账户积分余额")
|
||||
_comment_column("users", "is_active", "是否启用,True启用")
|
||||
_comment_column("users", "is_admin", "是否管理员,True管理员")
|
||||
_comment_column("users", "user_type", "用户类型:frontend前台用户,admin后台管理员")
|
||||
_comment_column("users", "frontend_user_kind", "前台用户类型:internal内部用户,external外部用户")
|
||||
_comment_column("users", "team_id", "当前归属团队ID,仅前台用户有意义")
|
||||
_comment_column("users", "last_login_at", "最后登录时间")
|
||||
_comment_column("users", "password_set_at", "密码设置时间,NULL表示未设置密码")
|
||||
_comment_column("users", "allowed_menus", "允许访问的菜单列表(JSON),NULL表示继承默认")
|
||||
_comment_column("users", "private_portrait_asset_limit", "私域人像素材总量上限,0表示关闭模块")
|
||||
_comment_column("users", "created_at", "创建时间")
|
||||
_comment_column("users", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# projects 表
|
||||
# ============================================================
|
||||
_comment_table("projects", "项目表")
|
||||
_comment_column("projects", "id", "主键ID")
|
||||
_comment_column("projects", "user_id", "所属用户ID")
|
||||
_comment_column("projects", "name", "项目名称")
|
||||
_comment_column("projects", "industry", "所属行业")
|
||||
_comment_column("projects", "created_at", "创建时间")
|
||||
_comment_column("projects", "updated_at", "更新时间")
|
||||
_comment_column("projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# credit_ratios 表
|
||||
# ============================================================
|
||||
_comment_table("credit_ratios", "积分计费规则表")
|
||||
_comment_column("credit_ratios", "id", "主键ID")
|
||||
_comment_column("credit_ratios", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
|
||||
_comment_column("credit_ratios", "gen_type", "生成类型:image图片,video视频")
|
||||
_comment_column("credit_ratios", "resolution", "分辨率档位:图片(2K/4K) / 视频(480p/720p/1080p)")
|
||||
_comment_column("credit_ratios", "ratio", "生成倍率,最终积分 = (基础积分+单位积分×时长/张数) × 倍率")
|
||||
_comment_column("credit_ratios", "base_credits", "生成基础积分")
|
||||
_comment_column("credit_ratios", "per_second_credits", "视频每秒积分 / 图片每张积分")
|
||||
_comment_column("credit_ratios", "input_video_ratio", "传入视频积分倍率")
|
||||
_comment_column("credit_ratios", "input_video_base_credits", "传入视频基础积分")
|
||||
_comment_column("credit_ratios", "input_video_per_second_credits", "传入视频每秒积分")
|
||||
_comment_column("credit_ratios", "input_image_ratio", "传入图片积分倍率")
|
||||
_comment_column("credit_ratios", "input_image_base_credits", "传入图片基础积分")
|
||||
_comment_column("credit_ratios", "input_image_per_image_credits", "传入图片每张积分")
|
||||
_comment_column("credit_ratios", "created_at", "创建时间")
|
||||
_comment_column("credit_ratios", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# credit_records 表
|
||||
# ============================================================
|
||||
_comment_table("credit_records", "积分流水表")
|
||||
_comment_column("credit_records", "id", "主键ID")
|
||||
_comment_column("credit_records", "user_id", "所属用户ID")
|
||||
_comment_column("credit_records", "type", "流水类型:charge扣费,recharge充值,refund退款,gift赠送")
|
||||
_comment_column("credit_records", "amount", "流水金额,扣费为负数,充值/退款/赠送为正数")
|
||||
_comment_column("credit_records", "balance_after", "流水后账户余额")
|
||||
_comment_column("credit_records", "description", "流水描述")
|
||||
_comment_column("credit_records", "related_id", "关联业务ID,如生成任务ID/订单ID")
|
||||
_comment_column("credit_records", "biz_key", "业务幂等键,格式如 owner_type:owner_id:attempt_no:charge_kind:action")
|
||||
_comment_column("credit_records", "refund_for_biz_key", "退款时,对应的扣费biz_key")
|
||||
_comment_column("credit_records", "owner_type", "归属类型:chat_generation_task/ generation_record等")
|
||||
_comment_column("credit_records", "owner_id", "归属业务记录ID")
|
||||
_comment_column("credit_records", "attempt_no", "计费尝试次数,重试时递增")
|
||||
_comment_column("credit_records", "charge_kind", "扣费大类:media媒体生成,prompt提示词等")
|
||||
_comment_column("credit_records", "charge_action", "扣费动作:charge扣费,refund退款")
|
||||
_comment_column("credit_records", "credit_subject", "计费科目:image/video/text")
|
||||
_comment_column("credit_records", "media_type", "媒体类型:与credit_subject配合细分")
|
||||
_comment_column("credit_records", "billing_scene", "计费场景:如chat_creation、project等")
|
||||
_comment_column("credit_records", "source_module", "来源模块:generation_record/module_generation等")
|
||||
_comment_column("credit_records", "source_project_id", "来源项目ID")
|
||||
_comment_column("credit_records", "source_step_id", "来源步骤ID")
|
||||
_comment_column("credit_records", "source_step_code", "来源步骤编码")
|
||||
_comment_column("credit_records", "token_usage_id", "关联Token消耗记录ID")
|
||||
_comment_column("credit_records", "input_tokens", "输入Token数量快照")
|
||||
_comment_column("credit_records", "output_tokens", "输出Token数量快照")
|
||||
_comment_column("credit_records", "total_tokens", "总Token数量快照")
|
||||
_comment_column("credit_records", "engine_type", "引擎类型:image/video/text")
|
||||
_comment_column("credit_records", "engine_id", "使用的引擎ID")
|
||||
_comment_column("credit_records", "engine_name", "引擎名称快照")
|
||||
_comment_column("credit_records", "engine_provider", "引擎供应商快照:ark/其他")
|
||||
_comment_column("credit_records", "engine_model_name", "引擎模型名快照")
|
||||
_comment_column("credit_records", "user_type_snapshot", "用户类型快照:frontend/admin")
|
||||
_comment_column("credit_records", "frontend_user_kind_snapshot", "前台用户类型快照:internal/external")
|
||||
_comment_column("credit_records", "team_id_snapshot", "团队ID快照,流水发生时的归属团队")
|
||||
_comment_column("credit_records", "team_name_snapshot", "团队名称快照")
|
||||
_comment_column("credit_records", "created_at", "创建时间")
|
||||
_comment_column("credit_records", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# chat_generation_tasks 表
|
||||
# ============================================================
|
||||
_comment_table("chat_generation_tasks", "AI创作任务表(不绑定项目的聊天式生成)")
|
||||
_comment_column("chat_generation_tasks", "id", "主键ID,顶层/子任务ID")
|
||||
_comment_column("chat_generation_tasks", "user_id", "所属用户ID")
|
||||
_comment_column("chat_generation_tasks", "original_prompt", "原始用户提示词")
|
||||
_comment_column("chat_generation_tasks", "optimized_prompt", "优化后的提示词")
|
||||
_comment_column("chat_generation_tasks", "gen_type", "生成类型:image图片,video视频")
|
||||
_comment_column("chat_generation_tasks", "duration", "视频时长(秒)")
|
||||
_comment_column("chat_generation_tasks", "aspect_ratio", "视频比例:16:9/9:16等")
|
||||
_comment_column("chat_generation_tasks", "resolution", "用户选择的分辨率")
|
||||
_comment_column("chat_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
|
||||
_comment_column("chat_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
||||
_comment_column("chat_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
|
||||
_comment_column("chat_generation_tasks", "image_size", "图片分辨率档位:2K/4K")
|
||||
_comment_column("chat_generation_tasks", "image_proportion", "图片比例:1:1/16:9等")
|
||||
_comment_column("chat_generation_tasks", "image_px", "图片像素,如2048×2048")
|
||||
_comment_column("chat_generation_tasks", "status", "任务状态:generating/success/failed等")
|
||||
_comment_column("chat_generation_tasks", "pipeline_stage", "流水线阶段:prompt_optimized/resource_generated等")
|
||||
_comment_column("chat_generation_tasks", "generation_mode", "生成模式:chatapi_async单份异步/chatapi_main多份主任务")
|
||||
_comment_column("chat_generation_tasks", "parent_task_id", "父任务ID,多份生成时子任务关联主任务")
|
||||
_comment_column("chat_generation_tasks", "generation_count", "生成份数,主任务表示总共多少份")
|
||||
_comment_column("chat_generation_tasks", "generation_index", "第N份子任务,主任务为NULL")
|
||||
_comment_column("chat_generation_tasks", "generation_attempt_no", "生成尝试次数,重试时递增")
|
||||
_comment_column("chat_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
|
||||
_comment_column("chat_generation_tasks", "provider_create_claim_token", "供应商创建任务分布式租约token")
|
||||
_comment_column("chat_generation_tasks", "provider_create_lease_until", "供应商创建租约过期时间")
|
||||
_comment_column("chat_generation_tasks", "provider_create_started_at", "供应商创建任务开始时间")
|
||||
_comment_column("chat_generation_tasks", "media_references", "参考素材JSON数组")
|
||||
_comment_column("chat_generation_tasks", "provider_task_id", "供应商任务ID")
|
||||
_comment_column("chat_generation_tasks", "seedance_task_id", "Seedance任务ID(兼容字段)")
|
||||
_comment_column("chat_generation_tasks", "remote_result_url", "供应商返回的远程资源URL")
|
||||
_comment_column("chat_generation_tasks", "image_url", "图片结果URL")
|
||||
_comment_column("chat_generation_tasks", "video_url", "视频结果URL")
|
||||
_comment_column("chat_generation_tasks", "video_cover_url", "视频封面URL")
|
||||
_comment_column("chat_generation_tasks", "engine_id", "使用的引擎ID")
|
||||
_comment_column("chat_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
|
||||
_comment_column("chat_generation_tasks", "provider_response_json", "供应商完整响应JSON")
|
||||
_comment_column("chat_generation_tasks", "credits_cost", "媒体生成消耗的总积分")
|
||||
_comment_column("chat_generation_tasks", "text_credits_cost", "提示词优化消耗积分")
|
||||
_comment_column("chat_generation_tasks", "text_tokens_used", "提示词优化Token消耗")
|
||||
_comment_column("chat_generation_tasks", "video_tokens_used", "视频生成Token消耗")
|
||||
_comment_column("chat_generation_tasks", "image_tokens_used", "图片生成Token消耗")
|
||||
_comment_column("chat_generation_tasks", "retry_count", "重试次数(兼容旧字段)")
|
||||
_comment_column("chat_generation_tasks", "manual_retry_count", "用户手动重试次数")
|
||||
_comment_column("chat_generation_tasks", "poll_error_count", "轮询错误次数")
|
||||
_comment_column("chat_generation_tasks", "poll_count", "轮询总次数")
|
||||
_comment_column("chat_generation_tasks", "last_poll_at", "最后一次轮询时间")
|
||||
_comment_column("chat_generation_tasks", "poll_started_at", "本次轮询开始时间")
|
||||
_comment_column("chat_generation_tasks", "next_poll_at", "下一次轮询触发时间")
|
||||
_comment_column("chat_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
|
||||
_comment_column("chat_generation_tasks", "poll_claim_token", "轮询分布式租约token")
|
||||
_comment_column("chat_generation_tasks", "poll_lease_until", "轮询租约过期时间")
|
||||
_comment_column("chat_generation_tasks", "deadline_at", "任务截止时间,超时自动失败")
|
||||
_comment_column("chat_generation_tasks", "generated_at", "资源生成完成时间")
|
||||
_comment_column("chat_generation_tasks", "error_message", "错误信息")
|
||||
_comment_column("chat_generation_tasks", "idempotency_key", "幂等键,防重复创建")
|
||||
_comment_column("chat_generation_tasks", "download_celery_task_id", "下载步骤Celery任务ID")
|
||||
_comment_column("chat_generation_tasks", "download_enqueued_at", "下载入队时间")
|
||||
_comment_column("chat_generation_tasks", "download_started_at", "下载开始时间")
|
||||
_comment_column("chat_generation_tasks", "download_claim_token", "下载租约token")
|
||||
_comment_column("chat_generation_tasks", "download_lease_until", "下载租约过期时间")
|
||||
_comment_column("chat_generation_tasks", "download_next_retry_at", "下载下次重试时间")
|
||||
_comment_column("chat_generation_tasks", "download_attempt_count", "下载重试次数")
|
||||
_comment_column("chat_generation_tasks", "download_last_error", "下载最后一次错误信息")
|
||||
_comment_column("chat_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
|
||||
_comment_column("chat_generation_tasks", "created_at", "创建时间")
|
||||
_comment_column("chat_generation_tasks", "updated_at", "更新时间")
|
||||
_comment_column("chat_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# generation_records 表
|
||||
# ============================================================
|
||||
_comment_table("generation_records", "项目生成记录表(绑定项目的旧版生成)")
|
||||
_comment_column("generation_records", "id", "主键ID")
|
||||
_comment_column("generation_records", "user_id", "所属用户ID")
|
||||
_comment_column("generation_records", "project_id", "所属项目ID")
|
||||
_comment_column("generation_records", "original_prompt", "原始提示词")
|
||||
_comment_column("generation_records", "optimized_prompt", "优化后的提示词")
|
||||
_comment_column("generation_records", "prompt_usage_snapshot_json", "提示词消耗快照JSON")
|
||||
_comment_column("generation_records", "gen_type", "生成类型:image/video")
|
||||
_comment_column("generation_records", "duration", "视频时长秒数")
|
||||
_comment_column("generation_records", "aspect_ratio", "视频比例")
|
||||
_comment_column("generation_records", "resolution", "分辨率档位")
|
||||
_comment_column("generation_records", "provider_generation_resolution", "供应商实际分辨率")
|
||||
_comment_column("generation_records", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
||||
_comment_column("generation_records", "video_upscale_snapshot_json", "视频超分快照JSON")
|
||||
_comment_column("generation_records", "image_size", "图片分辨率档位")
|
||||
_comment_column("generation_records", "image_proportion", "图片比例")
|
||||
_comment_column("generation_records", "image_px", "图片像素尺寸")
|
||||
_comment_column("generation_records", "status", "任务状态")
|
||||
_comment_column("generation_records", "pipeline_stage", "流水线阶段")
|
||||
_comment_column("generation_records", "video_url", "视频结果URL")
|
||||
_comment_column("generation_records", "video_cover_url", "视频封面URL")
|
||||
_comment_column("generation_records", "image_url", "图片结果URL")
|
||||
_comment_column("generation_records", "media_references", "参考素材JSON数组")
|
||||
_comment_column("generation_records", "include_media_references", "是否包含参考素材")
|
||||
_comment_column("generation_records", "video_url_expires_at", "视频URL过期时间")
|
||||
_comment_column("generation_records", "seedance_task_id", "Seedance任务ID")
|
||||
_comment_column("generation_records", "credits_cost", "媒体生成消耗积分")
|
||||
_comment_column("generation_records", "text_credits_cost", "提示词消耗积分")
|
||||
_comment_column("generation_records", "text_tokens_used", "提示词Token数")
|
||||
_comment_column("generation_records", "video_tokens_used", "视频Token数")
|
||||
_comment_column("generation_records", "image_tokens_used", "图片Token数")
|
||||
_comment_column("generation_records", "generated_at", "生成完成时间")
|
||||
_comment_column("generation_records", "error_message", "错误信息")
|
||||
_comment_column("generation_records", "idempotency_key", "幂等键")
|
||||
_comment_column("generation_records", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("generation_records", "resource_generation_started_at", "资源生成开始时间")
|
||||
_comment_column("generation_records", "deadline_at", "任务截止时间")
|
||||
_comment_column("generation_records", "engine_id", "使用引擎ID")
|
||||
_comment_column("generation_records", "engine_snapshot_json", "引擎参数快照JSON")
|
||||
_comment_column("generation_records", "provider_response_json", "供应商响应JSON")
|
||||
_comment_column("generation_records", "remote_result_url", "远程资源URL")
|
||||
_comment_column("generation_records", "provider_create_claim_token", "供应商创建租约token")
|
||||
_comment_column("generation_records", "provider_create_lease_until", "供应商创建租约过期")
|
||||
_comment_column("generation_records", "provider_create_started_at", "供应商创建开始时间")
|
||||
_comment_column("generation_records", "retry_count", "重试次数(兼容)")
|
||||
_comment_column("generation_records", "manual_retry_count", "手动重试次数")
|
||||
_comment_column("generation_records", "poll_error_count", "轮询错误次数")
|
||||
_comment_column("generation_records", "poll_count", "轮询次数")
|
||||
_comment_column("generation_records", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("generation_records", "poll_started_at", "轮询开始时间")
|
||||
_comment_column("generation_records", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("generation_records", "poll_interval_seconds", "轮询间隔秒")
|
||||
_comment_column("generation_records", "poll_claim_token", "轮询租约token")
|
||||
_comment_column("generation_records", "poll_lease_until", "轮询租约过期")
|
||||
_comment_column("generation_records", "download_celery_task_id", "下载Celery任务ID")
|
||||
_comment_column("generation_records", "download_enqueued_at", "下载开始入队时间")
|
||||
_comment_column("generation_records", "download_started_at", "下载开始时间")
|
||||
_comment_column("generation_records", "download_claim_token", "下载租约token")
|
||||
_comment_column("generation_records", "download_lease_until", "下载租约过期")
|
||||
_comment_column("generation_records", "download_next_retry_at", "下载下次重试")
|
||||
_comment_column("generation_records", "download_attempt_count", "下载重试次数")
|
||||
_comment_column("generation_records", "download_last_error", "下载最后错误")
|
||||
_comment_column("generation_records", "download_storage_date_dir", "下载存储日期目录")
|
||||
_comment_column("generation_records", "created_at", "创建时间")
|
||||
_comment_column("generation_records", "updated_at", "更新时间")
|
||||
_comment_column("generation_records", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# generated_resources 表
|
||||
# ============================================================
|
||||
_comment_table("generated_resources", "生成资源账本表(统一记录所有生成的图片/视频)")
|
||||
_comment_column("generated_resources", "id", "主键ID")
|
||||
_comment_column("generated_resources", "user_id", "所属用户ID")
|
||||
_comment_column("generated_resources", "resource_type", "资源类型:image/video")
|
||||
_comment_column("generated_resources", "resource_url", "资源访问URL")
|
||||
_comment_column("generated_resources", "remote_url", "供应商原始远程URL")
|
||||
_comment_column("generated_resources", "storage_type", "存储类型:local本地/oss对象存储")
|
||||
_comment_column("generated_resources", "storage_path", "存储路径")
|
||||
_comment_column("generated_resources", "file_name", "文件名,平台素材名称")
|
||||
_comment_column("generated_resources", "file_size_bytes", "文件大小(字节)")
|
||||
_comment_column("generated_resources", "source_model", "来源模型:chat_generation_task/generation_record")
|
||||
_comment_column("generated_resources", "source_model_module", "来源模块描述")
|
||||
_comment_column("generated_resources", "source_id", "来源记录ID")
|
||||
_comment_column("generated_resources", "engine_id", "使用引擎ID")
|
||||
_comment_column("generated_resources", "engine_type", "引擎类型:image/video")
|
||||
_comment_column("generated_resources", "provider", "供应商:ark/其他")
|
||||
_comment_column("generated_resources", "model_name", "模型名称")
|
||||
_comment_column("generated_resources", "generated_at", "资源生成完成时间")
|
||||
_comment_column("generated_resources", "resource_month", "资源归属月份,按月统计")
|
||||
_comment_column("generated_resources", "extra_json", "扩展字段JSON")
|
||||
_comment_column("generated_resources", "created_at", "创建时间")
|
||||
_comment_column("generated_resources", "updated_at", "更新时间")
|
||||
_comment_column("generated_resources", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# upload_resources 表
|
||||
# ============================================================
|
||||
_comment_table("upload_resources", "用户上传资源账本表(用户上传/模块上传/切片文件)")
|
||||
_comment_column("upload_resources", "id", "主键ID")
|
||||
_comment_column("upload_resources", "user_id", "所属用户ID")
|
||||
_comment_column("upload_resources", "module", "所属模块:conversation/generation_record等")
|
||||
_comment_column("upload_resources", "resource_type", "资源类型:image/video/audio/file")
|
||||
_comment_column("upload_resources", "resource_url", "资源访问URL")
|
||||
_comment_column("upload_resources", "storage_path", "存储路径,唯一")
|
||||
_comment_column("upload_resources", "file_name", "原始文件名")
|
||||
_comment_column("upload_resources", "file_ext", "文件扩展名")
|
||||
_comment_column("upload_resources", "mime_type", "MIME类型")
|
||||
_comment_column("upload_resources", "file_size_bytes", "文件大小(字节)")
|
||||
_comment_column("upload_resources", "duration_seconds", "音视频时长(秒)")
|
||||
_comment_column("upload_resources", "duration_source", "时长来源:probe探测/用户设置")
|
||||
_comment_column("upload_resources", "width", "图片/视频宽度(像素)")
|
||||
_comment_column("upload_resources", "height", "图片/视频高度(像素)")
|
||||
_comment_column("upload_resources", "source_model", "关联业务模型")
|
||||
_comment_column("upload_resources", "source_id", "关联业务记录ID")
|
||||
_comment_column("upload_resources", "source_module", "关联业务模块")
|
||||
_comment_column("upload_resources", "bind_status", "绑定状态:pending待绑定/bound已绑定/unbound已解绑")
|
||||
_comment_column("upload_resources", "delete_policy", "删除策略:user_deletable用户可删/keep_forever永久保留")
|
||||
_comment_column("upload_resources", "created_by", "创建来源:api用户上传/worker系统生成")
|
||||
_comment_column("upload_resources", "metadata_json", "媒体元数据JSON")
|
||||
_comment_column("upload_resources", "capacity_released_at", "容量统计中已释放时间")
|
||||
_comment_column("upload_resources", "physical_deleted_at", "物理文件删除时间")
|
||||
_comment_column("upload_resources", "file_delete_status", "文件删除状态:active待删/deleting删除中/deleted已删除/error失败")
|
||||
_comment_column("upload_resources", "file_delete_error", "文件删除失败信息")
|
||||
_comment_column("upload_resources", "created_at", "创建时间")
|
||||
_comment_column("upload_resources", "updated_at", "更新时间")
|
||||
_comment_column("upload_resources", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# image_engines 表
|
||||
# ============================================================
|
||||
_comment_table("image_engines", "图片生成引擎配置表")
|
||||
_comment_column("image_engines", "id", "主键ID")
|
||||
_comment_column("image_engines", "name", "引擎显示名称")
|
||||
_comment_column("image_engines", "provider", "供应商:ark/其他")
|
||||
_comment_column("image_engines", "api_base", "API基础地址")
|
||||
_comment_column("image_engines", "api_key", "API密钥")
|
||||
_comment_column("image_engines", "model_name", "模型名")
|
||||
_comment_column("image_engines", "supported_models", "支持的模型列表JSON")
|
||||
_comment_column("image_engines", "supported_sizes", "支持尺寸JSON:{分辨率:{比例:像素}}")
|
||||
_comment_column("image_engines", "default_size", "默认分辨率档位")
|
||||
_comment_column("image_engines", "max_image_count", "允许生成图片数量上限")
|
||||
_comment_column("image_engines", "multi_generation_enabled", "是否允许多份生成")
|
||||
_comment_column("image_engines", "max_generation_count", "多份生成最大份数")
|
||||
_comment_column("image_engines", "multi_image_max_images", "组图接口参考图+生成图数量上限")
|
||||
_comment_column("image_engines", "max_reference_image_count", "最多参考图片张数")
|
||||
_comment_column("image_engines", "output_format", "输出格式,空表示使用默认")
|
||||
_comment_column("image_engines", "generate_url", "生成接口URL,留空使用SDK默认")
|
||||
_comment_column("image_engines", "is_active", "是否启用")
|
||||
_comment_column("image_engines", "priority", "排序优先级,越大越优先")
|
||||
_comment_column("image_engines", "created_at", "创建时间")
|
||||
_comment_column("image_engines", "updated_at", "更新时间")
|
||||
_comment_column("image_engines", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# video_engines 表
|
||||
# ============================================================
|
||||
_comment_table("video_engines", "视频生成引擎配置表")
|
||||
_comment_column("video_engines", "id", "主键ID")
|
||||
_comment_column("video_engines", "name", "引擎显示名称")
|
||||
_comment_column("video_engines", "provider", "供应商:ark/其他")
|
||||
_comment_column("video_engines", "api_base", "API基础地址")
|
||||
_comment_column("video_engines", "api_key", "API密钥")
|
||||
_comment_column("video_engines", "model_name", "模型名")
|
||||
_comment_column("video_engines", "supported_ratios", "支持比例JSON数组")
|
||||
_comment_column("video_engines", "supported_resolutions", "支持分辨率JSON数组")
|
||||
_comment_column("video_engines", "supported_durations", "支持时长JSON数组")
|
||||
_comment_column("video_engines", "max_duration", "最大时长秒数")
|
||||
_comment_column("video_engines", "max_image_count", "最多参考图片张数,0表示不支持")
|
||||
_comment_column("video_engines", "max_video_count", "最多参考视频段数,0表示不支持")
|
||||
_comment_column("video_engines", "max_audio_count", "最多参考音频段数,0表示不支持")
|
||||
_comment_column("video_engines", "multi_generation_enabled", "是否允许多份生成")
|
||||
_comment_column("video_engines", "max_generation_count", "多份生成最大份数")
|
||||
_comment_column("video_engines", "supports_first_last_frame", "是否支持首尾帧参考")
|
||||
_comment_column("video_engines", "supports_universal_reference", "是否支持通用参考素材")
|
||||
_comment_column("video_engines", "generate_url", "生成接口URL")
|
||||
_comment_column("video_engines", "query_url", "查询接口URL")
|
||||
_comment_column("video_engines", "is_active", "是否启用")
|
||||
_comment_column("video_engines", "priority", "排序优先级")
|
||||
_comment_column("video_engines", "created_at", "创建时间")
|
||||
_comment_column("video_engines", "updated_at", "更新时间")
|
||||
_comment_column("video_engines", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# model_configs 表
|
||||
# ============================================================
|
||||
_comment_table("model_configs", "文本模型配置表(提示词优化等文本模型)")
|
||||
_comment_column("model_configs", "id", "主键ID")
|
||||
_comment_column("model_configs", "name", "模型显示名称")
|
||||
_comment_column("model_configs", "provider", "供应商")
|
||||
_comment_column("model_configs", "api_base", "API基础地址")
|
||||
_comment_column("model_configs", "api_key", "API密钥")
|
||||
_comment_column("model_configs", "model_name", "模型名")
|
||||
_comment_column("model_configs", "weight", "权重,权重选择时使用")
|
||||
_comment_column("model_configs", "max_tokens", "最大输出Token数")
|
||||
_comment_column("model_configs", "temperature", "采样温度")
|
||||
_comment_column("model_configs", "is_active", "是否启用")
|
||||
_comment_column("model_configs", "priority", "排序优先级")
|
||||
_comment_column("model_configs", "created_at", "创建时间")
|
||||
_comment_column("model_configs", "updated_at", "更新时间")
|
||||
_comment_column("model_configs", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# system_configs 表
|
||||
# ============================================================
|
||||
_comment_table("system_configs", "系统配置表")
|
||||
_comment_column("system_configs", "id", "主键ID")
|
||||
_comment_column("system_configs", "key", "配置键名,唯一")
|
||||
_comment_column("system_configs", "value", "配置值")
|
||||
_comment_column("system_configs", "description", "配置说明")
|
||||
_comment_column("system_configs", "created_at", "创建时间")
|
||||
_comment_column("system_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# operation_logs 表
|
||||
# ============================================================
|
||||
_comment_table("operation_logs", "操作日志表")
|
||||
_comment_column("operation_logs", "id", "主键ID")
|
||||
_comment_column("operation_logs", "user_id", "操作用户ID")
|
||||
_comment_column("operation_logs", "username", "操作用户名")
|
||||
_comment_column("operation_logs", "action", "操作动作:CREATE/UPDATE/DELETE等")
|
||||
_comment_column("operation_logs", "method", "HTTP方法:GET/POST/PUT/DELETE")
|
||||
_comment_column("operation_logs", "path", "请求路径")
|
||||
_comment_column("operation_logs", "detail", "操作详情JSON")
|
||||
_comment_column("operation_logs", "ip", "客户端IP")
|
||||
_comment_column("operation_logs", "created_at", "创建时间")
|
||||
_comment_column("operation_logs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# notifications 表
|
||||
# ============================================================
|
||||
_comment_table("notifications", "通知消息表")
|
||||
_comment_column("notifications", "id", "主键ID")
|
||||
_comment_column("notifications", "user_id", "接收用户ID,NULL表示全体广播")
|
||||
_comment_column("notifications", "title", "通知标题")
|
||||
_comment_column("notifications", "content", "通知内容")
|
||||
_comment_column("notifications", "type", "通知类型:system系统公告/billing账单通知等")
|
||||
_comment_column("notifications", "is_read", "是否已读")
|
||||
_comment_column("notifications", "related_id", "关联业务ID")
|
||||
_comment_column("notifications", "created_at", "创建时间")
|
||||
_comment_column("notifications", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# recharge_packages 表
|
||||
# ============================================================
|
||||
_comment_table("recharge_packages", "积分充值套餐表")
|
||||
_comment_column("recharge_packages", "id", "主键ID")
|
||||
_comment_column("recharge_packages", "name", "套餐名称")
|
||||
_comment_column("recharge_packages", "credits", "套餐包含积分")
|
||||
_comment_column("recharge_packages", "price", "套餐价格(元)")
|
||||
_comment_column("recharge_packages", "bonus_credits", "赠送积分")
|
||||
_comment_column("recharge_packages", "description", "套餐描述")
|
||||
_comment_column("recharge_packages", "package_type", "套餐类型:normal普通/gift赠送首充等")
|
||||
_comment_column("recharge_packages", "is_gift", "是否赠送套餐")
|
||||
_comment_column("recharge_packages", "is_active", "是否启用")
|
||||
_comment_column("recharge_packages", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("recharge_packages", "created_at", "创建时间")
|
||||
_comment_column("recharge_packages", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# payment_orders 表
|
||||
# ============================================================
|
||||
_comment_table("payment_orders", "支付订单表")
|
||||
_comment_column("payment_orders", "id", "主键ID")
|
||||
_comment_column("payment_orders", "user_id", "下单用户ID")
|
||||
_comment_column("payment_orders", "order_no", "订单号,唯一")
|
||||
_comment_column("payment_orders", "amount", "支付金额(元)")
|
||||
_comment_column("payment_orders", "credits", "获得积分总数(含赠送)")
|
||||
_comment_column("payment_orders", "payment_method", "支付方式:wxpay/alipay等")
|
||||
_comment_column("payment_orders", "status", "订单状态:pending待支付/paid已支付/refunded已退款/failed失败")
|
||||
_comment_column("payment_orders", "paid_at", "支付成功时间")
|
||||
_comment_column("payment_orders", "trade_no", "第三方支付流水号")
|
||||
_comment_column("payment_orders", "refund_trade_no", "退款流水号")
|
||||
_comment_column("payment_orders", "refunded_at", "退款完成时间")
|
||||
_comment_column("payment_orders", "refund_amount", "退款金额")
|
||||
_comment_column("payment_orders", "created_at", "创建时间")
|
||||
_comment_column("payment_orders", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# video_upscale_tasks 表
|
||||
# ============================================================
|
||||
_comment_table("video_upscale_tasks", "视频超分任务表")
|
||||
_comment_column("video_upscale_tasks", "id", "主键ID")
|
||||
_comment_column("video_upscale_tasks", "chat_generation_task_id", "关联AI创作任务ID,与generation_record_id二选一")
|
||||
_comment_column("video_upscale_tasks", "generation_record_id", "关联项目生成记录ID,与chat_generation_task_id二选一")
|
||||
_comment_column("video_upscale_tasks", "api_generation_task_id", "关联API生成任务ID")
|
||||
_comment_column("video_upscale_tasks", "status", "任务状态:pending/processing/success/failed")
|
||||
_comment_column("video_upscale_tasks", "stage", "阶段:upscale_queued/upscale_processing等")
|
||||
_comment_column("video_upscale_tasks", "processor_key", "处理节点标识")
|
||||
_comment_column("video_upscale_tasks", "attempt_count", "执行尝试次数")
|
||||
_comment_column("video_upscale_tasks", "failure_count", "失败次数")
|
||||
_comment_column("video_upscale_tasks", "manual_retry_count", "手动重试次数")
|
||||
_comment_column("video_upscale_tasks", "next_retry_at", "下次重试时间")
|
||||
_comment_column("video_upscale_tasks", "last_error", "最后错误信息")
|
||||
_comment_column("video_upscale_tasks", "source_local_path", "源视频本地路径")
|
||||
_comment_column("video_upscale_tasks", "source_file_size_bytes", "源文件大小(字节)")
|
||||
_comment_column("video_upscale_tasks", "source_width", "源视频宽度")
|
||||
_comment_column("video_upscale_tasks", "source_height", "源视频高度")
|
||||
_comment_column("video_upscale_tasks", "source_duration_seconds", "源视频时长秒数")
|
||||
_comment_column("video_upscale_tasks", "source_deleted_at", "源文件删除时间")
|
||||
_comment_column("video_upscale_tasks", "source_delete_error", "源文件删除错误")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url", "源文件远程URL")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_signed_at", "远程URL签名时间")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_expires_at", "远程URL过期时间")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_last_probe_at", "远程URL最后探测时间")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_probe_status", "远程URL探测状态")
|
||||
_comment_column("video_upscale_tasks", "input_source_type", "输入源类型:local/remote")
|
||||
_comment_column("video_upscale_tasks", "input_source_fallback_count", "输入源回退次数")
|
||||
_comment_column("video_upscale_tasks", "target_width", "目标宽度像素")
|
||||
_comment_column("video_upscale_tasks", "target_height", "目标高度像素")
|
||||
_comment_column("video_upscale_tasks", "effective_target_width", "实际生效目标宽度")
|
||||
_comment_column("video_upscale_tasks", "effective_target_height", "实际生效目标高度")
|
||||
_comment_column("video_upscale_tasks", "provider_task_id", "供应商超分任务ID")
|
||||
_comment_column("video_upscale_tasks", "provider_request_json", "供应商请求JSON")
|
||||
_comment_column("video_upscale_tasks", "provider_response_json", "供应商响应JSON")
|
||||
_comment_column("video_upscale_tasks", "provider_output_url", "供应商输出URL")
|
||||
_comment_column("video_upscale_tasks", "provider_output_url_expires_at", "供应商输出URL过期")
|
||||
_comment_column("video_upscale_tasks", "provider_submitted_at", "提交供应商时间")
|
||||
_comment_column("video_upscale_tasks", "final_local_path", "最终本地文件路径")
|
||||
_comment_column("video_upscale_tasks", "final_resource_url", "最终资源访问URL")
|
||||
_comment_column("video_upscale_tasks", "final_file_size_bytes", "最终文件大小(字节)")
|
||||
_comment_column("video_upscale_tasks", "celery_task_id", "Celery任务ID")
|
||||
_comment_column("video_upscale_tasks", "lease_token", "分布式租约token")
|
||||
_comment_column("video_upscale_tasks", "lease_until", "租约过期时间")
|
||||
_comment_column("video_upscale_tasks", "started_at", "开始处理时间")
|
||||
_comment_column("video_upscale_tasks", "completed_at", "完成时间")
|
||||
_comment_column("video_upscale_tasks", "failed_at", "失败时间")
|
||||
_comment_column("video_upscale_tasks", "created_at", "创建时间")
|
||||
_comment_column("video_upscale_tasks", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# shot_replicate_task_sets 表
|
||||
# ============================================================
|
||||
_comment_table("shot_replicate_task_sets", "拆镜复刻总任务集")
|
||||
_comment_column("shot_replicate_task_sets", "id", "主键ID")
|
||||
_comment_column("shot_replicate_task_sets", "user_id", "所属用户ID")
|
||||
_comment_column("shot_replicate_task_sets", "title", "任务集标题")
|
||||
_comment_column("shot_replicate_task_sets", "video_url", "上传视频访问URL")
|
||||
_comment_column("shot_replicate_task_sets", "video_path", "上传视频存储路径")
|
||||
_comment_column("shot_replicate_task_sets", "video_duration_seconds", "上传视频总时长秒数")
|
||||
_comment_column("shot_replicate_task_sets", "status", "总任务状态:pending_analysis/analyzing/analysis_done等")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_status", "AI分析状态:pending/processing/success/failed")
|
||||
_comment_column("shot_replicate_task_sets", "split_status", "切片状态:none/slicing/sliced")
|
||||
_comment_column("shot_replicate_task_sets", "original_video_content", "原视频内容描述")
|
||||
_comment_column("shot_replicate_task_sets", "original_video_category", "原视频行业分类")
|
||||
_comment_column("shot_replicate_task_sets", "original_video_audience", "原视频目标受众")
|
||||
_comment_column("shot_replicate_task_sets", "ai_suggestion_json", "AI复刻建议JSON")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_raw_json", "AI分析原始JSON")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_result_json", "AI分析结果JSON")
|
||||
_comment_column("shot_replicate_task_sets", "segment_count", "总拆镜头数")
|
||||
_comment_column("shot_replicate_task_sets", "completed_segment_count", "已完成镜头数")
|
||||
_comment_column("shot_replicate_task_sets", "failed_segment_count", "失败镜头数")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_attempt_no", "AI分析尝试次数")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_claim_token", "AI分析租约token")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_started_at", "AI分析开始时间")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_lease_until", "AI分析租约过期")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_error_message", "AI分析错误信息")
|
||||
_comment_column("shot_replicate_task_sets", "split_error_message", "切片错误信息")
|
||||
_comment_column("shot_replicate_task_sets", "idempotency_key", "幂等键")
|
||||
_comment_column("shot_replicate_task_sets", "created_at", "创建时间")
|
||||
_comment_column("shot_replicate_task_sets", "updated_at", "更新时间")
|
||||
_comment_column("shot_replicate_task_sets", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# teams 表(已经有部分comment,补齐未加的)
|
||||
# ============================================================
|
||||
_comment_table("teams", "团队表")
|
||||
_comment_column("teams", "id", "主键ID")
|
||||
_comment_column("teams", "name", "团队名称")
|
||||
_comment_column("teams", "code", "团队编码")
|
||||
_comment_column("teams", "description", "团队备注")
|
||||
_comment_column("teams", "status", "团队状态:active启用,disabled禁用")
|
||||
_comment_column("teams", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("teams", "manager_id", "团队管理人ID")
|
||||
_comment_column("teams", "created_at", "创建时间")
|
||||
_comment_column("teams", "updated_at", "更新时间")
|
||||
_comment_column("teams", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# user_resource_capacity_configs 表(已部分有comment)
|
||||
# ============================================================
|
||||
_comment_table("user_resource_capacity_configs", "用户个人容量配置表")
|
||||
_comment_column("user_resource_capacity_configs", "id", "主键ID")
|
||||
_comment_column("user_resource_capacity_configs", "user_id", "用户ID")
|
||||
_comment_column("user_resource_capacity_configs", "enabled", "是否启用该用户个人容量限制")
|
||||
_comment_column("user_resource_capacity_configs", "limit_value", "容量数值,最小1,最多3位小数")
|
||||
_comment_column("user_resource_capacity_configs", "limit_unit", "容量单位:MB/GB/TB")
|
||||
_comment_column("user_resource_capacity_configs", "limit_bytes", "换算后的容量字节数")
|
||||
_comment_column("user_resource_capacity_configs", "created_at", "创建时间")
|
||||
_comment_column("user_resource_capacity_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# resources_material 表(已部分有comment)
|
||||
# ============================================================
|
||||
_comment_table("resources_material", "资源素材对接表(第三方平台素材同步)")
|
||||
_comment_column("resources_material", "id", "主键")
|
||||
_comment_column("resources_material", "oauth_id", "授权表user_oauth自增id")
|
||||
_comment_column("resources_material", "advertiser_id", "广告主id")
|
||||
_comment_column("resources_material", "target_table", "资源表名称")
|
||||
_comment_column("resources_material", "target_id", "资源表id")
|
||||
_comment_column("resources_material", "material_id", "素材id")
|
||||
_comment_column("resources_material", "upload_id", "上传资源平台id,图片id,视频id")
|
||||
_comment_column("resources_material", "resource_type", "资源类型,image或者video")
|
||||
_comment_column("resources_material", "user_id", "用户登录id")
|
||||
_comment_column("resources_material", "task_id", "前测任务id")
|
||||
_comment_column("resources_material", "note", "前测失败备注或者其他备注")
|
||||
_comment_column("resources_material", "status", "前测状态(FAILED/PENDING/SUCCESS)")
|
||||
_comment_column("resources_material", "pre_result", "前测结果,JSON数组对象")
|
||||
_comment_column("resources_material", "pre_test_template_id", "前测模板id")
|
||||
_comment_column("resources_material", "created_at", "创建时间")
|
||||
_comment_column("resources_material", "updated_at", "更新时间")
|
||||
_comment_column("resources_material", "deleted_at", "软删除时间")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 注释回滚时选择清空所有注释即可,不影响功能
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'users', 'projects', 'credit_ratios', 'credit_records',
|
||||
'chat_generation_tasks', 'generation_records',
|
||||
'generated_resources', 'upload_resources',
|
||||
'image_engines', 'video_engines', 'model_configs',
|
||||
'system_configs', 'operation_logs', 'notifications',
|
||||
'recharge_packages', 'payment_orders',
|
||||
'video_upscale_tasks', 'shot_replicate_task_sets',
|
||||
'teams', 'user_resource_capacity_configs',
|
||||
'resources_material'
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
# 清空表注释
|
||||
for t in [
|
||||
"users", "projects", "credit_ratios", "credit_records",
|
||||
"chat_generation_tasks", "generation_records",
|
||||
"generated_resources", "upload_resources",
|
||||
"image_engines", "video_engines", "model_configs",
|
||||
"system_configs", "operation_logs", "notifications",
|
||||
"recharge_packages", "payment_orders",
|
||||
"video_upscale_tasks", "shot_replicate_task_sets",
|
||||
"teams", "user_resource_capacity_configs",
|
||||
"resources_material",
|
||||
]:
|
||||
op.execute(f"COMMENT ON TABLE {t} IS NULL")
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"""2026080401_add_vp_v3_virtual_portrait_tables_and_quota
|
||||
|
||||
Revision ID: 2026080401
|
||||
Revises: 2026073101
|
||||
Create Date: 2026-08-04 00:00:00.000000
|
||||
|
||||
API V3 虚拟素材库中转表 + 密钥配额表:
|
||||
1. vp_v3_api_key_quotas: 每个 API Key 的虚拟素材配额(项目数/素材数/存储 MB)
|
||||
2. vp_v3_projects: V3 虚拟素材项目(=火山一个 AssetGroup)
|
||||
3. vp_v3_assets: V3 虚拟素材(图片/视频)
|
||||
|
||||
备注:
|
||||
* 数据与前台用户私域素材库(private_portrait_* 表)完全隔离
|
||||
* 归属按 api_keys.id(V3 调用方)而非 users.id
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '2026080401'
|
||||
down_revision: Union[str, None] = '2026073101'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ==============================================================
|
||||
# 1. vp_v3_api_key_quotas:API Key 虚拟素材配额
|
||||
# ==============================================================
|
||||
op.create_table(
|
||||
'vp_v3_api_key_quotas',
|
||||
sa.Column('id', sa.String(length=32), nullable=False, comment='主键ID'),
|
||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
||||
comment='所属 API Key,唯一:一个 API Key 只有一份虚拟素材配额'),
|
||||
# 配额上限(默认 0=不可用)
|
||||
sa.Column('project_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='虚拟项目上限,默认 0 不可创建'),
|
||||
sa.Column('asset_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='虚拟素材总数上限(图片+视频),默认 0 不可上传'),
|
||||
sa.Column('storage_mb_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='上传存储上限 MB,默认 0 不可上传文件'),
|
||||
# 已使用量(冗余,每次增删同步,和 COUNT 不一致时以 COUNT 为准)
|
||||
sa.Column('project_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='已创建项目数(未删除)'),
|
||||
sa.Column('asset_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='已上传素材数(未删除,图片+视频)'),
|
||||
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='已占用存储 MB(未删除文件大小合计,1MB=1024*1024)'),
|
||||
sa.Column('remark', sa.Text(), nullable=True, comment='后台备注'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
||||
comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
comment='最后更新时间'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_unique_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
|
||||
op.create_index('idx_vp_v3_api_key_quotas_api_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
|
||||
|
||||
# ==============================================================
|
||||
# 2. vp_v3_projects:V3 虚拟素材项目
|
||||
# ==============================================================
|
||||
op.create_table(
|
||||
'vp_v3_projects',
|
||||
sa.Column('id', sa.String(length=32), nullable=False, comment='项目ID'),
|
||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
||||
comment='所属 API Key(V3 调用方)'),
|
||||
sa.Column('name', sa.String(length=128), nullable=False, comment='项目展示名称'),
|
||||
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='名称安全 slug(构建远端 GroupName 用)'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
|
||||
comment='火山 ProjectName(快照)'),
|
||||
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
|
||||
comment='火山 AssetGroup Id'),
|
||||
sa.Column('remote_group_name', sa.String(length=256), nullable=True,
|
||||
comment='火山 AssetGroup Name 快照'),
|
||||
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'active'"),
|
||||
index=True,
|
||||
comment='项目状态:active/creating_remote_group/create_group_failed/deleting'),
|
||||
# 计数
|
||||
sa.Column('asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('active_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('active_image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('active_video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='项目占用存储 MB(未删除素材文件大小合计)'),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
# 远端删除状态
|
||||
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
|
||||
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
|
||||
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('remote_delete_error', sa.Text(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True, comment='创建失败等错误信息'),
|
||||
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应'),
|
||||
# 软删除 + 时间
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
||||
comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
comment='最后更新时间'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('idx_vp_v3_projects_key_status_created', 'vp_v3_projects',
|
||||
['api_key_id', 'status', 'created_at'])
|
||||
op.create_index('idx_vp_v3_projects_remote_project_name', 'vp_v3_projects', ['remote_project_name'])
|
||||
op.create_index('idx_vp_v3_projects_remote_group_id', 'vp_v3_projects', ['remote_group_id'])
|
||||
op.execute(
|
||||
"CREATE INDEX idx_vp_v3_projects_key_deleted ON vp_v3_projects (api_key_id, deleted_at)"
|
||||
" WHERE deleted_at IS NULL;"
|
||||
)
|
||||
|
||||
# ==============================================================
|
||||
# 3. vp_v3_assets:V3 虚拟素材
|
||||
# ==============================================================
|
||||
op.create_table(
|
||||
'vp_v3_assets',
|
||||
sa.Column('id', sa.String(length=32), nullable=False, comment='素材ID'),
|
||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
||||
comment='所属 API Key(V3 调用方)'),
|
||||
sa.Column('project_id', sa.String(length=32), nullable=False, comment='所属项目ID'),
|
||||
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
|
||||
comment='火山 ProjectName'),
|
||||
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
|
||||
comment='火山 AssetGroup Id'),
|
||||
sa.Column('remote_asset_id', sa.String(length=128), nullable=True, comment='火山素材 Id'),
|
||||
sa.Column('asset_type', sa.String(length=16), nullable=False, server_default=sa.text("'Image'"),
|
||||
comment='素材类型:Image=图片 / Video=视频', index=True),
|
||||
sa.Column('name', sa.String(length=128), nullable=True, comment='素材展示名称', index=True),
|
||||
sa.Column('source_url', sa.Text(), nullable=False, comment='本地上传后的访问 URL'),
|
||||
sa.Column('preview_url', sa.Text(), nullable=True, comment='给前端预览/显示用的 URL'),
|
||||
sa.Column('remote_url', sa.Text(), nullable=True, comment='火山返回的资源访问 URL(可能带签名)'),
|
||||
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True,
|
||||
comment='remote_url 过期时间'),
|
||||
sa.Column('upload_resource_id', sa.String(length=32), nullable=True, index=True,
|
||||
comment='本地上传 resource_id,供容量释放用'),
|
||||
sa.Column('video_duration', sa.Float(), nullable=True, comment='视频时长,秒'),
|
||||
sa.Column('video_cover_url', sa.Text(), nullable=True, comment='视频封面预览'),
|
||||
sa.Column('file_size_bytes', sa.Integer(), nullable=True, comment='素材文件大小,字节'),
|
||||
sa.Column('mime_type', sa.String(length=128), nullable=True),
|
||||
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'creating'"),
|
||||
index=True,
|
||||
comment='素材状态:creating/审核中 active/可用 failed/失败 deleting/删除中'),
|
||||
sa.Column('moderation_json', sa.Text(), nullable=True, comment='火山审核结果 JSON'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True, comment='失败原因'),
|
||||
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应 JSON'),
|
||||
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True, index=True,
|
||||
comment='下次轮询时间(创建中状态自动轮询)'),
|
||||
sa.Column('poll_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
|
||||
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
|
||||
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('remote_delete_error', sa.Text(), nullable=True),
|
||||
# 软删除 + 时间
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
||||
comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
comment='最后更新时间'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['vp_v3_projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_unique_constraint('uq_vp_v3_assets_remote_asset_id', 'vp_v3_assets', ['remote_asset_id'])
|
||||
op.create_index('idx_vp_v3_assets_key_status_created', 'vp_v3_assets',
|
||||
['api_key_id', 'status', 'created_at'])
|
||||
op.create_index('idx_vp_v3_assets_project_status_created', 'vp_v3_assets',
|
||||
['project_id', 'status', 'created_at'])
|
||||
op.create_index('idx_vp_v3_assets_asset_type', 'vp_v3_assets', ['asset_type'])
|
||||
op.create_index('idx_vp_v3_assets_remote_delete_status', 'vp_v3_assets', ['remote_delete_status'])
|
||||
op.execute(
|
||||
"CREATE INDEX idx_vp_v3_assets_next_poll_status ON vp_v3_assets (next_poll_at, status)"
|
||||
" WHERE deleted_at IS NULL AND next_poll_at IS NOT NULL;"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('vp_v3_assets')
|
||||
op.drop_table('vp_v3_projects')
|
||||
op.drop_index('idx_vp_v3_api_key_quotas_api_key_id', table_name='vp_v3_api_key_quotas')
|
||||
op.drop_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', type_='unique')
|
||||
op.drop_table('vp_v3_api_key_quotas')
|
||||
@@ -0,0 +1,984 @@
|
||||
"""2026080601_add_missing_table_and_column_comments
|
||||
|
||||
Revision ID: 2026080601
|
||||
Revises: 2026080401
|
||||
Create Date: 2026-08-06 00:00:00.000000
|
||||
|
||||
给前端迁移遗漏的 models 表添加表注释和字段注释。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = '2026080601'
|
||||
down_revision: Union[str, None] = '2026080401'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ============================================================
|
||||
# notification_reads 表
|
||||
# ============================================================
|
||||
_comment_table("notification_reads", "通知已读记录表")
|
||||
_comment_column("notification_reads", "id", "主键ID")
|
||||
_comment_column("notification_reads", "notification_id", "通知ID")
|
||||
_comment_column("notification_reads", "user_id", "已读用户ID")
|
||||
_comment_column("notification_reads", "created_at", "创建时间")
|
||||
_comment_column("notification_reads", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# menu_configs 表
|
||||
# ============================================================
|
||||
_comment_table("menu_configs", "菜单配置表")
|
||||
_comment_column("menu_configs", "id", "主键ID")
|
||||
_comment_column("menu_configs", "label", "菜单显示名称")
|
||||
_comment_column("menu_configs", "path", "菜单路由路径")
|
||||
_comment_column("menu_configs", "icon", "菜单图标名称")
|
||||
_comment_column("menu_configs", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("menu_configs", "is_active", "是否启用")
|
||||
_comment_column("menu_configs", "parent_id", "父菜单ID")
|
||||
_comment_column("menu_configs", "menu_type", "菜单类型:page页面/directory目录/link链接")
|
||||
_comment_column("menu_configs", "menu_target", "菜单目标:frontend前台/admin后台")
|
||||
_comment_column("menu_configs", "is_default", "是否默认菜单,新用户自动分配")
|
||||
_comment_column("menu_configs", "created_at", "创建时间")
|
||||
_comment_column("menu_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# team_join_requests 表
|
||||
# ============================================================
|
||||
_comment_table("team_join_requests", "团队加入申请记录表")
|
||||
_comment_column("team_join_requests", "id", "主键ID")
|
||||
_comment_column("team_join_requests", "team_id", "目标团队ID")
|
||||
_comment_column("team_join_requests", "user_id", "申请人用户ID")
|
||||
_comment_column("team_join_requests", "invitation_id", "关联邀请ID(通过邀请链接申请时记录)")
|
||||
_comment_column("team_join_requests", "status", "申请状态:pending待处理/approved已通过/rejected已拒绝")
|
||||
_comment_column("team_join_requests", "note", "申请备注")
|
||||
_comment_column("team_join_requests", "handled_by", "处理人用户ID")
|
||||
_comment_column("team_join_requests", "created_at", "创建时间")
|
||||
_comment_column("team_join_requests", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# team_invitations 表
|
||||
# ============================================================
|
||||
_comment_table("team_invitations", "团队邀请记录表")
|
||||
_comment_column("team_invitations", "id", "主键ID")
|
||||
_comment_column("team_invitations", "team_id", "所属团队ID")
|
||||
_comment_column("team_invitations", "code", "邀请码,唯一")
|
||||
_comment_column("team_invitations", "created_by", "创建人用户ID")
|
||||
_comment_column("team_invitations", "status", "邀请状态:active启用/disabled禁用")
|
||||
_comment_column("team_invitations", "max_uses", "最大使用次数,NULL表示不限")
|
||||
_comment_column("team_invitations", "use_count", "已使用次数")
|
||||
_comment_column("team_invitations", "expires_at", "过期时间,NULL表示永不过期")
|
||||
_comment_column("team_invitations", "created_at", "创建时间")
|
||||
_comment_column("team_invitations", "updated_at", "更新时间")
|
||||
_comment_column("team_invitations", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# contact_requests 表
|
||||
# ============================================================
|
||||
_comment_table("contact_requests", "用户联系/咨询申请表")
|
||||
_comment_column("contact_requests", "id", "主键ID")
|
||||
_comment_column("contact_requests", "user_id", "提交用户ID")
|
||||
_comment_column("contact_requests", "phone", "联系电话")
|
||||
_comment_column("contact_requests", "company_name", "公司名称")
|
||||
_comment_column("contact_requests", "industry", "所属行业")
|
||||
_comment_column("contact_requests", "name", "联系人姓名")
|
||||
_comment_column("contact_requests", "message", "留言内容")
|
||||
_comment_column("contact_requests", "is_handled", "是否已处理")
|
||||
_comment_column("contact_requests", "submit_date", "提交日期(YYYY-MM-DD),用于每日限1次控制")
|
||||
_comment_column("contact_requests", "created_at", "创建时间")
|
||||
_comment_column("contact_requests", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# token_usage 表
|
||||
# ============================================================
|
||||
_comment_table("token_usage", "Token消耗记录表")
|
||||
_comment_column("token_usage", "id", "主键ID")
|
||||
_comment_column("token_usage", "model_config_id", "模型配置ID")
|
||||
_comment_column("token_usage", "user_id", "所属用户ID")
|
||||
_comment_column("token_usage", "input_tokens", "输入Token数")
|
||||
_comment_column("token_usage", "output_tokens", "输出Token数")
|
||||
_comment_column("token_usage", "total_tokens", "总Token数")
|
||||
_comment_column("token_usage", "owner_type", "归属类型:chat_generation_task/module_generation_step等")
|
||||
_comment_column("token_usage", "owner_id", "归属记录ID")
|
||||
_comment_column("token_usage", "biz_key", "业务幂等键")
|
||||
_comment_column("token_usage", "source_module", "来源模块")
|
||||
_comment_column("token_usage", "source_step_code", "来源步骤编码")
|
||||
_comment_column("token_usage", "created_at", "创建时间")
|
||||
_comment_column("token_usage", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# industry_configs 表
|
||||
# ============================================================
|
||||
_comment_table("industry_configs", "行业配置表")
|
||||
_comment_column("industry_configs", "id", "主键ID")
|
||||
_comment_column("industry_configs", "key", "行业唯一标识键")
|
||||
_comment_column("industry_configs", "label", "行业显示名称")
|
||||
_comment_column("industry_configs", "icon", "图标名称")
|
||||
_comment_column("industry_configs", "description", "行业描述")
|
||||
_comment_column("industry_configs", "skills", "行业技能列表JSON数组")
|
||||
_comment_column("industry_configs", "is_active", "是否启用")
|
||||
_comment_column("industry_configs", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("industry_configs", "created_at", "创建时间")
|
||||
_comment_column("industry_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# chat_generation_task_events 表
|
||||
# ============================================================
|
||||
_comment_table("chat_generation_task_events", "AI创作任务事件日志表(追加写入)")
|
||||
_comment_column("chat_generation_task_events", "id", "主键ID")
|
||||
_comment_column("chat_generation_task_events", "owner_type", "归属类型:chat_generation_task/generation_record")
|
||||
_comment_column("chat_generation_task_events", "task_id", "关联AI创作任务ID")
|
||||
_comment_column("chat_generation_task_events", "generation_record_id", "关联项目生成记录ID")
|
||||
_comment_column("chat_generation_task_events", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("chat_generation_task_events", "generation_mode", "生成模式")
|
||||
_comment_column("chat_generation_task_events", "event_type", "事件类型")
|
||||
_comment_column("chat_generation_task_events", "from_status", "变更前状态")
|
||||
_comment_column("chat_generation_task_events", "to_status", "变更后状态")
|
||||
_comment_column("chat_generation_task_events", "from_stage", "变更前阶段")
|
||||
_comment_column("chat_generation_task_events", "to_stage", "变更后阶段")
|
||||
_comment_column("chat_generation_task_events", "message", "事件描述信息")
|
||||
_comment_column("chat_generation_task_events", "detail_json", "事件详情JSON")
|
||||
_comment_column("chat_generation_task_events", "created_at", "创建时间")
|
||||
|
||||
# ============================================================
|
||||
# chat_provider_call_logs 表
|
||||
# ============================================================
|
||||
_comment_table("chat_provider_call_logs", "供应商调用审计日志表")
|
||||
_comment_column("chat_provider_call_logs", "id", "主键ID")
|
||||
_comment_column("chat_provider_call_logs", "owner_type", "归属类型:chat_generation_task/generation_record")
|
||||
_comment_column("chat_provider_call_logs", "task_id", "关联AI创作任务ID")
|
||||
_comment_column("chat_provider_call_logs", "generation_record_id", "关联项目生成记录ID")
|
||||
_comment_column("chat_provider_call_logs", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("chat_provider_call_logs", "generation_mode", "生成模式")
|
||||
_comment_column("chat_provider_call_logs", "provider", "供应商:ark/seedance等")
|
||||
_comment_column("chat_provider_call_logs", "api_type", "API类型:image_generate/video_create等")
|
||||
_comment_column("chat_provider_call_logs", "model", "模型名称")
|
||||
_comment_column("chat_provider_call_logs", "engine_id", "引擎ID")
|
||||
_comment_column("chat_provider_call_logs", "status", "调用状态:success/failed")
|
||||
_comment_column("chat_provider_call_logs", "latency_ms", "调用耗时(毫秒)")
|
||||
_comment_column("chat_provider_call_logs", "http_status", "HTTP状态码")
|
||||
_comment_column("chat_provider_call_logs", "provider_task_id", "供应商任务ID")
|
||||
_comment_column("chat_provider_call_logs", "request_hash", "请求内容哈希")
|
||||
_comment_column("chat_provider_call_logs", "response_hash", "响应内容哈希")
|
||||
_comment_column("chat_provider_call_logs", "request_excerpt", "请求内容摘录")
|
||||
_comment_column("chat_provider_call_logs", "response_excerpt", "响应内容摘录")
|
||||
_comment_column("chat_provider_call_logs", "prompt_tokens", "提示词Token数")
|
||||
_comment_column("chat_provider_call_logs", "completion_tokens", "补全Token数")
|
||||
_comment_column("chat_provider_call_logs", "total_tokens", "总Token数")
|
||||
_comment_column("chat_provider_call_logs", "error_code", "错误码")
|
||||
_comment_column("chat_provider_call_logs", "error_message", "错误信息")
|
||||
_comment_column("chat_provider_call_logs", "created_at", "创建时间")
|
||||
|
||||
# ============================================================
|
||||
# open_type 表
|
||||
# ============================================================
|
||||
_comment_table("open_type", "开户方式管理表")
|
||||
_comment_column("open_type", "id", "主键")
|
||||
_comment_column("open_type", "type_name", "标题名称")
|
||||
_comment_column("open_type", "open_type", "开户方式id")
|
||||
_comment_column("open_type", "description", "开户方式描述")
|
||||
_comment_column("open_type", "thumb", "缩略图")
|
||||
_comment_column("open_type", "created_at", "创建时间")
|
||||
_comment_column("open_type", "updated_at", "更新时间")
|
||||
_comment_column("open_type", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# pre_test_template 表
|
||||
# ============================================================
|
||||
_comment_table("pre_test_template", "素材前测模板表")
|
||||
_comment_column("pre_test_template", "id", "主键")
|
||||
_comment_column("pre_test_template", "name", "模板名称")
|
||||
_comment_column("pre_test_template", "user_id", "用户id")
|
||||
_comment_column("pre_test_template", "note", "模板备注")
|
||||
_comment_column("pre_test_template", "platform", "投放平台(AD/QIANCHUAN/LOCAL)")
|
||||
_comment_column("pre_test_template", "external_action", "转化目标")
|
||||
_comment_column("pre_test_template", "cpa_bid", "目标转化成本:[1, 10000]")
|
||||
_comment_column("pre_test_template", "audience_gender", "性别(ALL/MALE/FEMALE)")
|
||||
_comment_column("pre_test_template", "audience_age", "受众年龄,JSON数组")
|
||||
_comment_column("pre_test_template", "audience_region", "受众地区,JSON数组(二级行政区域code)")
|
||||
_comment_column("pre_test_template", "audience_network", "网络类型,JSON数组")
|
||||
_comment_column("pre_test_template", "cus_name", "客户主体名称")
|
||||
_comment_column("pre_test_template", "pricing_type", "出价类型(OCPC/CPA/OCPM)")
|
||||
_comment_column("pre_test_template", "cost_cap", "是否最优成本出价(仅AD支持)")
|
||||
_comment_column("pre_test_template", "target_cost", "是否稳定成本出价(仅AD支持)")
|
||||
_comment_column("pre_test_template", "nobid", "是否最大转化出价(仅AD支持)")
|
||||
_comment_column("pre_test_template", "cpc_bid", "目标点击成本:[1, 10000]")
|
||||
_comment_column("pre_test_template", "budget", "预算金额:[1, 10000]")
|
||||
_comment_column("pre_test_template", "is_default", "是否默认模板")
|
||||
_comment_column("pre_test_template", "created_at", "创建时间")
|
||||
_comment_column("pre_test_template", "updated_at", "更新时间")
|
||||
_comment_column("pre_test_template", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# material_cost 表
|
||||
# ============================================================
|
||||
_comment_table("material_cost", "素材消耗数据表(广告投放消耗统计)")
|
||||
_comment_column("material_cost", "id", "主键")
|
||||
_comment_column("material_cost", "oauth_id", "授权表user_oauth自增id")
|
||||
_comment_column("material_cost", "advertiser_id", "广告主id")
|
||||
_comment_column("material_cost", "material_id", "素材id")
|
||||
_comment_column("material_cost", "consume_date", "消耗日期")
|
||||
_comment_column("material_cost", "stat_cost", "消耗金额")
|
||||
_comment_column("material_cost", "show_cnt", "展示数")
|
||||
_comment_column("material_cost", "cpm_platform", "平均千次展现费用(元)")
|
||||
_comment_column("material_cost", "click_cnt", "点击数")
|
||||
_comment_column("material_cost", "ctr", "点击率")
|
||||
_comment_column("material_cost", "cpc_platform", "平均点击单价(元)")
|
||||
_comment_column("material_cost", "convert_cnt", "转化数")
|
||||
_comment_column("material_cost", "conversion_cost", "平均转化成本(元)")
|
||||
_comment_column("material_cost", "conversion_rate", "转化率")
|
||||
_comment_column("material_cost", "deep_convert_cnt", "深度转化数")
|
||||
_comment_column("material_cost", "deep_convert_cost", "深度转化成本(元)")
|
||||
_comment_column("material_cost", "deep_convert_rate", "深度转化率")
|
||||
_comment_column("material_cost", "active", "激活数")
|
||||
_comment_column("material_cost", "active_cost", "激活成本(元)")
|
||||
_comment_column("material_cost", "active_rate", "激活率")
|
||||
_comment_column("material_cost", "active_register", "注册数")
|
||||
_comment_column("material_cost", "active_register_cost", "注册成本(元)")
|
||||
_comment_column("material_cost", "active_register_rate", "注册率")
|
||||
_comment_column("material_cost", "attribution_next_day_open_cnt", "次留数")
|
||||
_comment_column("material_cost", "attribution_next_day_open_cost", "次留成本")
|
||||
_comment_column("material_cost", "attribution_next_day_open_rate", "次留率")
|
||||
_comment_column("material_cost", "active_pay", "首次付费数")
|
||||
_comment_column("material_cost", "active_pay_cost", "首次付费成本(元)")
|
||||
_comment_column("material_cost", "active_pay_rate", "首次付费率")
|
||||
_comment_column("material_cost", "phone", "点击电话按钮")
|
||||
_comment_column("material_cost", "form", "用户在门店落地页多线沟通提交表单的次数")
|
||||
_comment_column("material_cost", "download_start", "用户点击下载开始的次数")
|
||||
_comment_column("material_cost", "form_submit", "用户查看附加创意后,提交表单的次数")
|
||||
_comment_column("material_cost", "button", "用户点击按钮button的次数")
|
||||
_comment_column("material_cost", "view", "用户在关键页面的浏览次数")
|
||||
_comment_column("material_cost", "message", "用户点击短信咨询的次数")
|
||||
_comment_column("material_cost", "consult", "用户点击在线咨询按钮的次数")
|
||||
_comment_column("material_cost", "consult_effective", "用户在门店落地页多线沟通的在线咨询中有效咨询的次数")
|
||||
_comment_column("material_cost", "shopping", "用户购买商品的次数")
|
||||
_comment_column("material_cost", "customer_effective", "有效获客")
|
||||
_comment_column("material_cost", "attribution_game_in_app_ltv_1day", "当日付费金额")
|
||||
_comment_column("material_cost", "attribution_game_in_app_roi_1day", "当日付费ROI")
|
||||
_comment_column("material_cost", "loan_completion", "完件数")
|
||||
_comment_column("material_cost", "loan_completion_cost", "完件成本(元)")
|
||||
_comment_column("material_cost", "loan_completion_rate", "完件率")
|
||||
_comment_column("material_cost", "loan_credit", "授信数")
|
||||
_comment_column("material_cost", "loan_credit_cost", "授信成本(元)")
|
||||
_comment_column("material_cost", "loan_credit_rate", "授信率")
|
||||
_comment_column("material_cost", "in_app_order_gmv", "引流电商订单GMV")
|
||||
_comment_column("material_cost", "in_app_order_roi", "引流电商订单ROI")
|
||||
_comment_column("material_cost", "in_app_pay_gmv", "引流电商支付GMV")
|
||||
_comment_column("material_cost", "in_app_pay_roi", "引流电商支付ROI")
|
||||
_comment_column("material_cost", "total_play", "播放量")
|
||||
_comment_column("material_cost", "valid_play", "有效播放数")
|
||||
_comment_column("material_cost", "valid_play_cost", "有效播放成本(元)")
|
||||
_comment_column("material_cost", "valid_play_rate", "有效播放率")
|
||||
_comment_column("material_cost", "valid_play_of_mille", "千次有效播放数")
|
||||
_comment_column("material_cost", "valid_play_cost_of_mille", "千次有效播放成本(元)")
|
||||
_comment_column("material_cost", "average_play_time_per_play", "平均单次播放时长")
|
||||
_comment_column("material_cost", "play_over_rate", "完播率")
|
||||
_comment_column("material_cost", "dy_like", "点赞数")
|
||||
_comment_column("material_cost", "dy_comment", "评论量")
|
||||
_comment_column("material_cost", "dy_share", "分享量")
|
||||
_comment_column("material_cost", "report_cnt", "举报数")
|
||||
_comment_column("material_cost", "created_at", "创建时间")
|
||||
_comment_column("material_cost", "updated_at", "更新时间")
|
||||
_comment_column("material_cost", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_oauth 表
|
||||
# ============================================================
|
||||
_comment_table("user_oauth", "用户授权账户表(第三方平台授权)")
|
||||
_comment_column("user_oauth", "id", "主键")
|
||||
_comment_column("user_oauth", "account_id", "授权账户id")
|
||||
_comment_column("user_oauth", "account_name", "授权账户name")
|
||||
_comment_column("user_oauth", "account_role", "授权账户角色")
|
||||
_comment_column("user_oauth", "account_username", "授权账户登录账号")
|
||||
_comment_column("user_oauth", "account_userid", "授权账户登录userid")
|
||||
_comment_column("user_oauth", "user_id", "用户id")
|
||||
_comment_column("user_oauth", "open_type", "开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)")
|
||||
_comment_column("user_oauth", "port_type", "平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)")
|
||||
_comment_column("user_oauth", "appid", "授权应用id")
|
||||
_comment_column("user_oauth", "access_token", "授权token")
|
||||
_comment_column("user_oauth", "access_token_expired", "token过期时间")
|
||||
_comment_column("user_oauth", "refresh_token", "授权刷新token")
|
||||
_comment_column("user_oauth", "refresh_token_expired", "刷新token过期时间")
|
||||
_comment_column("user_oauth", "material_auth_status", "是否敏感物料授权(true=是,false=否)")
|
||||
_comment_column("user_oauth", "created_at", "创建时间")
|
||||
_comment_column("user_oauth", "updated_at", "更新时间")
|
||||
_comment_column("user_oauth", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_oauth_account 表
|
||||
# ============================================================
|
||||
_comment_table("user_oauth_account", "授权账户详情表(广告账户映射)")
|
||||
_comment_column("user_oauth_account", "id", "主键")
|
||||
_comment_column("user_oauth_account", "oauth_id", "授权表中的id")
|
||||
_comment_column("user_oauth_account", "advertiser_id", "广告主账户id")
|
||||
_comment_column("user_oauth_account", "advertiser_name", "广告账户名")
|
||||
_comment_column("user_oauth_account", "advertiser_role", "广告账户类型")
|
||||
_comment_column("user_oauth_account", "created_at", "创建时间")
|
||||
_comment_column("user_oauth_account", "updated_at", "更新时间")
|
||||
_comment_column("user_oauth_account", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_oauth_app 表
|
||||
# ============================================================
|
||||
_comment_table("user_oauth_app", "授权应用管理表(应用密钥配置)")
|
||||
_comment_column("user_oauth_app", "id", "主键")
|
||||
_comment_column("user_oauth_app", "app_id", "应用id")
|
||||
_comment_column("user_oauth_app", "secret", "应用密钥")
|
||||
_comment_column("user_oauth_app", "status", "状态,1=正常,2=禁用")
|
||||
_comment_column("user_oauth_app", "max_count", "应用最大可以授权多少个用户")
|
||||
_comment_column("user_oauth_app", "auth_url", "应用授权链接")
|
||||
_comment_column("user_oauth_app", "company", "应用归属公司名称")
|
||||
_comment_column("user_oauth_app", "open_type", "开户方式")
|
||||
_comment_column("user_oauth_app", "create_by", "创建者")
|
||||
_comment_column("user_oauth_app", "created_at", "创建时间")
|
||||
_comment_column("user_oauth_app", "updated_at", "更新时间")
|
||||
_comment_column("user_oauth_app", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# upload_task 表
|
||||
# ============================================================
|
||||
_comment_table("upload_task", "上传任务表(素材上传记录)")
|
||||
_comment_column("upload_task", "id", "主键")
|
||||
_comment_column("upload_task", "user_id", "用户登录id")
|
||||
_comment_column("upload_task", "advertiser_id", "广告主id")
|
||||
_comment_column("upload_task", "resource_id", "资源id")
|
||||
_comment_column("upload_task", "status", "上传状态:1待上传,2上传中,3上传成功,4上传失败")
|
||||
_comment_column("upload_task", "note", "上传备注")
|
||||
_comment_column("upload_task", "oauth_id", "授权表id")
|
||||
_comment_column("upload_task", "other_info", "其他信息")
|
||||
_comment_column("upload_task", "created_at", "创建时间")
|
||||
_comment_column("upload_task", "updated_at", "更新时间")
|
||||
_comment_column("upload_task", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_resource_month_stats 表
|
||||
# ============================================================
|
||||
_comment_table("user_resource_month_stats", "用户月份资源空间聚合表")
|
||||
_comment_column("user_resource_month_stats", "id", "主键ID")
|
||||
_comment_column("user_resource_month_stats", "user_id", "所属用户ID")
|
||||
_comment_column("user_resource_month_stats", "stat_month", "统计月份")
|
||||
_comment_column("user_resource_month_stats", "active_size_bytes", "活跃资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "deleted_size_bytes", "已删除资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "upload_size_bytes", "上传资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "image_size_bytes", "图片资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "video_size_bytes", "视频资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "audio_size_bytes", "音频资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "active_count", "活跃资源数量")
|
||||
_comment_column("user_resource_month_stats", "deleted_count", "已删除资源数量")
|
||||
_comment_column("user_resource_month_stats", "image_count", "图片资源数量")
|
||||
_comment_column("user_resource_month_stats", "video_count", "视频资源数量")
|
||||
_comment_column("user_resource_month_stats", "upload_count", "上传资源数量")
|
||||
_comment_column("user_resource_month_stats", "audio_count", "音频资源数量")
|
||||
_comment_column("user_resource_month_stats", "shot_segment_count", "拆镜切片数量")
|
||||
_comment_column("user_resource_month_stats", "last_recalculated_at", "最后重新计算时间")
|
||||
_comment_column("user_resource_month_stats", "created_at", "创建时间")
|
||||
_comment_column("user_resource_month_stats", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# user_resource_total_stats 表
|
||||
# ============================================================
|
||||
_comment_table("user_resource_total_stats", "用户全局资源空间聚合表")
|
||||
_comment_column("user_resource_total_stats", "id", "主键ID")
|
||||
_comment_column("user_resource_total_stats", "user_id", "所属用户ID")
|
||||
_comment_column("user_resource_total_stats", "active_size_bytes", "活跃资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "deleted_size_bytes", "已删除资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "upload_size_bytes", "上传资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "image_size_bytes", "图片资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "video_size_bytes", "视频资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "audio_size_bytes", "音频资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "active_count", "活跃资源数量")
|
||||
_comment_column("user_resource_total_stats", "deleted_count", "已删除资源数量")
|
||||
_comment_column("user_resource_total_stats", "image_count", "图片资源数量")
|
||||
_comment_column("user_resource_total_stats", "video_count", "视频资源数量")
|
||||
_comment_column("user_resource_total_stats", "upload_count", "上传资源数量")
|
||||
_comment_column("user_resource_total_stats", "audio_count", "音频资源数量")
|
||||
_comment_column("user_resource_total_stats", "shot_segment_count", "拆镜切片数量")
|
||||
_comment_column("user_resource_total_stats", "last_recalculated_at", "最后重新计算时间")
|
||||
_comment_column("user_resource_total_stats", "created_at", "创建时间")
|
||||
_comment_column("user_resource_total_stats", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# home_material_assets 表
|
||||
# ============================================================
|
||||
_comment_table("home_material_assets", "首页素材资产表")
|
||||
_comment_column("home_material_assets", "id", "主键ID")
|
||||
_comment_column("home_material_assets", "category_id", "行业类别ID")
|
||||
_comment_column("home_material_assets", "title", "素材标题")
|
||||
_comment_column("home_material_assets", "media_type", "素材类型:image图片,video视频")
|
||||
_comment_column("home_material_assets", "original_url", "原始素材URL")
|
||||
_comment_column("home_material_assets", "original_storage_path", "原始素材本地路径")
|
||||
_comment_column("home_material_assets", "watermarked_url", "水印素材URL")
|
||||
_comment_column("home_material_assets", "watermarked_storage_path", "水印素材本地路径")
|
||||
_comment_column("home_material_assets", "cover_url", "视频封面URL")
|
||||
_comment_column("home_material_assets", "cover_storage_path", "视频封面本地路径")
|
||||
_comment_column("home_material_assets", "watermark_id", "水印图片ID")
|
||||
_comment_column("home_material_assets", "watermark_config_json", "水印配置快照JSON")
|
||||
_comment_column("home_material_assets", "generation_prompt", "生成提词")
|
||||
_comment_column("home_material_assets", "media_references_json", "附件/参考素材JSON字符串")
|
||||
_comment_column("home_material_assets", "status", "处理状态:draft/processing/success/failed")
|
||||
_comment_column("home_material_assets", "error_message", "处理失败原因")
|
||||
_comment_column("home_material_assets", "width", "素材宽度")
|
||||
_comment_column("home_material_assets", "height", "素材高度")
|
||||
_comment_column("home_material_assets", "duration_seconds", "视频时长,图片为空")
|
||||
_comment_column("home_material_assets", "file_size_bytes", "原始文件大小")
|
||||
_comment_column("home_material_assets", "watermarked_file_size_bytes", "水印后文件大小")
|
||||
_comment_column("home_material_assets", "is_active", "是否前台展示")
|
||||
_comment_column("home_material_assets", "sort_order", "排序,越小越靠前")
|
||||
_comment_column("home_material_assets", "processed_at", "处理完成时间")
|
||||
_comment_column("home_material_assets", "created_by", "创建管理员ID")
|
||||
_comment_column("home_material_assets", "updated_by", "更新管理员ID")
|
||||
_comment_column("home_material_assets", "created_at", "创建时间")
|
||||
_comment_column("home_material_assets", "updated_at", "更新时间")
|
||||
_comment_column("home_material_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# home_material_categories 表
|
||||
# ============================================================
|
||||
_comment_table("home_material_categories", "首页素材行业类别表")
|
||||
_comment_column("home_material_categories", "id", "主键ID")
|
||||
_comment_column("home_material_categories", "name", "行业名称")
|
||||
_comment_column("home_material_categories", "key", "行业唯一标识,前台可按key查询")
|
||||
_comment_column("home_material_categories", "description", "行业描述")
|
||||
_comment_column("home_material_categories", "icon", "前端图标名称")
|
||||
_comment_column("home_material_categories", "is_active", "是否启用")
|
||||
_comment_column("home_material_categories", "sort_order", "排序,越小越靠前")
|
||||
_comment_column("home_material_categories", "created_by", "创建管理员ID")
|
||||
_comment_column("home_material_categories", "updated_by", "更新管理员ID")
|
||||
_comment_column("home_material_categories", "created_at", "创建时间")
|
||||
_comment_column("home_material_categories", "updated_at", "更新时间")
|
||||
_comment_column("home_material_categories", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# home_material_watermarks 表
|
||||
# ============================================================
|
||||
_comment_table("home_material_watermarks", "首页素材水印图片库")
|
||||
_comment_column("home_material_watermarks", "id", "主键ID")
|
||||
_comment_column("home_material_watermarks", "name", "水印名称")
|
||||
_comment_column("home_material_watermarks", "file_url", "水印图片URL")
|
||||
_comment_column("home_material_watermarks", "storage_path", "水印图片本地路径")
|
||||
_comment_column("home_material_watermarks", "file_name", "原始文件名")
|
||||
_comment_column("home_material_watermarks", "file_size_bytes", "文件大小")
|
||||
_comment_column("home_material_watermarks", "width", "水印图片宽度")
|
||||
_comment_column("home_material_watermarks", "height", "水印图片高度")
|
||||
_comment_column("home_material_watermarks", "is_default", "是否默认水印")
|
||||
_comment_column("home_material_watermarks", "is_active", "是否启用")
|
||||
_comment_column("home_material_watermarks", "created_by", "创建管理员ID")
|
||||
_comment_column("home_material_watermarks", "updated_by", "更新管理员ID")
|
||||
_comment_column("home_material_watermarks", "created_at", "创建时间")
|
||||
_comment_column("home_material_watermarks", "updated_at", "更新时间")
|
||||
_comment_column("home_material_watermarks", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# module_generation_projects 表
|
||||
# ============================================================
|
||||
_comment_table("module_generation_projects", "通用模块生成项目/总任务表")
|
||||
_comment_column("module_generation_projects", "id", "主键ID")
|
||||
_comment_column("module_generation_projects", "user_id", "所属用户ID")
|
||||
_comment_column("module_generation_projects", "module", "业务模块标识")
|
||||
_comment_column("module_generation_projects", "flow_version", "项目流程版本号")
|
||||
_comment_column("module_generation_projects", "title", "项目标题")
|
||||
_comment_column("module_generation_projects", "status", "项目状态:pending/processing/success/failed")
|
||||
_comment_column("module_generation_projects", "current_step_code", "当前执行步骤编码")
|
||||
_comment_column("module_generation_projects", "final_image_url", "最终生成图片URL")
|
||||
_comment_column("module_generation_projects", "final_video_url", "最终生成视频URL")
|
||||
_comment_column("module_generation_projects", "final_video_cover_url", "最终生成视频封面URL")
|
||||
_comment_column("module_generation_projects", "error_message", "错误信息")
|
||||
_comment_column("module_generation_projects", "idempotency_key", "幂等键")
|
||||
_comment_column("module_generation_projects", "completed_at", "项目完成时间")
|
||||
_comment_column("module_generation_projects", "created_at", "创建时间")
|
||||
_comment_column("module_generation_projects", "updated_at", "更新时间")
|
||||
_comment_column("module_generation_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# module_generation_steps 表
|
||||
# ============================================================
|
||||
_comment_table("module_generation_steps", "通用模块生成步骤表")
|
||||
_comment_column("module_generation_steps", "id", "主键ID")
|
||||
_comment_column("module_generation_steps", "project_id", "所属项目ID")
|
||||
_comment_column("module_generation_steps", "user_id", "所属用户ID")
|
||||
_comment_column("module_generation_steps", "module", "业务模块标识")
|
||||
_comment_column("module_generation_steps", "step_index", "步骤序号")
|
||||
_comment_column("module_generation_steps", "step_code", "步骤编码")
|
||||
_comment_column("module_generation_steps", "status", "步骤状态:pending/processing/success/failed")
|
||||
_comment_column("module_generation_steps", "version", "步骤重建版本号")
|
||||
_comment_column("module_generation_steps", "is_current", "是否为当前版本")
|
||||
_comment_column("module_generation_steps", "parent_step_id", "父步骤ID")
|
||||
_comment_column("module_generation_steps", "source_step_id", "源步骤ID(复制来源)")
|
||||
_comment_column("module_generation_steps", "chat_task_id", "关联AI创作任务ID")
|
||||
_comment_column("module_generation_steps", "input_json", "步骤输入JSON")
|
||||
_comment_column("module_generation_steps", "output_json", "步骤输出JSON")
|
||||
_comment_column("module_generation_steps", "error_message", "错误信息")
|
||||
_comment_column("module_generation_steps", "started_at", "步骤开始时间")
|
||||
_comment_column("module_generation_steps", "completed_at", "步骤完成时间")
|
||||
_comment_column("module_generation_steps", "token_usage_id", "关联Token消耗记录ID")
|
||||
_comment_column("module_generation_steps", "model_config_id", "模型配置ID")
|
||||
_comment_column("module_generation_steps", "input_tokens", "输入Token数")
|
||||
_comment_column("module_generation_steps", "output_tokens", "输出Token数")
|
||||
_comment_column("module_generation_steps", "total_tokens", "总Token数")
|
||||
_comment_column("module_generation_steps", "text_credits_cost", "提示词优化消耗积分")
|
||||
_comment_column("module_generation_steps", "created_at", "创建时间")
|
||||
_comment_column("module_generation_steps", "updated_at", "更新时间")
|
||||
_comment_column("module_generation_steps", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# shot_replicate_segments 表
|
||||
# ============================================================
|
||||
_comment_table("shot_replicate_segments", "拆镜复刻片段表")
|
||||
_comment_column("shot_replicate_segments", "id", "主键ID")
|
||||
_comment_column("shot_replicate_segments", "task_set_id", "所属任务集ID")
|
||||
_comment_column("shot_replicate_segments", "user_id", "所属用户ID")
|
||||
_comment_column("shot_replicate_segments", "segment_index", "镜头序号")
|
||||
_comment_column("shot_replicate_segments", "source_mode", "来源模式:auto自动拆镜/manual手动")
|
||||
_comment_column("shot_replicate_segments", "start_second", "片段开始时间(秒)")
|
||||
_comment_column("shot_replicate_segments", "end_second", "片段结束时间(秒)")
|
||||
_comment_column("shot_replicate_segments", "duration_seconds", "片段时长(秒)")
|
||||
_comment_column("shot_replicate_segments", "time_node", "时间节点显示字符串")
|
||||
_comment_column("shot_replicate_segments", "split_status", "切片状态:pending/slicing/sliced/failed")
|
||||
_comment_column("shot_replicate_segments", "analysis_status", "AI分析状态:pending/processing/success/failed")
|
||||
_comment_column("shot_replicate_segments", "replicate_status", "复刻状态:not_started/processing/completed/failed")
|
||||
_comment_column("shot_replicate_segments", "segment_video_url", "片段视频访问URL")
|
||||
_comment_column("shot_replicate_segments", "segment_video_path", "片段视频存储路径")
|
||||
_comment_column("shot_replicate_segments", "original_video_content", "原视频内容描述")
|
||||
_comment_column("shot_replicate_segments", "original_video_category", "原视频行业分类")
|
||||
_comment_column("shot_replicate_segments", "original_video_audience", "原视频目标受众")
|
||||
_comment_column("shot_replicate_segments", "segment_content", "片段内容描述")
|
||||
_comment_column("shot_replicate_segments", "segment_category", "片段行业分类")
|
||||
_comment_column("shot_replicate_segments", "segment_audience", "片段目标受众")
|
||||
_comment_column("shot_replicate_segments", "analysis_json", "AI分析结果JSON")
|
||||
_comment_column("shot_replicate_segments", "ai_suggestion_json", "AI复刻建议JSON")
|
||||
_comment_column("shot_replicate_segments", "module_project_id", "关联模块生成项目ID")
|
||||
_comment_column("shot_replicate_segments", "split_claim_token", "切片租约token")
|
||||
_comment_column("shot_replicate_segments", "split_celery_task_id", "切片Celery任务ID")
|
||||
_comment_column("shot_replicate_segments", "split_enqueued_at", "切片入队时间")
|
||||
_comment_column("shot_replicate_segments", "split_started_at", "切片开始时间")
|
||||
_comment_column("shot_replicate_segments", "split_lease_until", "切片租约过期时间")
|
||||
_comment_column("shot_replicate_segments", "split_next_retry_at", "切片下次重试时间")
|
||||
_comment_column("shot_replicate_segments", "split_retry_count", "切片重试次数")
|
||||
_comment_column("shot_replicate_segments", "split_last_error", "切片最后错误信息")
|
||||
_comment_column("shot_replicate_segments", "split_completed_at", "切片完成时间")
|
||||
_comment_column("shot_replicate_segments", "analysis_attempt_no", "AI分析尝试次数")
|
||||
_comment_column("shot_replicate_segments", "analysis_claim_token", "AI分析租约token")
|
||||
_comment_column("shot_replicate_segments", "analysis_started_at", "AI分析开始时间")
|
||||
_comment_column("shot_replicate_segments", "analysis_lease_until", "AI分析租约过期")
|
||||
_comment_column("shot_replicate_segments", "analysis_error_message", "AI分析错误信息")
|
||||
_comment_column("shot_replicate_segments", "created_at", "创建时间")
|
||||
_comment_column("shot_replicate_segments", "updated_at", "更新时间")
|
||||
_comment_column("shot_replicate_segments", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_projects 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_projects", "用户私域人像素材项目表")
|
||||
_comment_column("private_portrait_projects", "id", "主键ID")
|
||||
_comment_column("private_portrait_projects", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_projects", "library_type", "素材库类型:real_person真人认证/aigc_virtual虚拟人像")
|
||||
_comment_column("private_portrait_projects", "name", "用户展示项目名")
|
||||
_comment_column("private_portrait_projects", "name_slug", "项目名安全slug")
|
||||
_comment_column("private_portrait_projects", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_projects", "description", "项目描述")
|
||||
_comment_column("private_portrait_projects", "status", "项目状态:active/creating/create_failed/deleting")
|
||||
_comment_column("private_portrait_projects", "asset_group_count", "素材分组数量")
|
||||
_comment_column("private_portrait_projects", "asset_count", "素材总数")
|
||||
_comment_column("private_portrait_projects", "image_asset_count", "图片素材数")
|
||||
_comment_column("private_portrait_projects", "video_asset_count", "视频素材数")
|
||||
_comment_column("private_portrait_projects", "active_asset_count", "有效素材数")
|
||||
_comment_column("private_portrait_projects", "active_image_asset_count", "有效图片素材数")
|
||||
_comment_column("private_portrait_projects", "active_video_asset_count", "有效视频素材数")
|
||||
_comment_column("private_portrait_projects", "last_used_at", "最后使用时间")
|
||||
_comment_column("private_portrait_projects", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_projects", "updated_at", "更新时间")
|
||||
_comment_column("private_portrait_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_asset_groups 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_asset_groups", "本地项目组与火山Asset Group映射表")
|
||||
_comment_column("private_portrait_asset_groups", "id", "主键ID")
|
||||
_comment_column("private_portrait_asset_groups", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_asset_groups", "project_id", "所属项目ID")
|
||||
_comment_column("private_portrait_asset_groups", "library_type", "素材库类型:real_person/aigc_virtual")
|
||||
_comment_column("private_portrait_asset_groups", "remote_group_id", "火山远端AssetGroup ID")
|
||||
_comment_column("private_portrait_asset_groups", "remote_group_name", "火山远端AssetGroup名称")
|
||||
_comment_column("private_portrait_asset_groups", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_asset_groups", "group_type", "分组类型")
|
||||
_comment_column("private_portrait_asset_groups", "status", "分组状态:active/creating/create_failed/deleting")
|
||||
_comment_column("private_portrait_asset_groups", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
|
||||
_comment_column("private_portrait_asset_groups", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("private_portrait_asset_groups", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("private_portrait_asset_groups", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("private_portrait_asset_groups", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_asset_groups", "updated_at", "更新时间")
|
||||
_comment_column("private_portrait_asset_groups", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_assets 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_assets", "火山Asset本地映射表(素材文件记录)")
|
||||
_comment_column("private_portrait_assets", "id", "主键ID")
|
||||
_comment_column("private_portrait_assets", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_assets", "project_id", "所属项目ID")
|
||||
_comment_column("private_portrait_assets", "group_id", "所属分组ID")
|
||||
_comment_column("private_portrait_assets", "library_type", "素材库类型:real_person/aigc_virtual")
|
||||
_comment_column("private_portrait_assets", "remote_group_id", "火山远端AssetGroup ID")
|
||||
_comment_column("private_portrait_assets", "remote_asset_id", "火山远端Asset ID")
|
||||
_comment_column("private_portrait_assets", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_assets", "asset_type", "素材类型:Image图片/Video视频")
|
||||
_comment_column("private_portrait_assets", "name", "素材名称")
|
||||
_comment_column("private_portrait_assets", "source_url", "本地上传后的访问URL")
|
||||
_comment_column("private_portrait_assets", "preview_url", "前端预览URL")
|
||||
_comment_column("private_portrait_assets", "remote_url", "火山返回的资源访问URL")
|
||||
_comment_column("private_portrait_assets", "remote_url_expired_at", "火山URL过期时间")
|
||||
_comment_column("private_portrait_assets", "video_duration", "视频素材时长,秒")
|
||||
_comment_column("private_portrait_assets", "video_cover_url", "视频素材封面预览地址")
|
||||
_comment_column("private_portrait_assets", "file_size", "素材文件大小,字节")
|
||||
_comment_column("private_portrait_assets", "mime_type", "MIME类型")
|
||||
_comment_column("private_portrait_assets", "status", "素材状态:creating/active/failed/deleting")
|
||||
_comment_column("private_portrait_assets", "moderation_json", "火山审核结果JSON")
|
||||
_comment_column("private_portrait_assets", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("private_portrait_assets", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("private_portrait_assets", "poll_count", "轮询次数")
|
||||
_comment_column("private_portrait_assets", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
|
||||
_comment_column("private_portrait_assets", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("private_portrait_assets", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("private_portrait_assets", "error_message", "错误信息")
|
||||
_comment_column("private_portrait_assets", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("private_portrait_assets", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_assets", "updated_at", "更新时间")
|
||||
_comment_column("private_portrait_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_validate_sessions 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_validate_sessions", "火山真人认证H5会话表")
|
||||
_comment_column("private_portrait_validate_sessions", "id", "主键ID")
|
||||
_comment_column("private_portrait_validate_sessions", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_validate_sessions", "project_id", "关联项目ID")
|
||||
_comment_column("private_portrait_validate_sessions", "byted_token", "火山byted_token")
|
||||
_comment_column("private_portrait_validate_sessions", "h5_link", "认证H5链接")
|
||||
_comment_column("private_portrait_validate_sessions", "callback_url", "火山回调URL")
|
||||
_comment_column("private_portrait_validate_sessions", "result_code", "认证结果码")
|
||||
_comment_column("private_portrait_validate_sessions", "algorithm_base_resp_code", "算法基础响应码")
|
||||
_comment_column("private_portrait_validate_sessions", "verify_type", "认证类型")
|
||||
_comment_column("private_portrait_validate_sessions", "status", "会话状态:created/group_active/expired/failed")
|
||||
_comment_column("private_portrait_validate_sessions", "remote_group_id", "火山远端AssetGroup ID")
|
||||
_comment_column("private_portrait_validate_sessions", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_validate_sessions", "expired_at", "会话过期时间")
|
||||
_comment_column("private_portrait_validate_sessions", "error_message", "错误信息")
|
||||
_comment_column("private_portrait_validate_sessions", "raw_callback_json", "火山回调原始JSON")
|
||||
_comment_column("private_portrait_validate_sessions", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("private_portrait_validate_sessions", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_validate_sessions", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# vp_v3_projects 表
|
||||
# ============================================================
|
||||
_comment_table("vp_v3_projects", "API V3虚拟素材项目表(按API Key隔离)")
|
||||
_comment_column("vp_v3_projects", "id", "主键ID")
|
||||
_comment_column("vp_v3_projects", "api_key_id", "所属API Key(V3调用方)")
|
||||
_comment_column("vp_v3_projects", "name", "项目展示名称")
|
||||
_comment_column("vp_v3_projects", "name_slug", "名称安全slug(构建远端GroupName用)")
|
||||
_comment_column("vp_v3_projects", "description", "项目描述")
|
||||
_comment_column("vp_v3_projects", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("vp_v3_projects", "remote_group_id", "火山AssetGroup Id")
|
||||
_comment_column("vp_v3_projects", "remote_group_name", "火山AssetGroup Name快照")
|
||||
_comment_column("vp_v3_projects", "status", "项目状态:active/creating_remote_group/create_group_failed/deleting")
|
||||
_comment_column("vp_v3_projects", "asset_count", "素材总数")
|
||||
_comment_column("vp_v3_projects", "active_asset_count", "有效素材数")
|
||||
_comment_column("vp_v3_projects", "image_asset_count", "图片素材数")
|
||||
_comment_column("vp_v3_projects", "video_asset_count", "视频素材数")
|
||||
_comment_column("vp_v3_projects", "active_image_asset_count", "有效图片素材数")
|
||||
_comment_column("vp_v3_projects", "active_video_asset_count", "有效视频素材数")
|
||||
_comment_column("vp_v3_projects", "storage_mb_used", "项目占用存储MB")
|
||||
_comment_column("vp_v3_projects", "last_used_at", "最后使用时间")
|
||||
_comment_column("vp_v3_projects", "remote_delete_status", "远端删除状态")
|
||||
_comment_column("vp_v3_projects", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("vp_v3_projects", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("vp_v3_projects", "error_message", "创建失败等错误信息")
|
||||
_comment_column("vp_v3_projects", "raw_response_json", "火山原始响应")
|
||||
_comment_column("vp_v3_projects", "created_at", "创建时间")
|
||||
_comment_column("vp_v3_projects", "updated_at", "更新时间")
|
||||
_comment_column("vp_v3_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# vp_v3_assets 表
|
||||
# ============================================================
|
||||
_comment_table("vp_v3_assets", "API V3虚拟素材表(图片/视频)")
|
||||
_comment_column("vp_v3_assets", "id", "主键ID")
|
||||
_comment_column("vp_v3_assets", "api_key_id", "所属API Key")
|
||||
_comment_column("vp_v3_assets", "project_id", "所属项目ID")
|
||||
_comment_column("vp_v3_assets", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("vp_v3_assets", "remote_group_id", "火山AssetGroup ID")
|
||||
_comment_column("vp_v3_assets", "remote_asset_id", "火山远端Asset ID")
|
||||
_comment_column("vp_v3_assets", "asset_type", "素材类型:Image=图片/Video=视频")
|
||||
_comment_column("vp_v3_assets", "name", "素材名称")
|
||||
_comment_column("vp_v3_assets", "source_url", "本地上传后的访问URL(UploadResource返回的)")
|
||||
_comment_column("vp_v3_assets", "preview_url", "给前端预览/显示用的URL")
|
||||
_comment_column("vp_v3_assets", "remote_url", "火山返回的资源访问URL")
|
||||
_comment_column("vp_v3_assets", "remote_url_expired_at", "火山URL过期时间")
|
||||
_comment_column("vp_v3_assets", "upload_resource_id", "本地UploadResource账本resource_id")
|
||||
_comment_column("vp_v3_assets", "video_duration", "视频时长,秒")
|
||||
_comment_column("vp_v3_assets", "video_cover_url", "视频封面预览")
|
||||
_comment_column("vp_v3_assets", "file_size_bytes", "素材文件大小,字节")
|
||||
_comment_column("vp_v3_assets", "mime_type", "MIME类型")
|
||||
_comment_column("vp_v3_assets", "status", "素材状态:creating/审核中active/可用failed/失败deleting/删除中")
|
||||
_comment_column("vp_v3_assets", "moderation_json", "火山审核结果JSON")
|
||||
_comment_column("vp_v3_assets", "error_message", "失败原因")
|
||||
_comment_column("vp_v3_assets", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("vp_v3_assets", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("vp_v3_assets", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("vp_v3_assets", "poll_count", "轮询次数")
|
||||
_comment_column("vp_v3_assets", "remote_delete_status", "远端删除状态")
|
||||
_comment_column("vp_v3_assets", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("vp_v3_assets", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("vp_v3_assets", "created_at", "创建时间")
|
||||
_comment_column("vp_v3_assets", "updated_at", "更新时间")
|
||||
_comment_column("vp_v3_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# vp_v3_api_key_quotas 表
|
||||
# ============================================================
|
||||
_comment_table("vp_v3_api_key_quotas", "API V3虚拟素材库配额表(每个ApiKey一份)")
|
||||
_comment_column("vp_v3_api_key_quotas", "id", "主键ID")
|
||||
_comment_column("vp_v3_api_key_quotas", "api_key_id", "所属API Key,唯一:一个API Key只有一份虚拟素材配额")
|
||||
_comment_column("vp_v3_api_key_quotas", "project_limit", "虚拟项目上限,默认0不可创建")
|
||||
_comment_column("vp_v3_api_key_quotas", "asset_limit", "虚拟素材总数上限(图片+视频),默认0不可上传")
|
||||
_comment_column("vp_v3_api_key_quotas", "storage_mb_limit", "上传存储上限MB,默认0不可上传文件")
|
||||
_comment_column("vp_v3_api_key_quotas", "project_used", "已创建项目数(未删除)")
|
||||
_comment_column("vp_v3_api_key_quotas", "asset_used", "已上传素材数(未删除,图片+视频)")
|
||||
_comment_column("vp_v3_api_key_quotas", "storage_mb_used", "已占用存储MB(未删除文件大小合计)")
|
||||
_comment_column("vp_v3_api_key_quotas", "remark", "后台备注")
|
||||
_comment_column("vp_v3_api_key_quotas", "created_at", "创建时间")
|
||||
_comment_column("vp_v3_api_key_quotas", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_keys 表
|
||||
# ============================================================
|
||||
_comment_table("api_keys", "对外开放API密钥管理表")
|
||||
_comment_column("api_keys", "id", "主键ID")
|
||||
_comment_column("api_keys", "company_name", "公司/组织名称")
|
||||
_comment_column("api_keys", "api_key_hash", "API Key哈希值,唯一")
|
||||
_comment_column("api_keys", "api_key_prefix", "API Key前缀")
|
||||
_comment_column("api_keys", "api_key_encrypted", "AES-256-GCM加密的完整API Key")
|
||||
_comment_column("api_keys", "description", "描述信息")
|
||||
_comment_column("api_keys", "callable_models", "可调用模型配置JSON数组")
|
||||
_comment_column("api_keys", "quota_limit", "配额总量,NULL=无限")
|
||||
_comment_column("api_keys", "quota_cycle", "配额周期:daily/monthly/one_time/NULL=无限")
|
||||
_comment_column("api_keys", "quota_used", "当前周期已使用量")
|
||||
_comment_column("api_keys", "valid_from", "有效期开始时间")
|
||||
_comment_column("api_keys", "valid_until", "有效期结束时间")
|
||||
_comment_column("api_keys", "max_concurrent_video_tasks", "最大并发视频任务数,NULL=无限")
|
||||
_comment_column("api_keys", "is_active", "是否启用")
|
||||
_comment_column("api_keys", "last_used_at", "最后使用时间")
|
||||
_comment_column("api_keys", "created_at", "创建时间")
|
||||
_comment_column("api_keys", "updated_at", "更新时间")
|
||||
_comment_column("api_keys", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# api_generation_tasks 表
|
||||
# ============================================================
|
||||
_comment_table("api_generation_tasks", "对外开放API生成任务表")
|
||||
_comment_column("api_generation_tasks", "id", "主键ID")
|
||||
_comment_column("api_generation_tasks", "api_key_id", "所属API Key")
|
||||
_comment_column("api_generation_tasks", "external_idempotency_key", "外部幂等键")
|
||||
_comment_column("api_generation_tasks", "original_prompt", "原始提示词")
|
||||
_comment_column("api_generation_tasks", "optimized_prompt", "优化后的提示词")
|
||||
_comment_column("api_generation_tasks", "gen_type", "生成类型:image/video")
|
||||
_comment_column("api_generation_tasks", "duration", "视频时长(秒)")
|
||||
_comment_column("api_generation_tasks", "aspect_ratio", "视频比例")
|
||||
_comment_column("api_generation_tasks", "resolution", "分辨率档位")
|
||||
_comment_column("api_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
|
||||
_comment_column("api_generation_tasks", "image_size", "图片分辨率档位")
|
||||
_comment_column("api_generation_tasks", "image_proportion", "图片比例")
|
||||
_comment_column("api_generation_tasks", "image_px", "图片像素")
|
||||
_comment_column("api_generation_tasks", "generation_count", "生成份数")
|
||||
_comment_column("api_generation_tasks", "engine_id", "引擎ID")
|
||||
_comment_column("api_generation_tasks", "model_name", "模型名称")
|
||||
_comment_column("api_generation_tasks", "media_references", "用户原始上传的媒体URL")
|
||||
_comment_column("api_generation_tasks", "local_media_json", "下载到本地的媒体文件路径JSON")
|
||||
_comment_column("api_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
|
||||
_comment_column("api_generation_tasks", "request_params_json", "完整原始请求参数")
|
||||
_comment_column("api_generation_tasks", "status", "任务状态")
|
||||
_comment_column("api_generation_tasks", "pipeline_stage", "流水线阶段")
|
||||
_comment_column("api_generation_tasks", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("api_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
|
||||
_comment_column("api_generation_tasks", "deadline_at", "任务截止时间")
|
||||
_comment_column("api_generation_tasks", "provider_task_id", "供应商任务ID")
|
||||
_comment_column("api_generation_tasks", "remote_result_url", "供应商远程资源URL")
|
||||
_comment_column("api_generation_tasks", "provider_response_json", "供应商响应JSON")
|
||||
_comment_column("api_generation_tasks", "image_url", "图片结果URL")
|
||||
_comment_column("api_generation_tasks", "video_url", "视频结果URL")
|
||||
_comment_column("api_generation_tasks", "video_cover_url", "视频封面URL")
|
||||
_comment_column("api_generation_tasks", "error_message", "错误信息")
|
||||
_comment_column("api_generation_tasks", "generated_at", "生成完成时间")
|
||||
_comment_column("api_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
||||
_comment_column("api_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
|
||||
_comment_column("api_generation_tasks", "credits_cost", "消耗积分")
|
||||
_comment_column("api_generation_tasks", "video_tokens_used", "视频Token消耗")
|
||||
_comment_column("api_generation_tasks", "image_tokens_used", "图片Token消耗")
|
||||
_comment_column("api_generation_tasks", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("api_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
|
||||
_comment_column("api_generation_tasks", "poll_count", "轮询次数")
|
||||
_comment_column("api_generation_tasks", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("api_generation_tasks", "provider_create_claim_token", "供应商创建任务租约token")
|
||||
_comment_column("api_generation_tasks", "provider_create_lease_until", "供应商创建租约过期")
|
||||
_comment_column("api_generation_tasks", "provider_create_started_at", "供应商创建开始时间")
|
||||
_comment_column("api_generation_tasks", "poll_started_at", "轮询开始时间")
|
||||
_comment_column("api_generation_tasks", "poll_claim_token", "轮询租约token")
|
||||
_comment_column("api_generation_tasks", "poll_lease_until", "轮询租约过期")
|
||||
_comment_column("api_generation_tasks", "poll_error_count", "轮询错误次数")
|
||||
_comment_column("api_generation_tasks", "download_celery_task_id", "下载Celery任务ID")
|
||||
_comment_column("api_generation_tasks", "download_enqueued_at", "下载入队时间")
|
||||
_comment_column("api_generation_tasks", "download_started_at", "下载开始时间")
|
||||
_comment_column("api_generation_tasks", "download_claim_token", "下载租约token")
|
||||
_comment_column("api_generation_tasks", "download_lease_until", "下载租约过期")
|
||||
_comment_column("api_generation_tasks", "download_next_retry_at", "下载下次重试时间")
|
||||
_comment_column("api_generation_tasks", "download_attempt_count", "下载重试次数")
|
||||
_comment_column("api_generation_tasks", "download_last_error", "下载最后错误信息")
|
||||
_comment_column("api_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
|
||||
_comment_column("api_generation_tasks", "local_path", "本地存储路径")
|
||||
_comment_column("api_generation_tasks", "created_at", "创建时间")
|
||||
_comment_column("api_generation_tasks", "updated_at", "更新时间")
|
||||
_comment_column("api_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# api_usage_logs 表
|
||||
# ============================================================
|
||||
_comment_table("api_usage_logs", "API调用详细消耗记录表")
|
||||
_comment_column("api_usage_logs", "id", "主键ID")
|
||||
_comment_column("api_usage_logs", "api_key_id", "所属API Key")
|
||||
_comment_column("api_usage_logs", "api_generation_task_id", "关联生成任务ID")
|
||||
_comment_column("api_usage_logs", "price_action", "操作类型:deduct=扣除, refund=退回")
|
||||
_comment_column("api_usage_logs", "request_type", "请求类型:video_create/image_generate")
|
||||
_comment_column("api_usage_logs", "model_name", "模型名称")
|
||||
_comment_column("api_usage_logs", "gen_type", "生成类型:image/video")
|
||||
_comment_column("api_usage_logs", "resolution", "分辨率")
|
||||
_comment_column("api_usage_logs", "duration", "视频时长(秒)")
|
||||
_comment_column("api_usage_logs", "credits_cost", "实际扣除金额")
|
||||
_comment_column("api_usage_logs", "refund_amount", "退回金额")
|
||||
_comment_column("api_usage_logs", "quota_before", "操作前配额余额")
|
||||
_comment_column("api_usage_logs", "quota_after", "操作后配额余额")
|
||||
_comment_column("api_usage_logs", "tokens_used", "Token用量")
|
||||
_comment_column("api_usage_logs", "request_duration_ms", "端到端耗时")
|
||||
_comment_column("api_usage_logs", "price_detail_json", "价格计算明细JSON")
|
||||
_comment_column("api_usage_logs", "status", "调用状态:success/failed")
|
||||
_comment_column("api_usage_logs", "error_message", "错误信息")
|
||||
_comment_column("api_usage_logs", "error_code", "错误码")
|
||||
_comment_column("api_usage_logs", "request_payload_json", "原始请求快照")
|
||||
_comment_column("api_usage_logs", "created_at", "创建时间")
|
||||
_comment_column("api_usage_logs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_model_pricings 表
|
||||
# ============================================================
|
||||
_comment_table("api_model_pricings", "API模型价格表(全局统一配置)")
|
||||
_comment_column("api_model_pricings", "id", "主键ID")
|
||||
_comment_column("api_model_pricings", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
|
||||
_comment_column("api_model_pricings", "gen_type", "生成类型:image/video")
|
||||
_comment_column("api_model_pricings", "resolution", "分辨率档位")
|
||||
_comment_column("api_model_pricings", "price_ratio", "价格系数(乘数)")
|
||||
_comment_column("api_model_pricings", "base_price", "基础价格(元)")
|
||||
_comment_column("api_model_pricings", "per_second_price", "每秒价格(视频,元)")
|
||||
_comment_column("api_model_pricings", "input_video_ratio", "传入视频系数")
|
||||
_comment_column("api_model_pricings", "input_video_base_price", "传入视频基础价(元)")
|
||||
_comment_column("api_model_pricings", "input_video_per_second_price", "传入视频每秒价(元)")
|
||||
_comment_column("api_model_pricings", "input_image_ratio", "传入图片系数")
|
||||
_comment_column("api_model_pricings", "input_image_base_price", "传入图片基础价(元)")
|
||||
_comment_column("api_model_pricings", "input_image_per_image_price", "传入图片每张价(元)")
|
||||
_comment_column("api_model_pricings", "created_at", "创建时间")
|
||||
_comment_column("api_model_pricings", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_upscale_links 表
|
||||
# ============================================================
|
||||
_comment_table("api_upscale_links", "API任务与超分任务关联表")
|
||||
_comment_column("api_upscale_links", "id", "主键ID")
|
||||
_comment_column("api_upscale_links", "api_generation_task_id", "关联API生成任务ID")
|
||||
_comment_column("api_upscale_links", "video_upscale_task_id", "关联视频超分任务ID")
|
||||
_comment_column("api_upscale_links", "created_at", "创建时间")
|
||||
_comment_column("api_upscale_links", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_key_upscale_configs 表
|
||||
# ============================================================
|
||||
_comment_table("api_key_upscale_configs", "API Key级别超分配置表")
|
||||
_comment_column("api_key_upscale_configs", "id", "主键ID")
|
||||
_comment_column("api_key_upscale_configs", "api_key_id", "所属API Key")
|
||||
_comment_column("api_key_upscale_configs", "enabled", "是否启用超分")
|
||||
_comment_column("api_key_upscale_configs", "delete_source_after_success", "超分成功后是否删除源文件")
|
||||
_comment_column("api_key_upscale_configs", "rules_json", "超分规则JSON数组")
|
||||
_comment_column("api_key_upscale_configs", "created_at", "创建时间")
|
||||
_comment_column("api_key_upscale_configs", "updated_at", "更新时间")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'notification_reads', 'menu_configs',
|
||||
'team_join_requests', 'team_invitations',
|
||||
'contact_requests', 'token_usage',
|
||||
'industry_configs', 'chat_generation_task_events',
|
||||
'chat_provider_call_logs', 'open_type',
|
||||
'pre_test_template', 'material_cost',
|
||||
'user_oauth', 'user_oauth_account', 'user_oauth_app',
|
||||
'upload_task', 'user_resource_month_stats',
|
||||
'user_resource_total_stats', 'home_material_assets',
|
||||
'home_material_categories', 'home_material_watermarks',
|
||||
'module_generation_projects', 'module_generation_steps',
|
||||
'shot_replicate_segments',
|
||||
'private_portrait_projects', 'private_portrait_asset_groups',
|
||||
'private_portrait_assets', 'private_portrait_validate_sessions',
|
||||
'vp_v3_projects', 'vp_v3_assets', 'vp_v3_api_key_quotas',
|
||||
'api_keys', 'api_generation_tasks', 'api_usage_logs',
|
||||
'api_model_pricings', 'api_upscale_links',
|
||||
'api_key_upscale_configs'
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
for t in [
|
||||
"notification_reads", "menu_configs",
|
||||
"team_join_requests", "team_invitations",
|
||||
"contact_requests", "token_usage",
|
||||
"industry_configs", "chat_generation_task_events",
|
||||
"chat_provider_call_logs", "open_type",
|
||||
"pre_test_template", "material_cost",
|
||||
"user_oauth", "user_oauth_account", "user_oauth_app",
|
||||
"upload_task", "user_resource_month_stats",
|
||||
"user_resource_total_stats", "home_material_assets",
|
||||
"home_material_categories", "home_material_watermarks",
|
||||
"module_generation_projects", "module_generation_steps",
|
||||
"shot_replicate_segments",
|
||||
"private_portrait_projects", "private_portrait_asset_groups",
|
||||
"private_portrait_assets", "private_portrait_validate_sessions",
|
||||
"vp_v3_projects", "vp_v3_assets", "vp_v3_api_key_quotas",
|
||||
"api_keys", "api_generation_tasks", "api_usage_logs",
|
||||
"api_model_pricings", "api_upscale_links",
|
||||
"api_key_upscale_configs",
|
||||
]:
|
||||
op.execute(f"COMMENT ON TABLE {t} IS NULL")
|
||||
@@ -0,0 +1,194 @@
|
||||
"""add api v3 tables (api_keys, api_generation_tasks, api_usage_logs, api_key_upscale_configs, api_upscale_links)
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 20da1d353914
|
||||
Create Date: 2026-07-28 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6g'
|
||||
down_revision: Union[str, None] = '20da1d353914'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === 1. api_keys ===
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('company_name', sa.String(128), nullable=False),
|
||||
sa.Column('api_key_hash', sa.String(64), nullable=False, unique=True),
|
||||
sa.Column('api_key_prefix', sa.String(16), nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('callable_models', sa.Text, nullable=False, server_default='[]'),
|
||||
sa.Column('quota_limit', sa.Float, nullable=True),
|
||||
sa.Column('quota_cycle', sa.String(16), nullable=True),
|
||||
sa.Column('quota_used', sa.Float, nullable=False, server_default='0.0'),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('max_concurrent_video_tasks', sa.Integer, nullable=True),
|
||||
sa.Column('is_active', sa.Boolean, nullable=False, server_default='true'),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index('idx_api_key_hash', 'api_keys', ['api_key_hash'], unique=True)
|
||||
op.create_index('idx_api_keys_active', 'api_keys', ['is_active'])
|
||||
op.create_index('idx_api_keys_company', 'api_keys', ['company_name'])
|
||||
|
||||
# === 2. api_generation_tasks ===
|
||||
op.create_table(
|
||||
'api_generation_tasks',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('external_idempotency_key', sa.String(64), nullable=True),
|
||||
sa.Column('original_prompt', sa.Text, nullable=False),
|
||||
sa.Column('optimized_prompt', sa.Text, nullable=True),
|
||||
sa.Column('gen_type', sa.String(16), nullable=False, default='video'),
|
||||
sa.Column('duration', sa.Integer, nullable=True),
|
||||
sa.Column('aspect_ratio', sa.String(8), nullable=True),
|
||||
sa.Column('resolution', sa.String(8), nullable=True),
|
||||
sa.Column('provider_generation_resolution', sa.String(16), nullable=True),
|
||||
sa.Column('image_size', sa.String(16), nullable=True),
|
||||
sa.Column('image_proportion', sa.String(8), nullable=True),
|
||||
sa.Column('image_px', sa.String(16), nullable=True),
|
||||
sa.Column('generation_count', sa.Integer, nullable=False, default=1, server_default='1'),
|
||||
sa.Column('engine_id', sa.String(32), nullable=True),
|
||||
sa.Column('media_references', sa.Text, nullable=True),
|
||||
sa.Column('engine_snapshot_json', sa.Text, nullable=True),
|
||||
sa.Column('request_params_json', sa.Text, nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, default='pending'),
|
||||
sa.Column('pipeline_stage', sa.String(32), nullable=True),
|
||||
sa.Column('generation_attempt_no', sa.Integer, nullable=False, default=1, server_default='1'),
|
||||
sa.Column('resource_generation_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('deadline_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('provider_task_id', sa.String(128), nullable=True),
|
||||
sa.Column('remote_result_url', sa.Text, nullable=True),
|
||||
sa.Column('provider_response_json', sa.Text, nullable=True),
|
||||
sa.Column('image_url', sa.String(512), nullable=True),
|
||||
sa.Column('video_url', sa.String(512), nullable=True),
|
||||
sa.Column('video_cover_url', sa.String(512), nullable=True),
|
||||
sa.Column('video_upscale_enabled_snapshot', sa.Boolean, nullable=False, default=False, server_default='false'),
|
||||
sa.Column('video_upscale_snapshot_json', sa.Text, nullable=True),
|
||||
sa.Column('credits_cost', sa.Float, nullable=False, default=0.0, server_default='0.0'),
|
||||
sa.Column('video_tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
|
||||
sa.Column('image_tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
|
||||
sa.Column('error_message', sa.Text, nullable=True),
|
||||
sa.Column('generated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('poll_interval_seconds', sa.Integer, nullable=False, default=30, server_default='30'),
|
||||
sa.Column('poll_count', sa.Integer, nullable=False, default=0, server_default='0'),
|
||||
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('provider_create_claim_token', sa.String(64), nullable=True),
|
||||
sa.Column('provider_create_lease_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('provider_create_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('poll_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('poll_claim_token', sa.String(64), nullable=True),
|
||||
sa.Column('poll_lease_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('poll_error_count', sa.Integer, nullable=False, default=0, server_default='0'),
|
||||
sa.Column('download_celery_task_id', sa.String(160), nullable=True),
|
||||
sa.Column('download_enqueued_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('download_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('download_claim_token', sa.String(64), nullable=True),
|
||||
sa.Column('download_lease_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('download_next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('download_attempt_count', sa.Integer, nullable=False, default=0, server_default='0'),
|
||||
sa.Column('download_last_error', sa.Text, nullable=True),
|
||||
sa.Column('download_storage_date_dir', sa.String(16), nullable=True),
|
||||
sa.Column('local_path', sa.Text, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index('idx_api_generation_tasks_api_key', 'api_generation_tasks', ['api_key_id'])
|
||||
op.create_index('idx_api_generation_tasks_status', 'api_generation_tasks', ['status'])
|
||||
op.create_index('idx_api_generation_tasks_provider_task_id', 'api_generation_tasks', ['provider_task_id'])
|
||||
op.create_index('idx_api_generation_tasks_next_poll_at', 'api_generation_tasks', ['next_poll_at'])
|
||||
op.create_index('idx_api_generation_tasks_api_key_created', 'api_generation_tasks', ['api_key_id', 'created_at'])
|
||||
op.create_index(
|
||||
'uq_api_generation_tasks_key_idempotency',
|
||||
'api_generation_tasks',
|
||||
['api_key_id', 'external_idempotency_key'],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL AND external_idempotency_key IS NOT NULL"),
|
||||
)
|
||||
|
||||
# === 3. api_usage_logs ===
|
||||
op.create_table(
|
||||
'api_usage_logs',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('api_generation_task_id', sa.String(32), sa.ForeignKey('api_generation_tasks.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('request_type', sa.String(32), nullable=False),
|
||||
sa.Column('model_name', sa.String(128), nullable=False),
|
||||
sa.Column('gen_type', sa.String(16), nullable=False),
|
||||
sa.Column('credits_cost', sa.Float, nullable=False, default=0.0, server_default='0.0'),
|
||||
sa.Column('tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
|
||||
sa.Column('request_duration_ms', sa.Integer, nullable=False, default=0, server_default='0'),
|
||||
sa.Column('status', sa.String(32), nullable=False),
|
||||
sa.Column('error_message', sa.Text, nullable=True),
|
||||
sa.Column('error_code', sa.String(64), nullable=True),
|
||||
sa.Column('request_payload_json', sa.Text, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_api_usage_logs_api_key', 'api_usage_logs', ['api_key_id'])
|
||||
op.create_index('idx_api_usage_logs_task_id', 'api_usage_logs', ['api_generation_task_id'])
|
||||
op.create_index('idx_api_usage_logs_api_key_created', 'api_usage_logs', ['api_key_id', 'created_at'])
|
||||
|
||||
# === 4. api_key_upscale_configs ===
|
||||
op.create_table(
|
||||
'api_key_upscale_configs',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False, unique=True),
|
||||
sa.Column('enabled', sa.Boolean, nullable=False, default=False, server_default='false'),
|
||||
sa.Column('delete_source_after_success', sa.Boolean, nullable=False, default=True, server_default='true'),
|
||||
sa.Column('rules_json', sa.Text, nullable=False, server_default='[]'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# === 5. api_upscale_links ===
|
||||
op.create_table(
|
||||
'api_upscale_links',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('api_generation_task_id', sa.String(32), sa.ForeignKey('api_generation_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('video_upscale_task_id', sa.String(32), sa.ForeignKey('video_upscale_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_api_upscale_links_api_task', 'api_upscale_links', ['api_generation_task_id'])
|
||||
op.create_index('idx_api_upscale_links_video_task', 'api_upscale_links', ['video_upscale_task_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_api_upscale_links_video_task', table_name='api_upscale_links')
|
||||
op.drop_index('idx_api_upscale_links_api_task', table_name='api_upscale_links')
|
||||
op.drop_table('api_upscale_links')
|
||||
|
||||
op.drop_table('api_key_upscale_configs')
|
||||
|
||||
op.drop_index('idx_api_usage_logs_api_key_created', table_name='api_usage_logs')
|
||||
op.drop_index('idx_api_usage_logs_task_id', table_name='api_usage_logs')
|
||||
op.drop_index('idx_api_usage_logs_api_key', table_name='api_usage_logs')
|
||||
op.drop_table('api_usage_logs')
|
||||
|
||||
op.drop_index('uq_api_generation_tasks_key_idempotency', table_name='api_generation_tasks')
|
||||
op.drop_index('idx_api_generation_tasks_api_key_created', table_name='api_generation_tasks')
|
||||
op.drop_index('idx_api_generation_tasks_next_poll_at', table_name='api_generation_tasks')
|
||||
op.drop_index('idx_api_generation_tasks_provider_task_id', table_name='api_generation_tasks')
|
||||
op.drop_index('idx_api_generation_tasks_status', table_name='api_generation_tasks')
|
||||
op.drop_index('idx_api_generation_tasks_api_key', table_name='api_generation_tasks')
|
||||
op.drop_table('api_generation_tasks')
|
||||
|
||||
op.drop_index('idx_api_keys_company', table_name='api_keys')
|
||||
op.drop_index('idx_api_keys_active', table_name='api_keys')
|
||||
op.drop_index('idx_api_key_hash', table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add api_model_pricings table
|
||||
|
||||
Revision ID: b2c3d4e5f6g7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-07-28 14:00:00.000000
|
||||
|
||||
API 模型价格表 - 使用直接金额(元)计费,镜像 credit_ratios 结构。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2c3d4e5f6g7h'
|
||||
down_revision: Union[str, None] = 'a1b2c3d4e5f6g'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'api_model_pricings',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('model_config_id', sa.String(32), nullable=False, index=True),
|
||||
sa.Column('gen_type', sa.String(16), nullable=False, default='video', index=True),
|
||||
sa.Column('resolution', sa.String(16), nullable=False, index=True),
|
||||
sa.Column('price_ratio', sa.Float, nullable=False, default=1.0),
|
||||
sa.Column('base_price', sa.Float, nullable=False, default=0.0),
|
||||
sa.Column('per_second_price', sa.Float, nullable=False, default=0.0),
|
||||
sa.Column('input_video_ratio', sa.Float, nullable=False, default=1.0),
|
||||
sa.Column('input_video_base_price', sa.Float, nullable=False, default=0.0),
|
||||
sa.Column('input_video_per_second_price', sa.Float, nullable=False, default=0.0),
|
||||
sa.Column('input_image_ratio', sa.Float, nullable=False, default=1.0),
|
||||
sa.Column('input_image_base_price', sa.Float, nullable=False, default=0.0),
|
||||
sa.Column('input_image_per_image_price', sa.Float, nullable=False, default=0.0),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_api_model_pricings_gen_type_engine_resolution',
|
||||
'api_model_pricings',
|
||||
['gen_type', 'model_config_id', 'resolution'],
|
||||
)
|
||||
op.create_index(
|
||||
'ix_api_model_pricings_gen_type_resolution',
|
||||
'api_model_pricings',
|
||||
['gen_type', 'resolution'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_api_model_pricings_gen_type_resolution', table_name='api_model_pricings')
|
||||
op.drop_index('ix_api_model_pricings_gen_type_engine_resolution', table_name='api_model_pricings')
|
||||
op.drop_table('api_model_pricings')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add api_key_encrypted column to api_keys
|
||||
|
||||
Revision ID: c3d4e5f6g7h8
|
||||
Revises: b2c3d4e5f6g7h
|
||||
Create Date: 2026-07-28 16:00:00.000000
|
||||
|
||||
添加 api_key_encrypted 字段用于存储加密的完整 API Key,支持随时揭秘复制。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3d4e5f6g7h8'
|
||||
down_revision: Union[str, None] = 'b2c3d4e5f6g7h'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'api_keys',
|
||||
sa.Column('api_key_encrypted', sa.Text, nullable=True, comment='AES-256-GCM 加密的完整 API Key'),
|
||||
)
|
||||
# 为现有记录设置空值(新创建的 Key 会自动加密)
|
||||
op.execute("UPDATE api_keys SET api_key_encrypted = '' WHERE api_key_encrypted IS NULL")
|
||||
op.alter_column('api_keys', 'api_key_encrypted', nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('api_keys', 'api_key_encrypted')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add model_name to api_generation_tasks
|
||||
|
||||
Revision ID: c4d5e6f7g8h9
|
||||
Revises: c3d4e5f6g7h8
|
||||
Create Date: 2026-07-29 16:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c4d5e6f7g8h9'
|
||||
down_revision: Union[str, None] = 'c3d4e5f6g7h8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'api_generation_tasks',
|
||||
sa.Column('model_name', sa.String(128), nullable=False, server_default='', comment="模型名称,如 doubao-seedance-2-0-260128"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('api_generation_tasks', 'model_name')
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"""add api_generation_task_id to video_upscale_tasks
|
||||
|
||||
Revision ID: d5e6f7g8h9i0
|
||||
Revises: c4d5e6f7g8h9
|
||||
Create Date: 2026-07-29 16:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd5e6f7g8h9i0'
|
||||
down_revision: Union[str, None] = 'c4d5e6f7g8h9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 api_generation_task_id 字段
|
||||
op.add_column(
|
||||
'video_upscale_tasks',
|
||||
sa.Column('api_generation_task_id', sa.String(32), nullable=True, comment="API v3 任务ID,关联 api_generation_tasks.id"),
|
||||
)
|
||||
op.create_index(
|
||||
'idx_video_upscale_tasks_api_generation_task_id',
|
||||
'video_upscale_tasks',
|
||||
['api_generation_task_id'],
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'fk_video_upscale_tasks_api_generation_task_id',
|
||||
'video_upscale_tasks',
|
||||
'api_generation_tasks',
|
||||
['api_generation_task_id'],
|
||||
['id'],
|
||||
ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# 删除旧的检查约束,创建新的(允许 api_generation_task_id)
|
||||
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
|
||||
op.execute("""
|
||||
ALTER TABLE video_upscale_tasks
|
||||
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
|
||||
CHECK (
|
||||
(chat_generation_task_id IS NOT NULL)::int +
|
||||
(generation_record_id IS NOT NULL)::int +
|
||||
(api_generation_task_id IS NOT NULL)::int = 1
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复旧约束
|
||||
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
|
||||
op.execute("""
|
||||
ALTER TABLE video_upscale_tasks
|
||||
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
|
||||
CHECK (
|
||||
(chat_generation_task_id IS NOT NULL)::int +
|
||||
(generation_record_id IS NOT NULL)::int = 1
|
||||
)
|
||||
""")
|
||||
|
||||
op.drop_constraint('fk_video_upscale_tasks_api_generation_task_id', 'video_upscale_tasks', type_='foreignkey')
|
||||
op.drop_index('idx_video_upscale_tasks_api_generation_task_id', table_name='video_upscale_tasks')
|
||||
op.drop_column('video_upscale_tasks', 'api_generation_task_id')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""enhance api_usage_logs with detailed consumption fields
|
||||
|
||||
Revision ID: e6f7g8h9i0j1
|
||||
Revises: d5e6f7g8h9i0
|
||||
Create Date: 2026-07-29 17:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e6f7g8h9i0j1'
|
||||
down_revision: Union[str, None] = 'd5e6f7g8h9i0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加新字段
|
||||
op.add_column('api_usage_logs', sa.Column('price_action', sa.String(16), nullable=False, server_default='deduct', comment='deduct=扣除, refund=退回'))
|
||||
op.add_column('api_usage_logs', sa.Column('resolution', sa.String(16), nullable=True, comment="分辨率: 480p/720p/1080p/2K/4K"))
|
||||
op.add_column('api_usage_logs', sa.Column('duration', sa.Integer(), nullable=True, comment="视频时长(秒)"))
|
||||
op.add_column('api_usage_logs', sa.Column('refund_amount', sa.Float(), nullable=False, server_default='0.0', comment='退回金额'))
|
||||
op.add_column('api_usage_logs', sa.Column('quota_before', sa.Float(), nullable=True, comment='操作前配额余额'))
|
||||
op.add_column('api_usage_logs', sa.Column('quota_after', sa.Float(), nullable=True, comment='操作后配额余额'))
|
||||
op.add_column('api_usage_logs', sa.Column('price_detail_json', sa.Text(), nullable=True, comment='价格计算明细JSON'))
|
||||
|
||||
# 添加索引
|
||||
op.create_index('idx_api_usage_logs_action', 'api_usage_logs', ['price_action'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_api_usage_logs_action', table_name='api_usage_logs')
|
||||
op.drop_column('api_usage_logs', 'price_detail_json')
|
||||
op.drop_column('api_usage_logs', 'quota_after')
|
||||
op.drop_column('api_usage_logs', 'quota_before')
|
||||
op.drop_column('api_usage_logs', 'refund_amount')
|
||||
op.drop_column('api_usage_logs', 'duration')
|
||||
op.drop_column('api_usage_logs', 'resolution')
|
||||
op.drop_column('api_usage_logs', 'price_action')
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"""add local_media_json to api_generation_tasks
|
||||
|
||||
Revision ID: f7g8h9i0j1k2
|
||||
Revises: e6f7g8h9i0j1
|
||||
Create Date: 2026-07-29 18:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f7g8h9i0j1k2'
|
||||
down_revision: Union[str, None] = 'e6f7g8h9i0j1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'api_generation_tasks',
|
||||
sa.Column('local_media_json', sa.Text, nullable=True, comment='下载到本地的媒体文件路径JSON'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('api_generation_tasks', 'local_media_json')
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.admin_api.api_keys.routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,376 @@
|
||||
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,
|
||||
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),
|
||||
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=100)
|
||||
|
||||
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"],
|
||||
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,
|
||||
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)
|
||||
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -10,6 +10,9 @@ from app.api.admin.recharge_package import router as recharge_package_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)
|
||||
@@ -22,3 +25,6 @@ router.include_router(recharge_package_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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ApiError(BaseModel):
|
||||
"""API 错误详情。"""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class ApiErrorResponse(BaseModel):
|
||||
"""API 错误响应(旧格式,保留兼容)。"""
|
||||
|
||||
error: ApiError
|
||||
@@ -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]}",
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,162 @@
|
||||
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
|
||||
|
||||
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:
|
||||
"""创建视频生成任务(异步)。"""
|
||||
try:
|
||||
# 路由层校验:权限、幂等性
|
||||
existing_task = await _validate_request(db, key_context, req)
|
||||
if existing_task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"幂等键已存在: 任务 {req.idempotency_key} 已创建",
|
||||
)
|
||||
|
||||
# 调用服务层创建任务
|
||||
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 = 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_type:Image/Video;Video 必须提供 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+8)naive 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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -27,6 +27,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"
|
||||
@@ -43,3 +44,8 @@ class CeleryTaskName(str, Enum):
|
||||
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
|
||||
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
|
||||
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+105
-1
@@ -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
|
||||
@@ -554,12 +554,116 @@ 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"):
|
||||
raise exc # 交给其他处理器
|
||||
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"}
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.models.contact_request import ContactRequest
|
||||
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
|
||||
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
|
||||
|
||||
__all__ = [
|
||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||
@@ -54,4 +55,6 @@ __all__ = [
|
||||
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
|
||||
"PrivatePortraitProject", "PrivatePortraitValidateSession",
|
||||
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
|
||||
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
|
||||
"ApiModelPricing",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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="原始请求快照")
|
||||
@@ -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表示未删除"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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="本地上传后的访问 URL(UploadResource 返回的)")
|
||||
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 Key(V3 调用方)",
|
||||
)
|
||||
|
||||
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="火山原始响应")
|
||||
@@ -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,140 @@
|
||||
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 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,36 @@
|
||||
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
|
||||
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="存储上限 MB,0=不可上传文件")
|
||||
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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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="错误信息(失败时返回)")
|
||||
@@ -11,7 +11,7 @@ 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)
|
||||
|
||||
@@ -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="上传存储上限 MB,0=不可上传文件")
|
||||
|
||||
# 已使用
|
||||
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 上传返回)")
|
||||
@@ -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,494 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
# 3. 调用 Volcano Ark SDK(同步函数,在线程中执行)
|
||||
from app.services.image_gen import submit_image_task, download_image
|
||||
from app.config import settings
|
||||
import os
|
||||
|
||||
# 构建 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.to_thread(
|
||||
submit_image_task,
|
||||
db,
|
||||
engine,
|
||||
task,
|
||||
True, # include_media_references
|
||||
req.generation_count or 1,
|
||||
)
|
||||
|
||||
# 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 download_image(url, dest_path)
|
||||
except Exception as dl_err:
|
||||
logger.warning("Image download failed: %s", dl_err)
|
||||
|
||||
downloaded_items.append(ApiImageGenerateDataItem(
|
||||
url=dest_path, # 使用本地路径
|
||||
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 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,151 @@
|
||||
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 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,215 @@
|
||||
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:
|
||||
"""创建视频生成任务记录。"""
|
||||
# 提取文本提示词
|
||||
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 兼容)
|
||||
from app.services.api_v3.file_service import process_media_url
|
||||
|
||||
media_refs = [] # 扁平格式: {"type": "image", "url": "...", "role": "..."}
|
||||
local_media_refs = [] # 本地下载路径(嵌套格式)
|
||||
|
||||
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"),
|
||||
})
|
||||
|
||||
# 下载文件到本地
|
||||
try:
|
||||
local_path = await process_media_url(original_url, media_type)
|
||||
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,212 @@
|
||||
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,
|
||||
) -> "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=0,
|
||||
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,
|
||||
}
|
||||
@@ -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+8)naive 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/大小/网络)
|
||||
- 失败:抛 HTTPException(400/413/415/502/500),不留临时文件
|
||||
4. 配额校验(素材数 + 存储 MB,用下载后的实际 file_size_bytes)
|
||||
- 失败:**立刻删除本地已下载的文件**,避免占用磁盘;再抛 403
|
||||
5. Video 时长校验(payload.video_duration 优先,否则用 ffprobe 探测到的值;>60s 报错)
|
||||
- 失败:删本地文件 → 抛 400
|
||||
6. 写 VpV3Asset(Creating 状态,带 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,557 @@
|
||||
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+8)naive 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。
|
||||
"""
|
||||
# 先删除项目下所有素材的远端资源
|
||||
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,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for asset in assets:
|
||||
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,163 @@
|
||||
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)
|
||||
_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)
|
||||
_check(quota.asset_limit, quota.asset_used, asset_count_delta, "素材总数")
|
||||
return quota
|
||||
|
||||
|
||||
def remote_project_name() -> str:
|
||||
"""火山 ProjectName(V3 中转统一共用这个 Project)。"""
|
||||
return PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
||||
|
||||
|
||||
def remote_group_name(*, api_key_id: str, project_slug: str, id: str) -> str:
|
||||
"""火山 GroupName:vp-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+8)naive 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_id(hash 形式)
|
||||
"""
|
||||
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:
|
||||
"""把传入的远程 URL(http/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
|
||||
@@ -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,841 @@
|
||||
"""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) - 当前余额作为 before,after 需要计算
|
||||
"""
|
||||
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):
|
||||
task.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:
|
||||
task.source_width = source_info.width
|
||||
task.source_height = source_info.height
|
||||
task.source_duration_seconds = 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,
|
||||
)
|
||||
|
||||
# 如果已存在超分任务(重复调用),检查超分状态
|
||||
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="gen_api_upscale",
|
||||
)
|
||||
else:
|
||||
# 远程超分(火山 MediaKit)
|
||||
api_upscale_submit_remote_simple.apply_async(
|
||||
args=[upscale_task.id],
|
||||
queue="gen_api_upscale",
|
||||
)
|
||||
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,364 @@
|
||||
"""API v3 简化超分任务。
|
||||
|
||||
不使用复杂的 CeleryRuntimeLease 锁机制,直接执行超分流程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
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 run_local_ffmpeg_upscale
|
||||
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:
|
||||
# 执行本地 FFmpeg 超分
|
||||
output_path = await run_local_ffmpeg_upscale(upscale)
|
||||
|
||||
# 计算相对 URL 路径
|
||||
# output_path 是绝对路径,需要转换为 /generate/api/videos/... 格式
|
||||
date_dir = _now_str()
|
||||
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 = f"./storage/generate/api/videos/{_now_str()}/{upscale.api_generation_task_id or "unknown"}.mp4"
|
||||
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="gen_api_upscale",
|
||||
)
|
||||
|
||||
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="gen_api_upscale",
|
||||
)
|
||||
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())
|
||||
@@ -33,7 +33,11 @@ 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.api_generation_tasks",
|
||||
"app.tasks.api_recovery_tasks",
|
||||
"app.tasks.api_upscale_tasks",
|
||||
)
|
||||
|
||||
|
||||
@@ -85,6 +89,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)),
|
||||
@@ -120,6 +132,16 @@ def _beat_schedule() -> dict:
|
||||
"schedule": 300,
|
||||
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.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
|
||||
|
||||
|
||||
@@ -240,6 +262,11 @@ if broker_url:
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_GROUP.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
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:
|
||||
@@ -404,6 +431,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))
|
||||
@@ -412,8 +440,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+8)naive 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))
|
||||
@@ -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(
|
||||
|
||||
@@ -3,9 +3,22 @@ app/__init__.py
|
||||
app/config.py
|
||||
app/dependencies.py
|
||||
app/main.py
|
||||
app/admin_api/api_keys/__init__.py
|
||||
app/admin_api/api_keys/routes.py
|
||||
app/admin_api/api_model_pricings/__init__.py
|
||||
app/admin_api/api_model_pricings/routes.py
|
||||
app/api/__init__.py
|
||||
app/api/admin/__init__.py
|
||||
app/api/admin/contact.py
|
||||
app/api/admin/home_material.py
|
||||
app/api/admin/menu_config.py
|
||||
app/api/admin/private_portrait.py
|
||||
app/api/admin/recharge_package.py
|
||||
app/api/admin/resource_capacity.py
|
||||
app/api/admin/team.py
|
||||
app/api/admin/upload.py
|
||||
app/api/admin/video_prompt_schema_config.py
|
||||
app/api/admin/video_upscale.py
|
||||
app/api/v1/__init__.py
|
||||
app/api/v1/admin.py
|
||||
app/api/v1/auth.py
|
||||
@@ -14,34 +27,73 @@ app/api/v1/contact.py
|
||||
app/api/v1/credits.py
|
||||
app/api/v1/generation.py
|
||||
app/api/v1/generation_ai.py
|
||||
app/api/v1/home_materials.py
|
||||
app/api/v1/hot_opening_replicate.py
|
||||
app/api/v1/image_engines.py
|
||||
app/api/v1/industries.py
|
||||
app/api/v1/material_admin.py
|
||||
app/api/v1/material_consumption.py
|
||||
app/api/v1/menu_configs.py
|
||||
app/api/v1/notifications.py
|
||||
app/api/v1/open_type.py
|
||||
app/api/v1/payments.py
|
||||
app/api/v1/pre_test_template.py
|
||||
app/api/v1/private_portrait.py
|
||||
app/api/v1/private_portrait_virtual.py
|
||||
app/api/v1/projects.py
|
||||
app/api/v1/recent_generation.py
|
||||
app/api/v1/recharge_packages.py
|
||||
app/api/v1/resources_material.py
|
||||
app/api/v1/shot_replicate.py
|
||||
app/api/v1/sms.py
|
||||
app/api/v1/team.py
|
||||
app/api/v1/test.py
|
||||
app/api/v1/upload_material.py
|
||||
app/api/v1/upload_resource.py
|
||||
app/api/v1/user_oauth.py
|
||||
app/api/v1/user_oauth_account.py
|
||||
app/api/v1/user_oauth_app.py
|
||||
app/api/v1/video_engines.py
|
||||
app/api/v2/__init__.py
|
||||
app/api/v2/hot_opening_replicate.py
|
||||
app/api/v2/shot_replicate.py
|
||||
app/api/v3/__init__.py
|
||||
app/api/v3/images.py
|
||||
app/api/v3/models.py
|
||||
app/api/v3/videos.py
|
||||
app/cmd/__init__.py
|
||||
app/cmd/backfill_upload_resources.py
|
||||
app/cmd/batch_video_upscale.py
|
||||
app/cmd/retry_video_upscale.py
|
||||
app/enums/__init__.py
|
||||
app/enums/admin_upload.py
|
||||
app/enums/audio_reference.py
|
||||
app/enums/celery_queue.py
|
||||
app/enums/celery_runtime.py
|
||||
app/enums/common.py
|
||||
app/enums/credit_record.py
|
||||
app/enums/generation.py
|
||||
app/enums/generation_history.py
|
||||
app/enums/generation_provider.py
|
||||
app/enums/generation_record.py
|
||||
app/enums/generation_status.py
|
||||
app/enums/generation_task.py
|
||||
app/enums/home_material.py
|
||||
app/enums/hot_opening_replicate.py
|
||||
app/enums/llm_billing.py
|
||||
app/enums/module_generation_flow.py
|
||||
app/enums/notification.py
|
||||
app/enums/private_portrait.py
|
||||
app/enums/recent_generation.py
|
||||
app/enums/resource_capacity.py
|
||||
app/enums/shot_replicate.py
|
||||
app/enums/sms.py
|
||||
app/enums/team.py
|
||||
app/enums/token_usage.py
|
||||
app/enums/upload_resource.py
|
||||
app/enums/user.py
|
||||
app/enums/video_prompt_schema.py
|
||||
app/enums/video_upscale.py
|
||||
app/middleware/__init__.py
|
||||
app/middleware/anti_crawler.py
|
||||
app/middleware/logging.py
|
||||
@@ -76,17 +128,40 @@ app/models/resources_material.py
|
||||
app/models/shot_replicate_segment.py
|
||||
app/models/shot_replicate_task_set.py
|
||||
app/models/system_config.py
|
||||
app/models/team.py
|
||||
app/models/team_invitation.py
|
||||
app/models/team_join_request.py
|
||||
app/models/token_usage.py
|
||||
app/models/upload_resource.py
|
||||
app/models/upload_task.py
|
||||
app/models/user.py
|
||||
app/models/user_oauth.py
|
||||
app/models/user_oauth_account.py
|
||||
app/models/user_oauth_app.py
|
||||
app/models/user_resource_capacity_config.py
|
||||
app/models/user_resource_month_stat.py
|
||||
app/models/user_resource_total_stat.py
|
||||
app/models/video_engine.py
|
||||
app/models/video_upscale_task.py
|
||||
app/models/api/__init__.py
|
||||
app/models/api/api_generation_task.py
|
||||
app/models/api/api_key.py
|
||||
app/models/api/api_key_upscale_config.py
|
||||
app/models/api/api_model_pricing.py
|
||||
app/models/api/api_upscale_link.py
|
||||
app/models/api/api_usage_log.py
|
||||
app/models/home_material/__init__.py
|
||||
app/models/home_material/asset.py
|
||||
app/models/home_material/category.py
|
||||
app/models/home_material/watermark.py
|
||||
app/models/private_portrait/__init__.py
|
||||
app/models/private_portrait/asset.py
|
||||
app/models/private_portrait/asset_group.py
|
||||
app/models/private_portrait/project.py
|
||||
app/models/private_portrait/validate_session.py
|
||||
app/schemas/__init__.py
|
||||
app/schemas/admin.py
|
||||
app/schemas/admin_upload.py
|
||||
app/schemas/auth.py
|
||||
app/schemas/captcha.py
|
||||
app/schemas/common.py
|
||||
@@ -95,24 +170,46 @@ app/schemas/credit.py
|
||||
app/schemas/credit_ratio.py
|
||||
app/schemas/generation.py
|
||||
app/schemas/generation_ai.py
|
||||
app/schemas/home_material.py
|
||||
app/schemas/hot_opening_replicate.py
|
||||
app/schemas/image_engine.py
|
||||
app/schemas/industry.py
|
||||
app/schemas/menu.py
|
||||
app/schemas/module_generation_v2.py
|
||||
app/schemas/notification.py
|
||||
app/schemas/open_type.py
|
||||
app/schemas/payment.py
|
||||
app/schemas/pre_test_template.py
|
||||
app/schemas/private_portrait.py
|
||||
app/schemas/project.py
|
||||
app/schemas/recent_generation.py
|
||||
app/schemas/recharge_package.py
|
||||
app/schemas/resource_capacity.py
|
||||
app/schemas/resources_material.py
|
||||
app/schemas/shot_replicate.py
|
||||
app/schemas/sms.py
|
||||
app/schemas/team.py
|
||||
app/schemas/team_invitation.py
|
||||
app/schemas/team_join_request.py
|
||||
app/schemas/team_manager.py
|
||||
app/schemas/upload_resource.py
|
||||
app/schemas/user.py
|
||||
app/schemas/user_oauth.py
|
||||
app/schemas/user_oauth_account.py
|
||||
app/schemas/user_oauth_app.py
|
||||
app/schemas/video_engine.py
|
||||
app/schemas/video_prompt_schema_config.py
|
||||
app/schemas/video_upscale.py
|
||||
app/schemas/admin_api/__init__.py
|
||||
app/schemas/admin_api/api_key.py
|
||||
app/schemas/admin_api/api_model_pricing.py
|
||||
app/schemas/admin_api/api_upscale.py
|
||||
app/schemas/admin_api/api_usage.py
|
||||
app/schemas/api_v3/__init__.py
|
||||
app/schemas/api_v3/common.py
|
||||
app/schemas/api_v3/image.py
|
||||
app/schemas/api_v3/model.py
|
||||
app/schemas/api_v3/video.py
|
||||
app/services/__init__.py
|
||||
app/services/admin_credit_record_service.py
|
||||
app/services/auth.py
|
||||
@@ -122,24 +219,15 @@ app/services/credit_ratio_service.py
|
||||
app/services/credit_record_meta_service.py
|
||||
app/services/credits.py
|
||||
app/services/error_codes.py
|
||||
app/services/generation_ai_service.py
|
||||
app/services/generation_billing_service.py
|
||||
app/services/generation_download_service.py
|
||||
app/services/generation_log_service.py
|
||||
app/services/generation_module_hook_service.py
|
||||
app/services/generation_prompt_service.py
|
||||
app/services/generation_provider_service.py
|
||||
app/services/generation_provider_types.py
|
||||
app/services/generation_recovery_service.py
|
||||
app/services/generation_refund_service.py
|
||||
app/services/generation_task_factory_service.py
|
||||
app/services/hot_opening_replicate_service.py
|
||||
app/services/hot_opening_video_prompt_service.py
|
||||
app/services/image_gen.py
|
||||
app/services/llm.py
|
||||
app/services/log_config.py
|
||||
app/services/material_admin_service.py
|
||||
app/services/material_consumption_queue.py
|
||||
app/services/material_consumption_service.py
|
||||
app/services/media_token_usage_snapshot_service.py
|
||||
app/services/module_async_recovery_service.py
|
||||
app/services/module_generation_flow_base_service.py
|
||||
app/services/module_generation_log_service.py
|
||||
@@ -147,11 +235,15 @@ app/services/module_generation_step_common_service.py
|
||||
app/services/module_generation_step_update_service.py
|
||||
app/services/notification.py
|
||||
app/services/operation_log.py
|
||||
app/services/operation_log_service.py
|
||||
app/services/payment.py
|
||||
app/services/pre_test_queue.py
|
||||
app/services/pre_test_template_service.py
|
||||
app/services/provider_limit.py
|
||||
app/services/recent_generation_service.py
|
||||
app/services/redis_registry_service.py
|
||||
app/services/resource_accounting_service.py
|
||||
app/services/resource_capacity_service.py
|
||||
app/services/resource_signed_url_service.py
|
||||
app/services/resources_material_service.py
|
||||
app/services/shot_replicate_flow_service.py
|
||||
@@ -160,20 +252,123 @@ app/services/shot_replicate_taskset_service.py
|
||||
app/services/shot_video_analysis_service.py
|
||||
app/services/shot_video_split_service.py
|
||||
app/services/sms.py
|
||||
app/services/system_config_cache.py
|
||||
app/services/team_invitation_service.py
|
||||
app/services/team_manager_service.py
|
||||
app/services/team_service.py
|
||||
app/services/upload_material_service.py
|
||||
app/services/upload_queue.py
|
||||
app/services/upload_video_asset_service.py
|
||||
app/services/user_oauth_account_service.py
|
||||
app/services/user_oauth_app_service.py
|
||||
app/services/user_oauth_service.py
|
||||
app/services/video_cover_service.py
|
||||
app/services/video_gen.py
|
||||
app/services/video_prompt_schema_config_service.py
|
||||
app/services/video_queue.py
|
||||
app/services/video_url.py
|
||||
app/services/admin_upload/__init__.py
|
||||
app/services/admin_upload/service.py
|
||||
app/services/admin_upload/storage.py
|
||||
app/services/api_v3/__init__.py
|
||||
app/services/api_v3/auth_service.py
|
||||
app/services/api_v3/engine_service.py
|
||||
app/services/api_v3/generation_service.py
|
||||
app/services/api_v3/key_service.py
|
||||
app/services/api_v3/pricing_service.py
|
||||
app/services/api_v3/quota_service.py
|
||||
app/services/api_v3/task_service.py
|
||||
app/services/api_v3/upscale_service.py
|
||||
app/services/api_v3/usage_log_service.py
|
||||
app/services/celery_runtime/__init__.py
|
||||
app/services/celery_runtime/recovery_service.py
|
||||
app/services/celery_runtime/runtime_service.py
|
||||
app/services/celery_runtime/worker_service.py
|
||||
app/services/generation/__init__.py
|
||||
app/services/generation/billing_service.py
|
||||
app/services/generation/download_service.py
|
||||
app/services/generation/history_delete_service.py
|
||||
app/services/generation/history_meta_service.py
|
||||
app/services/generation/log_service.py
|
||||
app/services/generation/media_reference_service.py
|
||||
app/services/generation/module_hook_service.py
|
||||
app/services/generation/poll_schedule_service.py
|
||||
app/services/generation/prompt_optimize_service.py
|
||||
app/services/generation/prompt_service.py
|
||||
app/services/generation/provider_service.py
|
||||
app/services/generation/recovery_service.py
|
||||
app/services/generation/refund_service.py
|
||||
app/services/generation/task_factory_service.py
|
||||
app/services/generation/ai/__init__.py
|
||||
app/services/generation/ai/engine_service.py
|
||||
app/services/generation/ai/image_batch_service.py
|
||||
app/services/generation/ai/service.py
|
||||
app/services/generation/ai/task_create_service.py
|
||||
app/services/generation/ai/task_group_service.py
|
||||
app/services/generation/pipeline/__init__.py
|
||||
app/services/generation/pipeline/db_lock_service.py
|
||||
app/services/generation/pipeline/enqueue_service.py
|
||||
app/services/generation/pipeline/generation_record_config_service.py
|
||||
app/services/generation/pipeline/generation_record_service.py
|
||||
app/services/generation/pipeline/lifecycle_service.py
|
||||
app/services/generation/pipeline/owner_service.py
|
||||
app/services/generation/pipeline/recovery_repository.py
|
||||
app/services/home_material/__init__.py
|
||||
app/services/home_material/query.py
|
||||
app/services/home_material/service.py
|
||||
app/services/home_material/storage.py
|
||||
app/services/home_material/watermark_processor.py
|
||||
app/services/llm_billing/__init__.py
|
||||
app/services/llm_billing/config.py
|
||||
app/services/llm_billing/context.py
|
||||
app/services/llm_billing/service.py
|
||||
app/services/module_generation_v2/__init__.py
|
||||
app/services/module_generation_v2/config.py
|
||||
app/services/module_generation_v2/dispatch_service.py
|
||||
app/services/module_generation_v2/flow_service.py
|
||||
app/services/module_generation_v2/media_service.py
|
||||
app/services/private_portrait/__init__.py
|
||||
app/services/private_portrait/ark_client.py
|
||||
app/services/private_portrait/asset_service.py
|
||||
app/services/private_portrait/project_service.py
|
||||
app/services/private_portrait/quota_service.py
|
||||
app/services/private_portrait/rate_limiter.py
|
||||
app/services/private_portrait/reference_resolver.py
|
||||
app/services/private_portrait/upload_service.py
|
||||
app/services/private_portrait/admin/__init__.py
|
||||
app/services/private_portrait/admin/asset_query_service.py
|
||||
app/services/private_portrait/admin/project_query_service.py
|
||||
app/services/private_portrait/admin/stats_query_service.py
|
||||
app/services/private_portrait/real_person/__init__.py
|
||||
app/services/private_portrait/real_person/service.py
|
||||
app/services/private_portrait/virtual/__init__.py
|
||||
app/services/private_portrait/virtual/service.py
|
||||
app/services/upload_resource/__init__.py
|
||||
app/services/upload_resource/accounting_service.py
|
||||
app/services/upload_resource/backfill_service.py
|
||||
app/services/upload_resource/bind_service.py
|
||||
app/services/upload_resource/capacity_service.py
|
||||
app/services/upload_resource/core_service.py
|
||||
app/services/upload_resource/delete_service.py
|
||||
app/services/upload_resource/file_cleanup_service.py
|
||||
app/services/upload_resource/history_service.py
|
||||
app/services/upload_resource/log_service.py
|
||||
app/services/upload_resource/path_resolver.py
|
||||
app/services/video_upscale/__init__.py
|
||||
app/services/video_upscale/config_service.py
|
||||
app/services/video_upscale/guard_service.py
|
||||
app/services/video_upscale/local_ffmpeg_service.py
|
||||
app/services/video_upscale/log_service.py
|
||||
app/services/video_upscale/media_service.py
|
||||
app/services/video_upscale/owner_service.py
|
||||
app/services/video_upscale/snapshot_service.py
|
||||
app/services/video_upscale/task_service.py
|
||||
app/services/video_upscale/volc_service.py
|
||||
app/tasks/__init__.py
|
||||
app/tasks/api_celery_app.py
|
||||
app/tasks/api_generation_tasks.py
|
||||
app/tasks/async_runner.py
|
||||
app/tasks/celery_app.py
|
||||
app/tasks/cleanup.py
|
||||
app/tasks/celery_runtime_tasks.py
|
||||
app/tasks/generation_create_tasks.py
|
||||
app/tasks/generation_download_tasks.py
|
||||
app/tasks/generation_poll_tasks.py
|
||||
@@ -181,19 +376,28 @@ app/tasks/generation_recovery_tasks.py
|
||||
app/tasks/hot_opening_replicate_tasks.py
|
||||
app/tasks/material_consumption_task.py
|
||||
app/tasks/module_async_recovery_tasks.py
|
||||
app/tasks/module_generation_v2_tasks.py
|
||||
app/tasks/pre_test_result_task.py
|
||||
app/tasks/private_portrait_asset_tasks.py
|
||||
app/tasks/shot_replicate_flow_tasks.py
|
||||
app/tasks/shot_replicate_tasks.py
|
||||
app/tasks/token_refresh_task.py
|
||||
app/tasks/user_oauth_tasks.py
|
||||
app/tasks/video_generation.py
|
||||
app/tasks/video_upscale_tasks.py
|
||||
app/types/__init__.py
|
||||
app/types/generation/__init__.py
|
||||
app/types/generation/provider.py
|
||||
app/utils/__init__.py
|
||||
app/utils/area.py
|
||||
app/utils/datetime_util.py
|
||||
app/utils/douyinApi.py
|
||||
app/utils/douyinRequest.py
|
||||
app/utils/exceptions.py
|
||||
app/utils/id_gen.py
|
||||
app/utils/kuaishouApi.py
|
||||
app/utils/kuaishouRequest.py
|
||||
app/utils/logger.py
|
||||
app/utils/media.py
|
||||
app/utils/redis.py
|
||||
app/utils/security.py
|
||||
videogen_api.egg-info/PKG-INFO
|
||||
|
||||
Reference in New Issue
Block a user