feat: initialize aivideo project
This commit is contained in:
70
backend/app/common/config/settings.py
Normal file
70
backend/app/common/config/settings.py
Normal 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()
|
||||
|
||||
3
backend/app/common/db/base.py
Normal file
3
backend/app/common/db/base.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.models.base import Base
|
||||
from app.models.entities import * # noqa: F401,F403
|
||||
|
||||
31
backend/app/common/db/session.py
Normal file
31
backend/app/common/db/session.py
Normal 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()
|
||||
45
backend/app/common/errors/app_error.py
Normal file
45
backend/app/common/errors/app_error.py
Normal 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)
|
||||
|
||||
53
backend/app/common/middleware/logging.py
Normal file
53
backend/app/common/middleware/logging.py
Normal 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
|
||||
|
||||
14
backend/app/common/middleware/request_id.py
Normal file
14
backend/app/common/middleware/request_id.py
Normal 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
|
||||
|
||||
30
backend/app/common/responses/api_response.py
Normal file
30
backend/app/common/responses/api_response.py
Normal 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,
|
||||
}
|
||||
)
|
||||
|
||||
69
backend/app/common/security/deps.py
Normal file
69
backend/app/common/security/deps.py
Normal 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
|
||||
|
||||
77
backend/app/common/security/jwt.py
Normal file
77
backend/app/common/security/jwt.py
Normal 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,
|
||||
)
|
||||
|
||||
9
backend/app/common/security/password.py
Normal file
9
backend/app/common/security/password.py
Normal 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"))
|
||||
17
backend/app/common/utils/id_gen.py
Normal file
17
backend/app/common/utils/id_gen.py
Normal 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))
|
||||
|
||||
7
backend/app/common/utils/pagination.py
Normal file
7
backend/app/common/utils/pagination.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user