merge main

This commit is contained in:
2026-08-11 10:16:38 +08:00
156 changed files with 22362 additions and 1211 deletions
+6
View File
@@ -41,7 +41,10 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.contact_request import ContactRequest
from app.models.invoice import Invoice, InvoiceOrder
from app.models.invoice_header import InvoiceHeader
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
__all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
@@ -62,4 +65,7 @@ __all__ = [
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
"PrivatePortraitProject", "PrivatePortraitValidateSession",
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing",
"Invoice", "InvoiceOrder", "InvoiceHeader",
]
+15
View File
@@ -0,0 +1,15 @@
from app.models.api.api_key import ApiKey
from app.models.api.api_generation_task import ApiGenerationTask
from app.models.api.api_usage_log import ApiUsageLog
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_upscale_link import ApiUpscaleLink
from app.models.api.api_model_pricing import ApiModelPricing
__all__ = [
"ApiKey",
"ApiGenerationTask",
"ApiUsageLog",
"ApiKeyUpscaleConfig",
"ApiUpscaleLink",
"ApiModelPricing",
]
@@ -0,0 +1,125 @@
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class ApiGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
"""对外开放 API 的生成任务表。
该表设计满足 ProviderGenerationRecordLike 协议,
使现有的 Volcano Ark SDK 封装函数可以直接复用。
"""
__tablename__ = "api_generation_tasks"
__table_args__ = (
# 幂等键唯一索引
Index(
"uq_api_generation_tasks_key_idempotency",
"api_key_id",
"external_idempotency_key",
unique=True,
postgresql_where=text("deleted_at IS NULL AND external_idempotency_key IS NOT NULL"),
),
# 视频轮询调度索引
Index(
"idx_api_generation_tasks_next_poll_at",
"next_poll_at",
postgresql_where=text(
"deleted_at IS NULL "
"AND status = 'generating' "
"AND gen_type = 'video' "
"AND next_poll_at IS NOT NULL"
),
),
Index("idx_api_generation_tasks_api_key_created", "api_key_id", "created_at"),
Index("idx_api_generation_tasks_provider_task_id", "provider_task_id"),
Index("idx_api_generation_tasks_status", "status"),
CheckConstraint("generation_count BETWEEN 1 AND 5", name="ck_api_generation_tasks_generation_count"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True
)
external_idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
# === ProviderGenerationRecordLike 协议字段 ===
original_prompt: Mapped[str] = mapped_column(Text, nullable=False)
optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
gen_type: Mapped[str] = mapped_column(String(16), default="video", nullable=False)
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
provider_generation_resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
image_size: Mapped[str | None] = mapped_column(String(16), nullable=True)
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_px: Mapped[str | None] = mapped_column(String(16), nullable=True)
generation_count: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
model_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="模型名称")
media_references: Mapped[str | None] = mapped_column(Text, nullable=True, comment="用户原始上传的媒体URL")
local_media_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="下载到本地的媒体文件路径JSON")
engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# === 请求参数快照 ===
request_params_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="完整原始请求参数")
# === 流水线状态(镜像 ChatGenerationTask ===
status: Mapped[str] = mapped_column(String(32), default="pending", nullable=False)
pipeline_stage: Mapped[str | None] = mapped_column(String(32), nullable=True)
generation_attempt_no: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
resource_generation_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === 供应商交互 ===
provider_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
remote_result_url: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# === 结果 ===
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === 超分 ===
video_upscale_enabled_snapshot: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
video_upscale_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# === 配额消耗 ===
credits_cost: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0")
video_tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
image_tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# === 轮询控制 ===
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=30, server_default="30")
poll_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === Celery 执行租约(镜像 ChatGenerationTask ===
provider_create_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_create_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
provider_create_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
poll_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
poll_error_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
download_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
download_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
download_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
download_attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
download_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
download_storage_date_dir: Mapped[str | None] = mapped_column(String(16), nullable=True)
# === 存储 ===
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
from app.utils.security import encrypt_text, decrypt_text
class ApiKey(Base, TimestampMixin, SoftDeleteMixin):
"""对外开放 API 的密钥管理表。
每个 api-key 对应一个外部调用方(公司/组织),
可配置可调用模型、配额、有效期、并发限制。
"""
__tablename__ = "api_keys"
__table_args__ = (
Index("idx_api_keys_active", "is_active", postgresql_where=text("deleted_at IS NULL")),
Index("idx_api_keys_company", "company_name"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
company_name: Mapped[str] = mapped_column(String(128), nullable=False)
api_key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
api_key_prefix: Mapped[str] = mapped_column(String(16), nullable=False)
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False, comment="AES-256-GCM 加密的完整 API Key")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
def decrypt_api_key(self) -> str | None:
"""解密并返回完整 API Key。"""
return decrypt_text(self.api_key_encrypted)
def set_plaintext_key(self, plaintext: str) -> None:
"""设置明文 API Key(自动加密存储)。"""
self.api_key_encrypted = encrypt_text(plaintext)
# === 可调用模型配置 ===
callable_models: Mapped[str] = mapped_column(Text, nullable=False, server_default="[]",
comment='JSON数组: [{"engine_type":"video","engine_id":"xxx","model_name":"doubao-seedance-2-0-260128"}]')
# === 配额配置(不设置=无限制) ===
quota_limit: Mapped[float | None] = mapped_column(Float, nullable=True, comment="配额总量,NULL=无限")
quota_cycle: Mapped[str | None] = mapped_column(String(16), nullable=True, comment="daily|monthly|one_time|NULL=无限")
quota_used: Mapped[float] = mapped_column(Float, nullable=False, default=0.0, server_default="0.0", comment="当前周期已使用量")
# === 有效期(不设置=永不过期) ===
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# === 并发限制(不设置=无限制) ===
max_concurrent_video_tasks: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="最大并发视频任务数,NULL=无限")
# === 状态 ===
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,27 @@
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiKeyUpscaleConfig(Base, TimestampMixin):
"""API Key 级别的超分配置表。
每个 API Key 可独立配置超分规则,不依赖现有的 video_upscale 配置。
"""
__tablename__ = "api_key_upscale_configs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), unique=True, nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
delete_source_after_success: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
rules_json: Mapped[str] = mapped_column(
Text, nullable=False, server_default="[]",
comment='JSON数组: [{"target_resolution":"1080p","provider_generation_resolution":"720p","processor_key":"volc_standard_v1","enabled":true}]'
)
@@ -0,0 +1,40 @@
from sqlalchemy import Float, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiModelPricing(Base, TimestampMixin):
"""API 模型价格表(全局统一配置)。
完全镜像 credit_ratios 表结构,将积分字段替换为金额字段(元)。
所有 API Key 共用一套价格表。
model_config_id 兼容 credit_ratios 字段名约定:
- gen_type=image 时,该字段保存 image_engines.id
- gen_type=video 时,该字段保存 video_engines.id
"""
__tablename__ = "api_model_pricings"
__table_args__ = (
Index("ix_api_model_pricings_gen_type_engine_resolution", "gen_type", "model_config_id", "resolution"),
Index("ix_api_model_pricings_gen_type_resolution", "gen_type", "resolution"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
model_config_id: Mapped[str] = mapped_column(String(32), index=True)
gen_type: Mapped[str] = mapped_column(String(16), default="video", index=True)
resolution: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
# === 价格字段(元) ===
price_ratio: Mapped[float] = mapped_column(Float, nullable=False, default=1.0, comment="价格系数(乘数)")
base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="基础价格(元)")
per_second_price: Mapped[float] = mapped_column(Float, default=0.0, comment="每秒价格(视频,元)")
# === 传入媒体附加费 ===
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0, comment="传入视频系数")
input_video_base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入视频基础价(元)")
input_video_per_second_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入视频每秒价(元)")
input_image_ratio: Mapped[float] = mapped_column(Float, default=1.0, comment="传入图片系数")
input_image_base_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入图片基础价(元)")
input_image_per_image_price: Mapped[float] = mapped_column(Float, default=0.0, comment="传入图片每张价(元)")
@@ -0,0 +1,22 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiUpscaleLink(Base, TimestampMixin):
"""API 任务与超分任务的关联表。
由于不能修改现有的 video_upscale_tasks 表结构,
通过此关联表追踪 API 任务对应的超分子任务。
"""
__tablename__ = "api_upscale_links"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_generation_task_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_generation_tasks.id", ondelete="CASCADE"), nullable=False, index=True
)
video_upscale_task_id: Mapped[str] = mapped_column(
String(32), ForeignKey("video_upscale_tasks.id", ondelete="CASCADE"), nullable=False, index=True
)
@@ -0,0 +1,61 @@
from sqlalchemy import Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ApiUsageLog(Base, TimestampMixin):
"""API 调用详细消耗记录表。
记录每次 API 请求的完整消费信息,包括:
- 扣除金额和退回金额
- 模型详情(名称、分辨率、时长等)
- 对应的生成任务 ID
- 操作类型(扣除/退回)
"""
__tablename__ = "api_usage_logs"
__table_args__ = (
Index("idx_api_usage_logs_api_key_created", "api_key_id", "created_at"),
Index("idx_api_usage_logs_task_id", "api_generation_task_id"),
Index("idx_api_usage_logs_action", "price_action"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True
)
api_generation_task_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("api_generation_tasks.id", ondelete="SET NULL"), nullable=True
)
# === 操作类型 ===
price_action: Mapped[str] = mapped_column(String(16), nullable=False, comment="deduct=扣除, refund=退回")
# === 请求信息 ===
request_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="video_create|image_generate")
model_name: Mapped[str] = mapped_column(String(128), nullable=False)
gen_type: Mapped[str] = mapped_column(String(16), nullable=False)
resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
# === 金额信息 ===
credits_cost: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0", comment="实际扣除金额")
refund_amount: Mapped[float] = mapped_column(Float, default=0.0, server_default="0.0", comment="退回金额")
quota_before: Mapped[float | None] = mapped_column(Float, nullable=True, comment="操作前配额余额")
quota_after: Mapped[float | None] = mapped_column(Float, nullable=True, comment="操作后配额余额")
# === Token 用量 ===
tokens_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
request_duration_ms: Mapped[int] = mapped_column(Integer, default=0, server_default="0", comment="端到端耗时")
# === 价格明细(JSON ===
price_detail_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="价格计算明细JSON")
# === 结果 ===
status: Mapped[str] = mapped_column(String(32), nullable=False, comment="success|failed")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
# === 调试 ===
request_payload_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始请求快照")
+3 -3
View File
@@ -49,16 +49,16 @@ class Base(AsyncAttrs, DeclarativeBase):
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="更新时间"
)
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
DateTime(timezone=True), nullable=True, index=True, comment="软删除时间,NULL表示未删除"
)
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class Invoice(Base, TimestampMixin):
__tablename__ = "invoices"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
invoice_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
header_type: Mapped[str] = mapped_column(String(16), nullable=False)
header_name: Mapped[str] = mapped_column(String(128), nullable=False)
header_tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_register_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
header_register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
header_bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True)
email: Mapped[str] = mapped_column(String(128), nullable=False)
total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
total_credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="processing")
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
issued_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index('idx_invoices_user_created', 'user_id', 'created_at'),
Index('idx_invoices_status_created', 'status', 'created_at'),
)
class InvoiceOrder(Base, TimestampMixin):
__tablename__ = "invoice_orders"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
invoice_id: Mapped[str] = mapped_column(
String(32), ForeignKey("invoices.id", ondelete="CASCADE"), index=True
)
order_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), nullable=False)
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
__table_args__ = (
UniqueConstraint('invoice_id', 'order_id', name='uq_invoice_orders'),
Index('idx_invoice_orders_invoice', 'invoice_id'),
Index('idx_invoice_orders_order', 'order_id'),
)
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class InvoiceHeader(Base):
"""发票抬头表"""
__tablename__ = "invoice_headers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="主键")
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID"
)
type: Mapped[str] = mapped_column(String(16), nullable=False, comment="抬头类型: personal/company")
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="抬头名称")
tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="税号")
register_address: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="注册地址")
register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="注册电话")
bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="开户行")
bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="银行账号")
email: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="接收邮箱")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="更新时间"
)
__table_args__ = (
Index('idx_invoice_headers_user', 'user_id'),
)
@@ -34,6 +34,12 @@ class VideoUpscaleTask(Base, TimestampMixin):
ForeignKey("generation_records.id", ondelete="CASCADE"),
nullable=True,
)
api_generation_task_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("api_generation_tasks.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", server_default="pending")
stage: Mapped[str] = mapped_column(String(48), nullable=False, default="upscale_queued", server_default="upscale_queued")
@@ -0,0 +1,9 @@
from app.models.virtual_portrait_v3.api_key_quota import VpV3ApiKeyQuota
from app.models.virtual_portrait_v3.project import VpV3Project
from app.models.virtual_portrait_v3.asset import VpV3Asset
__all__ = [
"VpV3ApiKeyQuota",
"VpV3Project",
"VpV3Asset",
]
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class VpV3ApiKeyQuota(Base, TimestampMixin):
"""API V3 虚拟素材库配额(每个 ApiKey 一份,默认 0=不可用)。
配额在创建/删除项目、上传/删除素材时实时统计(直接 COUNT/SUM),
避免缓存不准;配额字段默认 0,后台管理配置后才可用。
"""
__tablename__ = "vp_v3_api_key_quotas"
__table_args__ = (
Index("uq_vp_v3_api_key_quotas_key_id", "api_key_id", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
comment="所属 API Key,唯一:一个 API Key 只有一份虚拟素材配额",
)
# 配额上限(默认 0 = 不可使用该功能)
project_limit: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="虚拟项目上限,默认 0 不可创建",
)
asset_limit: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="虚拟素材总数上限(图片+视频),默认 0 不可上传",
)
storage_mb_limit: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="上传存储上限 MB,默认 0 不可上传文件",
)
# 已使用量(冗余字段提升性能,每次增删同步,和真实 COUNT 不一致时以 COUNT 为准)
project_used: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="已创建项目数(未删除)",
)
asset_used: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="已上传素材数(未删除,图片+视频)",
)
storage_mb_used: Mapped[float] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
comment="已占用存储 MB(未删除文件大小合计,1MB=1024*1024",
)
remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment="后台备注")
@@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import (
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class VpV3Asset(Base, TimestampMixin, SoftDeleteMixin):
"""API V3 虚拟素材(图片/视频),归属某个 Project=火山 1 个 AssetGroup)。
字段语义和 private_portrait.PrivatePortraitAsset 保持一致,便于 service 层复用逻辑。
"""
__tablename__ = "vp_v3_assets"
__table_args__ = (
Index("uq_vp_v3_assets_remote_asset_id", "remote_asset_id", unique=True),
Index("idx_vp_v3_assets_key_status_created", "api_key_id", "status", "created_at"),
Index("idx_vp_v3_assets_project_status_created", "project_id", "status", "created_at"),
Index(
"idx_vp_v3_assets_next_poll_status",
"next_poll_at",
"status",
postgresql_where=text("deleted_at IS NULL AND next_poll_at IS NOT NULL"),
),
Index("idx_vp_v3_assets_remote_delete_status", "remote_delete_status"),
Index("idx_vp_v3_assets_asset_type", "asset_type"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True,
)
project_id: Mapped[str] = mapped_column(
String(32), ForeignKey("vp_v3_projects.id", ondelete="CASCADE"), nullable=False, index=True,
)
# 火山远端映射
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
# 素材元信息
asset_type: Mapped[str] = mapped_column(
String(16), nullable=False, default=PrivatePortraitAssetType.IMAGE.value, index=True,
comment="素材类型:Image=图片 / Video=视频",
)
name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
# 资源 URL
source_url: Mapped[str] = mapped_column(Text, nullable=False, comment="本地上传后的访问 URLUploadResource 返回的)")
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="给前端预览/显示用的 URL(签名 URL 可能过期)")
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山返回的资源访问 URL(可能带签名和过期)")
remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
upload_resource_id: Mapped[str | None] = mapped_column(
String(32), nullable=True, index=True, comment="本地 UploadResource 账本 resource_id(容量释放用)",
)
video_duration: Mapped[float | None] = mapped_column(Float, nullable=True, comment="视频时长,秒")
video_cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面预览")
file_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材文件大小,字节")
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
status: Mapped[str] = mapped_column(
String(32), nullable=False,
default=PrivatePortraitAssetStatus.CREATING.value,
server_default=PrivatePortraitAssetStatus.CREATING.value,
index=True,
comment="素材状态:creating/审核中 active/可用 failed/失败 deleting/删除中",
)
moderation_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山审核结果 JSON")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因")
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山原始响应 JSON")
# 轮询控制(异步审核)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
poll_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
# 远端删除
remote_delete_status: Mapped[str] = mapped_column(
String(32), nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -0,0 +1,86 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.private_portrait import (
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class VpV3Project(Base, TimestampMixin, SoftDeleteMixin):
"""API V3 虚拟素材项目(按 API Key 隔离)。
一个 VpV3Project 对应火山远端的 1 个 AssetGroup(一对一:这里不做 nested group)。
"""
__tablename__ = "vp_v3_projects"
__table_args__ = (
Index("idx_vp_v3_projects_key_status_created", "api_key_id", "status", "created_at"),
Index(
"idx_vp_v3_projects_key_deleted",
"api_key_id",
"deleted_at",
postgresql_where=text("deleted_at IS NULL"),
),
Index("idx_vp_v3_projects_remote_project_name", "remote_project_name"),
Index("idx_vp_v3_projects_remote_group_id", "remote_group_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(32), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, index=True,
comment="所属 API KeyV3 调用方)",
)
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="项目展示名称")
name_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="名称安全 slug(构建远端 GroupName 用)")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# 火山远端映射
remote_project_name: Mapped[str] = mapped_column(
String(256), nullable=False, index=True, comment="火山 ProjectName(快照)",
)
remote_group_id: Mapped[str] = mapped_column(
String(128), nullable=False, index=True, comment="火山 AssetGroup Id",
)
remote_group_name: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="火山 AssetGroup Name 快照")
status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitProjectStatus.ACTIVE.value,
server_default=PrivatePortraitProjectStatus.ACTIVE.value,
index=True,
comment="项目状态:active/creating_remote_group/create_group_failed/deleting",
)
# 计数
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
active_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
active_image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
active_video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
storage_mb_used: Mapped[float] = mapped_column(Integer, nullable=False, default=0, server_default="0",
comment="项目占用存储 MB(未删除素材文件大小合计)")
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 远端删除状态(沿用 private_portrait 枚举)
remote_delete_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default=PrivatePortraitRemoteDeleteStatus.NONE.value,
server_default=PrivatePortraitRemoteDeleteStatus.NONE.value,
index=True,
)
remote_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="创建失败等错误信息")
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="火山原始响应")