merge main

This commit is contained in:
2026-08-11 10:16:38 +08:00
156 changed files with 22362 additions and 1211 deletions
+12
View File
@@ -162,6 +162,18 @@ class AdminCreditRecordSummaryOut(BaseModel):
total_recharge: float = 0.0
total_consume: float = 0.0
total_refund: float = 0.0
# 消费类分解(仅 type=consume,不含 team_internal 团队内部转账;真实扣费 + 预扣占用 = total_consume
# - total_charge : 真实扣费 charge(含历史 NULL),对应"筛选明细类型=消费 且 action=charge/NULL"求和
# - total_hold : 预扣占用 hold
total_charge: float = 0.0
total_hold: float = 0.0
# 回退类分解(仅 type=refund;真实退款 + 预扣释放 = total_refund
# - total_refund_real : 真实退款 refund(含历史 NULL)
# - total_hold_release: 预扣释放 hold_release
total_refund_real: float = 0.0
total_hold_release: float = 0.0
# 净消耗 = max(total_consume - total_refund, 0) = 实际"用掉了"的积分
net_consume: float = 0.0
transaction_count: int = 0
generation_count: int = 0
generation_attempt_count: int = 0
@@ -0,0 +1,31 @@
from app.schemas.admin_api.api_key import (
ApiKeyCreateRequest,
ApiKeyUpdateRequest,
ApiKeyResponse,
ApiKeyCreateResponse,
ApiKeyListItem,
ApiKeyListOut,
)
from app.schemas.admin_api.api_upscale import (
ApiUpscaleConfigData,
ApiUpscaleConfigSaveRequest,
ApiUpscaleConfigResponse,
)
from app.schemas.admin_api.api_usage import (
ApiUsageLogResponse,
ApiUsageSummaryResponse,
)
__all__ = [
"ApiKeyCreateRequest",
"ApiKeyUpdateRequest",
"ApiKeyResponse",
"ApiKeyCreateResponse",
"ApiKeyListItem",
"ApiKeyListOut",
"ApiUpscaleConfigData",
"ApiUpscaleConfigSaveRequest",
"ApiUpscaleConfigResponse",
"ApiUsageLogResponse",
"ApiUsageSummaryResponse",
]
@@ -0,0 +1,156 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
# 保留供其他地方使用
def _empty_to_null(value):
"""将空字符串转为 None,避免 Pydantic 校验失败。"""
if value == "" or value == "null":
return None
return value
class ApiKeyCallableModel(BaseModel):
"""API Key 可调用模型配置。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
engine_type: str = Field(..., description="video | image", alias="engineType")
engine_id: str = Field(..., description="引擎ID", alias="engineId")
model_name: str = Field(..., description="模型名称", alias="modelName")
class ApiKeyCreateRequest(BaseModel):
"""创建 API Key 请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
company_name: str = Field(..., max_length=128, description="公司名称", alias="companyName")
description: str | None = Field(None, description="备注")
callable_models: list[ApiKeyCallableModel] = Field(
default_factory=list, description="可调用模型列表", alias="callableModels",
)
quota_limit: float | None = Field(None, description="配额总量,NULL=无限", alias="quotaLimit")
quota_cycle: str | None = Field(None, description="daily | monthly | one_time | NULL=无限", alias="quotaCycle")
valid_from: datetime | None = Field(None, description="生效时间", alias="validFrom")
valid_until: datetime | None = Field(None, description="过期时间", alias="validUntil")
max_concurrent_video_tasks: int | None = Field(
None, description="最大并发视频任务数", alias="maxConcurrentVideoTasks",
)
@field_validator("valid_from", "valid_until", mode="before")
@classmethod
def empty_str_to_none(cls, v):
if v == "" or v == "null" or v == 0:
return None
return v
class ApiKeyUpdateRequest(BaseModel):
"""更新 API Key 请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
company_name: str | None = Field(None, max_length=128, alias="companyName")
description: str | None = None
callable_models: list[ApiKeyCallableModel] | None = Field(None, alias="callableModels")
quota_limit: float | None = Field(None, alias="quotaLimit")
quota_cycle: str | None = Field(None, alias="quotaCycle")
valid_from: datetime | None = Field(None, alias="validFrom")
valid_until: datetime | None = Field(None, alias="validUntil")
max_concurrent_video_tasks: int | None = Field(None, alias="maxConcurrentVideoTasks")
is_active: bool | None = Field(None, alias="isActive")
@field_validator("valid_from", "valid_until", mode="before")
@classmethod
def empty_str_to_none(cls, v):
if v == "" or v == "null" or v == 0:
return None
return v
class ApiKeyResponse(BaseModel):
"""API Key 详情响应。"""
id: str
company_name: str
api_key_prefix: str = Field(..., description="Key 前缀,如 vk_xxxx****")
description: str | None
callable_models: list[ApiKeyCallableModel]
quota_limit: float | None
quota_cycle: str | None
quota_used: float
valid_from: datetime | None
valid_until: datetime | None
max_concurrent_video_tasks: int | None
is_active: bool
last_used_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ApiKeyCreateResponse(BaseModel):
"""创建 API Key 响应(包含完整明文 Key,仅此一次)。"""
id: str
company_name: str
api_key: str = Field(..., description="完整 API Key,仅创建时返回一次")
api_key_prefix: str
valid_until: datetime | None
created_at: datetime
class ApiKeyRevealResponse(BaseModel):
"""揭秘 API Key 响应(随时可获取明文)。"""
id: str
company_name: str
api_key: str = Field(..., description="完整 API Key")
api_key_prefix: str
class ApiKeyListItem(BaseModel):
"""API Key 列表项。"""
id: str
company_name: str
api_key_prefix: str
description: str | None
callable_models: list[ApiKeyCallableModel] = []
quota_limit: float | None
quota_cycle: str | None
quota_used: float
is_active: bool
valid_from: datetime | None
valid_until: datetime | None
max_concurrent_video_tasks: int | None
last_used_at: datetime | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ApiKeyQuotaAdjustRequest(BaseModel):
"""配额调整请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
action: str = Field(
...,
pattern=r"^(adjust|reset_usage|set_limit|change_cycle)$",
description="adjust=增加总额 | reset_usage=重置已用 | set_limit=设置限额 | change_cycle=修改周期",
)
quota_limit_delta: float | None = Field(None, ge=0, description="增加总额时的增量", alias="quotaLimitDelta")
quota_limit: float | None = Field(None, description="设置新限额时的值(NULL=无限)", alias="quotaLimit")
quota_cycle: str | None = Field(None, description="修改周期时的值", alias="quotaCycle")
reason: str | None = Field(None, max_length=500, description="调整原因/备注")
class ApiKeyListOut(BaseModel):
"""API Key 列表响应。"""
total: int
items: list[ApiKeyListItem]
@@ -0,0 +1,33 @@
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import NaiveDatetime
class ApiModelPricingCreate(BaseModel):
"""创建 API 模型价格请求。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
model_config_id: str = Field(
..., max_length=32, description="引擎ID", alias="modelConfigId",
)
gen_type: str = Field(default="video", max_length=16, description="image | video", alias="genType")
resolution: str = Field(..., max_length=16, description="分辨率")
price_ratio: float = Field(default=1.0, gt=0, description="价格系数", alias="priceRatio")
base_price: float = Field(default=0.0, ge=0, description="基础价格(元)", alias="basePrice")
per_second_price: float = Field(default=0.0, ge=0, description="每秒价格(元)", alias="perSecondPrice")
input_video_ratio: float = Field(default=1.0, ge=0, description="传入视频系数", alias="inputVideoRatio")
input_video_base_price: float = Field(default=0.0, ge=0, description="传入视频基础价(元)", alias="inputVideoBasePrice")
input_video_per_second_price: float = Field(default=0.0, ge=0, description="传入视频每秒价(元)", alias="inputVideoPerSecondPrice")
input_image_ratio: float = Field(default=1.0, ge=0, description="传入图片系数", alias="inputImageRatio")
input_image_base_price: float = Field(default=0.0, ge=0, description="传入图片基础价(元)", alias="inputImageBasePrice")
input_image_per_image_price: float = Field(default=0.0, ge=0, description="传入图片每张价(元)", alias="inputImagePerImagePrice")
class ApiModelPricingOut(ApiModelPricingCreate):
"""API 模型价格响应。"""
id: str
created_at: NaiveDatetime
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
@@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict, Field
class ApiUpscaleRule(BaseModel):
"""API 超分规则。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
target_resolution: str = Field(..., description="目标分辨率: 480p | 720p | 1080p | 2K | 4K", alias="targetResolution")
provider_generation_resolution: str = Field(..., description="供应商生成分辨率", alias="providerGenerationResolution")
processor_key: str = Field(
...,
description="处理器: local_ffmpeg_crop_v1 | volc_standard_v1 | volc_professional_v1 | volc_large_model_v1",
alias="processorKey",
)
enabled: bool = True
class ApiUpscaleConfigData(BaseModel):
"""API 超分配置数据。支持 camelCase 和 snake_case 两种字段名。"""
model_config = ConfigDict(populate_by_name=True)
enabled: bool = False
delete_source_after_success: bool = Field(True, alias="deleteSourceAfterSuccess")
rules: list[ApiUpscaleRule] = Field(default_factory=list)
class ApiUpscaleConfigSaveRequest(BaseModel):
"""保存 API 超分配置请求。"""
data: ApiUpscaleConfigData
class ApiUpscaleConfigResponse(BaseModel):
"""API 超分配置响应。"""
data: ApiUpscaleConfigData
@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel
class ApiUsageLogResponse(BaseModel):
"""API 使用日志响应。"""
id: str
api_key_id: str
api_generation_task_id: str | None
request_type: str
model_name: str
gen_type: str
credits_cost: float
tokens_used: int
request_duration_ms: int
status: str
error_message: str | None
error_code: str | None
created_at: datetime
class Config:
from_attributes = True
class ApiUsageSummaryResponse(BaseModel):
"""API 使用汇总响应。"""
total_requests: int
total_credits_cost: float
total_tokens_used: int
success_count: int
failed_count: int
avg_duration_ms: int
total: int = 0
page: int = 1
page_size: int = 20
items: list[ApiUsageLogResponse]
@@ -0,0 +1,33 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3QuotaConfigData(BaseModel):
"""后台保存虚拟素材库配额。"""
project_limit: int = Field(0, ge=0, description="虚拟项目上限,0=不可创建")
asset_limit: int = Field(0, ge=0, description="虚拟素材总数上限,0=不可上传")
storage_mb_limit: int = Field(0, ge=0, description="存储上限 MB0=不可上传文件")
remark: str | None = Field(None, max_length=500, description="后台备注")
class VpV3QuotaConfigResponse(BaseModel):
"""虚拟素材库配额响应。"""
model_config = ConfigDict(extra="ignore")
api_key_id: str = Field(description="API Key ID")
# 上限
project_limit: int = Field(0, description="虚拟项目上限")
asset_limit: int = Field(0, description="虚拟素材上限")
storage_mb_limit: int = Field(0, description="存储上限 MB")
remark: str | None = Field(None, description="备注")
# 已使用
project_used: int = Field(0, description="已创建项目数")
asset_used: int = Field(0, description="已上传素材数")
storage_mb_used: float = Field(0.0, description="已使用存储 MB")
enabled: bool = Field(False, description="是否启用(任一上限 > 0")
@@ -0,0 +1,31 @@
from app.schemas.api_v3.video import (
ApiVideoContentPart,
ApiVideoCreateRequest,
ApiVideoCreateResponse,
ApiVideoStatusResponse,
)
from app.schemas.api_v3.image import (
ApiImageGenerateRequest,
ApiImageGenerateResponse,
)
from app.schemas.api_v3.model import (
ApiModelInfo,
ApiModelsResponse,
)
from app.schemas.api_v3.common import (
ApiError,
ApiErrorResponse,
)
__all__ = [
"ApiVideoContentPart",
"ApiVideoCreateRequest",
"ApiVideoCreateResponse",
"ApiVideoStatusResponse",
"ApiImageGenerateRequest",
"ApiImageGenerateResponse",
"ApiModelInfo",
"ApiModelsResponse",
"ApiError",
"ApiErrorResponse",
]
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class ApiError(BaseModel):
"""API 错误详情。"""
code: str
message: str
class ApiErrorResponse(BaseModel):
"""API 错误响应。"""
error: ApiError
+34
View File
@@ -0,0 +1,34 @@
from pydantic import BaseModel, Field
class ApiImageGenerateRequest(BaseModel):
"""图片生成请求。支持全量 Volcano Ark SDK 参数。"""
model: str = Field(..., description="模型名称, 如 doubao-seedream-5-0-260128")
prompt: str = Field(..., description="图片描述提示词")
size: str | None = Field("2K", description="图片尺寸: 2K | 4K 或 2048x2048")
response_format: str | None = Field("url", description="返回格式: url | b64_json")
watermark: bool | None = Field(False, description="是否添加水印")
image: list[str] | None = Field(None, description="参考图片URL列表")
output_format: str | None = Field(None, description="输出格式: jpeg | png | webp")
sequential_image_generation: str | None = Field(
None, description="组图模式: auto 开启"
)
generation_count: int | None = Field(1, ge=1, le=5, description="生成数量: 1-5")
class ApiImageGenerateDataItem(BaseModel):
"""单张图片结果。"""
url: str | None = None
b64_json: str | None = None
size: str | None = None
output_format: str | None = None
class ApiImageGenerateResponse(BaseModel):
"""图片生成响应(同步返回)。"""
created: int
data: list[ApiImageGenerateDataItem]
model: str
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel, Field
class ApiModelInfo(BaseModel):
"""可用模型信息。"""
model: str = Field(..., description="模型名称")
engine_type: str = Field(..., description="引擎类型: video | image")
engine_id: str = Field(..., description="引擎ID")
supported_ratios: list[str] | None = None
supported_resolutions: list[str] | None = None
supported_durations: list[int] | None = None
supported_sizes: list[str] | None = None
class ApiModelsResponse(BaseModel):
"""可用模型列表响应。"""
models: list[ApiModelInfo]
+135
View File
@@ -0,0 +1,135 @@
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class ApiVideoContentPart(BaseModel):
"""视频生成内容部分:文本/图片/视频/音频参考。"""
type: str = Field(..., description="内容类型: text | image_url | video_url | audio_url")
text: str | None = None
image_url: dict | None = Field(None, description="图片URL对象: {\"url\": \"...\"}")
video_url: dict | None = Field(None, description="视频URL对象: {\"url\": \"...\"}")
audio_url: dict | None = Field(None, description="音频URL对象: {\"url\": \"...\"}")
role: str | None = Field(
None,
description="参考角色: first_frame | last_frame | reference_image | reference_video | reference_audio",
)
@field_validator("type")
@classmethod
def validate_type(cls, v):
allowed = {"text", "image_url", "video_url", "audio_url"}
if v not in allowed:
raise ValueError(f"type 必须是 {allowed} 之一,当前值: {v}")
return v
@field_validator("role")
@classmethod
def validate_role(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
role_map = {
"image_url": {"first_frame", "last_frame", "reference_image"},
"video_url": {"reference_video"},
"audio_url": {"reference_audio"},
}
allowed_roles = role_map.get(type_value, set())
if v not in allowed_roles:
raise ValueError(
f"type={type_value} 时 role 必须是 {allowed_roles} 之一,当前值: {v}"
)
return v
@field_validator("image_url")
@classmethod
def validate_image_url(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
if type_value == "image_url" and (not v or not v.get("url")):
raise ValueError("type=image_url 时 image_url.url 不能为空")
return v
@field_validator("video_url")
@classmethod
def validate_video_url(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
if type_value == "video_url" and (not v or not v.get("url")):
raise ValueError("type=video_url 时 video_url.url 不能为空")
return v
@field_validator("audio_url")
@classmethod
def validate_audio_url(cls, v, info):
if v is None:
return v
type_value = info.data.get("type")
if type_value == "audio_url" and (not v or not v.get("url")):
raise ValueError("type=audio_url 时 audio_url.url 不能为空")
return v
class ApiVideoCreateRequest(BaseModel):
"""视频生成请求。支持全量 Volcano Ark SDK 参数。"""
model: str = Field(..., description="模型名称, 如 doubao-seedance-2-0-260128")
content: list[ApiVideoContentPart] = Field(
..., min_length=1, description="生成内容: 文本提示词 + 可选的图片/视频/音频参考"
)
ratio: str | None = Field("16:9", description="视频比例: 16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9")
duration: int | None = Field(5, ge=3, le=30, description="视频时长(秒): 3-30")
resolution: str | None = Field("480p", description="分辨率: 480p | 720p | 1080p")
generate_audio: bool | None = Field(True, description="是否生成音频")
watermark: bool | None = Field(False, description="是否添加水印")
idempotency_key: str | None = Field(None, description="幂等键,防止重复创建")
@field_validator("ratio")
@classmethod
def validate_ratio(cls, v):
if v is None:
return v
allowed = {"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}
if v not in allowed:
raise ValueError(f"ratio 必须是 {allowed} 之一,当前值: {v}")
return v
@field_validator("resolution")
@classmethod
def validate_resolution(cls, v):
if v is None:
return v
allowed = {"480p", "720p", "1080p"}
if v not in allowed:
raise ValueError(f"resolution 必须是 {allowed} 之一,当前值: {v}")
return v
class ApiVideoCreateResponse(BaseModel):
"""视频任务创建响应。"""
id: str = Field(..., description="任务ID")
class ApiVideoContent(BaseModel):
"""视频内容(成功时返回)。"""
video_url: str = Field(..., description="视频URL")
class ApiVideoStatusResponse(BaseModel):
"""视频任务状态查询响应。"""
id: str = Field(..., description="任务ID")
model: str = Field(..., description="模型名称")
status: str = Field(..., description="任务状态: queued | running | succeeded | failed | expired")
created_at: int = Field(..., description="创建时间戳(Unix)")
updated_at: int = Field(..., description="更新时间戳(Unix)")
content: ApiVideoContent | None = Field(None, description="视频内容(成功时返回)")
duration: int | None = Field(None, description="视频时长(秒)")
ratio: str | None = Field(None, description="视频比例")
resolution: str | None = Field(None, description="分辨率")
error: str | None = Field(None, description="错误信息(失败时返回)")
+135
View File
@@ -0,0 +1,135 @@
import re
from typing import Any
from pydantic import BaseModel, Field, model_validator
from app.schemas.common import NaiveDatetimeOptional
_EMAIL_REGEX = re.compile(r"^[\w.\-]+@[\w.\-]+\.\w+$")
class InvoiceCreateRequest(BaseModel):
"""创建发票请求。"""
header_type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
header_name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
header_tax_no: str | None = Field(None, max_length=32, description="税号")
header_register_address: str | None = Field(None, max_length=256, description="注册地址")
header_register_phone: str | None = Field(None, max_length=32, description="注册电话")
header_bank_name: str | None = Field(None, max_length=128, description="开户行")
header_bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str = Field(..., max_length=128, description="电子邮箱(必填)")
order_ids: list[str] = Field(..., min_length=1, description="订单ID列表")
@model_validator(mode="after")
def validate_email(self) -> "InvoiceCreateRequest":
if not _EMAIL_REGEX.match(self.email):
raise ValueError("邮箱格式不正确")
return self
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceCreateRequest":
if self.header_type == "company" and not self.header_tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceStatusUpdateRequest(BaseModel):
"""更新发票状态请求。"""
status: str = Field(..., pattern="^(success|failed)$", description="目标状态")
failure_reason: str | None = Field(None, max_length=500, description="失败原因")
@model_validator(mode="after")
def validate_failure_reason(self) -> "InvoiceStatusUpdateRequest":
if self.status == "failed" and not self.failure_reason:
raise ValueError("开具失败时必须填写失败原因")
return self
class InvoiceOrderOut(BaseModel):
"""发票关联订单响应。"""
model_config = {"from_attributes": True}
id: str
invoice_id: str
order_id: str
order_no: str
amount: float
credits: float
class InvoiceOut(BaseModel):
"""发票响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
invoice_no: str
header_type: str
header_name: str
header_tax_no: str | None = None
header_register_address: str | None = None
header_register_phone: str | None = None
header_bank_name: str | None = None
header_bank_account: str | None = None
email: str
total_amount: float
total_credits: float
status: str
failure_reason: str | None = None
issued_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
orders: list[InvoiceOrderOut] = []
# ── 发票抬头 ──────────────────────────────────────────────
class InvoiceHeaderCreate(BaseModel):
"""创建发票抬头请求。"""
type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool = Field(False, description="是否设为默认")
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceHeaderCreate":
if self.type == "company" and not self.tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceHeaderUpdate(BaseModel):
"""更新发票抬头请求。"""
name: str | None = Field(None, min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool | None = Field(None, description="是否设为默认")
class InvoiceHeaderOut(BaseModel):
"""发票抬头响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
type: str
name: str
tax_no: str | None = None
register_address: str | None = None
register_phone: str | None = None
bank_name: str | None = None
bank_account: str | None = None
email: str | None = None
is_default: bool = False
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
+2 -2
View File
@@ -11,11 +11,11 @@ class VideoEngineCreate(BaseModel):
model_name: str = Field(default="", max_length=128)
supported_ratios: str = Field(default='["16:9","4:3","1:1","3:4","9:16","21:9"]')
supported_resolutions: str = Field(default='["480p","720p","1080p"]')
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15]')
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]')
max_duration: int = Field(default=15)
max_image_count: int = Field(default=2)
max_video_count: int = Field(default=0)
max_audio_count: int = Field(default=0, ge=0, le=3, description="最大参考音频数量,0 表示不支持音频参考")
max_audio_count: int = Field(default=0, description="最大参考音频数量,0 表示不支持音频参考")
multi_generation_enabled: bool = Field(
default=False,
description="是否允许客户端选择生成多个视频;关闭时客户端只能选择 1 份",
@@ -0,0 +1,37 @@
from app.schemas.virtual_portrait_v3.common import VpV3EnumMeta
from app.schemas.virtual_portrait_v3.quota import VpV3QuotaConfigOut
from app.schemas.virtual_portrait_v3.project import (
VpV3IdOut,
VpV3ProjectCreate,
VpV3ProjectDeleteOut,
VpV3ProjectListOut,
VpV3ProjectOut,
VpV3ProjectUpdate,
)
from app.schemas.virtual_portrait_v3.asset import (
VpV3AssetCreate,
VpV3AssetDeleteOut,
VpV3AssetListOut,
VpV3AssetOut,
VpV3SelectableAssetListOut,
VpV3SelectableAssetOut,
)
from app.schemas.virtual_portrait_v3.upload import VpV3UploadOut
__all__ = [
"VpV3EnumMeta",
"VpV3QuotaConfigOut",
"VpV3IdOut",
"VpV3ProjectCreate",
"VpV3ProjectUpdate",
"VpV3ProjectOut",
"VpV3ProjectListOut",
"VpV3ProjectDeleteOut",
"VpV3AssetCreate",
"VpV3AssetOut",
"VpV3AssetListOut",
"VpV3AssetDeleteOut",
"VpV3SelectableAssetOut",
"VpV3SelectableAssetListOut",
"VpV3UploadOut",
]
@@ -0,0 +1,106 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class VpV3AssetCreate(BaseModel):
"""在项目下创建虚拟素材请求(一步到位:接收远程 URL 先下载到本地,再同步火山)。"""
source_url: str = Field(
...,
min_length=8,
max_length=2000,
description=(
"素材源 URL(必须 http/https 公网可访问的图片/视频直链,"
"系统先将其下载保存到本地存储并占用存储配额,再同步到火山)"
),
)
name: str | None = Field(
None, max_length=100, description="素材名称(可选;不传则自动从 URL 文件名 / Content-Disposition 推断)",
)
asset_type: str = Field(
..., pattern=r"^(Image|Video)$", description="素材类型:Image=图片 / Video=视频",
)
video_duration: float | None = Field(
None, description="视频时长,秒(Video 可选;不传时系统自动用 ffprobe 探测;最大 60 秒)",
)
video_cover_url: str | None = Field(
None, description="视频封面图 URL(可选,仅 Video 用,建议 16:9)",
)
class VpV3AssetOut(BaseModel):
"""虚拟素材详情响应。"""
model_config = ConfigDict(extra="ignore")
asset_id: str = Field(description="素材 ID")
project_id: str = Field(description="所属项目 ID")
name: str | None = Field(None, description="素材名称")
asset_type: str = Field(description="素材类型:Image/Video")
status: str = Field(description="素材状态")
# 显示 URL
source_url: str = Field(description="原始上传 URL")
preview_url: str | None = Field(None, description="显示用预览 URL(可能带签名过期)")
remote_url: str | None = Field(None, description="火山返回的资源访问 URL(可能带签名过期)")
remote_url_expired_at: datetime | None = Field(None, description="remote_url 过期时间")
video_duration: float | None = Field(None, description="视频时长秒")
video_cover_url: str | None = Field(None, description="视频封面")
file_size_bytes: int | None = Field(None, description="文件大小字节")
mime_type: str | None = Field(None, description="MIME 类型")
moderation_json: dict | None = Field(None, description="火山审核 JSON(失败时可查看原因)")
error_message: str | None = Field(None, description="失败原因")
remote_delete_status: str = Field("none", description="远端删除状态")
created_at: datetime = Field(description="创建时间")
updated_at: datetime = Field(description="最后更新时间")
class VpV3AssetListOut(BaseModel):
"""虚拟素材列表响应。"""
items: list[VpV3AssetOut] = Field(default_factory=list)
total: int = Field(0, description="总数")
page: int = Field(1, description="当前页码")
page_size: int = Field(20, description="每页数量")
class VpV3AssetDeleteOut(BaseModel):
"""删除响应。"""
success: bool = Field(True)
remote_delete_status: str = Field(description="远端删除状态")
class VpV3SelectableAssetOut(BaseModel):
"""AI 创作选择器使用的素材条目。"""
model_config = ConfigDict(extra="ignore")
asset_id: str = Field(description="素材 ID(带入生成用 source=vp_v3_asset + asset_id")
project_id: str = Field(description="项目 ID")
name: str | None = Field(None)
asset_type: str = Field(description="Image/Video")
status: str = Field(description="状态=Active")
source_url: str = Field(description="原始上传 URL")
preview_url: str | None = Field(None, description="预览 URL(直接显示用)")
video_duration: float | None = Field(None)
video_cover_url: str | None = Field(None)
file_size_bytes: int | None = Field(None)
created_at: datetime = Field(description="创建时间")
class VpV3SelectableAssetListOut(BaseModel):
"""AI 创作选择器素材列表。"""
items: list[VpV3SelectableAssetOut] = Field(default_factory=list)
total: int = Field(0)
page: int = Field(1)
page_size: int = Field(20)
@@ -0,0 +1,14 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3EnumMeta(BaseModel):
"""虚拟素材库枚举元数据。"""
model_config = ConfigDict(extra="ignore")
asset_type: dict[str, str] = Field(description="素材类型:Image=图片 / Video=视频")
asset_status: dict[str, str] = Field(description="素材状态:creating/审核中 active/可用 failed/失败")
project_status: dict[str, str] = Field(description="项目状态:active/creating_remote_group/create_group_failed/deleting")
remote_delete_status: dict[str, str] = Field(description="远端删除状态:none/pending/processing/deleted/failed")
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class VpV3ProjectCreate(BaseModel):
"""创建虚拟素材项目请求。"""
name: str = Field(..., min_length=1, max_length=100, description="项目名称,1-100 字符")
description: str | None = Field(None, max_length=500, description="项目描述,最多 500 字符")
class VpV3ProjectUpdate(BaseModel):
"""更新虚拟素材项目请求。"""
name: str | None = Field(None, min_length=1, max_length=100, description="项目名称,1-100 字符")
description: str | None = Field(None, max_length=500, description="项目描述,最多 500 字符")
class VpV3ProjectOut(BaseModel):
"""虚拟素材项目详情响应。"""
model_config = ConfigDict(extra="ignore")
project_id: str = Field(description="项目 ID")
name: str = Field(description="项目名称")
description: str | None = Field(None, description="项目描述")
status: str = Field(description="项目状态")
# 计数
asset_count: int = Field(0, description="素材总数(含失败、删除中)")
active_asset_count: int = Field(0, description="可用素材数(status=active")
image_asset_count: int = Field(0, description="图片素材数")
video_asset_count: int = Field(0, description="视频素材数")
storage_mb_used: float = Field(0, description="已占用存储 MB")
remote_delete_status: str = Field("none", description="远端删除状态")
error_message: str | None = Field(None, description="最近一次错误信息")
created_at: datetime = Field(description="创建时间")
updated_at: datetime = Field(description="最后更新时间")
class VpV3ProjectListOut(BaseModel):
"""虚拟素材项目列表响应。"""
items: list[VpV3ProjectOut] = Field(default_factory=list)
total: int = Field(0, description="总数")
page: int = Field(1, ge=1, description="当前页码")
page_size: int = Field(20, ge=1, description="每页数量")
class VpV3ProjectDeleteOut(BaseModel):
"""删除响应。"""
success: bool = Field(True)
remote_delete_status: str = Field(description="远端删除状态:none/pending/...")
class VpV3IdOut(BaseModel):
"""创建接口的简单 ID 响应。"""
Id: str = Field(description="资源 ID")
@@ -0,0 +1,21 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3QuotaConfigOut(BaseModel):
"""当前 API Key 的虚拟素材配额&已使用量。"""
model_config = ConfigDict(extra="ignore")
# 上限
project_limit: int = Field(0, description="虚拟项目上限,0=不可创建")
asset_limit: int = Field(0, description="虚拟素材总数上限,0=不可上传")
storage_mb_limit: int = Field(0, description="上传存储上限 MB0=不可上传文件")
# 已使用
project_used: int = Field(0, description="已创建项目数(未删除)")
asset_used: int = Field(0, description="已上传素材数(未删除,图片+视频)")
storage_mb_used: float = Field(0, description="已占用存储 MB(未删除文件大小合计)")
enabled: bool = Field(False, description="该 API Key 是否可使用虚拟素材库功能(任一上限 > 0 即可)")
@@ -0,0 +1,16 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class VpV3UploadOut(BaseModel):
"""上传文件响应(写入 UploadResource 账本后返回)。"""
model_config = ConfigDict(extra="ignore")
url: str = Field(description="上传后的访问 URL,创建素材时作为 source_url 传入")
filename: str = Field(description="文件名")
type: str = Field(description="资源类型:Image/Video")
resource_id: str = Field(description="UploadResource 的 resource_id,创建素材时请回传 upload_resource_id")
file_size_bytes: int = Field(0, description="文件大小字节")
duration_seconds: float | None = Field(None, description="视频时长秒(Video 上传返回)")