feat: initialize aivideo project

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

View File

@@ -0,0 +1,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)