feat: initialize aivideo project
This commit is contained in:
282
backend/app/core/bootstrap.py
Normal file
282
backend/app/core/bootstrap.py
Normal 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()
|
||||
|
||||
241
backend/app/core/providers.py
Normal file
241
backend/app/core/providers.py
Normal 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)
|
||||
53
backend/app/core/storage.py
Normal file
53
backend/app/core/storage.py
Normal 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()
|
||||
Reference in New Issue
Block a user