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

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
.env
.env.*
!.env.example
.venv/
venv/
backend/.pytest_cache/
backend/storage_data/
backend/alembic/versions/__pycache__/
frontend-web/.next/
frontend-web/node_modules/
frontend-web/out/
frontend-admin/.next/
frontend-admin/node_modules/
frontend-admin/out/
node_modules/
*.log
*.sqlite3
dist/
coverage/
.DS_Store
git仓库连接信息.txt

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# AIVideo
基于 `docs/AI视频平台开发文档.md` 实现的单仓 AI 视频平台原型,包含:
- `backend`FastAPI + SQLAlchemy + Celery 风格任务链路
- `frontend-web`用户前台Next.js
- `frontend-admin`管理后台Next.js
- `sql`:初始化库表与基础种子数据
- `deploy`:部署相关文件占位
## 目录结构
```text
AIVideo/
backend/
frontend-web/
frontend-admin/
docs/
deploy/
sql/
```
## 快速开始
### 1. 启动基础依赖
```bash
docker compose up -d mysql redis minio
```
### 2. 启动后端
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
copy .env.example .env
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 3. 启动前台
```bash
npm install
npm --workspace frontend-web run dev
```
### 4. 启动后台
```bash
npm --workspace frontend-admin run dev
```
## 默认账号
- 用户端:自行注册
- 管理后台:`admin / Admin@123456`
## 说明
- 本地默认支持 mock OpenAI 与 mock Seedance 任务链路。
- 若配置真实供应商账号与可访问的 `baseUrl`,后端会按对应协议发起真实请求。
- 任务结果默认落到 `backend/storage_data`,并通过 `/storage` 暴露访问。

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,70 @@
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_name: str = "AIVideo"
app_env: str = "development"
app_host: str = "0.0.0.0"
app_port: int = 8000
app_debug: bool = True
database_url: str = "sqlite:///./aivideo.sqlite3"
redis_url: str = "redis://127.0.0.1:6379/0"
celery_task_always_eager: bool = True
jwt_secret: str = "replace_me"
jwt_refresh_secret: str = "replace_me_too"
jwt_access_expire_minutes: int = 120
jwt_refresh_expire_days: int = 30
jwt_cookie_domain: str | None = None
cors_origins: str = Field(
default="http://localhost:3000,http://localhost:3001"
)
storage_provider: str = "local"
local_storage_path: str = "storage_data"
storage_bucket: str = "ai-video"
storage_public_base_url: str = "http://127.0.0.1:8000/storage"
point_exchange_ratio: int = 100
invite_reward_min_consume_points: int = 100
redeem_code_fail_limit_per_hour: int = 20
task_default_poll_interval_seconds: int = 5
task_max_poll_minutes: int = 30
task_daily_create_limit: int = 50
mock_task_run_seconds: int = 18
mock_task_progress_step: int = 25
admin_username: str = "admin"
admin_password: str = "Admin@123456"
admin_nickname: str = "Super Admin"
@property
def parsed_cors_origins(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def project_root(self) -> Path:
return Path(__file__).resolve().parents[3]
@property
def storage_root(self) -> Path:
return self.project_root / self.local_storage_path
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,3 @@
from app.models.base import Base
from app.models.entities import * # noqa: F401,F403

View File

@@ -0,0 +1,31 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.common.config.settings import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
future=True,
echo=settings.app_debug,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(
bind=engine,
autocommit=False,
autoflush=True,
expire_on_commit=False,
class_=Session,
)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,45 @@
class AppError(Exception):
def __init__(
self,
message: str,
*,
code: int,
status_code: int,
errors: list[dict] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.code = code
self.status_code = status_code
self.errors = errors or []
class AuthenticationError(AppError):
def __init__(self, message: str = "unauthorized") -> None:
super().__init__(message, code=10001, status_code=401)
class AuthorizationError(AppError):
def __init__(self, message: str = "forbidden") -> None:
super().__init__(message, code=10002, status_code=403)
class ValidationAppError(AppError):
def __init__(self, message: str, errors: list[dict] | None = None) -> None:
super().__init__(message, code=10003, status_code=422, errors=errors)
class NotFoundAppError(AppError):
def __init__(self, message: str, *, code: int = 40400) -> None:
super().__init__(message, code=code, status_code=404)
class ConflictAppError(AppError):
def __init__(self, message: str, *, code: int = 40900) -> None:
super().__init__(message, code=code, status_code=409)
class BusinessAppError(AppError):
def __init__(self, message: str, *, code: int, status_code: int = 400) -> None:
super().__init__(message, code=code, status_code=status_code)

View File

@@ -0,0 +1,53 @@
import json
import logging
import time
from datetime import datetime, timezone
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
logger = logging.getLogger("aivideo")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname.lower(),
"message": record.getMessage(),
}
extra = getattr(record, "extra_payload", None)
if isinstance(extra, dict):
payload.update(extra)
return json.dumps(payload, ensure_ascii=False)
def configure_logging() -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.setLevel(logging.INFO)
logger.handlers.clear()
logger.addHandler(handler)
logger.propagate = False
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
started = time.perf_counter()
response = await call_next(request)
duration_ms = round((time.perf_counter() - started) * 1000, 2)
logger.info(
"http_request",
extra={
"extra_payload": {
"request_id": getattr(request.state, "request_id", ""),
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": duration_ms,
}
},
)
return response

View File

@@ -0,0 +1,14 @@
from uuid import uuid4
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-Id", f"req_{uuid4().hex[:16]}")
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-Id"] = request_id
return response

View File

@@ -0,0 +1,30 @@
from math import ceil
from typing import Any
from fastapi.responses import JSONResponse
def success_response(data: Any = None, message: str = "ok", status_code: int = 200) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={"code": 0, "message": message, "data": data},
)
def paginated_response(
items: list[Any],
*,
total: int,
page: int,
page_size: int,
) -> JSONResponse:
return success_response(
{
"items": items,
"page": page,
"pageSize": page_size,
"total": total,
"totalPages": ceil(total / page_size) if page_size else 1,
}
)

View File

@@ -0,0 +1,69 @@
from typing import Literal
from fastapi import Cookie, Depends, Header
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.errors.app_error import AuthenticationError, AuthorizationError
from app.common.security.jwt import decode_access_token
from app.models.entities import AdminUser, User
def _extract_token(
authorization: str | None,
cookie_token: str | None,
) -> str:
if authorization and authorization.startswith("Bearer "):
return authorization.split(" ", 1)[1].strip()
if cookie_token:
return cookie_token
raise AuthenticationError()
def get_current_user(
db: Session = Depends(get_db),
authorization: str | None = Header(default=None),
user_access_token: str | None = Cookie(default=None),
) -> User:
token = _extract_token(authorization, user_access_token)
try:
payload = decode_access_token(token)
except Exception as exc: # noqa: BLE001
raise AuthenticationError() from exc
if payload.get("scope") != "user":
raise AuthenticationError()
user = db.scalar(select(User).where(User.public_id == payload["sub"]))
if not user:
raise AuthenticationError()
if user.status != 1:
raise AuthorizationError("user disabled")
return user
def get_current_admin(
db: Session = Depends(get_db),
authorization: str | None = Header(default=None),
admin_access_token: str | None = Cookie(default=None),
) -> AdminUser:
token = _extract_token(authorization, admin_access_token)
try:
payload = decode_access_token(token)
except Exception as exc: # noqa: BLE001
raise AuthenticationError() from exc
if payload.get("scope") != "admin":
raise AuthenticationError()
admin = db.scalar(select(AdminUser).where(AdminUser.username == payload["sub"]))
if not admin:
raise AuthenticationError()
if admin.status != 1:
raise AuthorizationError("admin disabled")
return admin
def require_admin_permission(_permission: Literal["any"] = "any"):
def dependency(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
return admin
return dependency

View File

@@ -0,0 +1,77 @@
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from fastapi import Response
from app.common.config.settings import get_settings
settings = get_settings()
def _encode(payload: dict[str, Any], secret: str, expires_delta: timedelta) -> str:
now = datetime.now(timezone.utc)
body = {
**payload,
"iat": int(now.timestamp()),
"exp": int((now + expires_delta).timestamp()),
}
return jwt.encode(body, secret, algorithm="HS256")
def create_access_token(subject: str, *, scope: str) -> str:
return _encode(
{"sub": subject, "scope": scope, "type": "access"},
settings.jwt_secret,
timedelta(minutes=settings.jwt_access_expire_minutes),
)
def create_refresh_token(subject: str, *, scope: str) -> str:
return _encode(
{"sub": subject, "scope": scope, "type": "refresh"},
settings.jwt_refresh_secret,
timedelta(days=settings.jwt_refresh_expire_days),
)
def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
def decode_refresh_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.jwt_refresh_secret, algorithms=["HS256"])
def set_auth_cookies(response: Response, access_token: str, refresh_token: str, *, prefix: str) -> None:
common_kwargs = {
"httponly": True,
"secure": False,
"samesite": "lax",
"domain": settings.jwt_cookie_domain or None,
}
response.set_cookie(
key=f"{prefix}_access_token",
value=access_token,
max_age=settings.jwt_access_expire_minutes * 60,
**common_kwargs,
)
response.set_cookie(
key=f"{prefix}_refresh_token",
value=refresh_token,
max_age=settings.jwt_refresh_expire_days * 24 * 3600,
**common_kwargs,
)
def clear_auth_cookies(response: Response, *, prefix: str) -> None:
response.delete_cookie(
key=f"{prefix}_access_token",
domain=settings.jwt_cookie_domain or None,
)
response.delete_cookie(
key=f"{prefix}_refresh_token",
domain=settings.jwt_cookie_domain or None,
)

View File

@@ -0,0 +1,9 @@
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))

View File

@@ -0,0 +1,17 @@
from datetime import datetime
from random import choices
from string import ascii_uppercase, digits
from uuid import uuid4
def new_public_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex[:16]}"
def new_order_no(prefix: str) -> str:
return f"{prefix}_{datetime.now():%Y%m%d%H%M%S}{uuid4().hex[:6]}"
def new_invite_code(length: int = 6) -> str:
return "".join(choices(ascii_uppercase + digits, k=length))

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel, Field
class PaginationQuery(BaseModel):
page: int = Field(default=1, ge=1)
page_size: int = Field(default=10, ge=1, le=100)

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()

123
backend/app/main.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from app.common.config.settings import get_settings
from app.common.errors.app_error import AppError
from app.common.middleware.logging import LoggingMiddleware, configure_logging
from app.common.middleware.request_id import RequestIdMiddleware
from app.core.bootstrap import init_database
from app.modules.admins.router import auth_router as admin_auth_router
from app.modules.admins.router import router as admins_router
from app.modules.assets.router import router as assets_router
from app.modules.auth.router import router as auth_router
from app.modules.growth_rules.router import router as growth_rules_router
from app.modules.invites.router import router as invites_router
from app.modules.payments.router import admin_router as admin_payments_router
from app.modules.payments.router import router as payments_router
from app.modules.pricing.router import router as pricing_router
from app.modules.providers.router import router as providers_router
from app.modules.system.router import router as system_router
from app.modules.users.router import router as users_router
from app.modules.video_models.router import router as video_models_router
from app.modules.video_tasks.router import admin_router as admin_video_tasks_router
from app.modules.video_tasks.router import router as video_tasks_router
from app.modules.wallets.router import router as wallets_router
settings = get_settings()
@asynccontextmanager
async def lifespan(_: FastAPI):
configure_logging()
settings.storage_root.mkdir(parents=True, exist_ok=True)
init_database()
yield
app = FastAPI(title=settings.app_name, debug=settings.app_debug, lifespan=lifespan)
app.add_middleware(RequestIdMiddleware)
app.add_middleware(LoggingMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.parsed_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/storage", StaticFiles(directory=settings.storage_root), name="storage")
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(
status_code=exc.status_code,
content={
"code": exc.code,
"message": exc.message,
"requestId": getattr(request.state, "request_id", ""),
"errors": exc.errors,
},
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"code": 10003,
"message": "validation error",
"requestId": getattr(request.state, "request_id", ""),
"errors": exc.errors(),
},
)
@app.exception_handler(Exception)
async def unhandled_error_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"code": 50000,
"message": "internal server error",
"requestId": getattr(request.state, "request_id", ""),
"errors": [],
},
)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/ready")
def ready():
return {"status": "ok"}
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(wallets_router)
app.include_router(payments_router)
app.include_router(invites_router)
app.include_router(assets_router)
app.include_router(video_models_router)
app.include_router(video_tasks_router)
app.include_router(admin_auth_router)
app.include_router(admins_router)
app.include_router(admin_payments_router)
app.include_router(providers_router)
app.include_router(pricing_router)
app.include_router(growth_rules_router)
app.include_router(system_router)
app.include_router(admin_video_tasks_router)

View File

@@ -0,0 +1,51 @@
from app.models.base import Base
from app.models.entities import (
AdminUser,
CallbackLog,
GrowthRewardRule,
InviteCode,
InviteRelation,
MediaAsset,
OperationLog,
PaymentChannel,
PricingRule,
ProviderAccount,
ProviderModel,
RechargeOrder,
RechargePlan,
RedeemCode,
SystemConfig,
User,
VideoGenerationTask,
VideoModel,
VideoModelSupplierBinding,
VideoTaskEvent,
Wallet,
WalletTransaction,
)
__all__ = [
"Base",
"AdminUser",
"CallbackLog",
"GrowthRewardRule",
"InviteCode",
"InviteRelation",
"MediaAsset",
"OperationLog",
"PaymentChannel",
"PricingRule",
"ProviderAccount",
"ProviderModel",
"RechargeOrder",
"RechargePlan",
"RedeemCode",
"SystemConfig",
"User",
"VideoGenerationTask",
"VideoModel",
"VideoModelSupplierBinding",
"VideoTaskEvent",
"Wallet",
"WalletTransaction",
]

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import DateTime, MetaData, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
metadata = MetaData()
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@@ -0,0 +1,438 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
PKBigInt = BigInteger().with_variant(Integer, "sqlite")
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
public_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
username: Mapped[str | None] = mapped_column(String(64), unique=True)
nickname: Mapped[str] = mapped_column(String(100), default="")
avatar_url: Mapped[str] = mapped_column(String(500), default="")
email: Mapped[str | None] = mapped_column(String(191), unique=True)
mobile: Mapped[str | None] = mapped_column(String(32), unique=True)
password_hash: Mapped[str] = mapped_column(String(255), default="")
status: Mapped[int] = mapped_column(Integer, default=1)
register_ip: Mapped[str] = mapped_column(String(64), default="")
last_login_at: Mapped[datetime | None] = mapped_column(DateTime)
last_login_ip: Mapped[str] = mapped_column(String(64), default="")
wallet: Mapped["Wallet"] = relationship(back_populates="user", uselist=False)
class AdminUser(Base, TimestampMixin):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
nickname: Mapped[str] = mapped_column(String(100), default="")
avatar_url: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[int] = mapped_column(Integer, default=1)
is_super_admin: Mapped[bool] = mapped_column(Boolean, default=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime)
last_login_ip: Mapped[str] = mapped_column(String(64), default="")
class Wallet(Base, TimestampMixin):
__tablename__ = "wallets"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True, nullable=False)
balance_points: Mapped[int] = mapped_column(BigInteger, default=0)
frozen_points: Mapped[int] = mapped_column(BigInteger, default=0)
total_recharged_points: Mapped[int] = mapped_column(BigInteger, default=0)
total_consumed_points: Mapped[int] = mapped_column(BigInteger, default=0)
total_refunded_points: Mapped[int] = mapped_column(BigInteger, default=0)
user: Mapped[User] = relationship(back_populates="wallet")
class WalletTransaction(Base):
__tablename__ = "wallet_transactions"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
transaction_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
wallet_id: Mapped[int] = mapped_column(ForeignKey("wallets.id"), nullable=False)
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
direction: Mapped[str] = mapped_column(String(16), nullable=False)
amount_points: Mapped[int] = mapped_column(BigInteger, nullable=False)
balance_before_points: Mapped[int] = mapped_column(BigInteger, nullable=False)
balance_after_points: Mapped[int] = mapped_column(BigInteger, nullable=False)
frozen_before_points: Mapped[int] = mapped_column(BigInteger, default=0)
frozen_after_points: Mapped[int] = mapped_column(BigInteger, default=0)
related_type: Mapped[str] = mapped_column(String(32), default="")
related_id: Mapped[int | None] = mapped_column(BigInteger)
remark: Mapped[str] = mapped_column(String(255), default="")
operator_type: Mapped[str] = mapped_column(String(16), default="system")
operator_id: Mapped[int | None] = mapped_column(BigInteger)
extra_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False
)
class GrowthRewardRule(Base, TimestampMixin):
__tablename__ = "growth_reward_rules"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
rule_type: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
reward_points: Mapped[int] = mapped_column(BigInteger, default=0)
trigger_condition: Mapped[str] = mapped_column(String(64), default="")
min_consume_points: Mapped[int] = mapped_column(BigInteger, default=0)
remark: Mapped[str] = mapped_column(String(255), default="")
updated_by_admin_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"))
class RedeemCode(Base, TimestampMixin):
__tablename__ = "redeem_codes"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
batch_no: Mapped[str] = mapped_column(String(64), nullable=False)
redeem_code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
points: Mapped[int] = mapped_column(BigInteger, nullable=False)
status: Mapped[str] = mapped_column(String(32), default="unused")
used_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
wallet_transaction_id: Mapped[int | None] = mapped_column(
ForeignKey("wallet_transactions.id")
)
used_ip: Mapped[str] = mapped_column(String(64), default="")
used_user_agent: Mapped[str] = mapped_column(String(255), default="")
expired_at: Mapped[datetime | None] = mapped_column(DateTime)
used_at: Mapped[datetime | None] = mapped_column(DateTime)
created_by_admin_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"))
remark: Mapped[str] = mapped_column(String(255), default="")
class InviteCode(Base, TimestampMixin):
__tablename__ = "invite_codes"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
invite_code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
invite_link: Mapped[str] = mapped_column(String(255), default="")
status: Mapped[int] = mapped_column(Integer, default=1)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
max_use_count: Mapped[int | None] = mapped_column(Integer)
used_count: Mapped[int] = mapped_column(Integer, default=0)
class InviteRelation(Base, TimestampMixin):
__tablename__ = "invite_relations"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
inviter_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
invitee_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True, nullable=False)
invite_code_id: Mapped[int] = mapped_column(ForeignKey("invite_codes.id"), nullable=False)
reward_status: Mapped[str] = mapped_column(String(32), default="pending")
reward_points: Mapped[int] = mapped_column(BigInteger, default=0)
first_consumed_task_id: Mapped[int | None] = mapped_column(BigInteger)
first_consumed_at: Mapped[datetime | None] = mapped_column(DateTime)
rewarded_at: Mapped[datetime | None] = mapped_column(DateTime)
reward_wallet_transaction_id: Mapped[int | None] = mapped_column(
ForeignKey("wallet_transactions.id")
)
register_ip: Mapped[str] = mapped_column(String(64), default="")
register_device_fingerprint: Mapped[str] = mapped_column(String(128), default="")
risk_status: Mapped[str] = mapped_column(String(32), default="normal")
class RechargePlan(Base, TimestampMixin):
__tablename__ = "recharge_plans"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
pay_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
point_ratio: Mapped[int] = mapped_column(Integer, default=100)
give_points: Mapped[int] = mapped_column(BigInteger, default=0)
bonus_points: Mapped[int] = mapped_column(BigInteger, default=0)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[int] = mapped_column(Integer, default=1)
start_at: Mapped[datetime | None] = mapped_column(DateTime)
end_at: Mapped[datetime | None] = mapped_column(DateTime)
class PaymentChannel(Base, TimestampMixin):
__tablename__ = "payment_channels"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
channel_code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
channel_name: Mapped[str] = mapped_column(String(100), nullable=False)
provider_type: Mapped[str] = mapped_column(String(32), default="manual")
config_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
status: Mapped[int] = mapped_column(Integer, default=1)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class RechargeOrder(Base, TimestampMixin):
__tablename__ = "recharge_orders"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
order_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
recharge_plan_id: Mapped[int | None] = mapped_column(ForeignKey("recharge_plans.id"))
payment_channel_id: Mapped[int | None] = mapped_column(ForeignKey("payment_channels.id"))
payment_channel_code: Mapped[str] = mapped_column(String(32), nullable=False)
pay_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
point_ratio_snapshot: Mapped[int] = mapped_column(Integer, nullable=False)
give_points: Mapped[int] = mapped_column(BigInteger, default=0)
bonus_points: Mapped[int] = mapped_column(BigInteger, default=0)
arrival_points: Mapped[int] = mapped_column(BigInteger, default=0)
currency: Mapped[str] = mapped_column(String(16), default="CNY")
status: Mapped[str] = mapped_column(String(32), default="pending")
third_party_order_no: Mapped[str] = mapped_column(String(100), default="")
client_ip: Mapped[str] = mapped_column(String(64), default="")
paid_at: Mapped[datetime | None] = mapped_column(DateTime)
expired_at: Mapped[datetime | None] = mapped_column(DateTime)
callback_payload: Mapped[dict[str, Any] | None] = mapped_column(JSON)
class ProviderAccount(Base, TimestampMixin):
__tablename__ = "provider_accounts"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
provider_code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
provider_name: Mapped[str] = mapped_column(String(100), nullable=False)
api_format: Mapped[str] = mapped_column(String(64), nullable=False)
base_url: Mapped[str] = mapped_column(String(255), nullable=False)
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
api_secret_encrypted: Mapped[str | None] = mapped_column(Text)
webhook_secret_encrypted: Mapped[str | None] = mapped_column(Text)
timeout_seconds: Mapped[int] = mapped_column(Integer, default=60)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
status: Mapped[int] = mapped_column(Integer, default=1)
remark: Mapped[str] = mapped_column(String(255), default="")
class ProviderModel(Base, TimestampMixin):
__tablename__ = "provider_models"
__table_args__ = (
UniqueConstraint("provider_account_id", "model_code", name="uk_provider_models"),
)
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
provider_account_id: Mapped[int] = mapped_column(
ForeignKey("provider_accounts.id"), nullable=False
)
model_code: Mapped[str] = mapped_column(String(64), nullable=False)
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
request_content_type: Mapped[str] = mapped_column(
String(64), default="application/json"
)
scene_type: Mapped[str] = mapped_column(String(32), default="video_generation")
supports_text_to_video: Mapped[bool] = mapped_column(Boolean, default=True)
supports_image_to_video: Mapped[bool] = mapped_column(Boolean, default=False)
supports_video_reference: Mapped[bool] = mapped_column(Boolean, default=False)
supports_audio_reference: Mapped[bool] = mapped_column(Boolean, default=False)
supports_generate_audio: Mapped[bool] = mapped_column(Boolean, default=False)
supports_remix: Mapped[bool] = mapped_column(Boolean, default=False)
supports_webhook: Mapped[bool] = mapped_column(Boolean, default=False)
min_duration: Mapped[int] = mapped_column(Integer, default=4)
max_duration: Mapped[int] = mapped_column(Integer, default=15)
status: Mapped[int] = mapped_column(Integer, default=1)
default_ratio: Mapped[str] = mapped_column(String(20), default="16:9")
default_resolution: Mapped[str] = mapped_column(String(20), default="1280x720")
class VideoModel(Base, TimestampMixin):
__tablename__ = "video_models"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
model_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
frontend_title: Mapped[str] = mapped_column(String(100), nullable=False)
frontend_description: Mapped[str] = mapped_column(String(255), default="")
default_duration_seconds: Mapped[int] = mapped_column(Integer, default=8)
default_ratio: Mapped[str] = mapped_column(String(20), default="16:9")
default_resolution: Mapped[str] = mapped_column(String(20), default="1280x720")
status: Mapped[int] = mapped_column(Integer, default=1)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class VideoModelSupplierBinding(Base, TimestampMixin):
__tablename__ = "video_model_supplier_bindings"
__table_args__ = (
UniqueConstraint(
"video_model_id",
"provider_model_id",
name="uk_video_model_supplier_binding",
),
)
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
video_model_id: Mapped[int] = mapped_column(ForeignKey("video_models.id"), nullable=False)
provider_model_id: Mapped[int] = mapped_column(
ForeignKey("provider_models.id"), nullable=False
)
routing_priority: Mapped[int] = mapped_column(Integer, default=100)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[int] = mapped_column(Integer, default=1)
timeout_seconds_override: Mapped[int | None] = mapped_column(Integer)
class PricingRule(Base, TimestampMixin):
__tablename__ = "pricing_rules"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
rule_name: Mapped[str] = mapped_column(String(100), nullable=False)
video_model_id: Mapped[int] = mapped_column(ForeignKey("video_models.id"), nullable=False)
billing_mode: Mapped[str] = mapped_column(String(32), default="per_second")
points_per_second: Mapped[int] = mapped_column(Integer, nullable=False)
minimum_points: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[int] = mapped_column(Integer, default=1)
effective_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expired_at: Mapped[datetime | None] = mapped_column(DateTime)
version_no: Mapped[int] = mapped_column(Integer, default=1)
class MediaAsset(Base, TimestampMixin):
__tablename__ = "media_assets"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
asset_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
media_type: Mapped[str] = mapped_column(String(16), nullable=False)
source_type: Mapped[str] = mapped_column(String(16), default="upload")
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), default="")
file_ext: Mapped[str] = mapped_column(String(32), default="")
file_size: Mapped[int] = mapped_column(BigInteger, default=0)
storage_provider: Mapped[str] = mapped_column(String(32), default="local")
storage_bucket: Mapped[str] = mapped_column(String(100), default="")
storage_key: Mapped[str] = mapped_column(String(255), nullable=False)
public_url: Mapped[str] = mapped_column(String(500), default="")
sha256: Mapped[str] = mapped_column(String(64), default="")
width: Mapped[int | None] = mapped_column(Integer)
height: Mapped[int | None] = mapped_column(Integer)
duration_seconds: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(32), default="active")
deleted_at: Mapped[datetime | None] = mapped_column(DateTime)
class VideoGenerationTask(Base, TimestampMixin):
__tablename__ = "video_generation_tasks"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
task_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
video_model_id: Mapped[int] = mapped_column(ForeignKey("video_models.id"), nullable=False)
provider_account_id: Mapped[int] = mapped_column(
ForeignKey("provider_accounts.id"), nullable=False
)
provider_model_id: Mapped[int] = mapped_column(ForeignKey("provider_models.id"), nullable=False)
provider_binding_id: Mapped[int | None] = mapped_column(
ForeignKey("video_model_supplier_bindings.id")
)
pricing_rule_id: Mapped[int] = mapped_column(ForeignKey("pricing_rules.id"), nullable=False)
external_task_id: Mapped[str] = mapped_column(String(100), default="")
submit_mode: Mapped[str] = mapped_column(String(32), default="async")
task_status: Mapped[str] = mapped_column(String(32), nullable=False)
generation_mode: Mapped[str] = mapped_column(String(32), nullable=False)
prompt_text: Mapped[str | None] = mapped_column(Text)
request_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
response_payload: Mapped[dict[str, Any] | None] = mapped_column(JSON)
duration_seconds: Mapped[int] = mapped_column(Integer, default=5)
ratio: Mapped[str] = mapped_column(String(20), default="16:9")
resolution: Mapped[str] = mapped_column(String(20), default="1280x720")
generate_audio: Mapped[bool] = mapped_column(Boolean, default=False)
estimated_points: Mapped[int] = mapped_column(BigInteger, default=0)
frozen_points: Mapped[int] = mapped_column(BigInteger, default=0)
final_points: Mapped[int] = mapped_column(BigInteger, default=0)
supplier_cost_amount: Mapped[Decimal] = mapped_column(Numeric(10, 4), default=0)
supplier_cost_currency: Mapped[str] = mapped_column(String(16), default="")
result_asset_id: Mapped[int | None] = mapped_column(ForeignKey("media_assets.id"))
fail_reason: Mapped[str] = mapped_column(String(500), default="")
submitted_at: Mapped[datetime | None] = mapped_column(DateTime)
started_at: Mapped[datetime | None] = mapped_column(DateTime)
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime)
poll_count: Mapped[int] = mapped_column(Integer, default=0)
user_visible: Mapped[bool] = mapped_column(Boolean, default=True)
user_deleted_at: Mapped[datetime | None] = mapped_column(DateTime)
class VideoTaskEvent(Base):
__tablename__ = "video_task_events"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
video_task_id: Mapped[int] = mapped_column(
ForeignKey("video_generation_tasks.id"), nullable=False
)
event_type: Mapped[str] = mapped_column(String(32), nullable=False)
event_message: Mapped[str] = mapped_column(String(255), default="")
payload: Mapped[dict[str, Any] | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class CallbackLog(Base, TimestampMixin):
__tablename__ = "callback_logs"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
source_type: Mapped[str] = mapped_column(String(32), nullable=False)
source_code: Mapped[str] = mapped_column(String(32), nullable=False)
related_no: Mapped[str] = mapped_column(String(64), default="")
request_headers: Mapped[dict[str, Any] | None] = mapped_column(JSON)
request_body: Mapped[dict[str, Any] | None] = mapped_column(JSON)
verify_status: Mapped[str] = mapped_column(String(32), default="pending")
process_status: Mapped[str] = mapped_column(String(32), default="pending")
response_body: Mapped[str | None] = mapped_column(Text)
error_message: Mapped[str] = mapped_column(String(500), default="")
class SystemConfig(Base, TimestampMixin):
__tablename__ = "system_configs"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
config_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
config_value: Mapped[str] = mapped_column(Text, nullable=False)
value_type: Mapped[str] = mapped_column(String(32), default="string")
group_name: Mapped[str] = mapped_column(String(64), default="default")
description: Mapped[str] = mapped_column(String(255), default="")
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
updated_by_admin_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"))
class OperationLog(Base):
__tablename__ = "operation_logs"
id: Mapped[int] = mapped_column(PKBigInt, primary_key=True, autoincrement=True)
admin_user_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"))
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
module_name: Mapped[str] = mapped_column(String(64), default="")
action_name: Mapped[str] = mapped_column(String(64), default="")
related_type: Mapped[str] = mapped_column(String(32), default="")
related_id: Mapped[int | None] = mapped_column(BigInteger)
request_method: Mapped[str] = mapped_column(String(16), default="")
request_path: Mapped[str] = mapped_column(String(255), default="")
request_ip: Mapped[str] = mapped_column(String(64), default="")
user_agent: Mapped[str] = mapped_column(String(255), default="")
request_body: Mapped[dict[str, Any] | None] = mapped_column(JSON)
response_body: Mapped[dict[str, Any] | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(32), default="success")
error_message: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)

View File

@@ -0,0 +1,50 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.entities import (
AdminUser,
InviteRelation,
RechargeOrder,
User,
VideoGenerationTask,
)
class AdminsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_admin_by_username(self, username: str) -> AdminUser | None:
return self.db.scalar(select(AdminUser).where(AdminUser.username == username))
def list_users(self):
return self.db.query(User).order_by(User.id.desc())
def get_user(self, user_id: int) -> User | None:
return self.db.scalar(select(User).where(User.id == user_id))
def count_users(self) -> int:
return self.db.query(func.count(User.id)).scalar() or 0
def count_paid_orders(self) -> int:
return (
self.db.query(func.count(RechargeOrder.id))
.filter(RechargeOrder.status == "paid")
.scalar()
or 0
)
def count_tasks(self) -> int:
return self.db.query(func.count(VideoGenerationTask.id)).scalar() or 0
def count_success_tasks(self) -> int:
return (
self.db.query(func.count(VideoGenerationTask.id))
.filter(VideoGenerationTask.task_status == "succeeded")
.scalar()
or 0
)
def invite_relations(self):
return self.db.query(InviteRelation).order_by(InviteRelation.id.desc())

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import get_current_admin, require_admin_permission
from app.modules.admins.schema import AdminLoginRequest, ManualAdjustRequest
from app.modules.admins.service import AdminsService
auth_router = APIRouter(prefix="/api/v1/admin/auth", tags=["admin-auth"])
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
@auth_router.post("/login")
def admin_login(
payload: AdminLoginRequest,
response: Response,
db: Session = Depends(get_db),
):
return {"code": 0, "message": "ok", "data": AdminsService(db).login(payload, response)}
@auth_router.post("/logout")
def admin_logout(response: Response, db: Session = Depends(get_db)):
AdminsService(db).logout(response)
return {"code": 0, "message": "ok", "data": {"success": True}}
@auth_router.get("/me")
def admin_me(admin=Depends(get_current_admin)):
return success_response(AdminsService.serialize_admin(admin))
@router.get("/dashboard")
def dashboard(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(AdminsService(db).dashboard())
@router.get("/users")
def list_users(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(AdminsService(db).list_users())
@router.get("/users/{user_id}")
def get_user_detail(
user_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(AdminsService(db).get_user_detail(user_id))
@router.post("/users/{user_id}/wallet-adjust")
def manual_adjust_wallet(
user_id: int,
payload: ManualAdjustRequest,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(
AdminsService(db).manual_adjust_wallet(user_id, payload.amount_points, payload.reason)
)
@router.get("/users/{user_id}/invite-relations")
def user_invite_relations(
user_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(AdminsService(db).user_invite_relations(user_id))
@router.get("/invite-relations")
def list_invite_relations(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(AdminsService(db).list_invite_relations())

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel, Field
class AdminLoginRequest(BaseModel):
username: str
password: str
class ManualAdjustRequest(BaseModel):
amount_points: int = Field(alias="amountPoints")
reason: str

View File

@@ -0,0 +1,134 @@
from datetime import datetime
from fastapi import Response
from sqlalchemy.orm import Session
from app.common.errors.app_error import AuthenticationError, NotFoundAppError
from app.common.security.jwt import (
clear_auth_cookies,
create_access_token,
create_refresh_token,
set_auth_cookies,
)
from app.common.security.password import verify_password
from app.models.entities import InviteRelation
from app.modules.admins.repository import AdminsRepository
from app.modules.wallets.service import WalletService
class AdminsService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AdminsRepository(db)
self.wallet_service = WalletService(db)
def login(self, payload, response: Response) -> dict:
admin = self.repository.get_admin_by_username(payload.username)
if not admin or not verify_password(payload.password, admin.password_hash):
raise AuthenticationError("invalid admin credentials")
admin.last_login_at = datetime.utcnow()
access_token = create_access_token(admin.username, scope="admin")
refresh_token = create_refresh_token(admin.username, scope="admin")
set_auth_cookies(response, access_token, refresh_token, prefix="admin")
self.db.commit()
return self.serialize_admin(admin)
def logout(self, response: Response) -> None:
clear_auth_cookies(response, prefix="admin")
@staticmethod
def serialize_admin(admin) -> dict:
return {
"id": admin.id,
"username": admin.username,
"nickname": admin.nickname,
"isSuperAdmin": admin.is_super_admin,
}
def dashboard(self) -> dict:
total_tasks = self.repository.count_tasks()
success_tasks = self.repository.count_success_tasks()
return {
"users": self.repository.count_users(),
"paidOrders": self.repository.count_paid_orders(),
"tasks": total_tasks,
"successRate": round(success_tasks / total_tasks * 100, 2) if total_tasks else 0,
}
def list_users(self) -> list[dict]:
rows = self.repository.list_users().limit(200).all()
return [
{
"id": item.id,
"publicId": item.public_id,
"username": item.username or "",
"nickname": item.nickname,
"email": item.email or "",
"status": item.status,
"createdAt": item.created_at.isoformat(),
}
for item in rows
]
def get_user_detail(self, user_id: int) -> dict:
user = self.repository.get_user(user_id)
if not user:
raise NotFoundAppError("user not found", code=10020)
wallet = self.wallet_service.get_wallet_summary(user.id)
return {
"id": user.id,
"publicId": user.public_id,
"username": user.username or "",
"nickname": user.nickname,
"email": user.email or "",
"status": user.status,
"wallet": wallet,
}
def manual_adjust_wallet(self, user_id: int, amount_points: int, reason: str) -> dict:
user = self.repository.get_user(user_id)
if not user:
raise NotFoundAppError("user not found", code=10020)
tx = self.wallet_service.add_points(
user.id,
amount_points,
biz_type="manual_adjust",
related_type="user",
related_id=user.id,
remark=reason,
operator_type="admin",
)
self.db.commit()
return {"transactionNo": tx.transaction_no, "amountPoints": amount_points}
def user_invite_relations(self, user_id: int) -> list[dict]:
rows = self.repository.invite_relations().filter(
(InviteRelation.inviter_user_id == user_id) | (InviteRelation.invitee_user_id == user_id)
).all()
return [
{
"id": item.id,
"inviterUserId": item.inviter_user_id,
"inviteeUserId": item.invitee_user_id,
"rewardStatus": item.reward_status,
"rewardPoints": item.reward_points,
"createdAt": item.created_at.isoformat(),
}
for item in rows
]
def list_invite_relations(self) -> list[dict]:
rows = self.repository.invite_relations().limit(200).all()
return [
{
"id": item.id,
"inviterUserId": item.inviter_user_id,
"inviteeUserId": item.invitee_user_id,
"rewardStatus": item.reward_status,
"rewardPoints": item.reward_points,
"rewardedAt": item.rewarded_at.isoformat() if item.rewarded_at else None,
"createdAt": item.created_at.isoformat(),
}
for item in rows
]

View File

@@ -0,0 +1,26 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import MediaAsset
class AssetsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_assets(self, user_id: int):
return (
self.db.query(MediaAsset)
.filter(MediaAsset.user_id == user_id, MediaAsset.status == "active")
.order_by(MediaAsset.id.desc())
)
def get_asset(self, user_id: int, asset_id: int) -> MediaAsset | None:
return self.db.scalar(
select(MediaAsset).where(
MediaAsset.id == asset_id,
MediaAsset.user_id == user_id,
MediaAsset.status == "active",
)
)

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, File, Form, UploadFile
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import get_current_user
from app.models.entities import User
from app.modules.assets.service import AssetsService
router = APIRouter(prefix="/api/v1/assets", tags=["assets"])
@router.post("/upload-token")
def create_upload_token(mediaType: str = "image", db: Session = Depends(get_db)):
return success_response(AssetsService(db).create_upload_token(mediaType))
@router.post("")
def upload_asset(
file: UploadFile = File(...),
mediaType: str = Form("image"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(AssetsService(db).save_asset(current_user.id, file, mediaType))
@router.get("")
def list_assets(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(AssetsService(db).list_assets(current_user.id))
@router.delete("/{asset_id}")
def delete_asset(
asset_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(AssetsService(db).delete_asset(current_user.id, asset_id))

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class UploadTokenRequest(BaseModel):
media_type: str = "image"

View File

@@ -0,0 +1,72 @@
from datetime import datetime
from pathlib import Path
from fastapi import UploadFile
from sqlalchemy.orm import Session
from app.common.errors.app_error import NotFoundAppError
from app.common.utils.id_gen import new_order_no
from app.core.storage import storage_service
from app.models.entities import MediaAsset
from app.modules.assets.repository import AssetsRepository
class AssetsService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AssetsRepository(db)
def create_upload_token(self, media_type: str) -> dict:
return {
"uploadToken": new_order_no("upload"),
"mediaType": media_type,
"uploadMode": "multipart",
}
def save_asset(self, user_id: int, file: UploadFile, media_type: str) -> dict:
stored = storage_service.save_upload(file, folder=f"uploads/{media_type}")
asset = MediaAsset(
asset_no=new_order_no("asset"),
user_id=user_id,
media_type=media_type,
source_type="upload",
original_filename=file.filename or "upload.bin",
mime_type=file.content_type or "application/octet-stream",
file_ext=Path(file.filename or "").suffix,
file_size=stored["file_size"],
storage_provider="local",
storage_bucket="local",
storage_key=stored["storage_key"],
public_url=stored["public_url"],
sha256=stored["sha256"],
status="active",
)
self.db.add(asset)
self.db.commit()
self.db.refresh(asset)
return self.serialize(asset)
def list_assets(self, user_id: int) -> list[dict]:
return [self.serialize(item) for item in self.repository.list_assets(user_id).all()]
def delete_asset(self, user_id: int, asset_id: int) -> dict:
asset = self.repository.get_asset(user_id, asset_id)
if not asset:
raise NotFoundAppError("asset not found", code=40003)
asset.status = "deleted"
asset.deleted_at = datetime.utcnow()
self.db.commit()
return {"assetId": asset_id, "deleted": True}
@staticmethod
def serialize(asset: MediaAsset) -> dict:
return {
"id": asset.id,
"assetNo": asset.asset_no,
"mediaType": asset.media_type,
"originalFilename": asset.original_filename,
"fileSize": asset.file_size,
"publicUrl": asset.public_url,
"createdAt": asset.created_at.isoformat(),
}

View File

@@ -0,0 +1,24 @@
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.models.entities import User
class AuthRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_user_by_account(self, account: str) -> User | None:
return self.db.scalar(
select(User).where(
or_(
User.email == account,
User.mobile == account,
User.username == account,
)
)
)
def get_user_by_public_id(self, public_id: str) -> User | None:
return self.db.scalar(select(User).where(User.public_id == public_id))

View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Cookie, Depends, Request, Response
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import get_current_user
from app.models.entities import User
from app.modules.auth.schema import LoginRequest, RegisterRequest
from app.modules.auth.service import AuthService
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post("/register")
def register(
payload: RegisterRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
data = AuthService(db).register(payload, request, response)
return {"code": 0, "message": "ok", "data": data}
@router.post("/login")
def login(
payload: LoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
data = AuthService(db).login(payload, request, response)
return {"code": 0, "message": "ok", "data": data}
@router.post("/refresh")
def refresh(
response: Response,
db: Session = Depends(get_db),
user_refresh_token: str | None = Cookie(default=None),
):
data = AuthService(db).refresh(user_refresh_token, response)
return {"code": 0, "message": "ok", "data": data}
@router.post("/logout")
def logout(response: Response, db: Session = Depends(get_db)):
AuthService(db).logout(response)
return {"code": 0, "message": "ok", "data": {"success": True}}
@router.get("/me")
def me(current_user: User = Depends(get_current_user)):
return success_response(AuthService.serialize_user(current_user))

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
account: EmailStr
password: str = Field(min_length=8, max_length=64)
invite_code: str | None = None
class LoginRequest(BaseModel):
account: str
password: str = Field(min_length=8, max_length=64)

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import datetime
from fastapi import Request, Response
from sqlalchemy.orm import Session
from app.common.errors.app_error import AuthenticationError, ConflictAppError
from app.common.security.jwt import (
clear_auth_cookies,
create_access_token,
create_refresh_token,
decode_refresh_token,
set_auth_cookies,
)
from app.common.security.password import hash_password, verify_password
from app.common.utils.id_gen import new_public_id
from app.models.entities import InviteCode, InviteRelation, User, Wallet
from app.modules.auth.repository import AuthRepository
from app.modules.wallets.service import WalletService
class AuthService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = AuthRepository(db)
self.wallet_service = WalletService(db)
def register(self, payload, request: Request, response: Response) -> dict:
if self.repository.get_user_by_account(payload.account):
raise ConflictAppError("account already exists", code=10010)
user = User(
public_id=new_public_id("usr"),
email=payload.account,
password_hash=hash_password(payload.password),
nickname=payload.account.split("@")[0],
status=1,
register_ip=request.client.host if request.client else "",
last_login_ip=request.client.host if request.client else "",
last_login_at=datetime.utcnow(),
)
self.db.add(user)
self.db.flush()
self.db.add(Wallet(user_id=user.id))
self.db.flush()
self._bind_invite_relation(user.id, payload.invite_code, request)
self.wallet_service.try_issue_signup_reward(user.id)
self.db.commit()
self.db.refresh(user)
self._issue_tokens(user.public_id, response)
return self.serialize_user(user)
def login(self, payload, request: Request, response: Response) -> dict:
user = self.repository.get_user_by_account(payload.account)
if not user or not verify_password(payload.password, user.password_hash):
raise AuthenticationError("invalid credentials")
user.last_login_at = datetime.utcnow()
user.last_login_ip = request.client.host if request.client else ""
self.db.commit()
self._issue_tokens(user.public_id, response)
return self.serialize_user(user)
def refresh(self, refresh_token: str | None, response: Response) -> dict:
if not refresh_token:
raise AuthenticationError()
try:
payload = decode_refresh_token(refresh_token)
except Exception as exc: # noqa: BLE001
raise AuthenticationError() from exc
if payload.get("scope") != "user":
raise AuthenticationError()
user = self.repository.get_user_by_public_id(payload["sub"])
if not user:
raise AuthenticationError()
self._issue_tokens(user.public_id, response)
return self.serialize_user(user)
def logout(self, response: Response) -> None:
clear_auth_cookies(response, prefix="user")
@staticmethod
def serialize_user(user: User) -> dict:
return {
"publicId": user.public_id,
"username": user.username or "",
"nickname": user.nickname,
"avatarUrl": user.avatar_url,
"email": user.email or "",
"mobile": user.mobile or "",
"status": user.status,
}
def _issue_tokens(self, public_id: str, response: Response) -> None:
access_token = create_access_token(public_id, scope="user")
refresh_token = create_refresh_token(public_id, scope="user")
set_auth_cookies(response, access_token, refresh_token, prefix="user")
def _bind_invite_relation(
self,
invitee_user_id: int,
invite_code_value: str | None,
request: Request,
) -> None:
if not invite_code_value:
return
invite_code = self.db.query(InviteCode).filter(
InviteCode.invite_code == invite_code_value,
InviteCode.status == 1,
).first()
if not invite_code:
return
self.db.add(
InviteRelation(
inviter_user_id=invite_code.user_id,
invitee_user_id=invitee_user_id,
invite_code_id=invite_code.id,
reward_status="pending",
reward_points=0,
register_ip=request.client.host if request.client else "",
)
)
invite_code.used_count += 1

View File

@@ -0,0 +1,15 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import GrowthRewardRule
class GrowthRulesRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_rule(self, rule_type: str) -> GrowthRewardRule | None:
return self.db.scalar(
select(GrowthRewardRule).where(GrowthRewardRule.rule_type == rule_type)
)

View File

@@ -0,0 +1,38 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import require_admin_permission
from app.modules.growth_rules.schema import GrowthRulePayload
from app.modules.growth_rules.service import GrowthRulesService
router = APIRouter(prefix="/api/v1/admin/growth-rules", tags=["admin-growth-rules"])
@router.get("")
def get_growth_rules(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(GrowthRulesService(db).get_rules())
@router.put("/signup")
def update_signup_rule(
payload: GrowthRulePayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(GrowthRulesService(db).update_signup_rule(payload))
@router.put("/invite")
def update_invite_rule(
payload: GrowthRulePayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(GrowthRulesService(db).update_invite_rule(payload))

View File

@@ -0,0 +1,9 @@
from pydantic import BaseModel
class GrowthRulePayload(BaseModel):
enabled: bool
reward_points: int
min_consume_points: int = 0
remark: str = ""

View File

@@ -0,0 +1,44 @@
from sqlalchemy.orm import Session
from app.common.errors.app_error import NotFoundAppError
from app.modules.growth_rules.repository import GrowthRulesRepository
class GrowthRulesService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = GrowthRulesRepository(db)
def get_rules(self) -> dict:
signup = self.repository.get_rule("signup_reward")
invite = self.repository.get_rule("invite_reward")
return {
"signupRewardEnabled": bool(signup.enabled) if signup else False,
"signupRewardPoints": signup.reward_points if signup else 0,
"inviteRewardEnabled": bool(invite.enabled) if invite else False,
"inviteRewardPoints": invite.reward_points if invite else 0,
"inviteRewardTrigger": invite.trigger_condition if invite else "on_first_consume",
"inviteRewardMinConsumePoints": invite.min_consume_points if invite else 0,
}
def update_signup_rule(self, payload) -> dict:
rule = self.repository.get_rule("signup_reward")
if not rule:
raise NotFoundAppError("signup rule not found", code=70010)
rule.enabled = payload.enabled
rule.reward_points = payload.reward_points
rule.remark = payload.remark
self.db.commit()
return self.get_rules()
def update_invite_rule(self, payload) -> dict:
rule = self.repository.get_rule("invite_reward")
if not rule:
raise NotFoundAppError("invite rule not found", code=70011)
rule.enabled = payload.enabled
rule.reward_points = payload.reward_points
rule.min_consume_points = payload.min_consume_points
rule.remark = payload.remark
self.db.commit()
return self.get_rules()

View File

@@ -0,0 +1,35 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import InviteCode, InviteRelation, User
class InviteRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_default_code(self, user_id: int) -> InviteCode | None:
return self.db.scalar(
select(InviteCode).where(
InviteCode.user_id == user_id,
InviteCode.is_default.is_(True),
)
)
def get_code(self, code_value: str) -> InviteCode | None:
return self.db.scalar(select(InviteCode).where(InviteCode.invite_code == code_value))
def inviter_relations(self, user_id: int) -> list[InviteRelation]:
return (
self.db.query(InviteRelation)
.filter(InviteRelation.inviter_user_id == user_id)
.order_by(InviteRelation.id.desc())
.all()
)
def users_by_ids(self, user_ids: list[int]) -> dict[int, User]:
if not user_ids:
return {}
rows = self.db.scalars(select(User).where(User.id.in_(user_ids))).all()
return {row.id: row for row in rows}

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import get_current_user
from app.models.entities import User
from app.modules.invites.service import InviteService
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
@router.get("")
def get_invite_summary(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(InviteService(db).get_invite_summary(current_user.id))
@router.post("/codes")
def create_invite_code(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(InviteService(db).create_invite_code(current_user.id))
@router.get("/relations")
def list_relations(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(InviteService(db).list_relations(current_user.id))
@router.get("/rewards")
def list_rewards(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(InviteService(db).list_rewards(current_user.id))

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class CreateInviteCodeRequest(BaseModel):
regenerate: bool = False

View File

@@ -0,0 +1,75 @@
from sqlalchemy.orm import Session
from app.common.utils.id_gen import new_invite_code
from app.models.entities import InviteCode, InviteRelation
from app.modules.invites.repository import InviteRepository
class InviteService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = InviteRepository(db)
def get_invite_summary(self, user_id: int) -> dict:
code = self.repository.get_default_code(user_id)
if not code:
code = self._create_default_code(user_id)
relations = self.repository.inviter_relations(user_id)
rewarded = [item for item in relations if item.reward_status == "rewarded"]
return {
"inviteCode": code.invite_code,
"inviteLink": code.invite_link,
"invitedUsers": len(relations),
"rewardedUsers": len(rewarded),
"rewardedPoints": sum(item.reward_points for item in rewarded),
}
def create_invite_code(self, user_id: int) -> dict:
code = self.repository.get_default_code(user_id)
if code:
return {"inviteCode": code.invite_code, "inviteLink": code.invite_link}
code = self._create_default_code(user_id)
return {"inviteCode": code.invite_code, "inviteLink": code.invite_link}
def list_relations(self, user_id: int) -> list[dict]:
relations = self.repository.inviter_relations(user_id)
users = self.repository.users_by_ids([item.invitee_user_id for item in relations])
return [
{
"inviteeUserId": item.invitee_user_id,
"inviteeNickname": users.get(item.invitee_user_id).nickname if users.get(item.invitee_user_id) else "",
"rewardStatus": item.reward_status,
"rewardPoints": item.reward_points,
"createdAt": item.created_at.isoformat(),
"rewardedAt": item.rewarded_at.isoformat() if item.rewarded_at else None,
}
for item in relations
]
def list_rewards(self, user_id: int) -> list[dict]:
relations = self.repository.inviter_relations(user_id)
return [
{
"inviteeUserId": item.invitee_user_id,
"rewardStatus": item.reward_status,
"rewardPoints": item.reward_points,
"rewardedAt": item.rewarded_at.isoformat() if item.rewarded_at else None,
}
for item in relations
if item.reward_points > 0
]
def _create_default_code(self, user_id: int) -> InviteCode:
code_value = new_invite_code()
code = InviteCode(
user_id=user_id,
invite_code=code_value,
invite_link=f"http://localhost:3000/register?inviteCode={code_value}",
status=1,
is_default=True,
)
self.db.add(code)
self.db.commit()
self.db.refresh(code)
return code

View File

@@ -0,0 +1,15 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import RechargeOrder
class PaymentsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_order_by_id(self, order_id: int) -> RechargeOrder | None:
return self.db.scalar(select(RechargeOrder).where(RechargeOrder.id == order_id))
def list_orders(self):
return self.db.query(RechargeOrder).order_by(RechargeOrder.id.desc())

View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import require_admin_permission
from app.modules.payments.service import PaymentsService
from app.modules.wallets.service import WalletService
router = APIRouter(prefix="/api/v1/payments", tags=["payments"])
@router.post("/mock-notify")
def mock_notify(order_no: str, db: Session = Depends(get_db)):
return success_response(WalletService(db).handle_mock_payment(order_no))
@router.get("/mock-pay")
def mock_pay(orderNo: str, db: Session = Depends(get_db)):
return success_response(WalletService(db).handle_mock_payment(orderNo))
admin_router = APIRouter(prefix="/api/v1/admin/recharge-orders", tags=["admin-payments"])
@admin_router.get("")
def list_orders(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(PaymentsService(db).list_orders())
@admin_router.get("/{order_id}")
def get_order_detail(
order_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(PaymentsService(db).get_order_detail(order_id))
@admin_router.post("/{order_id}/repair")
def repair_order(
order_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(PaymentsService(db).repair_order(order_id))

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel, Field
class RepairOrderRequest(BaseModel):
remark: str = "manual repair"
class MockPaymentNotifyRequest(BaseModel):
order_no: str = Field(alias="orderNo")

View File

@@ -0,0 +1,42 @@
from sqlalchemy.orm import Session
from app.common.errors.app_error import NotFoundAppError
from app.modules.payments.repository import PaymentsRepository
from app.modules.wallets.service import WalletService
class PaymentsService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = PaymentsRepository(db)
self.wallet_service = WalletService(db)
def repair_order(self, order_id: int) -> dict:
order = self.repository.get_order_by_id(order_id)
if not order:
raise NotFoundAppError("order not found", code=30001)
return self.wallet_service.handle_mock_payment(order.order_no)
def list_orders(self) -> list[dict]:
rows = self.repository.list_orders().limit(200).all()
return [self.serialize_order(item) for item in rows]
def get_order_detail(self, order_id: int) -> dict:
order = self.repository.get_order_by_id(order_id)
if not order:
raise NotFoundAppError("order not found", code=30001)
return self.serialize_order(order)
@staticmethod
def serialize_order(order) -> dict:
return {
"id": order.id,
"orderNo": order.order_no,
"userId": order.user_id,
"payAmount": f"{order.pay_amount:.2f}",
"arrivalPoints": order.arrival_points,
"status": order.status,
"paymentChannelCode": order.payment_channel_code,
"paidAt": order.paid_at.isoformat() if order.paid_at else None,
"createdAt": order.created_at.isoformat(),
}

View File

@@ -0,0 +1,16 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import PricingRule
class PricingRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_rules(self):
return self.db.query(PricingRule).order_by(PricingRule.id.desc())
def get_rule(self, rule_id: int) -> PricingRule | None:
return self.db.scalar(select(PricingRule).where(PricingRule.id == rule_id))

View File

@@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import require_admin_permission
from app.modules.pricing.schema import PricingRulePayload
from app.modules.pricing.service import PricingService
router = APIRouter(prefix="/api/v1/admin/pricing-rules", tags=["admin-pricing"])
@router.get("")
def list_pricing_rules(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(PricingService(db).list_rules())
@router.post("")
def create_pricing_rule(
payload: PricingRulePayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(PricingService(db).create_rule(payload))
@router.put("/{rule_id}")
def update_pricing_rule(
rule_id: int,
payload: PricingRulePayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(PricingService(db).update_rule(rule_id, payload))
@router.post("/{rule_id}/publish")
def publish_pricing_rule(
rule_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(PricingService(db).publish_rule(rule_id))

View File

@@ -0,0 +1,15 @@
from datetime import datetime
from pydantic import BaseModel
class PricingRulePayload(BaseModel):
rule_name: str
video_model_id: int
points_per_second: int
minimum_points: int
effective_at: datetime
expired_at: datetime | None = None
version_no: int = 1
status: int = 1

View File

@@ -0,0 +1,53 @@
from sqlalchemy.orm import Session
from app.common.errors.app_error import NotFoundAppError
from app.models.entities import PricingRule
from app.modules.pricing.repository import PricingRepository
class PricingService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = PricingRepository(db)
def list_rules(self) -> list[dict]:
return [self.serialize(item) for item in self.repository.list_rules().all()]
def create_rule(self, payload) -> dict:
item = PricingRule(**payload.model_dump())
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return self.serialize(item)
def update_rule(self, rule_id: int, payload) -> dict:
item = self.repository.get_rule(rule_id)
if not item:
raise NotFoundAppError("pricing rule not found", code=60004)
for key, value in payload.model_dump().items():
setattr(item, key, value)
self.db.commit()
return self.serialize(item)
def publish_rule(self, rule_id: int) -> dict:
item = self.repository.get_rule(rule_id)
if not item:
raise NotFoundAppError("pricing rule not found", code=60004)
item.status = 1
self.db.commit()
return self.serialize(item)
@staticmethod
def serialize(item: PricingRule) -> dict:
return {
"id": item.id,
"ruleName": item.rule_name,
"videoModelId": item.video_model_id,
"pointsPerSecond": item.points_per_second,
"minimumPoints": item.minimum_points,
"effectiveAt": item.effective_at.isoformat(),
"expiredAt": item.expired_at.isoformat() if item.expired_at else None,
"versionNo": item.version_no,
"status": item.status,
}

View File

@@ -0,0 +1,22 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import ProviderAccount, ProviderModel
class ProvidersRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_accounts(self):
return self.db.query(ProviderAccount).order_by(ProviderAccount.id.desc())
def get_account(self, account_id: int) -> ProviderAccount | None:
return self.db.scalar(select(ProviderAccount).where(ProviderAccount.id == account_id))
def list_models(self):
return self.db.query(ProviderModel).order_by(ProviderModel.id.desc())
def get_model(self, model_id: int) -> ProviderModel | None:
return self.db.scalar(select(ProviderModel).where(ProviderModel.id == model_id))

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import require_admin_permission
from app.modules.providers.schema import ProviderAccountPayload, ProviderModelPayload
from app.modules.providers.service import ProvidersService
router = APIRouter(prefix="/api/v1/admin", tags=["admin-providers"])
@router.get("/provider-accounts")
def list_provider_accounts(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(ProvidersService(db).list_accounts())
@router.post("/provider-accounts")
def create_provider_account(
payload: ProviderAccountPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(ProvidersService(db).create_account(payload))
@router.put("/provider-accounts/{account_id}")
def update_provider_account(
account_id: int,
payload: ProviderAccountPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(ProvidersService(db).update_account(account_id, payload))
@router.get("/provider-models")
def list_provider_models(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(ProvidersService(db).list_models())
@router.post("/provider-models")
def create_provider_model(
payload: ProviderModelPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(ProvidersService(db).create_model(payload))
@router.put("/provider-models/{model_id}")
def update_provider_model(
model_id: int,
payload: ProviderModelPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(ProvidersService(db).update_model(model_id, payload))

View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel
class ProviderAccountPayload(BaseModel):
provider_code: str
provider_name: str
api_format: str
base_url: str
api_key: str
api_secret: str | None = ""
webhook_secret: str | None = ""
timeout_seconds: int = 120
max_retries: int = 3
status: int = 1
remark: str = ""
class ProviderModelPayload(BaseModel):
provider_account_id: int
model_code: str
model_name: str
request_content_type: str = "application/json"
supports_text_to_video: bool = True
supports_image_to_video: bool = False
supports_video_reference: bool = False
supports_audio_reference: bool = False
supports_generate_audio: bool = False
supports_remix: bool = False
supports_webhook: bool = False
min_duration: int = 4
max_duration: int = 12
default_ratio: str = "16:9"
default_resolution: str = "1280x720"
status: int = 1

View File

@@ -0,0 +1,112 @@
from sqlalchemy.orm import Session
from app.common.errors.app_error import NotFoundAppError
from app.models.entities import ProviderAccount, ProviderModel
from app.modules.providers.repository import ProvidersRepository
class ProvidersService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = ProvidersRepository(db)
def list_accounts(self) -> list[dict]:
return [self.serialize_account(item) for item in self.repository.list_accounts().all()]
def create_account(self, payload) -> dict:
item = ProviderAccount(
provider_code=payload.provider_code,
provider_name=payload.provider_name,
api_format=payload.api_format,
base_url=payload.base_url,
api_key_encrypted=payload.api_key,
api_secret_encrypted=payload.api_secret,
webhook_secret_encrypted=payload.webhook_secret,
timeout_seconds=payload.timeout_seconds,
max_retries=payload.max_retries,
status=payload.status,
remark=payload.remark,
)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return self.serialize_account(item)
def update_account(self, account_id: int, payload) -> dict:
item = self.repository.get_account(account_id)
if not item:
raise NotFoundAppError("provider account not found", code=60001)
item.provider_code = payload.provider_code
item.provider_name = payload.provider_name
item.api_format = payload.api_format
item.base_url = payload.base_url
item.api_key_encrypted = payload.api_key
item.api_secret_encrypted = payload.api_secret
item.webhook_secret_encrypted = payload.webhook_secret
item.timeout_seconds = payload.timeout_seconds
item.max_retries = payload.max_retries
item.status = payload.status
item.remark = payload.remark
self.db.commit()
return self.serialize_account(item)
def list_models(self) -> list[dict]:
accounts = {item.id: item for item in self.repository.list_accounts().all()}
return [self.serialize_model(item, accounts) for item in self.repository.list_models().all()]
def create_model(self, payload) -> dict:
item = ProviderModel(**payload.model_dump())
self.db.add(item)
self.db.commit()
self.db.refresh(item)
account = self.repository.get_account(item.provider_account_id)
return self.serialize_model(item, {account.id: account} if account else {})
def update_model(self, model_id: int, payload) -> dict:
item = self.repository.get_model(model_id)
if not item:
raise NotFoundAppError("provider model not found", code=60002)
for key, value in payload.model_dump().items():
setattr(item, key, value)
self.db.commit()
account = self.repository.get_account(item.provider_account_id)
return self.serialize_model(item, {account.id: account} if account else {})
@staticmethod
def serialize_account(item: ProviderAccount) -> dict:
return {
"id": item.id,
"providerCode": item.provider_code,
"providerName": item.provider_name,
"apiFormat": item.api_format,
"baseUrl": item.base_url,
"timeoutSeconds": item.timeout_seconds,
"maxRetries": item.max_retries,
"status": item.status,
"remark": item.remark,
"updatedAt": item.updated_at.isoformat(),
}
@staticmethod
def serialize_model(item: ProviderModel, accounts: dict[int, ProviderAccount]) -> dict:
account = accounts.get(item.provider_account_id)
return {
"id": item.id,
"providerAccountId": item.provider_account_id,
"providerName": account.provider_name if account else "",
"modelCode": item.model_code,
"modelName": item.model_name,
"requestContentType": item.request_content_type,
"supportsTextToVideo": item.supports_text_to_video,
"supportsImageToVideo": item.supports_image_to_video,
"supportsVideoReference": item.supports_video_reference,
"supportsAudioReference": item.supports_audio_reference,
"supportsGenerateAudio": item.supports_generate_audio,
"supportsWebhook": item.supports_webhook,
"minDuration": item.min_duration,
"maxDuration": item.max_duration,
"defaultRatio": item.default_ratio,
"defaultResolution": item.default_resolution,
"status": item.status,
}

View File

@@ -0,0 +1,25 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import CallbackLog, RedeemCode, SystemConfig
class SystemRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_configs(self):
return self.db.query(SystemConfig).order_by(SystemConfig.group_name.asc(), SystemConfig.id.asc())
def get_config(self, config_key: str) -> SystemConfig | None:
return self.db.scalar(select(SystemConfig).where(SystemConfig.config_key == config_key))
def list_redeem_codes(self):
return self.db.query(RedeemCode).order_by(RedeemCode.id.desc())
def get_redeem_code(self, redeem_id: int) -> RedeemCode | None:
return self.db.scalar(select(RedeemCode).where(RedeemCode.id == redeem_id))
def list_callback_logs(self):
return self.db.query(CallbackLog).order_by(CallbackLog.id.desc())

View File

@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import require_admin_permission
from app.modules.system.schema import RedeemBatchCreatePayload, SystemConfigItemPayload
from app.modules.system.service import SystemService
router = APIRouter(prefix="/api/v1/admin", tags=["admin-system"])
@router.get("/system-config")
def list_system_configs(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(SystemService(db).list_configs())
@router.put("/system-config")
def upsert_system_config(
payload: SystemConfigItemPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(SystemService(db).upsert_config(payload))
@router.get("/redeem-codes")
def list_redeem_codes(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(SystemService(db).list_redeem_codes())
@router.post("/redeem-codes/batch-create")
def batch_create_redeem_codes(
payload: RedeemBatchCreatePayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(SystemService(db).batch_create_redeem_codes(payload))
@router.post("/redeem-codes/import")
def import_redeem_codes(
payload: RedeemBatchCreatePayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(SystemService(db).batch_create_redeem_codes(payload))
@router.put("/redeem-codes/{redeem_id}/disable")
def disable_redeem_code(
redeem_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(SystemService(db).disable_redeem_code(redeem_id))
@router.get("/callback-logs")
def list_callback_logs(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(SystemService(db).list_callback_logs())

View File

@@ -0,0 +1,18 @@
from pydantic import BaseModel
class SystemConfigItemPayload(BaseModel):
config_key: str
config_value: str
value_type: str = "string"
group_name: str = "default"
description: str = ""
is_public: bool = False
class RedeemBatchCreatePayload(BaseModel):
batch_no: str
points: int
quantity: int
remark: str = ""

View File

@@ -0,0 +1,94 @@
from app.common.errors.app_error import NotFoundAppError
from app.common.utils.id_gen import new_invite_code
from app.models.entities import RedeemCode, SystemConfig
from app.modules.system.repository import SystemRepository
class SystemService:
def __init__(self, db) -> None:
self.db = db
self.repository = SystemRepository(db)
def list_configs(self) -> list[dict]:
return [
{
"configKey": item.config_key,
"configValue": item.config_value,
"valueType": item.value_type,
"groupName": item.group_name,
"description": item.description,
"isPublic": item.is_public,
}
for item in self.repository.list_configs().all()
]
def upsert_config(self, payload) -> dict:
item = self.repository.get_config(payload.config_key)
if not item:
item = SystemConfig(**payload.model_dump())
self.db.add(item)
else:
for key, value in payload.model_dump().items():
setattr(item, key, value)
self.db.commit()
return {
"configKey": item.config_key,
"configValue": item.config_value,
"groupName": item.group_name,
}
def list_redeem_codes(self) -> list[dict]:
return [
{
"id": item.id,
"batchNo": item.batch_no,
"redeemCode": item.redeem_code,
"points": item.points,
"status": item.status,
"usedByUserId": item.used_by_user_id,
"usedAt": item.used_at.isoformat() if item.used_at else None,
}
for item in self.repository.list_redeem_codes().all()
]
def batch_create_redeem_codes(self, payload) -> list[dict]:
created = []
for _ in range(payload.quantity):
item = RedeemCode(
batch_no=payload.batch_no,
redeem_code=f"{payload.batch_no}-{new_invite_code(4)}-{new_invite_code(4)}",
points=payload.points,
status="unused",
remark=payload.remark,
)
self.db.add(item)
created.append(item)
self.db.commit()
return self.list_redeem_codes()[: payload.quantity]
def disable_redeem_code(self, redeem_id: int) -> dict:
item = self.repository.get_redeem_code(redeem_id)
if not item:
raise NotFoundAppError("redeem code not found", code=70020)
item.status = "disabled"
self.db.commit()
return {
"id": item.id,
"status": item.status,
}
def list_callback_logs(self) -> list[dict]:
return [
{
"id": item.id,
"sourceType": item.source_type,
"sourceCode": item.source_code,
"relatedNo": item.related_no,
"verifyStatus": item.verify_status,
"processStatus": item.process_status,
"errorMessage": item.error_message,
"createdAt": item.created_at.isoformat(),
}
for item in self.repository.list_callback_logs().all()
]

View File

@@ -0,0 +1,16 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.entities import User
class UsersRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_id(self, user_id: int) -> User | None:
return self.db.scalar(select(User).where(User.id == user_id))
def get_by_username(self, username: str) -> User | None:
return self.db.scalar(select(User).where(User.username == username))

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import get_current_user
from app.models.entities import User
from app.modules.users.schema import UpdateProfileRequest
from app.modules.users.service import UsersService
router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
@router.get("")
def get_profile(current_user: User = Depends(get_current_user)):
return success_response(
{
"publicId": current_user.public_id,
"username": current_user.username or "",
"nickname": current_user.nickname,
"avatarUrl": current_user.avatar_url,
"email": current_user.email or "",
"mobile": current_user.mobile or "",
}
)
@router.put("")
def update_profile(
payload: UpdateProfileRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
data = UsersService(db).update_profile(current_user, payload)
return success_response(data)

View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel, Field
class UpdateProfileRequest(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=32)
nickname: str | None = Field(default=None, min_length=1, max_length=32)
avatar_url: str | None = Field(default=None, max_length=500)

View File

@@ -0,0 +1,31 @@
from sqlalchemy.orm import Session
from app.common.errors.app_error import ConflictAppError
from app.models.entities import User
from app.modules.users.repository import UsersRepository
class UsersService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = UsersRepository(db)
def update_profile(self, user: User, payload) -> dict:
if payload.username and payload.username != user.username:
existing = self.repository.get_by_username(payload.username)
if existing and existing.id != user.id:
raise ConflictAppError("username already exists", code=10011)
user.username = payload.username
if payload.nickname is not None:
user.nickname = payload.nickname
if payload.avatar_url is not None:
user.avatar_url = payload.avatar_url
self.db.commit()
return {
"publicId": user.public_id,
"username": user.username or "",
"nickname": user.nickname,
"avatarUrl": user.avatar_url,
"email": user.email or "",
}

View File

@@ -0,0 +1,49 @@
from datetime import datetime
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session
from app.models.entities import PricingRule, ProviderModel, VideoModel, VideoModelSupplierBinding
class VideoModelsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_video_models(self):
return self.db.query(VideoModel).order_by(VideoModel.sort_order.asc(), VideoModel.id.asc())
def get_video_model(self, model_id: int) -> VideoModel | None:
return self.db.scalar(select(VideoModel).where(VideoModel.id == model_id))
def list_bindings(self):
return (
self.db.query(VideoModelSupplierBinding)
.order_by(
VideoModelSupplierBinding.video_model_id.asc(),
VideoModelSupplierBinding.routing_priority.asc(),
)
)
def get_binding(self, binding_id: int) -> VideoModelSupplierBinding | None:
return self.db.scalar(
select(VideoModelSupplierBinding).where(VideoModelSupplierBinding.id == binding_id)
)
def active_pricing_rule(self, video_model_id: int) -> PricingRule | None:
now = datetime.utcnow()
return self.db.scalar(
select(PricingRule)
.where(
PricingRule.video_model_id == video_model_id,
PricingRule.status == 1,
PricingRule.effective_at <= now,
or_(PricingRule.expired_at.is_(None), PricingRule.expired_at > now),
)
.order_by(PricingRule.version_no.desc(), PricingRule.id.desc())
)
def provider_models(self) -> dict[int, ProviderModel]:
rows = self.db.scalars(select(ProviderModel)).all()
return {row.id: row for row in rows}

View File

@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import require_admin_permission
from app.modules.video_models.schema import BindingPayload, VideoModelPayload
from app.modules.video_models.service import VideoModelsService
router = APIRouter(tags=["video-models"])
@router.get("/api/v1/video-models")
def list_public_video_models(db: Session = Depends(get_db)):
return success_response(VideoModelsService(db).list_public_models())
@router.get("/api/v1/admin/video-models")
def list_admin_video_models(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoModelsService(db).list_admin_models())
@router.post("/api/v1/admin/video-models")
def create_video_model(
payload: VideoModelPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoModelsService(db).create_model(payload))
@router.put("/api/v1/admin/video-models/{model_id}")
def update_video_model(
model_id: int,
payload: VideoModelPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoModelsService(db).update_model(model_id, payload))
@router.get("/api/v1/admin/video-model-bindings")
def list_bindings(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoModelsService(db).list_bindings())
@router.post("/api/v1/admin/video-model-bindings")
def create_binding(
payload: BindingPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoModelsService(db).create_binding(payload))
@router.put("/api/v1/admin/video-model-bindings/{binding_id}")
def update_binding(
binding_id: int,
payload: BindingPayload,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoModelsService(db).update_binding(binding_id, payload))

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel
class VideoModelPayload(BaseModel):
model_key: str
model_name: str
frontend_title: str
frontend_description: str = ""
default_duration_seconds: int = 8
default_ratio: str = "16:9"
default_resolution: str = "1280x720"
status: int = 1
sort_order: int = 0
class BindingPayload(BaseModel):
video_model_id: int
provider_model_id: int
routing_priority: int = 10
is_primary: bool = False
status: int = 1
timeout_seconds_override: int | None = None

View File

@@ -0,0 +1,113 @@
from sqlalchemy.orm import Session
from app.common.errors.app_error import NotFoundAppError
from app.models.entities import ProviderModel, VideoModel, VideoModelSupplierBinding
from app.modules.video_models.repository import VideoModelsRepository
class VideoModelsService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = VideoModelsRepository(db)
def list_public_models(self) -> list[dict]:
items = []
for item in self.repository.list_video_models().filter(VideoModel.status == 1).all():
pricing = self.repository.active_pricing_rule(item.id)
items.append(self.serialize_model(item, pricing))
return items
def list_admin_models(self) -> list[dict]:
return [
self.serialize_model(item, self.repository.active_pricing_rule(item.id))
for item in self.repository.list_video_models().all()
]
def create_model(self, payload) -> dict:
item = VideoModel(**payload.model_dump())
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return self.serialize_model(item, None)
def update_model(self, model_id: int, payload) -> dict:
item = self.repository.get_video_model(model_id)
if not item:
raise NotFoundAppError("video model not found", code=50001)
for key, value in payload.model_dump().items():
setattr(item, key, value)
self.db.commit()
return self.serialize_model(item, self.repository.active_pricing_rule(item.id))
def list_bindings(self) -> list[dict]:
provider_models = self.repository.provider_models()
video_models = {
item.id: item for item in self.repository.list_video_models().all()
}
return [
self.serialize_binding(item, provider_models, video_models)
for item in self.repository.list_bindings().all()
]
def create_binding(self, payload) -> dict:
item = VideoModelSupplierBinding(**payload.model_dump())
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return self._serialize_binding_single(item)
def update_binding(self, binding_id: int, payload) -> dict:
item = self.repository.get_binding(binding_id)
if not item:
raise NotFoundAppError("binding not found", code=60003)
for key, value in payload.model_dump().items():
setattr(item, key, value)
self.db.commit()
return self._serialize_binding_single(item)
@staticmethod
def serialize_model(item: VideoModel, pricing) -> dict:
return {
"id": item.id,
"modelKey": item.model_key,
"modelName": item.model_name,
"frontendTitle": item.frontend_title,
"frontendDescription": item.frontend_description,
"defaultDurationSeconds": item.default_duration_seconds,
"defaultRatio": item.default_ratio,
"defaultResolution": item.default_resolution,
"status": item.status,
"sortOrder": item.sort_order,
"pricing": {
"pointsPerSecond": pricing.points_per_second if pricing else 0,
"minimumPoints": pricing.minimum_points if pricing else 0,
},
}
@staticmethod
def serialize_binding(
item: VideoModelSupplierBinding,
provider_models: dict[int, ProviderModel],
video_models: dict[int, VideoModel],
) -> dict:
provider_model = provider_models.get(item.provider_model_id)
video_model = video_models.get(item.video_model_id)
return {
"id": item.id,
"videoModelId": item.video_model_id,
"videoModelName": video_model.model_name if video_model else "",
"providerModelId": item.provider_model_id,
"providerModelName": provider_model.model_name if provider_model else "",
"routingPriority": item.routing_priority,
"isPrimary": item.is_primary,
"status": item.status,
"timeoutSecondsOverride": item.timeout_seconds_override,
}
def _serialize_binding_single(self, item: VideoModelSupplierBinding) -> dict:
provider_models = self.repository.provider_models()
video_models = {
row.id: row for row in self.repository.list_video_models().all()
}
return self.serialize_binding(item, provider_models, video_models)

View File

@@ -0,0 +1,95 @@
from datetime import datetime
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.models.entities import (
MediaAsset,
PricingRule,
ProviderAccount,
ProviderModel,
VideoGenerationTask,
VideoModel,
VideoModelSupplierBinding,
VideoTaskEvent,
)
class VideoTasksRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_video_model(self, model_id: int) -> VideoModel | None:
return self.db.scalar(select(VideoModel).where(VideoModel.id == model_id))
def get_active_pricing(self, video_model_id: int) -> PricingRule | None:
now = datetime.utcnow()
return self.db.scalar(
select(PricingRule)
.where(
PricingRule.video_model_id == video_model_id,
PricingRule.status == 1,
PricingRule.effective_at <= now,
or_(PricingRule.expired_at.is_(None), PricingRule.expired_at > now),
)
.order_by(PricingRule.version_no.desc(), PricingRule.id.desc())
)
def get_bindings(self, video_model_id: int) -> list[VideoModelSupplierBinding]:
return (
self.db.query(VideoModelSupplierBinding)
.filter(
VideoModelSupplierBinding.video_model_id == video_model_id,
VideoModelSupplierBinding.status == 1,
)
.order_by(
VideoModelSupplierBinding.is_primary.desc(),
VideoModelSupplierBinding.routing_priority.asc(),
VideoModelSupplierBinding.id.asc(),
)
.all()
)
def get_provider_model(self, provider_model_id: int) -> ProviderModel | None:
return self.db.scalar(select(ProviderModel).where(ProviderModel.id == provider_model_id))
def get_provider_account(self, provider_account_id: int) -> ProviderAccount | None:
return self.db.scalar(select(ProviderAccount).where(ProviderAccount.id == provider_account_id))
def list_assets(self, user_id: int, asset_ids: list[int]) -> list[MediaAsset]:
if not asset_ids:
return []
return (
self.db.query(MediaAsset)
.filter(
MediaAsset.user_id == user_id,
MediaAsset.id.in_(asset_ids),
MediaAsset.status == "active",
)
.all()
)
def list_tasks(self, user_id: int):
return (
self.db.query(VideoGenerationTask)
.filter(VideoGenerationTask.user_id == user_id, VideoGenerationTask.user_visible == 1)
.order_by(VideoGenerationTask.id.desc())
)
def get_task(self, user_id: int, task_no: str) -> VideoGenerationTask | None:
return self.db.scalar(
select(VideoGenerationTask).where(
VideoGenerationTask.user_id == user_id,
VideoGenerationTask.task_no == task_no,
)
)
def get_task_by_id(self, task_id: int) -> VideoGenerationTask | None:
return self.db.scalar(select(VideoGenerationTask).where(VideoGenerationTask.id == task_id))
def task_events(self, task_id: int):
return (
self.db.query(VideoTaskEvent)
.filter(VideoTaskEvent.video_task_id == task_id)
.order_by(VideoTaskEvent.id.asc())
)

View File

@@ -0,0 +1,104 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import get_current_user, require_admin_permission
from app.models.entities import User
from app.modules.video_tasks.schema import CreateVideoTaskRequest
from app.modules.video_tasks.service import VideoTasksService
router = APIRouter(tags=["video-tasks"])
@router.post("/api/v1/video-tasks")
def create_task(
payload: CreateVideoTaskRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).create_task(current_user.id, payload))
@router.get("/api/v1/video-tasks")
def list_tasks(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).list_tasks(current_user.id))
@router.get("/api/v1/video-tasks/{task_no}")
def get_task_detail(
task_no: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).get_task_detail(current_user.id, task_no))
@router.post("/api/v1/video-tasks/{task_no}/retry")
def retry_task(
task_no: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).retry_task(current_user.id, task_no))
@router.post("/api/v1/video-tasks/{task_no}/cancel")
def cancel_task(
task_no: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).cancel_task(current_user.id, task_no))
@router.delete("/api/v1/video-tasks/{task_no}")
def delete_task(
task_no: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).delete_task(current_user.id, task_no))
admin_router = APIRouter(prefix="/api/v1/admin/video-tasks", tags=["admin-video-tasks"])
@admin_router.get("")
def admin_list_tasks(
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).admin_list_tasks())
@admin_router.get("/{task_id}")
def admin_get_task(
task_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).admin_get_task(task_id))
@admin_router.post("/{task_id}/retry")
def admin_retry_task(
task_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).admin_retry_task(task_id))
@admin_router.post("/{task_id}/refund")
def admin_refund_task(
task_id: int,
_=Depends(require_admin_permission()),
db: Session = Depends(get_db),
):
return success_response(VideoTasksService(db).admin_refund_task(task_id))

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel, Field
class CreateVideoTaskRequest(BaseModel):
videoModelId: int
prompt: str = Field(min_length=1, max_length=4000)
durationSeconds: int = Field(ge=4, le=15)
resolution: str = "1280x720"
ratio: str = "16:9"
generateAudio: bool = False
referenceImageAssetIds: list[int] = Field(default_factory=list)
referenceVideoAssetIds: list[int] = Field(default_factory=list)
referenceAudioAssetIds: list[int] = Field(default_factory=list)

View File

@@ -0,0 +1,343 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.common.errors.app_error import BusinessAppError, NotFoundAppError
from app.common.utils.id_gen import new_order_no
from app.core.providers import build_adapter
from app.core.storage import storage_service
from app.models.entities import MediaAsset, VideoGenerationTask, VideoTaskEvent
from app.modules.video_tasks.repository import VideoTasksRepository
from app.modules.wallets.service import WalletService
class VideoTasksService:
FINAL_STATUSES = {"succeeded", "failed", "cancelled", "timed_out"}
def __init__(self, db: Session) -> None:
self.db = db
self.repository = VideoTasksRepository(db)
self.wallet_service = WalletService(db)
def create_task(self, user_id: int, payload) -> dict:
video_model = self.repository.get_video_model(payload.videoModelId)
if not video_model or video_model.status != 1:
raise BusinessAppError("video model unavailable", code=50002)
pricing = self.repository.get_active_pricing(video_model.id)
if not pricing:
raise BusinessAppError("pricing rule unavailable", code=50003)
binding = self._select_binding(video_model.id)
provider_model = self.repository.get_provider_model(binding.provider_model_id)
provider_account = self.repository.get_provider_account(provider_model.provider_account_id)
estimated_points = max(
pricing.minimum_points,
pricing.points_per_second * payload.durationSeconds,
)
normalized = self._build_normalized_payload(user_id, payload, provider_model.id, provider_account.id)
task = VideoGenerationTask(
task_no=new_order_no("vt"),
user_id=user_id,
video_model_id=video_model.id,
provider_account_id=provider_account.id,
provider_model_id=provider_model.id,
provider_binding_id=binding.id,
pricing_rule_id=pricing.id,
task_status="queued",
generation_mode=self._infer_generation_mode(payload),
prompt_text=payload.prompt,
request_payload=normalized,
duration_seconds=payload.durationSeconds,
ratio=payload.ratio,
resolution=payload.resolution,
generate_audio=payload.generateAudio,
estimated_points=estimated_points,
frozen_points=estimated_points,
)
self.db.add(task)
self.db.flush()
self.wallet_service.freeze_points(
user_id,
estimated_points,
related_type="video_task",
related_id=task.id,
remark=f"freeze for {task.task_no}",
)
self._add_event(task.id, "created", "task created")
self._submit_task(task, provider_account, provider_model)
self.db.commit()
return {
"taskNo": task.task_no,
"taskStatus": task.task_status,
"estimatedPoints": task.estimated_points,
"frozenPoints": task.frozen_points,
}
def list_tasks(self, user_id: int) -> list[dict]:
tasks = self.repository.list_tasks(user_id).limit(100).all()
for task in tasks:
self._refresh_task_progress(task)
self.db.commit()
return [self.serialize_task_summary(task) for task in tasks if task.user_visible]
def get_task_detail(self, user_id: int, task_no: str) -> dict:
task = self.repository.get_task(user_id, task_no)
if not task:
raise NotFoundAppError("task not found", code=50006)
self._refresh_task_progress(task)
self.db.commit()
return self.serialize_task_detail(task)
def retry_task(self, user_id: int, task_no: str) -> dict:
task = self.repository.get_task(user_id, task_no)
if not task:
raise NotFoundAppError("task not found", code=50006)
payload = task.request_payload
class RetryPayload:
videoModelId = payload["videoModelId"]
prompt = payload["prompt"]
durationSeconds = payload["durationSeconds"]
resolution = payload["resolution"]
ratio = payload["ratio"]
generateAudio = payload["generateAudio"]
referenceImageAssetIds = payload.get("referenceImageAssetIds", [])
referenceVideoAssetIds = payload.get("referenceVideoAssetIds", [])
referenceAudioAssetIds = payload.get("referenceAudioAssetIds", [])
return self.create_task(user_id, RetryPayload)
def cancel_task(self, user_id: int, task_no: str) -> dict:
task = self.repository.get_task(user_id, task_no)
if not task:
raise NotFoundAppError("task not found", code=50006)
if task.task_status in self.FINAL_STATUSES:
return {"taskNo": task.task_no, "taskStatus": task.task_status}
task.task_status = "cancelled"
task.finished_at = datetime.utcnow()
self.wallet_service.release_frozen_points(
user_id,
task.frozen_points,
related_type="video_task",
related_id=task.id,
remark=f"cancel {task.task_no}",
)
self._add_event(task.id, "cancelled", "task cancelled")
self.db.commit()
return {"taskNo": task.task_no, "taskStatus": task.task_status}
def delete_task(self, user_id: int, task_no: str) -> dict:
task = self.repository.get_task(user_id, task_no)
if not task:
raise NotFoundAppError("task not found", code=50006)
task.user_visible = False
task.user_deleted_at = datetime.utcnow()
self.db.commit()
return {"taskNo": task.task_no, "deleted": True}
def admin_list_tasks(self) -> list[dict]:
tasks = self.db.query(VideoGenerationTask).order_by(VideoGenerationTask.id.desc()).limit(200).all()
for task in tasks:
self._refresh_task_progress(task)
self.db.commit()
return [self.serialize_task_summary(task) for task in tasks]
def admin_get_task(self, task_id: int) -> dict:
task = self.repository.get_task_by_id(task_id)
if not task:
raise NotFoundAppError("task not found", code=50006)
self._refresh_task_progress(task)
self.db.commit()
return self.serialize_task_detail(task)
def admin_retry_task(self, task_id: int) -> dict:
task = self.repository.get_task_by_id(task_id)
if not task:
raise NotFoundAppError("task not found", code=50006)
return self.retry_task(task.user_id, task.task_no)
def admin_refund_task(self, task_id: int) -> dict:
task = self.repository.get_task_by_id(task_id)
if not task:
raise NotFoundAppError("task not found", code=50006)
tx = self.wallet_service.add_points(
task.user_id,
task.final_points or task.frozen_points,
biz_type="refund",
related_type="video_task",
related_id=task.id,
remark=f"manual refund for {task.task_no}",
operator_type="admin",
)
self._add_event(task.id, "refund", "manual refund")
self.db.commit()
return {"taskNo": task.task_no, "refundTransactionNo": tx.transaction_no}
def _select_binding(self, video_model_id: int):
bindings = self.repository.get_bindings(video_model_id)
if not bindings:
raise BusinessAppError("no available provider", code=50003)
return bindings[0]
def _build_normalized_payload(self, user_id: int, payload, provider_model_id: int, provider_account_id: int) -> dict:
image_assets = self.repository.list_assets(user_id, payload.referenceImageAssetIds)
video_assets = self.repository.list_assets(user_id, payload.referenceVideoAssetIds)
audio_assets = self.repository.list_assets(user_id, payload.referenceAudioAssetIds)
return {
"videoModelId": payload.videoModelId,
"providerModelId": provider_model_id,
"providerAccountId": provider_account_id,
"prompt": payload.prompt,
"durationSeconds": payload.durationSeconds,
"resolution": payload.resolution,
"ratio": payload.ratio,
"generateAudio": payload.generateAudio,
"referenceImageAssetIds": payload.referenceImageAssetIds,
"referenceVideoAssetIds": payload.referenceVideoAssetIds,
"referenceAudioAssetIds": payload.referenceAudioAssetIds,
"referenceImages": [{"assetId": item.id, "url": item.public_url} for item in image_assets],
"referenceVideos": [{"assetId": item.id, "url": item.public_url} for item in video_assets],
"referenceAudios": [{"assetId": item.id, "url": item.public_url} for item in audio_assets],
}
@staticmethod
def _infer_generation_mode(payload) -> str:
if payload.referenceImageAssetIds or payload.referenceVideoAssetIds or payload.referenceAudioAssetIds:
return "multimodal"
return "text_to_video"
def _submit_task(self, task: VideoGenerationTask, provider_account, provider_model) -> None:
adapter = build_adapter(provider_account, provider_model)
result = adapter.submit_task(task.request_payload)
task.external_task_id = result["externalTaskId"]
task.task_status = result["normalizedStatus"]
task.submitted_at = datetime.utcnow()
task.response_payload = result
self._add_event(task.id, "submitted", "task submitted to provider")
def _refresh_task_progress(self, task: VideoGenerationTask) -> None:
if task.task_status in self.FINAL_STATUSES:
return
provider_model = self.repository.get_provider_model(task.provider_model_id)
provider_account = self.repository.get_provider_account(task.provider_account_id)
adapter = build_adapter(provider_account, provider_model)
result = adapter.query_task(task)
task.response_payload = result
status = result["normalizedStatus"]
if status == "succeeded" and task.result_asset_id is None:
content = adapter.download_result(task)
stored = storage_service.save_bytes(
content,
filename=f"{task.task_no}.mp4",
folder="generated/videos",
)
asset = MediaAsset(
asset_no=new_order_no("asset"),
user_id=task.user_id,
media_type="video",
source_type="generated",
original_filename=f"{task.task_no}.mp4",
mime_type="video/mp4",
file_ext=".mp4",
file_size=stored["file_size"],
storage_provider="local",
storage_bucket="local",
storage_key=stored["storage_key"],
public_url=stored["public_url"],
sha256=stored["sha256"],
status="active",
)
self.db.add(asset)
self.db.flush()
task.result_asset_id = asset.id
task.final_points = task.estimated_points
task.task_status = "succeeded"
task.finished_at = datetime.utcnow()
self.wallet_service.consume_frozen_points(
task.user_id,
task.frozen_points,
related_type="video_task",
related_id=task.id,
remark=f"consume for {task.task_no}",
)
self.wallet_service.try_issue_invite_reward(task.user_id, task.id, task.final_points)
self._add_event(task.id, "succeeded", "task succeeded")
elif status == "failed":
task.task_status = "failed"
task.fail_reason = result.get("rawResponse", {}).get("message", "provider failed")
task.finished_at = datetime.utcnow()
self.wallet_service.release_frozen_points(
task.user_id,
task.frozen_points,
related_type="video_task",
related_id=task.id,
remark=f"release for {task.task_no}",
)
self._add_event(task.id, "failed", "task failed")
else:
task.task_status = status
def _add_event(self, task_id: int, event_type: str, message: str) -> None:
self.db.add(
VideoTaskEvent(
video_task_id=task_id,
event_type=event_type,
event_message=message,
payload=None,
created_at=datetime.utcnow(),
)
)
def serialize_task_summary(self, task: VideoGenerationTask) -> dict:
return {
"id": task.id,
"taskNo": task.task_no,
"taskStatus": task.task_status,
"durationSeconds": task.duration_seconds,
"estimatedPoints": task.estimated_points,
"finalPoints": task.final_points,
"resultVideoUrl": self._result_url(task),
"failReason": task.fail_reason,
"createdAt": task.created_at.isoformat(),
"finishedAt": task.finished_at.isoformat() if task.finished_at else None,
}
def serialize_task_detail(self, task: VideoGenerationTask) -> dict:
provider_account = self.repository.get_provider_account(task.provider_account_id)
provider_model = self.repository.get_provider_model(task.provider_model_id)
video_model = self.repository.get_video_model(task.video_model_id)
events = self.repository.task_events(task.id).all()
return {
**self.serialize_task_summary(task),
"videoModel": {
"id": task.video_model_id,
"name": video_model.model_name if video_model else "",
},
"provider": {
"providerCode": provider_account.provider_code if provider_account else "",
"providerName": provider_account.provider_name if provider_account else "",
"modelCode": provider_model.model_code if provider_model else "",
"modelName": provider_model.model_name if provider_model else "",
},
"ratio": task.ratio,
"resolution": task.resolution,
"prompt": task.prompt_text or "",
"requestPayload": task.request_payload,
"responsePayload": task.response_payload,
"events": [
{
"eventType": item.event_type,
"eventMessage": item.event_message,
"createdAt": item.created_at.isoformat(),
}
for item in events
],
}
def _result_url(self, task: VideoGenerationTask) -> str:
if not task.result_asset_id:
return ""
asset = self.db.scalar(select(MediaAsset).where(MediaAsset.id == task.result_asset_id))
return asset.public_url if asset else ""

View File

@@ -0,0 +1,43 @@
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.entities import RechargeOrder, RechargePlan, RedeemCode, Wallet, WalletTransaction
class WalletRepository:
def __init__(self, db: Session) -> None:
self.db = db
def lock_wallet(self, user_id: int) -> Wallet:
return self.db.execute(
select(Wallet).where(Wallet.user_id == user_id).with_for_update()
).scalar_one()
def wallet_transactions(self, user_id: int):
return self.db.query(WalletTransaction).filter(WalletTransaction.user_id == user_id)
def recharge_orders(self, user_id: int):
return self.db.query(RechargeOrder).filter(RechargeOrder.user_id == user_id)
def get_recharge_plan(self, plan_id: int) -> RechargePlan | None:
return self.db.scalar(select(RechargePlan).where(RechargePlan.id == plan_id))
def get_order_by_no(self, order_no: str) -> RechargeOrder | None:
return self.db.scalar(select(RechargeOrder).where(RechargeOrder.order_no == order_no))
def lock_redeem_code(self, redeem_code: str) -> RedeemCode | None:
return self.db.execute(
select(RedeemCode)
.where(RedeemCode.redeem_code == redeem_code)
.with_for_update()
).scalar_one_or_none()
def latest_transactions_count(self, user_id: int) -> int:
return (
self.db.query(func.count(WalletTransaction.id))
.filter(WalletTransaction.user_id == user_id)
.scalar()
)

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.common.db.session import get_db
from app.common.responses.api_response import success_response
from app.common.security.deps import get_current_user
from app.models.entities import User
from app.modules.wallets.schema import CreateRechargeOrderRequest, ExchangeRedeemCodeRequest
from app.modules.wallets.service import WalletService
router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"])
@router.get("")
def get_wallet(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return success_response(WalletService(db).get_wallet_summary(current_user.id))
@router.get("/transactions")
def list_transactions(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(WalletService(db).list_transactions(current_user.id))
@router.post("/recharge-orders")
def create_recharge_order(
payload: CreateRechargeOrderRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(WalletService(db).create_recharge_order(current_user.id, payload))
@router.get("/recharge-options")
def get_recharge_options(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(WalletService(db).recharge_options())
@router.get("/recharge-orders")
def list_recharge_orders(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(WalletService(db).list_recharge_orders(current_user.id))
@router.post("/redeem-codes/exchange")
def exchange_redeem_code(
payload: ExchangeRedeemCodeRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(
WalletService(db).exchange_redeem_code(current_user.id, payload, request)
)
@router.get("/redeem-records")
def list_redeem_records(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return success_response(WalletService(db).list_redeem_records(current_user.id))

View File

@@ -0,0 +1,22 @@
from decimal import Decimal
from pydantic import BaseModel, Field
class CreateRechargeOrderRequest(BaseModel):
rechargePlanId: int
paymentChannelCode: str
class ExchangeRedeemCodeRequest(BaseModel):
redeemCode: str
class WalletAdjustRequest(BaseModel):
amount_points: int = Field(alias="amountPoints")
reason: str
class MockPayRequest(BaseModel):
orderNo: str
paidAmount: Decimal | None = Field(default=None)

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.common.config.settings import get_settings
from app.common.errors.app_error import BusinessAppError, NotFoundAppError
from app.common.utils.id_gen import new_order_no
from app.models.entities import (
GrowthRewardRule,
InviteRelation,
PaymentChannel,
RechargeOrder,
RechargePlan,
RedeemCode,
Wallet,
WalletTransaction,
)
from app.modules.wallets.repository import WalletRepository
settings = get_settings()
class WalletService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = WalletRepository(db)
def get_wallet_summary(self, user_id: int) -> dict:
wallet = self.repository.lock_wallet(user_id)
return {
"balancePoints": wallet.balance_points,
"frozenPoints": wallet.frozen_points,
"availablePoints": wallet.balance_points - wallet.frozen_points,
"pointExchangeRatio": settings.point_exchange_ratio,
}
def list_transactions(self, user_id: int) -> list[dict]:
records = (
self.repository.wallet_transactions(user_id)
.order_by(WalletTransaction.id.desc())
.limit(100)
.all()
)
return [
{
"transactionNo": item.transaction_no,
"bizType": item.biz_type,
"direction": item.direction,
"amountPoints": item.amount_points,
"remark": item.remark,
"createdAt": item.created_at.isoformat(),
}
for item in records
]
def list_recharge_orders(self, user_id: int) -> list[dict]:
records = (
self.repository.recharge_orders(user_id)
.order_by(RechargeOrder.id.desc())
.limit(100)
.all()
)
return [
{
"orderNo": item.order_no,
"payAmount": f"{item.pay_amount:.2f}",
"arrivalPoints": item.arrival_points,
"status": item.status,
"paymentChannelCode": item.payment_channel_code,
"paidAt": item.paid_at.isoformat() if item.paid_at else None,
"createdAt": item.created_at.isoformat(),
}
for item in records
]
def recharge_options(self) -> dict:
plans = (
self.db.query(RechargePlan)
.filter(RechargePlan.status == 1)
.order_by(RechargePlan.sort_order.asc(), RechargePlan.id.asc())
.all()
)
channels = (
self.db.query(PaymentChannel)
.filter(PaymentChannel.status == 1)
.order_by(PaymentChannel.sort_order.asc(), PaymentChannel.id.asc())
.all()
)
return {
"plans": [
{
"id": item.id,
"name": item.name,
"payAmount": f"{item.pay_amount:.2f}",
"arrivalPoints": item.give_points + item.bonus_points,
"bonusPoints": item.bonus_points,
}
for item in plans
],
"channels": [
{
"id": item.id,
"channelCode": item.channel_code,
"channelName": item.channel_name,
}
for item in channels
],
}
def list_redeem_records(self, user_id: int) -> list[dict]:
records = (
self.db.query(RedeemCode)
.filter(RedeemCode.used_by_user_id == user_id)
.order_by(RedeemCode.id.desc())
.all()
)
return [
{
"redeemCode": item.redeem_code,
"points": item.points,
"usedAt": item.used_at.isoformat() if item.used_at else None,
}
for item in records
]
def create_recharge_order(self, user_id: int, payload) -> dict:
plan = self.repository.get_recharge_plan(payload.rechargePlanId)
if not plan or plan.status != 1:
raise NotFoundAppError("recharge plan not found", code=30001)
channel = self.db.scalar(
select(PaymentChannel).where(
PaymentChannel.channel_code == payload.paymentChannelCode
)
)
if not channel or channel.status != 1:
raise NotFoundAppError("payment channel not found", code=30002)
arrival_points = plan.give_points + plan.bonus_points
order = RechargeOrder(
order_no=new_order_no("rc"),
user_id=user_id,
recharge_plan_id=plan.id,
payment_channel_id=channel.id,
payment_channel_code=channel.channel_code,
pay_amount=plan.pay_amount,
point_ratio_snapshot=plan.point_ratio,
give_points=plan.give_points,
bonus_points=plan.bonus_points,
arrival_points=arrival_points,
status="pending",
)
self.db.add(order)
self.db.commit()
return {
"orderNo": order.order_no,
"payAmount": f"{order.pay_amount:.2f}",
"arrivalPoints": order.arrival_points,
"payUrl": f"/api/v1/payments/mock-pay?orderNo={order.order_no}",
}
def handle_mock_payment(self, order_no: str) -> dict:
order = self.repository.get_order_by_no(order_no)
if not order:
raise NotFoundAppError("order not found", code=30001)
if order.status == "paid":
return {"orderNo": order.order_no, "status": order.status, "idempotent": True}
wallet = self.repository.lock_wallet(order.user_id)
before_balance = wallet.balance_points
wallet.balance_points += order.arrival_points
wallet.total_recharged_points += order.arrival_points
order.status = "paid"
order.paid_at = datetime.utcnow()
self.db.add(
WalletTransaction(
transaction_no=new_order_no("wt"),
user_id=order.user_id,
wallet_id=wallet.id,
biz_type="recharge",
direction="in",
amount_points=order.arrival_points,
balance_before_points=before_balance,
balance_after_points=wallet.balance_points,
frozen_before_points=wallet.frozen_points,
frozen_after_points=wallet.frozen_points,
related_type="recharge_order",
related_id=order.id,
remark=f"recharge order {order.order_no}",
operator_type="system",
created_at=datetime.utcnow(),
)
)
self.db.commit()
return {
"orderNo": order.order_no,
"status": order.status,
"arrivalPoints": order.arrival_points,
"idempotent": False,
}
def exchange_redeem_code(self, user_id: int, payload, request) -> dict:
redeem_code = self.repository.lock_redeem_code(payload.redeemCode)
if not redeem_code:
raise BusinessAppError("redeem code not found", code=20004)
if redeem_code.status == "used":
raise BusinessAppError("redeem code already used", code=20005)
if redeem_code.status in {"expired", "disabled"}:
raise BusinessAppError("redeem code unavailable", code=20006)
if redeem_code.expired_at and redeem_code.expired_at < datetime.utcnow():
redeem_code.status = "expired"
self.db.commit()
raise BusinessAppError("redeem code expired", code=20006)
wallet = self.repository.lock_wallet(user_id)
before_balance = wallet.balance_points
wallet.balance_points += redeem_code.points
tx = WalletTransaction(
transaction_no=new_order_no("wt"),
user_id=user_id,
wallet_id=wallet.id,
biz_type="redeem_code",
direction="in",
amount_points=redeem_code.points,
balance_before_points=before_balance,
balance_after_points=wallet.balance_points,
frozen_before_points=wallet.frozen_points,
frozen_after_points=wallet.frozen_points,
related_type="redeem_code",
related_id=redeem_code.id,
remark=f"redeem {redeem_code.redeem_code}",
operator_type="user",
operator_id=user_id,
created_at=datetime.utcnow(),
)
self.db.add(tx)
self.db.flush()
redeem_code.status = "used"
redeem_code.used_by_user_id = user_id
redeem_code.wallet_transaction_id = tx.id
redeem_code.used_at = datetime.utcnow()
redeem_code.used_ip = request.client.host if request.client else ""
redeem_code.used_user_agent = request.headers.get("user-agent", "")
self.db.commit()
return {
"redeemCode": redeem_code.redeem_code,
"points": redeem_code.points,
"walletBalance": wallet.balance_points,
}
def add_points(
self,
user_id: int,
amount_points: int,
*,
biz_type: str,
related_type: str,
related_id: int | None,
remark: str,
operator_type: str = "system",
operator_id: int | None = None,
) -> WalletTransaction:
wallet = self.repository.lock_wallet(user_id)
before_balance = wallet.balance_points
wallet.balance_points += amount_points
if biz_type == "recharge":
wallet.total_recharged_points += amount_points
if biz_type in {"refund", "unfreeze"}:
wallet.total_refunded_points += amount_points
tx = WalletTransaction(
transaction_no=new_order_no("wt"),
user_id=user_id,
wallet_id=wallet.id,
biz_type=biz_type,
direction="in",
amount_points=amount_points,
balance_before_points=before_balance,
balance_after_points=wallet.balance_points,
frozen_before_points=wallet.frozen_points,
frozen_after_points=wallet.frozen_points,
related_type=related_type,
related_id=related_id,
remark=remark,
operator_type=operator_type,
operator_id=operator_id,
created_at=datetime.utcnow(),
)
self.db.add(tx)
self.db.flush()
return tx
def freeze_points(self, user_id: int, amount_points: int, *, related_type: str, related_id: int | None, remark: str) -> None:
wallet = self.repository.lock_wallet(user_id)
available_points = wallet.balance_points - wallet.frozen_points
if available_points < amount_points:
raise BusinessAppError("insufficient balance", code=20001)
balance_before = wallet.balance_points
frozen_before = wallet.frozen_points
wallet.frozen_points += amount_points
self.db.add(
WalletTransaction(
transaction_no=new_order_no("wt"),
user_id=user_id,
wallet_id=wallet.id,
biz_type="freeze",
direction="freeze",
amount_points=amount_points,
balance_before_points=balance_before,
balance_after_points=wallet.balance_points,
frozen_before_points=frozen_before,
frozen_after_points=wallet.frozen_points,
related_type=related_type,
related_id=related_id,
remark=remark,
operator_type="system",
created_at=datetime.utcnow(),
)
)
def consume_frozen_points(self, user_id: int, amount_points: int, *, related_type: str, related_id: int | None, remark: str) -> None:
wallet = self.repository.lock_wallet(user_id)
if wallet.frozen_points < amount_points:
raise BusinessAppError("frozen points not enough", code=20003)
balance_before = wallet.balance_points
frozen_before = wallet.frozen_points
wallet.balance_points -= amount_points
wallet.frozen_points -= amount_points
wallet.total_consumed_points += amount_points
self.db.add(
WalletTransaction(
transaction_no=new_order_no("wt"),
user_id=user_id,
wallet_id=wallet.id,
biz_type="consume",
direction="out",
amount_points=amount_points,
balance_before_points=balance_before,
balance_after_points=wallet.balance_points,
frozen_before_points=frozen_before,
frozen_after_points=wallet.frozen_points,
related_type=related_type,
related_id=related_id,
remark=remark,
operator_type="system",
created_at=datetime.utcnow(),
)
)
def release_frozen_points(self, user_id: int, amount_points: int, *, related_type: str, related_id: int | None, remark: str) -> None:
wallet = self.repository.lock_wallet(user_id)
if wallet.frozen_points < amount_points:
amount_points = wallet.frozen_points
balance_before = wallet.balance_points
frozen_before = wallet.frozen_points
wallet.frozen_points -= amount_points
self.db.add(
WalletTransaction(
transaction_no=new_order_no("wt"),
user_id=user_id,
wallet_id=wallet.id,
biz_type="unfreeze",
direction="unfreeze",
amount_points=amount_points,
balance_before_points=balance_before,
balance_after_points=wallet.balance_points,
frozen_before_points=frozen_before,
frozen_after_points=wallet.frozen_points,
related_type=related_type,
related_id=related_id,
remark=remark,
operator_type="system",
created_at=datetime.utcnow(),
)
)
def try_issue_signup_reward(self, user_id: int) -> None:
rule = self.db.scalar(
select(GrowthRewardRule).where(GrowthRewardRule.rule_type == "signup_reward")
)
if not rule or not rule.enabled or rule.reward_points <= 0:
return
exists = self.db.scalar(
select(WalletTransaction).where(
WalletTransaction.user_id == user_id,
WalletTransaction.biz_type == "signup_reward",
)
)
if exists:
return
self.add_points(
user_id,
rule.reward_points,
biz_type="signup_reward",
related_type="growth_rule",
related_id=rule.id,
remark="signup reward",
)
def try_issue_invite_reward(self, user_id: int, task_id: int, final_points: int) -> None:
relation = self.db.scalar(
select(InviteRelation).where(InviteRelation.invitee_user_id == user_id)
)
if not relation or relation.reward_status == "rewarded":
return
rule = self.db.scalar(
select(GrowthRewardRule).where(GrowthRewardRule.rule_type == "invite_reward")
)
if (
not rule
or not rule.enabled
or final_points <= 0
or final_points < rule.min_consume_points
):
return
tx = self.add_points(
relation.inviter_user_id,
rule.reward_points,
biz_type="invite_reward",
related_type="invite_relation",
related_id=relation.id,
remark="invite reward",
)
relation.reward_status = "rewarded"
relation.reward_points = rule.reward_points
relation.first_consumed_task_id = task_id
relation.first_consumed_at = datetime.utcnow()
relation.rewarded_at = datetime.utcnow()
relation.reward_wallet_transaction_id = tx.id

View File

@@ -0,0 +1,10 @@
from celery import Celery
from app.common.config.settings import get_settings
settings = get_settings()
celery_app = Celery("aivideo", broker=settings.redis_url, backend=settings.redis_url)
celery_app.conf.task_always_eager = settings.celery_task_always_eager

View File

@@ -0,0 +1,7 @@
from app.workers.celery_app import celery_app
@celery_app.task(name="video.finalize")
def finalize_video_task(task_id: int) -> int:
return task_id

View File

@@ -0,0 +1,7 @@
from app.workers.celery_app import celery_app
@celery_app.task(name="video.poll")
def poll_video_task(task_id: int) -> int:
return task_id

View File

@@ -0,0 +1,7 @@
from app.workers.celery_app import celery_app
@celery_app.task(name="video.submit")
def submit_video_task(task_id: int) -> int:
return task_id

14
backend/requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
alembic==1.13.2
bcrypt==5.0.0
boto3==1.35.99
celery==5.4.0
email-validator==2.2.0
fastapi==0.115.6
httpx==0.28.1
pydantic-settings==2.7.1
PyJWT==2.10.1
pymysql==1.1.1
python-multipart==0.0.20
redis==5.2.1
sqlalchemy==2.0.36
uvicorn[standard]==0.32.1

12
deploy/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Deploy Notes
Recommended local stack:
- `mysql`: primary business database
- `redis`: cache and async queue broker
- `minio`: object storage
- `backend`: FastAPI API service
- `frontend-web`: user-facing Next.js app
- `frontend-admin`: admin Next.js app
Use the root `docker-compose.yml` for local integration.

42
docker-compose.yml Normal file
View File

@@ -0,0 +1,42 @@
version: "3.9"
services:
mysql:
image: mysql:8.0
container_name: aivideo-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: aivideo
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./sql:/docker-entrypoint-initdb.d
command: --default-authentication-plugin=mysql_native_password
redis:
image: redis:7
container_name: aivideo-redis
restart: unless-stopped
ports:
- "6379:6379"
minio:
image: minio/minio:latest
container_name: aivideo-minio
restart: unless-stopped
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
volumes:
mysql-data:
minio-data:

File diff suppressed because it is too large Load Diff

Binary file not shown.

41
frontend-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
frontend-admin/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
frontend-admin/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
frontend-admin/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,11 @@
import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: path.join(__dirname, ".."),
},
};
export default nextConfig;

View File

@@ -0,0 +1,30 @@
{
"name": "frontend-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"lucide-react": "^0.511.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,44 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
type CallbackLog = {
id: number;
sourceType: string;
sourceCode: string;
relatedNo: string;
verifyStatus: string;
processStatus: string;
errorMessage: string;
};
export default function CallbackLogsPage() {
const query = useQuery({
queryKey: ["callback-logs"],
queryFn: () => api.get<CallbackLog[]>("/api/v1/admin/callback-logs"),
});
return (
<section className="panel">
<h3></h3>
<div className="list-grid">
{query.data?.map((item) => (
<div className="list-item" key={item.id}>
<div className="toolbar">
<strong>{item.sourceType} / {item.sourceCode}</strong>
<StatusBadge value={item.processStatus} />
</div>
<div className="muted">
{item.relatedNo || "-"} · {item.verifyStatus}
</div>
{item.errorMessage ? <div className="muted">{item.errorMessage}</div> : null}
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
export default function DashboardPage() {
const dashboardQuery = useQuery({
queryKey: ["admin-dashboard"],
queryFn: () =>
api.get<{
users: number;
paidOrders: number;
tasks: number;
successRate: number;
}>("/api/v1/admin/dashboard"),
});
const data = dashboardQuery.data;
return (
<section className="stats-grid">
<article className="stat-card">
<h3></h3>
<div className="value">{data?.users ?? 0}</div>
</article>
<article className="stat-card">
<h3></h3>
<div className="value">{data?.paidOrders ?? 0}</div>
</article>
<article className="stat-card">
<h3></h3>
<div className="value">{data?.tasks ?? 0}</div>
</article>
<article className="stat-card">
<h3></h3>
<div className="value">{data?.successRate ?? 0}%</div>
</article>
</section>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
type GrowthRules = {
signupRewardEnabled: boolean;
signupRewardPoints: number;
inviteRewardEnabled: boolean;
inviteRewardPoints: number;
inviteRewardMinConsumePoints: number;
};
export default function GrowthRulesPage() {
const rulesQuery = useQuery({
queryKey: ["growth-rules"],
queryFn: () => api.get<GrowthRules>("/api/v1/admin/growth-rules"),
});
const [signup, setSignup] = useState({
enabled: true,
reward_points: 300,
min_consume_points: 0,
remark: "signup reward",
});
const [invite, setInvite] = useState({
enabled: true,
reward_points: 500,
min_consume_points: 100,
remark: "invite reward",
});
useEffect(() => {
if (rulesQuery.data) {
setSignup((previous) => ({
...previous,
enabled: rulesQuery.data.signupRewardEnabled,
reward_points: rulesQuery.data.signupRewardPoints,
}));
setInvite({
enabled: rulesQuery.data.inviteRewardEnabled,
reward_points: rulesQuery.data.inviteRewardPoints,
min_consume_points: rulesQuery.data.inviteRewardMinConsumePoints,
remark: "invite reward",
});
}
}, [rulesQuery.data]);
const signupMutation = useMutation({
mutationFn: () => api.put("/api/v1/admin/growth-rules/signup", signup),
onSuccess: () => rulesQuery.refetch(),
});
const inviteMutation = useMutation({
mutationFn: () => api.put("/api/v1/admin/growth-rules/invite", invite),
onSuccess: () => rulesQuery.refetch(),
});
return (
<div className="two-col-grid">
<section className="panel">
<h3></h3>
<div className="form-stack">
<label className="field-label">
<select
value={signup.enabled ? "1" : "0"}
onChange={(event) =>
setSignup((previous) => ({
...previous,
enabled: event.target.value === "1",
}))
}
>
<option value="1"></option>
<option value="0"></option>
</select>
</label>
<label className="field-label">
<input
type="number"
value={signup.reward_points}
onChange={(event) =>
setSignup((previous) => ({
...previous,
reward_points: Number(event.target.value),
}))
}
/>
</label>
<button className="primary-button" onClick={() => signupMutation.mutate()}>
</button>
</div>
</section>
<section className="panel">
<h3></h3>
<div className="form-stack">
<label className="field-label">
<select
value={invite.enabled ? "1" : "0"}
onChange={(event) =>
setInvite((previous) => ({
...previous,
enabled: event.target.value === "1",
}))
}
>
<option value="1"></option>
<option value="0"></option>
</select>
</label>
<label className="field-label">
<input
type="number"
value={invite.reward_points}
onChange={(event) =>
setInvite((previous) => ({
...previous,
reward_points: Number(event.target.value),
}))
}
/>
</label>
<label className="field-label">
<input
type="number"
value={invite.min_consume_points}
onChange={(event) =>
setInvite((previous) => ({
...previous,
min_consume_points: Number(event.target.value),
}))
}
/>
</label>
<button className="primary-button" onClick={() => inviteMutation.mutate()}>
</button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
type InviteRelation = {
id: number;
inviterUserId: number;
inviteeUserId: number;
rewardStatus: string;
rewardPoints: number;
};
export default function InviteRelationsPage() {
const query = useQuery({
queryKey: ["admin-invite-relations"],
queryFn: () => api.get<InviteRelation[]>("/api/v1/admin/invite-relations"),
});
return (
<section className="panel">
<h3></h3>
<div className="list-grid">
{query.data?.map((item) => (
<div className="list-item" key={item.id}>
<div className="toolbar">
<strong>
{item.inviterUserId} to {item.inviteeUserId}
</strong>
<StatusBadge value={item.rewardStatus} />
</div>
<div className="muted">{item.rewardPoints}</div>
</div>
))}
</div>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More