feat: initialize aivideo project
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
65
README.md
Normal 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
282
backend/app/core/bootstrap.py
Normal file
282
backend/app/core/bootstrap.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.common.db.session import SessionLocal, engine
|
||||||
|
from app.common.security.password import hash_password
|
||||||
|
from app.common.utils.id_gen import new_invite_code, new_public_id
|
||||||
|
from app.models import Base
|
||||||
|
from app.models.entities import (
|
||||||
|
AdminUser,
|
||||||
|
GrowthRewardRule,
|
||||||
|
PaymentChannel,
|
||||||
|
PricingRule,
|
||||||
|
ProviderAccount,
|
||||||
|
ProviderModel,
|
||||||
|
RechargePlan,
|
||||||
|
RedeemCode,
|
||||||
|
SystemConfig,
|
||||||
|
VideoModel,
|
||||||
|
VideoModelSupplierBinding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_database() -> None:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
seed_defaults(db)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_defaults(db: Session) -> None:
|
||||||
|
from app.common.config.settings import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
admin = db.scalar(select(AdminUser).where(AdminUser.username == settings.admin_username))
|
||||||
|
if not admin:
|
||||||
|
admin = AdminUser(
|
||||||
|
username=settings.admin_username,
|
||||||
|
password_hash=hash_password(settings.admin_password),
|
||||||
|
nickname=settings.admin_nickname,
|
||||||
|
is_super_admin=True,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
|
||||||
|
for rule_type, trigger, points in [
|
||||||
|
("signup_reward", "on_register", 300),
|
||||||
|
("invite_reward", "on_first_consume", 500),
|
||||||
|
]:
|
||||||
|
rule = db.scalar(select(GrowthRewardRule).where(GrowthRewardRule.rule_type == rule_type))
|
||||||
|
if not rule:
|
||||||
|
db.add(
|
||||||
|
GrowthRewardRule(
|
||||||
|
rule_type=rule_type,
|
||||||
|
enabled=True,
|
||||||
|
reward_points=points,
|
||||||
|
trigger_condition=trigger,
|
||||||
|
min_consume_points=settings.invite_reward_min_consume_points,
|
||||||
|
remark=rule_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for channel_code, channel_name, provider_type, sort_order in [
|
||||||
|
("alipay", "支付宝", "manual", 10),
|
||||||
|
("wechat_pay", "微信支付", "manual", 20),
|
||||||
|
]:
|
||||||
|
channel = db.scalar(
|
||||||
|
select(PaymentChannel).where(PaymentChannel.channel_code == channel_code)
|
||||||
|
)
|
||||||
|
if not channel:
|
||||||
|
db.add(
|
||||||
|
PaymentChannel(
|
||||||
|
channel_code=channel_code,
|
||||||
|
channel_name=channel_name,
|
||||||
|
provider_type=provider_type,
|
||||||
|
status=1,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not db.scalar(select(RechargePlan.id)):
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
RechargePlan(
|
||||||
|
name="体验包",
|
||||||
|
pay_amount=Decimal("29.90"),
|
||||||
|
point_ratio=100,
|
||||||
|
give_points=2990,
|
||||||
|
bonus_points=200,
|
||||||
|
sort_order=10,
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
RechargePlan(
|
||||||
|
name="标准包",
|
||||||
|
pay_amount=Decimal("99.00"),
|
||||||
|
point_ratio=100,
|
||||||
|
give_points=9900,
|
||||||
|
bonus_points=1200,
|
||||||
|
sort_order=20,
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
RechargePlan(
|
||||||
|
name="专业包",
|
||||||
|
pay_amount=Decimal("299.00"),
|
||||||
|
point_ratio=100,
|
||||||
|
give_points=29900,
|
||||||
|
bonus_points=4500,
|
||||||
|
sort_order=30,
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not db.scalar(select(ProviderAccount.id)):
|
||||||
|
openai_account = ProviderAccount(
|
||||||
|
provider_code="openai-mock",
|
||||||
|
provider_name="OpenAI Mock",
|
||||||
|
api_format="openai_official_video",
|
||||||
|
base_url="mock://openai",
|
||||||
|
api_key_encrypted="mock",
|
||||||
|
timeout_seconds=60,
|
||||||
|
max_retries=3,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
seedance_account = ProviderAccount(
|
||||||
|
provider_code="seedance-mock",
|
||||||
|
provider_name="Seedance Mock",
|
||||||
|
api_format="seedance_video_generation",
|
||||||
|
base_url="mock://seedance",
|
||||||
|
api_key_encrypted="mock",
|
||||||
|
timeout_seconds=60,
|
||||||
|
max_retries=3,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
db.add_all([openai_account, seedance_account])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
openai_model = ProviderModel(
|
||||||
|
provider_account_id=openai_account.id,
|
||||||
|
model_code="sora-2",
|
||||||
|
model_name="Sora 2",
|
||||||
|
request_content_type="multipart/form-data",
|
||||||
|
supports_text_to_video=True,
|
||||||
|
supports_image_to_video=True,
|
||||||
|
supports_generate_audio=True,
|
||||||
|
supports_webhook=True,
|
||||||
|
min_duration=4,
|
||||||
|
max_duration=12,
|
||||||
|
default_ratio="16:9",
|
||||||
|
default_resolution="1280x720",
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
seedance_model = ProviderModel(
|
||||||
|
provider_account_id=seedance_account.id,
|
||||||
|
model_code="seedance",
|
||||||
|
model_name="Seedance",
|
||||||
|
request_content_type="application/json",
|
||||||
|
supports_text_to_video=True,
|
||||||
|
supports_image_to_video=True,
|
||||||
|
supports_generate_audio=True,
|
||||||
|
supports_webhook=False,
|
||||||
|
min_duration=4,
|
||||||
|
max_duration=12,
|
||||||
|
default_ratio="16:9",
|
||||||
|
default_resolution="1280x720",
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
db.add_all([openai_model, seedance_model])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
standard_model = VideoModel(
|
||||||
|
model_key="standard-video",
|
||||||
|
model_name="标准视频",
|
||||||
|
frontend_title="标准视频",
|
||||||
|
frontend_description="平衡质量与速度,适合大多数日常创作。",
|
||||||
|
default_duration_seconds=8,
|
||||||
|
default_ratio="16:9",
|
||||||
|
default_resolution="1280x720",
|
||||||
|
sort_order=10,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
fast_model = VideoModel(
|
||||||
|
model_key="fast-video",
|
||||||
|
model_name="高速视频",
|
||||||
|
frontend_title="高速视频",
|
||||||
|
frontend_description="更快返回结果,适合灵感验证与批量尝试。",
|
||||||
|
default_duration_seconds=6,
|
||||||
|
default_ratio="16:9",
|
||||||
|
default_resolution="1280x720",
|
||||||
|
sort_order=20,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
db.add_all([standard_model, fast_model])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
VideoModelSupplierBinding(
|
||||||
|
video_model_id=standard_model.id,
|
||||||
|
provider_model_id=openai_model.id,
|
||||||
|
routing_priority=10,
|
||||||
|
is_primary=True,
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
VideoModelSupplierBinding(
|
||||||
|
video_model_id=fast_model.id,
|
||||||
|
provider_model_id=seedance_model.id,
|
||||||
|
routing_priority=10,
|
||||||
|
is_primary=True,
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
PricingRule(
|
||||||
|
rule_name="标准视频默认价格",
|
||||||
|
video_model_id=standard_model.id,
|
||||||
|
points_per_second=120,
|
||||||
|
minimum_points=500,
|
||||||
|
effective_at=datetime.utcnow() - timedelta(days=1),
|
||||||
|
version_no=1,
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
PricingRule(
|
||||||
|
rule_name="高速视频默认价格",
|
||||||
|
video_model_id=fast_model.id,
|
||||||
|
points_per_second=90,
|
||||||
|
minimum_points=400,
|
||||||
|
effective_at=datetime.utcnow() - timedelta(days=1),
|
||||||
|
version_no=1,
|
||||||
|
status=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
default_configs = {
|
||||||
|
"site.title": ("AIVideo", "site"),
|
||||||
|
"site.notice": ("欢迎体验 AIVideo 本地开发版。", "site"),
|
||||||
|
"reward.signup.enabled": ("1", "reward"),
|
||||||
|
"reward.signup.points": ("300", "reward"),
|
||||||
|
"reward.invite.enabled": ("1", "reward"),
|
||||||
|
"reward.invite.points": ("500", "reward"),
|
||||||
|
"invite.code.enabled": ("1", "invite"),
|
||||||
|
"task.default_poll_interval_seconds": ("5", "task"),
|
||||||
|
}
|
||||||
|
for key, (value, group_name) in default_configs.items():
|
||||||
|
if not db.scalar(select(SystemConfig).where(SystemConfig.config_key == key)):
|
||||||
|
db.add(
|
||||||
|
SystemConfig(
|
||||||
|
config_key=key,
|
||||||
|
config_value=value,
|
||||||
|
value_type="string",
|
||||||
|
group_name=group_name,
|
||||||
|
is_public=1 if key.startswith("site.") else 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not db.scalar(select(RedeemCode.id)):
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
RedeemCode(
|
||||||
|
batch_no="WELCOME",
|
||||||
|
redeem_code="SPRING-2026-ABCD-1234",
|
||||||
|
points=1000,
|
||||||
|
status="unused",
|
||||||
|
),
|
||||||
|
RedeemCode(
|
||||||
|
batch_no="WELCOME",
|
||||||
|
redeem_code="SPRING-2026-EFGH-5678",
|
||||||
|
points=1500,
|
||||||
|
status="unused",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
241
backend/app/core/providers.py
Normal file
241
backend/app/core/providers.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.common.config.settings import get_settings
|
||||||
|
from app.common.utils.id_gen import new_public_id
|
||||||
|
from app.models.entities import ProviderAccount, ProviderModel, VideoGenerationTask
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderAdapter:
|
||||||
|
def __init__(self, account: ProviderAccount, provider_model: ProviderModel) -> None:
|
||||||
|
self.account = account
|
||||||
|
self.provider_model = provider_model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_mock(self) -> bool:
|
||||||
|
return self.account.base_url.startswith("mock://")
|
||||||
|
|
||||||
|
def submit_task(self, payload: dict) -> dict:
|
||||||
|
if self.is_mock:
|
||||||
|
return {
|
||||||
|
"externalTaskId": new_public_id("ext"),
|
||||||
|
"normalizedStatus": "submitted",
|
||||||
|
"progress": 0,
|
||||||
|
"rawResponse": {
|
||||||
|
"mock": True,
|
||||||
|
"apiFormat": self.account.api_format,
|
||||||
|
"submittedPayload": payload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.account.api_format == "openai_official_video":
|
||||||
|
return self._submit_openai(payload)
|
||||||
|
if self.account.api_format == "seedance_video_generation":
|
||||||
|
return self._submit_seedance(payload)
|
||||||
|
raise ValueError("unsupported provider format")
|
||||||
|
|
||||||
|
def query_task(self, task: VideoGenerationTask) -> dict:
|
||||||
|
if self.is_mock:
|
||||||
|
return self._query_mock(task)
|
||||||
|
if self.account.api_format == "openai_official_video":
|
||||||
|
return self._query_openai(task.external_task_id)
|
||||||
|
if self.account.api_format == "seedance_video_generation":
|
||||||
|
return self._query_seedance(task.external_task_id)
|
||||||
|
raise ValueError("unsupported provider format")
|
||||||
|
|
||||||
|
def download_result(self, task: VideoGenerationTask) -> bytes:
|
||||||
|
if self.is_mock:
|
||||||
|
return self._download_mock(task)
|
||||||
|
if self.account.api_format == "openai_official_video":
|
||||||
|
return self._download_openai(task.external_task_id)
|
||||||
|
if self.account.api_format == "seedance_video_generation":
|
||||||
|
return self._download_seedance(task)
|
||||||
|
raise ValueError("unsupported provider format")
|
||||||
|
|
||||||
|
def _submit_openai(self, payload: dict) -> dict:
|
||||||
|
files = {
|
||||||
|
"prompt": (None, payload["prompt"]),
|
||||||
|
"model": (None, self.provider_model.model_code),
|
||||||
|
"seconds": (None, str(payload["durationSeconds"])),
|
||||||
|
"size": (None, payload["resolution"]),
|
||||||
|
}
|
||||||
|
response = httpx.post(
|
||||||
|
f"{self.account.base_url.rstrip('/')}/v1/videos",
|
||||||
|
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
|
||||||
|
files=files,
|
||||||
|
timeout=self.account.timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return {
|
||||||
|
"externalTaskId": data["id"],
|
||||||
|
"normalizedStatus": self._normalize_status(data.get("status")),
|
||||||
|
"progress": data.get("progress", 0),
|
||||||
|
"rawResponse": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _submit_seedance(self, payload: dict) -> dict:
|
||||||
|
content = [{"type": "text", "text": payload["prompt"]}]
|
||||||
|
for item in payload.get("referenceImages", []):
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": item["url"]},
|
||||||
|
"role": "reference_image",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = httpx.post(
|
||||||
|
f"{self.account.base_url.rstrip('/')}/v1/video/generations",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.account.api_key_encrypted}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.provider_model.model_code,
|
||||||
|
"content": content,
|
||||||
|
"duration": payload["durationSeconds"],
|
||||||
|
"ratio": payload["ratio"],
|
||||||
|
"generate_audio": payload["generateAudio"],
|
||||||
|
},
|
||||||
|
timeout=self.account.timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
external_id = data.get("id") or data.get("task_id")
|
||||||
|
return {
|
||||||
|
"externalTaskId": external_id,
|
||||||
|
"normalizedStatus": self._normalize_status(data.get("status")),
|
||||||
|
"progress": data.get("progress", 0),
|
||||||
|
"rawResponse": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _query_openai(self, external_task_id: str) -> dict:
|
||||||
|
response = httpx.get(
|
||||||
|
f"{self.account.base_url.rstrip('/')}/v1/videos/{external_task_id}",
|
||||||
|
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
|
||||||
|
timeout=self.account.timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return {
|
||||||
|
"externalTaskId": external_task_id,
|
||||||
|
"normalizedStatus": self._normalize_status(data.get("status")),
|
||||||
|
"progress": data.get("progress", 0),
|
||||||
|
"resultUrl": data.get("result_url", ""),
|
||||||
|
"rawResponse": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _query_seedance(self, external_task_id: str) -> dict:
|
||||||
|
response = httpx.get(
|
||||||
|
f"{self.account.base_url.rstrip('/')}/v1/video/generations/{external_task_id}",
|
||||||
|
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
|
||||||
|
timeout=self.account.timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
result_url = ""
|
||||||
|
if isinstance(data.get("result"), dict):
|
||||||
|
result_url = data["result"].get("video_url", "")
|
||||||
|
return {
|
||||||
|
"externalTaskId": external_task_id,
|
||||||
|
"normalizedStatus": self._normalize_status(data.get("status")),
|
||||||
|
"progress": data.get("progress", 0),
|
||||||
|
"resultUrl": result_url,
|
||||||
|
"rawResponse": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _download_openai(self, external_task_id: str) -> bytes:
|
||||||
|
response = httpx.get(
|
||||||
|
f"{self.account.base_url.rstrip('/')}/v1/videos/{external_task_id}/content",
|
||||||
|
headers={"Authorization": f"Bearer {self.account.api_key_encrypted}"},
|
||||||
|
timeout=self.account.timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
def _download_seedance(self, task: VideoGenerationTask) -> bytes:
|
||||||
|
payload = task.response_payload or {}
|
||||||
|
result_url = payload.get("resultUrl")
|
||||||
|
if not result_url and isinstance(payload.get("rawResponse"), dict):
|
||||||
|
result_url = payload["rawResponse"].get("result", {}).get("video_url")
|
||||||
|
if not result_url:
|
||||||
|
raise ValueError("missing result url")
|
||||||
|
response = httpx.get(result_url, timeout=self.account.timeout_seconds)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
def _query_mock(self, task: VideoGenerationTask) -> dict:
|
||||||
|
started = task.submitted_at or task.created_at
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
elapsed = max(0, int((now - started).total_seconds()))
|
||||||
|
total = max(1, settings.mock_task_run_seconds)
|
||||||
|
progress = min(100, int(elapsed / total * 100))
|
||||||
|
if elapsed < 3:
|
||||||
|
status = "submitted"
|
||||||
|
elif elapsed < total:
|
||||||
|
status = "running"
|
||||||
|
else:
|
||||||
|
status = "succeeded"
|
||||||
|
progress = 100
|
||||||
|
return {
|
||||||
|
"externalTaskId": task.external_task_id,
|
||||||
|
"normalizedStatus": status,
|
||||||
|
"progress": progress,
|
||||||
|
"resultUrl": "",
|
||||||
|
"rawResponse": {
|
||||||
|
"mock": True,
|
||||||
|
"elapsedSeconds": elapsed,
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _download_mock(self, task: VideoGenerationTask) -> bytes:
|
||||||
|
payload = task.request_payload or {}
|
||||||
|
resolution = payload.get("resolution", "1280x720")
|
||||||
|
width, height = resolution.split("x", 1)
|
||||||
|
with TemporaryDirectory() as tmp_dir:
|
||||||
|
target = Path(tmp_dir) / f"{task.task_no}.mp4"
|
||||||
|
command = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
f"color=c=#14213d:s={width}x{height}:d=3",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
str(target),
|
||||||
|
]
|
||||||
|
subprocess.run(command, check=True, capture_output=True)
|
||||||
|
return target.read_bytes()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_status(status: str | None) -> str:
|
||||||
|
mapping = {
|
||||||
|
"queued": "queued",
|
||||||
|
"pending": "queued",
|
||||||
|
"submitted": "submitted",
|
||||||
|
"running": "running",
|
||||||
|
"in_progress": "running",
|
||||||
|
"completed": "succeeded",
|
||||||
|
"succeeded": "succeeded",
|
||||||
|
"failed": "failed",
|
||||||
|
"error": "failed",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
"timed_out": "timed_out",
|
||||||
|
}
|
||||||
|
return mapping.get((status or "").lower(), "running")
|
||||||
|
|
||||||
|
|
||||||
|
def build_adapter(account: ProviderAccount, provider_model: ProviderModel) -> ProviderAdapter:
|
||||||
|
return ProviderAdapter(account, provider_model)
|
||||||
53
backend/app/core/storage.py
Normal file
53
backend/app/core/storage.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from app.common.config.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class LocalStorageService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.root = settings.storage_root
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def build_public_url(self, storage_key: str) -> str:
|
||||||
|
base = settings.storage_public_base_url.rstrip("/")
|
||||||
|
normalized_key = storage_key.replace("\\", "/")
|
||||||
|
return f"{base}/{normalized_key}"
|
||||||
|
|
||||||
|
def save_upload(self, upload: UploadFile, *, folder: str) -> dict:
|
||||||
|
ext = Path(upload.filename or "file.bin").suffix
|
||||||
|
storage_key = f"{folder}/{uuid4().hex}{ext}"
|
||||||
|
path = self.root / storage_key
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = upload.file.read()
|
||||||
|
path.write_bytes(content)
|
||||||
|
return {
|
||||||
|
"storage_key": storage_key,
|
||||||
|
"file_size": len(content),
|
||||||
|
"sha256": hashlib.sha256(content).hexdigest(),
|
||||||
|
"public_url": self.build_public_url(storage_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_bytes(self, content: bytes, *, filename: str, folder: str) -> dict:
|
||||||
|
ext = Path(filename).suffix or ".bin"
|
||||||
|
storage_key = f"{folder}/{uuid4().hex}{ext}"
|
||||||
|
path = self.root / storage_key
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(content)
|
||||||
|
return {
|
||||||
|
"storage_key": storage_key,
|
||||||
|
"file_size": len(content),
|
||||||
|
"sha256": hashlib.sha256(content).hexdigest(),
|
||||||
|
"public_url": self.build_public_url(storage_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
storage_service = LocalStorageService()
|
||||||
123
backend/app/main.py
Normal file
123
backend/app/main.py
Normal 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)
|
||||||
51
backend/app/models/__init__.py
Normal file
51
backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
18
backend/app/models/base.py
Normal file
18
backend/app/models/base.py
Normal 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
|
||||||
|
)
|
||||||
|
|
||||||
438
backend/app/models/entities.py
Normal file
438
backend/app/models/entities.py
Normal 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)
|
||||||
50
backend/app/modules/admins/repository.py
Normal file
50
backend/app/modules/admins/repository.py
Normal 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())
|
||||||
|
|
||||||
86
backend/app/modules/admins/router.py
Normal file
86
backend/app/modules/admins/router.py
Normal 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())
|
||||||
12
backend/app/modules/admins/schema.py
Normal file
12
backend/app/modules/admins/schema.py
Normal 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
|
||||||
|
|
||||||
134
backend/app/modules/admins/service.py
Normal file
134
backend/app/modules/admins/service.py
Normal 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
|
||||||
|
]
|
||||||
|
|
||||||
26
backend/app/modules/assets/repository.py
Normal file
26
backend/app/modules/assets/repository.py
Normal 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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
44
backend/app/modules/assets/router.py
Normal file
44
backend/app/modules/assets/router.py
Normal 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))
|
||||||
|
|
||||||
6
backend/app/modules/assets/schema.py
Normal file
6
backend/app/modules/assets/schema.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UploadTokenRequest(BaseModel):
|
||||||
|
media_type: str = "image"
|
||||||
|
|
||||||
72
backend/app/modules/assets/service.py
Normal file
72
backend/app/modules/assets/service.py
Normal 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(),
|
||||||
|
}
|
||||||
|
|
||||||
24
backend/app/modules/auth/repository.py
Normal file
24
backend/app/modules/auth/repository.py
Normal 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))
|
||||||
|
|
||||||
55
backend/app/modules/auth/router.py
Normal file
55
backend/app/modules/auth/router.py
Normal 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))
|
||||||
13
backend/app/modules/auth/schema.py
Normal file
13
backend/app/modules/auth/schema.py
Normal 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)
|
||||||
|
|
||||||
123
backend/app/modules/auth/service.py
Normal file
123
backend/app/modules/auth/service.py
Normal 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
|
||||||
15
backend/app/modules/growth_rules/repository.py
Normal file
15
backend/app/modules/growth_rules/repository.py
Normal 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)
|
||||||
|
)
|
||||||
|
|
||||||
38
backend/app/modules/growth_rules/router.py
Normal file
38
backend/app/modules/growth_rules/router.py
Normal 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))
|
||||||
|
|
||||||
9
backend/app/modules/growth_rules/schema.py
Normal file
9
backend/app/modules/growth_rules/schema.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class GrowthRulePayload(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
reward_points: int
|
||||||
|
min_consume_points: int = 0
|
||||||
|
remark: str = ""
|
||||||
|
|
||||||
44
backend/app/modules/growth_rules/service.py
Normal file
44
backend/app/modules/growth_rules/service.py
Normal 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()
|
||||||
|
|
||||||
35
backend/app/modules/invites/repository.py
Normal file
35
backend/app/modules/invites/repository.py
Normal 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}
|
||||||
|
|
||||||
43
backend/app/modules/invites/router.py
Normal file
43
backend/app/modules/invites/router.py
Normal 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))
|
||||||
6
backend/app/modules/invites/schema.py
Normal file
6
backend/app/modules/invites/schema.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInviteCodeRequest(BaseModel):
|
||||||
|
regenerate: bool = False
|
||||||
|
|
||||||
75
backend/app/modules/invites/service.py
Normal file
75
backend/app/modules/invites/service.py
Normal 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
|
||||||
|
|
||||||
15
backend/app/modules/payments/repository.py
Normal file
15
backend/app/modules/payments/repository.py
Normal 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())
|
||||||
50
backend/app/modules/payments/router.py
Normal file
50
backend/app/modules/payments/router.py
Normal 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))
|
||||||
10
backend/app/modules/payments/schema.py
Normal file
10
backend/app/modules/payments/schema.py
Normal 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")
|
||||||
|
|
||||||
42
backend/app/modules/payments/service.py
Normal file
42
backend/app/modules/payments/service.py
Normal 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(),
|
||||||
|
}
|
||||||
16
backend/app/modules/pricing/repository.py
Normal file
16
backend/app/modules/pricing/repository.py
Normal 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))
|
||||||
|
|
||||||
48
backend/app/modules/pricing/router.py
Normal file
48
backend/app/modules/pricing/router.py
Normal 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))
|
||||||
|
|
||||||
15
backend/app/modules/pricing/schema.py
Normal file
15
backend/app/modules/pricing/schema.py
Normal 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
|
||||||
|
|
||||||
53
backend/app/modules/pricing/service.py
Normal file
53
backend/app/modules/pricing/service.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
22
backend/app/modules/providers/repository.py
Normal file
22
backend/app/modules/providers/repository.py
Normal 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))
|
||||||
|
|
||||||
66
backend/app/modules/providers/router.py
Normal file
66
backend/app/modules/providers/router.py
Normal 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))
|
||||||
|
|
||||||
35
backend/app/modules/providers/schema.py
Normal file
35
backend/app/modules/providers/schema.py
Normal 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
|
||||||
|
|
||||||
112
backend/app/modules/providers/service.py
Normal file
112
backend/app/modules/providers/service.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
25
backend/app/modules/system/repository.py
Normal file
25
backend/app/modules/system/repository.py
Normal 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())
|
||||||
|
|
||||||
72
backend/app/modules/system/router.py
Normal file
72
backend/app/modules/system/router.py
Normal 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())
|
||||||
|
|
||||||
18
backend/app/modules/system/schema.py
Normal file
18
backend/app/modules/system/schema.py
Normal 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 = ""
|
||||||
|
|
||||||
94
backend/app/modules/system/service.py
Normal file
94
backend/app/modules/system/service.py
Normal 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()
|
||||||
|
]
|
||||||
|
|
||||||
16
backend/app/modules/users/repository.py
Normal file
16
backend/app/modules/users/repository.py
Normal 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))
|
||||||
|
|
||||||
37
backend/app/modules/users/router.py
Normal file
37
backend/app/modules/users/router.py
Normal 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)
|
||||||
|
|
||||||
8
backend/app/modules/users/schema.py
Normal file
8
backend/app/modules/users/schema.py
Normal 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)
|
||||||
|
|
||||||
31
backend/app/modules/users/service.py
Normal file
31
backend/app/modules/users/service.py
Normal 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 "",
|
||||||
|
}
|
||||||
|
|
||||||
49
backend/app/modules/video_models/repository.py
Normal file
49
backend/app/modules/video_models/repository.py
Normal 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}
|
||||||
|
|
||||||
71
backend/app/modules/video_models/router.py
Normal file
71
backend/app/modules/video_models/router.py
Normal 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))
|
||||||
|
|
||||||
23
backend/app/modules/video_models/schema.py
Normal file
23
backend/app/modules/video_models/schema.py
Normal 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
|
||||||
|
|
||||||
113
backend/app/modules/video_models/service.py
Normal file
113
backend/app/modules/video_models/service.py
Normal 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)
|
||||||
|
|
||||||
95
backend/app/modules/video_tasks/repository.py
Normal file
95
backend/app/modules/video_tasks/repository.py
Normal 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())
|
||||||
|
)
|
||||||
104
backend/app/modules/video_tasks/router.py
Normal file
104
backend/app/modules/video_tasks/router.py
Normal 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))
|
||||||
|
|
||||||
13
backend/app/modules/video_tasks/schema.py
Normal file
13
backend/app/modules/video_tasks/schema.py
Normal 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)
|
||||||
343
backend/app/modules/video_tasks/service.py
Normal file
343
backend/app/modules/video_tasks/service.py
Normal 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 ""
|
||||||
43
backend/app/modules/wallets/repository.py
Normal file
43
backend/app/modules/wallets/repository.py
Normal 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()
|
||||||
|
)
|
||||||
|
|
||||||
70
backend/app/modules/wallets/router.py
Normal file
70
backend/app/modules/wallets/router.py
Normal 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))
|
||||||
22
backend/app/modules/wallets/schema.py
Normal file
22
backend/app/modules/wallets/schema.py
Normal 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)
|
||||||
430
backend/app/modules/wallets/service.py
Normal file
430
backend/app/modules/wallets/service.py
Normal 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
|
||||||
10
backend/app/workers/celery_app.py
Normal file
10
backend/app/workers/celery_app.py
Normal 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
|
||||||
|
|
||||||
7
backend/app/workers/tasks_video_finalize.py
Normal file
7
backend/app/workers/tasks_video_finalize.py
Normal 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
|
||||||
|
|
||||||
7
backend/app/workers/tasks_video_poll.py
Normal file
7
backend/app/workers/tasks_video_poll.py
Normal 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
|
||||||
|
|
||||||
7
backend/app/workers/tasks_video_submit.py
Normal file
7
backend/app/workers/tasks_video_submit.py
Normal 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
14
backend/requirements.txt
Normal 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
12
deploy/README.md
Normal 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
42
docker-compose.yml
Normal 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:
|
||||||
|
|
||||||
3461
docs/AI视频平台开发文档.md
Normal file
3461
docs/AI视频平台开发文档.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/Seedance_2.0_客户使用手册.docx
Normal file
BIN
docs/Seedance_2.0_客户使用手册.docx
Normal file
Binary file not shown.
41
frontend-admin/.gitignore
vendored
Normal file
41
frontend-admin/.gitignore
vendored
Normal 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
5
frontend-admin/AGENTS.md
Normal 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
1
frontend-admin/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
36
frontend-admin/README.md
Normal file
36
frontend-admin/README.md
Normal 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.
|
||||||
18
frontend-admin/eslint.config.mjs
Normal file
18
frontend-admin/eslint.config.mjs
Normal 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;
|
||||||
11
frontend-admin/next.config.ts
Normal file
11
frontend-admin/next.config.ts
Normal 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;
|
||||||
30
frontend-admin/package.json
Normal file
30
frontend-admin/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend-admin/postcss.config.mjs
Normal file
7
frontend-admin/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
frontend-admin/public/file.svg
Normal file
1
frontend-admin/public/file.svg
Normal 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 |
1
frontend-admin/public/globe.svg
Normal file
1
frontend-admin/public/globe.svg
Normal 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 |
1
frontend-admin/public/next.svg
Normal file
1
frontend-admin/public/next.svg
Normal 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 |
1
frontend-admin/public/vercel.svg
Normal file
1
frontend-admin/public/vercel.svg
Normal 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 |
1
frontend-admin/public/window.svg
Normal file
1
frontend-admin/public/window.svg
Normal 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 |
44
frontend-admin/src/app/admin/(secure)/callback-logs/page.tsx
Normal file
44
frontend-admin/src/app/admin/(secure)/callback-logs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
42
frontend-admin/src/app/admin/(secure)/dashboard/page.tsx
Normal file
42
frontend-admin/src/app/admin/(secure)/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
150
frontend-admin/src/app/admin/(secure)/growth-rules/page.tsx
Normal file
150
frontend-admin/src/app/admin/(secure)/growth-rules/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
Reference in New Issue
Block a user