feat: initialize aivideo project

This commit is contained in:
2026-04-17 18:33:05 +08:00
commit 14b18d67fe
162 changed files with 26251 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
from __future__ import annotations
from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.common.db.session import SessionLocal, engine
from app.common.security.password import hash_password
from app.common.utils.id_gen import new_invite_code, new_public_id
from app.models import Base
from app.models.entities import (
AdminUser,
GrowthRewardRule,
PaymentChannel,
PricingRule,
ProviderAccount,
ProviderModel,
RechargePlan,
RedeemCode,
SystemConfig,
VideoModel,
VideoModelSupplierBinding,
)
def init_database() -> None:
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
seed_defaults(db)
def seed_defaults(db: Session) -> None:
from app.common.config.settings import get_settings
settings = get_settings()
admin = db.scalar(select(AdminUser).where(AdminUser.username == settings.admin_username))
if not admin:
admin = AdminUser(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
nickname=settings.admin_nickname,
is_super_admin=True,
status=1,
)
db.add(admin)
for rule_type, trigger, points in [
("signup_reward", "on_register", 300),
("invite_reward", "on_first_consume", 500),
]:
rule = db.scalar(select(GrowthRewardRule).where(GrowthRewardRule.rule_type == rule_type))
if not rule:
db.add(
GrowthRewardRule(
rule_type=rule_type,
enabled=True,
reward_points=points,
trigger_condition=trigger,
min_consume_points=settings.invite_reward_min_consume_points,
remark=rule_type,
)
)
for channel_code, channel_name, provider_type, sort_order in [
("alipay", "支付宝", "manual", 10),
("wechat_pay", "微信支付", "manual", 20),
]:
channel = db.scalar(
select(PaymentChannel).where(PaymentChannel.channel_code == channel_code)
)
if not channel:
db.add(
PaymentChannel(
channel_code=channel_code,
channel_name=channel_name,
provider_type=provider_type,
status=1,
sort_order=sort_order,
)
)
if not db.scalar(select(RechargePlan.id)):
db.add_all(
[
RechargePlan(
name="体验包",
pay_amount=Decimal("29.90"),
point_ratio=100,
give_points=2990,
bonus_points=200,
sort_order=10,
status=1,
),
RechargePlan(
name="标准包",
pay_amount=Decimal("99.00"),
point_ratio=100,
give_points=9900,
bonus_points=1200,
sort_order=20,
status=1,
),
RechargePlan(
name="专业包",
pay_amount=Decimal("299.00"),
point_ratio=100,
give_points=29900,
bonus_points=4500,
sort_order=30,
status=1,
),
]
)
if not db.scalar(select(ProviderAccount.id)):
openai_account = ProviderAccount(
provider_code="openai-mock",
provider_name="OpenAI Mock",
api_format="openai_official_video",
base_url="mock://openai",
api_key_encrypted="mock",
timeout_seconds=60,
max_retries=3,
status=1,
)
seedance_account = ProviderAccount(
provider_code="seedance-mock",
provider_name="Seedance Mock",
api_format="seedance_video_generation",
base_url="mock://seedance",
api_key_encrypted="mock",
timeout_seconds=60,
max_retries=3,
status=1,
)
db.add_all([openai_account, seedance_account])
db.flush()
openai_model = ProviderModel(
provider_account_id=openai_account.id,
model_code="sora-2",
model_name="Sora 2",
request_content_type="multipart/form-data",
supports_text_to_video=True,
supports_image_to_video=True,
supports_generate_audio=True,
supports_webhook=True,
min_duration=4,
max_duration=12,
default_ratio="16:9",
default_resolution="1280x720",
status=1,
)
seedance_model = ProviderModel(
provider_account_id=seedance_account.id,
model_code="seedance",
model_name="Seedance",
request_content_type="application/json",
supports_text_to_video=True,
supports_image_to_video=True,
supports_generate_audio=True,
supports_webhook=False,
min_duration=4,
max_duration=12,
default_ratio="16:9",
default_resolution="1280x720",
status=1,
)
db.add_all([openai_model, seedance_model])
db.flush()
standard_model = VideoModel(
model_key="standard-video",
model_name="标准视频",
frontend_title="标准视频",
frontend_description="平衡质量与速度,适合大多数日常创作。",
default_duration_seconds=8,
default_ratio="16:9",
default_resolution="1280x720",
sort_order=10,
status=1,
)
fast_model = VideoModel(
model_key="fast-video",
model_name="高速视频",
frontend_title="高速视频",
frontend_description="更快返回结果,适合灵感验证与批量尝试。",
default_duration_seconds=6,
default_ratio="16:9",
default_resolution="1280x720",
sort_order=20,
status=1,
)
db.add_all([standard_model, fast_model])
db.flush()
db.add_all(
[
VideoModelSupplierBinding(
video_model_id=standard_model.id,
provider_model_id=openai_model.id,
routing_priority=10,
is_primary=True,
status=1,
),
VideoModelSupplierBinding(
video_model_id=fast_model.id,
provider_model_id=seedance_model.id,
routing_priority=10,
is_primary=True,
status=1,
),
]
)
db.add_all(
[
PricingRule(
rule_name="标准视频默认价格",
video_model_id=standard_model.id,
points_per_second=120,
minimum_points=500,
effective_at=datetime.utcnow() - timedelta(days=1),
version_no=1,
status=1,
),
PricingRule(
rule_name="高速视频默认价格",
video_model_id=fast_model.id,
points_per_second=90,
minimum_points=400,
effective_at=datetime.utcnow() - timedelta(days=1),
version_no=1,
status=1,
),
]
)
default_configs = {
"site.title": ("AIVideo", "site"),
"site.notice": ("欢迎体验 AIVideo 本地开发版。", "site"),
"reward.signup.enabled": ("1", "reward"),
"reward.signup.points": ("300", "reward"),
"reward.invite.enabled": ("1", "reward"),
"reward.invite.points": ("500", "reward"),
"invite.code.enabled": ("1", "invite"),
"task.default_poll_interval_seconds": ("5", "task"),
}
for key, (value, group_name) in default_configs.items():
if not db.scalar(select(SystemConfig).where(SystemConfig.config_key == key)):
db.add(
SystemConfig(
config_key=key,
config_value=value,
value_type="string",
group_name=group_name,
is_public=1 if key.startswith("site.") else 0,
)
)
if not db.scalar(select(RedeemCode.id)):
db.add_all(
[
RedeemCode(
batch_no="WELCOME",
redeem_code="SPRING-2026-ABCD-1234",
points=1000,
status="unused",
),
RedeemCode(
batch_no="WELCOME",
redeem_code="SPRING-2026-EFGH-5678",
points=1500,
status="unused",
),
]
)
db.commit()

View File

@@ -0,0 +1,241 @@
from __future__ import annotations
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
import httpx
from app.common.config.settings import get_settings
from app.common.utils.id_gen import new_public_id
from app.models.entities import ProviderAccount, ProviderModel, VideoGenerationTask
settings = get_settings()
class ProviderAdapter:
def __init__(self, account: ProviderAccount, provider_model: ProviderModel) -> None:
self.account = account
self.provider_model = provider_model
@property
def is_mock(self) -> bool:
return self.account.base_url.startswith("mock://")
def submit_task(self, payload: dict) -> dict:
if self.is_mock:
return {
"externalTaskId": new_public_id("ext"),
"normalizedStatus": "submitted",
"progress": 0,
"rawResponse": {
"mock": True,
"apiFormat": self.account.api_format,
"submittedPayload": payload,
},
}
if self.account.api_format == "openai_official_video":
return self._submit_openai(payload)
if self.account.api_format == "seedance_video_generation":
return self._submit_seedance(payload)
raise ValueError("unsupported provider format")
def query_task(self, task: VideoGenerationTask) -> dict:
if self.is_mock:
return self._query_mock(task)
if self.account.api_format == "openai_official_video":
return self._query_openai(task.external_task_id)
if self.account.api_format == "seedance_video_generation":
return self._query_seedance(task.external_task_id)
raise ValueError("unsupported provider format")
def download_result(self, task: VideoGenerationTask) -> bytes:
if self.is_mock:
return self._download_mock(task)
if self.account.api_format == "openai_official_video":
return self._download_openai(task.external_task_id)
if self.account.api_format == "seedance_video_generation":
return self._download_seedance(task)
raise ValueError("unsupported provider format")
def _submit_openai(self, payload: dict) -> dict:
files = {
"prompt": (None, payload["prompt"]),
"model": (None, self.provider_model.model_code),
"seconds": (None, str(payload["durationSeconds"])),
"size": (None, payload["resolution"]),
}
response = httpx.post(
f"{self.account.base_url.rstrip('/')}/v1/videos",
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
files=files,
timeout=self.account.timeout_seconds,
)
response.raise_for_status()
data = response.json()
return {
"externalTaskId": data["id"],
"normalizedStatus": self._normalize_status(data.get("status")),
"progress": data.get("progress", 0),
"rawResponse": data,
}
def _submit_seedance(self, payload: dict) -> dict:
content = [{"type": "text", "text": payload["prompt"]}]
for item in payload.get("referenceImages", []):
content.append(
{
"type": "image_url",
"image_url": {"url": item["url"]},
"role": "reference_image",
}
)
response = httpx.post(
f"{self.account.base_url.rstrip('/')}/v1/video/generations",
headers={
"Authorization": f"Bearer {self.account.api_key_encrypted}",
"Content-Type": "application/json",
},
json={
"model": self.provider_model.model_code,
"content": content,
"duration": payload["durationSeconds"],
"ratio": payload["ratio"],
"generate_audio": payload["generateAudio"],
},
timeout=self.account.timeout_seconds,
)
response.raise_for_status()
data = response.json()
external_id = data.get("id") or data.get("task_id")
return {
"externalTaskId": external_id,
"normalizedStatus": self._normalize_status(data.get("status")),
"progress": data.get("progress", 0),
"rawResponse": data,
}
def _query_openai(self, external_task_id: str) -> dict:
response = httpx.get(
f"{self.account.base_url.rstrip('/')}/v1/videos/{external_task_id}",
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
timeout=self.account.timeout_seconds,
)
response.raise_for_status()
data = response.json()
return {
"externalTaskId": external_task_id,
"normalizedStatus": self._normalize_status(data.get("status")),
"progress": data.get("progress", 0),
"resultUrl": data.get("result_url", ""),
"rawResponse": data,
}
def _query_seedance(self, external_task_id: str) -> dict:
response = httpx.get(
f"{self.account.base_url.rstrip('/')}/v1/video/generations/{external_task_id}",
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
timeout=self.account.timeout_seconds,
)
response.raise_for_status()
data = response.json()
result_url = ""
if isinstance(data.get("result"), dict):
result_url = data["result"].get("video_url", "")
return {
"externalTaskId": external_task_id,
"normalizedStatus": self._normalize_status(data.get("status")),
"progress": data.get("progress", 0),
"resultUrl": result_url,
"rawResponse": data,
}
def _download_openai(self, external_task_id: str) -> bytes:
response = httpx.get(
f"{self.account.base_url.rstrip('/')}/v1/videos/{external_task_id}/content",
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
timeout=self.account.timeout_seconds,
)
response.raise_for_status()
return response.content
def _download_seedance(self, task: VideoGenerationTask) -> bytes:
payload = task.response_payload or {}
result_url = payload.get("resultUrl")
if not result_url and isinstance(payload.get("rawResponse"), dict):
result_url = payload["rawResponse"].get("result", {}).get("video_url")
if not result_url:
raise ValueError("missing result url")
response = httpx.get(result_url, timeout=self.account.timeout_seconds)
response.raise_for_status()
return response.content
def _query_mock(self, task: VideoGenerationTask) -> dict:
started = task.submitted_at or task.created_at
now = datetime.now(timezone.utc).replace(tzinfo=None)
elapsed = max(0, int((now - started).total_seconds()))
total = max(1, settings.mock_task_run_seconds)
progress = min(100, int(elapsed / total * 100))
if elapsed < 3:
status = "submitted"
elif elapsed < total:
status = "running"
else:
status = "succeeded"
progress = 100
return {
"externalTaskId": task.external_task_id,
"normalizedStatus": status,
"progress": progress,
"resultUrl": "",
"rawResponse": {
"mock": True,
"elapsedSeconds": elapsed,
"status": status,
"progress": progress,
},
}
def _download_mock(self, task: VideoGenerationTask) -> bytes:
payload = task.request_payload or {}
resolution = payload.get("resolution", "1280x720")
width, height = resolution.split("x", 1)
with TemporaryDirectory() as tmp_dir:
target = Path(tmp_dir) / f"{task.task_no}.mp4"
command = [
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
f"color=c=#14213d:s={width}x{height}:d=3",
"-pix_fmt",
"yuv420p",
str(target),
]
subprocess.run(command, check=True, capture_output=True)
return target.read_bytes()
@staticmethod
def _normalize_status(status: str | None) -> str:
mapping = {
"queued": "queued",
"pending": "queued",
"submitted": "submitted",
"running": "running",
"in_progress": "running",
"completed": "succeeded",
"succeeded": "succeeded",
"failed": "failed",
"error": "failed",
"cancelled": "cancelled",
"timed_out": "timed_out",
}
return mapping.get((status or "").lower(), "running")
def build_adapter(account: ProviderAccount, provider_model: ProviderModel) -> ProviderAdapter:
return ProviderAdapter(account, provider_model)

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
import hashlib
from pathlib import Path
from uuid import uuid4
from fastapi import UploadFile
from app.common.config.settings import get_settings
settings = get_settings()
class LocalStorageService:
def __init__(self) -> None:
self.root = settings.storage_root
self.root.mkdir(parents=True, exist_ok=True)
def build_public_url(self, storage_key: str) -> str:
base = settings.storage_public_base_url.rstrip("/")
normalized_key = storage_key.replace("\\", "/")
return f"{base}/{normalized_key}"
def save_upload(self, upload: UploadFile, *, folder: str) -> dict:
ext = Path(upload.filename or "file.bin").suffix
storage_key = f"{folder}/{uuid4().hex}{ext}"
path = self.root / storage_key
path.parent.mkdir(parents=True, exist_ok=True)
content = upload.file.read()
path.write_bytes(content)
return {
"storage_key": storage_key,
"file_size": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
"public_url": self.build_public_url(storage_key),
}
def save_bytes(self, content: bytes, *, filename: str, folder: str) -> dict:
ext = Path(filename).suffix or ".bin"
storage_key = f"{folder}/{uuid4().hex}{ext}"
path = self.root / storage_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return {
"storage_key": storage_key,
"file_size": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
"public_url": self.build_public_url(storage_key),
}
storage_service = LocalStorageService()