From 14b18d67fe74067c0a9aef500a840a0af5d16e57 Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Fri, 17 Apr 2026 18:33:05 +0800 Subject: [PATCH] feat: initialize aivideo project --- .gitignore | 26 + README.md | 65 + backend/app/__init__.py | 1 + backend/app/common/config/settings.py | 70 + backend/app/common/db/base.py | 3 + backend/app/common/db/session.py | 31 + backend/app/common/errors/app_error.py | 45 + backend/app/common/middleware/logging.py | 53 + backend/app/common/middleware/request_id.py | 14 + backend/app/common/responses/api_response.py | 30 + backend/app/common/security/deps.py | 69 + backend/app/common/security/jwt.py | 77 + backend/app/common/security/password.py | 9 + backend/app/common/utils/id_gen.py | 17 + backend/app/common/utils/pagination.py | 7 + backend/app/core/bootstrap.py | 282 + backend/app/core/providers.py | 241 + backend/app/core/storage.py | 53 + backend/app/main.py | 123 + backend/app/models/__init__.py | 51 + backend/app/models/base.py | 18 + backend/app/models/entities.py | 438 ++ backend/app/modules/admins/repository.py | 50 + backend/app/modules/admins/router.py | 86 + backend/app/modules/admins/schema.py | 12 + backend/app/modules/admins/service.py | 134 + backend/app/modules/assets/repository.py | 26 + backend/app/modules/assets/router.py | 44 + backend/app/modules/assets/schema.py | 6 + backend/app/modules/assets/service.py | 72 + backend/app/modules/auth/repository.py | 24 + backend/app/modules/auth/router.py | 55 + backend/app/modules/auth/schema.py | 13 + backend/app/modules/auth/service.py | 123 + .../app/modules/growth_rules/repository.py | 15 + backend/app/modules/growth_rules/router.py | 38 + backend/app/modules/growth_rules/schema.py | 9 + backend/app/modules/growth_rules/service.py | 44 + backend/app/modules/invites/repository.py | 35 + backend/app/modules/invites/router.py | 43 + backend/app/modules/invites/schema.py | 6 + backend/app/modules/invites/service.py | 75 + backend/app/modules/payments/repository.py | 15 + backend/app/modules/payments/router.py | 50 + backend/app/modules/payments/schema.py | 10 + backend/app/modules/payments/service.py | 42 + backend/app/modules/pricing/repository.py | 16 + backend/app/modules/pricing/router.py | 48 + backend/app/modules/pricing/schema.py | 15 + backend/app/modules/pricing/service.py | 53 + backend/app/modules/providers/repository.py | 22 + backend/app/modules/providers/router.py | 66 + backend/app/modules/providers/schema.py | 35 + backend/app/modules/providers/service.py | 112 + backend/app/modules/system/repository.py | 25 + backend/app/modules/system/router.py | 72 + backend/app/modules/system/schema.py | 18 + backend/app/modules/system/service.py | 94 + backend/app/modules/users/repository.py | 16 + backend/app/modules/users/router.py | 37 + backend/app/modules/users/schema.py | 8 + backend/app/modules/users/service.py | 31 + .../app/modules/video_models/repository.py | 49 + backend/app/modules/video_models/router.py | 71 + backend/app/modules/video_models/schema.py | 23 + backend/app/modules/video_models/service.py | 113 + backend/app/modules/video_tasks/repository.py | 95 + backend/app/modules/video_tasks/router.py | 104 + backend/app/modules/video_tasks/schema.py | 13 + backend/app/modules/video_tasks/service.py | 343 + backend/app/modules/wallets/repository.py | 43 + backend/app/modules/wallets/router.py | 70 + backend/app/modules/wallets/schema.py | 22 + backend/app/modules/wallets/service.py | 430 ++ backend/app/workers/celery_app.py | 10 + backend/app/workers/tasks_video_finalize.py | 7 + backend/app/workers/tasks_video_poll.py | 7 + backend/app/workers/tasks_video_submit.py | 7 + backend/requirements.txt | 14 + deploy/README.md | 12 + docker-compose.yml | 42 + docs/AI视频平台开发文档.md | 3461 +++++++++ docs/Seedance_2.0_客户使用手册.docx | Bin 0 -> 31366 bytes frontend-admin/.gitignore | 41 + frontend-admin/AGENTS.md | 5 + frontend-admin/CLAUDE.md | 1 + frontend-admin/README.md | 36 + frontend-admin/eslint.config.mjs | 18 + frontend-admin/next.config.ts | 11 + frontend-admin/package.json | 30 + frontend-admin/postcss.config.mjs | 7 + frontend-admin/public/file.svg | 1 + frontend-admin/public/globe.svg | 1 + frontend-admin/public/next.svg | 1 + frontend-admin/public/vercel.svg | 1 + frontend-admin/public/window.svg | 1 + .../app/admin/(secure)/callback-logs/page.tsx | 44 + .../src/app/admin/(secure)/dashboard/page.tsx | 42 + .../app/admin/(secure)/growth-rules/page.tsx | 150 + .../admin/(secure)/invite-relations/page.tsx | 40 + .../src/app/admin/(secure)/layout.tsx | 10 + .../app/admin/(secure)/pricing-rules/page.tsx | 44 + .../admin/(secure)/provider-accounts/page.tsx | 63 + .../admin/(secure)/provider-models/page.tsx | 52 + .../admin/(secure)/recharge-orders/page.tsx | 54 + .../app/admin/(secure)/redeem-codes/page.tsx | 107 + .../app/admin/(secure)/system-config/page.tsx | 65 + .../src/app/admin/(secure)/users/page.tsx | 95 + .../(secure)/video-model-bindings/page.tsx | 42 + .../app/admin/(secure)/video-models/page.tsx | 45 + .../app/admin/(secure)/video-tasks/page.tsx | 59 + frontend-admin/src/app/admin/login/page.tsx | 79 + frontend-admin/src/app/favicon.ico | Bin 0 -> 25931 bytes frontend-admin/src/app/globals.css | 309 + frontend-admin/src/app/layout.tsx | 37 + frontend-admin/src/app/page.tsx | 6 + frontend-admin/src/components/admin-shell.tsx | 117 + frontend-admin/src/components/providers.tsx | 26 + .../src/components/status-badge.tsx | 31 + frontend-admin/src/lib/api.ts | 60 + frontend-admin/tsconfig.json | 34 + frontend-web/.gitignore | 41 + frontend-web/AGENTS.md | 5 + frontend-web/CLAUDE.md | 1 + frontend-web/README.md | 36 + frontend-web/eslint.config.mjs | 18 + frontend-web/next.config.ts | 11 + frontend-web/package-lock.json | 6571 ++++++++++++++++ frontend-web/package.json | 30 + frontend-web/postcss.config.mjs | 7 + frontend-web/public/file.svg | 1 + frontend-web/public/globe.svg | 1 + frontend-web/public/next.svg | 1 + frontend-web/public/vercel.svg | 1 + frontend-web/public/window.svg | 1 + .../src/app/(dashboard)/invite/page.tsx | 97 + frontend-web/src/app/(dashboard)/layout.tsx | 10 + .../src/app/(dashboard)/profile/page.tsx | 97 + .../src/app/(dashboard)/wallet/page.tsx | 61 + .../app/(dashboard)/wallet/recharge/page.tsx | 119 + .../app/(dashboard)/wallet/redeem/page.tsx | 70 + .../app/(dashboard)/workspace/assets/page.tsx | 86 + .../app/(dashboard)/workspace/create/page.tsx | 213 + .../workspace/tasks/[taskNo]/page.tsx | 119 + .../app/(dashboard)/workspace/tasks/page.tsx | 73 + frontend-web/src/app/favicon.ico | Bin 0 -> 25931 bytes frontend-web/src/app/globals.css | 402 + frontend-web/src/app/layout.tsx | 36 + frontend-web/src/app/login/page.tsx | 85 + frontend-web/src/app/page.tsx | 6 + frontend-web/src/app/register/page.tsx | 10 + frontend-web/src/components/providers.tsx | 26 + frontend-web/src/components/register-form.tsx | 100 + frontend-web/src/components/site-shell.tsx | 118 + frontend-web/src/components/status-badge.tsx | 33 + frontend-web/src/lib/api.ts | 73 + frontend-web/src/lib/types.ts | 84 + frontend-web/tsconfig.json | 34 + package-lock.json | 6662 +++++++++++++++++ package.json | 16 + sql/001_init_schema.sql | 768 ++ 启动命令.txt | 9 + 162 files changed, 26251 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/common/config/settings.py create mode 100644 backend/app/common/db/base.py create mode 100644 backend/app/common/db/session.py create mode 100644 backend/app/common/errors/app_error.py create mode 100644 backend/app/common/middleware/logging.py create mode 100644 backend/app/common/middleware/request_id.py create mode 100644 backend/app/common/responses/api_response.py create mode 100644 backend/app/common/security/deps.py create mode 100644 backend/app/common/security/jwt.py create mode 100644 backend/app/common/security/password.py create mode 100644 backend/app/common/utils/id_gen.py create mode 100644 backend/app/common/utils/pagination.py create mode 100644 backend/app/core/bootstrap.py create mode 100644 backend/app/core/providers.py create mode 100644 backend/app/core/storage.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/entities.py create mode 100644 backend/app/modules/admins/repository.py create mode 100644 backend/app/modules/admins/router.py create mode 100644 backend/app/modules/admins/schema.py create mode 100644 backend/app/modules/admins/service.py create mode 100644 backend/app/modules/assets/repository.py create mode 100644 backend/app/modules/assets/router.py create mode 100644 backend/app/modules/assets/schema.py create mode 100644 backend/app/modules/assets/service.py create mode 100644 backend/app/modules/auth/repository.py create mode 100644 backend/app/modules/auth/router.py create mode 100644 backend/app/modules/auth/schema.py create mode 100644 backend/app/modules/auth/service.py create mode 100644 backend/app/modules/growth_rules/repository.py create mode 100644 backend/app/modules/growth_rules/router.py create mode 100644 backend/app/modules/growth_rules/schema.py create mode 100644 backend/app/modules/growth_rules/service.py create mode 100644 backend/app/modules/invites/repository.py create mode 100644 backend/app/modules/invites/router.py create mode 100644 backend/app/modules/invites/schema.py create mode 100644 backend/app/modules/invites/service.py create mode 100644 backend/app/modules/payments/repository.py create mode 100644 backend/app/modules/payments/router.py create mode 100644 backend/app/modules/payments/schema.py create mode 100644 backend/app/modules/payments/service.py create mode 100644 backend/app/modules/pricing/repository.py create mode 100644 backend/app/modules/pricing/router.py create mode 100644 backend/app/modules/pricing/schema.py create mode 100644 backend/app/modules/pricing/service.py create mode 100644 backend/app/modules/providers/repository.py create mode 100644 backend/app/modules/providers/router.py create mode 100644 backend/app/modules/providers/schema.py create mode 100644 backend/app/modules/providers/service.py create mode 100644 backend/app/modules/system/repository.py create mode 100644 backend/app/modules/system/router.py create mode 100644 backend/app/modules/system/schema.py create mode 100644 backend/app/modules/system/service.py create mode 100644 backend/app/modules/users/repository.py create mode 100644 backend/app/modules/users/router.py create mode 100644 backend/app/modules/users/schema.py create mode 100644 backend/app/modules/users/service.py create mode 100644 backend/app/modules/video_models/repository.py create mode 100644 backend/app/modules/video_models/router.py create mode 100644 backend/app/modules/video_models/schema.py create mode 100644 backend/app/modules/video_models/service.py create mode 100644 backend/app/modules/video_tasks/repository.py create mode 100644 backend/app/modules/video_tasks/router.py create mode 100644 backend/app/modules/video_tasks/schema.py create mode 100644 backend/app/modules/video_tasks/service.py create mode 100644 backend/app/modules/wallets/repository.py create mode 100644 backend/app/modules/wallets/router.py create mode 100644 backend/app/modules/wallets/schema.py create mode 100644 backend/app/modules/wallets/service.py create mode 100644 backend/app/workers/celery_app.py create mode 100644 backend/app/workers/tasks_video_finalize.py create mode 100644 backend/app/workers/tasks_video_poll.py create mode 100644 backend/app/workers/tasks_video_submit.py create mode 100644 backend/requirements.txt create mode 100644 deploy/README.md create mode 100644 docker-compose.yml create mode 100644 docs/AI视频平台开发文档.md create mode 100644 docs/Seedance_2.0_客户使用手册.docx create mode 100644 frontend-admin/.gitignore create mode 100644 frontend-admin/AGENTS.md create mode 100644 frontend-admin/CLAUDE.md create mode 100644 frontend-admin/README.md create mode 100644 frontend-admin/eslint.config.mjs create mode 100644 frontend-admin/next.config.ts create mode 100644 frontend-admin/package.json create mode 100644 frontend-admin/postcss.config.mjs create mode 100644 frontend-admin/public/file.svg create mode 100644 frontend-admin/public/globe.svg create mode 100644 frontend-admin/public/next.svg create mode 100644 frontend-admin/public/vercel.svg create mode 100644 frontend-admin/public/window.svg create mode 100644 frontend-admin/src/app/admin/(secure)/callback-logs/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/dashboard/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/growth-rules/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/invite-relations/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/layout.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/pricing-rules/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/provider-accounts/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/provider-models/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/recharge-orders/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/redeem-codes/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/system-config/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/users/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/video-model-bindings/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/video-models/page.tsx create mode 100644 frontend-admin/src/app/admin/(secure)/video-tasks/page.tsx create mode 100644 frontend-admin/src/app/admin/login/page.tsx create mode 100644 frontend-admin/src/app/favicon.ico create mode 100644 frontend-admin/src/app/globals.css create mode 100644 frontend-admin/src/app/layout.tsx create mode 100644 frontend-admin/src/app/page.tsx create mode 100644 frontend-admin/src/components/admin-shell.tsx create mode 100644 frontend-admin/src/components/providers.tsx create mode 100644 frontend-admin/src/components/status-badge.tsx create mode 100644 frontend-admin/src/lib/api.ts create mode 100644 frontend-admin/tsconfig.json create mode 100644 frontend-web/.gitignore create mode 100644 frontend-web/AGENTS.md create mode 100644 frontend-web/CLAUDE.md create mode 100644 frontend-web/README.md create mode 100644 frontend-web/eslint.config.mjs create mode 100644 frontend-web/next.config.ts create mode 100644 frontend-web/package-lock.json create mode 100644 frontend-web/package.json create mode 100644 frontend-web/postcss.config.mjs create mode 100644 frontend-web/public/file.svg create mode 100644 frontend-web/public/globe.svg create mode 100644 frontend-web/public/next.svg create mode 100644 frontend-web/public/vercel.svg create mode 100644 frontend-web/public/window.svg create mode 100644 frontend-web/src/app/(dashboard)/invite/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/layout.tsx create mode 100644 frontend-web/src/app/(dashboard)/profile/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/wallet/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/wallet/recharge/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/wallet/redeem/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/workspace/assets/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/workspace/create/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/workspace/tasks/[taskNo]/page.tsx create mode 100644 frontend-web/src/app/(dashboard)/workspace/tasks/page.tsx create mode 100644 frontend-web/src/app/favicon.ico create mode 100644 frontend-web/src/app/globals.css create mode 100644 frontend-web/src/app/layout.tsx create mode 100644 frontend-web/src/app/login/page.tsx create mode 100644 frontend-web/src/app/page.tsx create mode 100644 frontend-web/src/app/register/page.tsx create mode 100644 frontend-web/src/components/providers.tsx create mode 100644 frontend-web/src/components/register-form.tsx create mode 100644 frontend-web/src/components/site-shell.tsx create mode 100644 frontend-web/src/components/status-badge.tsx create mode 100644 frontend-web/src/lib/api.ts create mode 100644 frontend-web/src/lib/types.ts create mode 100644 frontend-web/tsconfig.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 sql/001_init_schema.sql create mode 100644 启动命令.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c42fccf --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..043c46f --- /dev/null +++ b/README.md @@ -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` 暴露访问。 + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/common/config/settings.py b/backend/app/common/config/settings.py new file mode 100644 index 0000000..25c6ff9 --- /dev/null +++ b/backend/app/common/config/settings.py @@ -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() + diff --git a/backend/app/common/db/base.py b/backend/app/common/db/base.py new file mode 100644 index 0000000..9f4bab4 --- /dev/null +++ b/backend/app/common/db/base.py @@ -0,0 +1,3 @@ +from app.models.base import Base +from app.models.entities import * # noqa: F401,F403 + diff --git a/backend/app/common/db/session.py b/backend/app/common/db/session.py new file mode 100644 index 0000000..4f58b88 --- /dev/null +++ b/backend/app/common/db/session.py @@ -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() diff --git a/backend/app/common/errors/app_error.py b/backend/app/common/errors/app_error.py new file mode 100644 index 0000000..e2802d1 --- /dev/null +++ b/backend/app/common/errors/app_error.py @@ -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) + diff --git a/backend/app/common/middleware/logging.py b/backend/app/common/middleware/logging.py new file mode 100644 index 0000000..864d663 --- /dev/null +++ b/backend/app/common/middleware/logging.py @@ -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 + diff --git a/backend/app/common/middleware/request_id.py b/backend/app/common/middleware/request_id.py new file mode 100644 index 0000000..906bf5f --- /dev/null +++ b/backend/app/common/middleware/request_id.py @@ -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 + diff --git a/backend/app/common/responses/api_response.py b/backend/app/common/responses/api_response.py new file mode 100644 index 0000000..51e9ef3 --- /dev/null +++ b/backend/app/common/responses/api_response.py @@ -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, + } + ) + diff --git a/backend/app/common/security/deps.py b/backend/app/common/security/deps.py new file mode 100644 index 0000000..299f054 --- /dev/null +++ b/backend/app/common/security/deps.py @@ -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 + diff --git a/backend/app/common/security/jwt.py b/backend/app/common/security/jwt.py new file mode 100644 index 0000000..c694a5b --- /dev/null +++ b/backend/app/common/security/jwt.py @@ -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, + ) + diff --git a/backend/app/common/security/password.py b/backend/app/common/security/password.py new file mode 100644 index 0000000..1136da7 --- /dev/null +++ b/backend/app/common/security/password.py @@ -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")) diff --git a/backend/app/common/utils/id_gen.py b/backend/app/common/utils/id_gen.py new file mode 100644 index 0000000..21aa08e --- /dev/null +++ b/backend/app/common/utils/id_gen.py @@ -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)) + diff --git a/backend/app/common/utils/pagination.py b/backend/app/common/utils/pagination.py new file mode 100644 index 0000000..6b709cd --- /dev/null +++ b/backend/app/common/utils/pagination.py @@ -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) + diff --git a/backend/app/core/bootstrap.py b/backend/app/core/bootstrap.py new file mode 100644 index 0000000..21ce171 --- /dev/null +++ b/backend/app/core/bootstrap.py @@ -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() + diff --git a/backend/app/core/providers.py b/backend/app/core/providers.py new file mode 100644 index 0000000..8c4833c --- /dev/null +++ b/backend/app/core/providers.py @@ -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) diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py new file mode 100644 index 0000000..bb0316c --- /dev/null +++ b/backend/app/core/storage.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..527f249 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..dfd19a3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..c828b08 --- /dev/null +++ b/backend/app/models/base.py @@ -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 + ) + diff --git a/backend/app/models/entities.py b/backend/app/models/entities.py new file mode 100644 index 0000000..5653c27 --- /dev/null +++ b/backend/app/models/entities.py @@ -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) diff --git a/backend/app/modules/admins/repository.py b/backend/app/modules/admins/repository.py new file mode 100644 index 0000000..fb4081b --- /dev/null +++ b/backend/app/modules/admins/repository.py @@ -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()) + diff --git a/backend/app/modules/admins/router.py b/backend/app/modules/admins/router.py new file mode 100644 index 0000000..c0486cf --- /dev/null +++ b/backend/app/modules/admins/router.py @@ -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()) diff --git a/backend/app/modules/admins/schema.py b/backend/app/modules/admins/schema.py new file mode 100644 index 0000000..d2dde4b --- /dev/null +++ b/backend/app/modules/admins/schema.py @@ -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 + diff --git a/backend/app/modules/admins/service.py b/backend/app/modules/admins/service.py new file mode 100644 index 0000000..e156753 --- /dev/null +++ b/backend/app/modules/admins/service.py @@ -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 + ] + diff --git a/backend/app/modules/assets/repository.py b/backend/app/modules/assets/repository.py new file mode 100644 index 0000000..2b5332c --- /dev/null +++ b/backend/app/modules/assets/repository.py @@ -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", + ) + ) + diff --git a/backend/app/modules/assets/router.py b/backend/app/modules/assets/router.py new file mode 100644 index 0000000..e51e661 --- /dev/null +++ b/backend/app/modules/assets/router.py @@ -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)) + diff --git a/backend/app/modules/assets/schema.py b/backend/app/modules/assets/schema.py new file mode 100644 index 0000000..c290367 --- /dev/null +++ b/backend/app/modules/assets/schema.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class UploadTokenRequest(BaseModel): + media_type: str = "image" + diff --git a/backend/app/modules/assets/service.py b/backend/app/modules/assets/service.py new file mode 100644 index 0000000..bd588e6 --- /dev/null +++ b/backend/app/modules/assets/service.py @@ -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(), + } + diff --git a/backend/app/modules/auth/repository.py b/backend/app/modules/auth/repository.py new file mode 100644 index 0000000..d97060f --- /dev/null +++ b/backend/app/modules/auth/repository.py @@ -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)) + diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py new file mode 100644 index 0000000..3236637 --- /dev/null +++ b/backend/app/modules/auth/router.py @@ -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)) diff --git a/backend/app/modules/auth/schema.py b/backend/app/modules/auth/schema.py new file mode 100644 index 0000000..0535cee --- /dev/null +++ b/backend/app/modules/auth/schema.py @@ -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) + diff --git a/backend/app/modules/auth/service.py b/backend/app/modules/auth/service.py new file mode 100644 index 0000000..afe9979 --- /dev/null +++ b/backend/app/modules/auth/service.py @@ -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 diff --git a/backend/app/modules/growth_rules/repository.py b/backend/app/modules/growth_rules/repository.py new file mode 100644 index 0000000..d7c7283 --- /dev/null +++ b/backend/app/modules/growth_rules/repository.py @@ -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) + ) + diff --git a/backend/app/modules/growth_rules/router.py b/backend/app/modules/growth_rules/router.py new file mode 100644 index 0000000..c18dfde --- /dev/null +++ b/backend/app/modules/growth_rules/router.py @@ -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)) + diff --git a/backend/app/modules/growth_rules/schema.py b/backend/app/modules/growth_rules/schema.py new file mode 100644 index 0000000..1e08fa0 --- /dev/null +++ b/backend/app/modules/growth_rules/schema.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class GrowthRulePayload(BaseModel): + enabled: bool + reward_points: int + min_consume_points: int = 0 + remark: str = "" + diff --git a/backend/app/modules/growth_rules/service.py b/backend/app/modules/growth_rules/service.py new file mode 100644 index 0000000..b4e8bda --- /dev/null +++ b/backend/app/modules/growth_rules/service.py @@ -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() + diff --git a/backend/app/modules/invites/repository.py b/backend/app/modules/invites/repository.py new file mode 100644 index 0000000..049c89e --- /dev/null +++ b/backend/app/modules/invites/repository.py @@ -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} + diff --git a/backend/app/modules/invites/router.py b/backend/app/modules/invites/router.py new file mode 100644 index 0000000..8f76eb9 --- /dev/null +++ b/backend/app/modules/invites/router.py @@ -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)) diff --git a/backend/app/modules/invites/schema.py b/backend/app/modules/invites/schema.py new file mode 100644 index 0000000..ee8089b --- /dev/null +++ b/backend/app/modules/invites/schema.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class CreateInviteCodeRequest(BaseModel): + regenerate: bool = False + diff --git a/backend/app/modules/invites/service.py b/backend/app/modules/invites/service.py new file mode 100644 index 0000000..34d581b --- /dev/null +++ b/backend/app/modules/invites/service.py @@ -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 + diff --git a/backend/app/modules/payments/repository.py b/backend/app/modules/payments/repository.py new file mode 100644 index 0000000..16279ea --- /dev/null +++ b/backend/app/modules/payments/repository.py @@ -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()) diff --git a/backend/app/modules/payments/router.py b/backend/app/modules/payments/router.py new file mode 100644 index 0000000..33cb73c --- /dev/null +++ b/backend/app/modules/payments/router.py @@ -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)) diff --git a/backend/app/modules/payments/schema.py b/backend/app/modules/payments/schema.py new file mode 100644 index 0000000..978c9f8 --- /dev/null +++ b/backend/app/modules/payments/schema.py @@ -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") + diff --git a/backend/app/modules/payments/service.py b/backend/app/modules/payments/service.py new file mode 100644 index 0000000..52ed1da --- /dev/null +++ b/backend/app/modules/payments/service.py @@ -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(), + } diff --git a/backend/app/modules/pricing/repository.py b/backend/app/modules/pricing/repository.py new file mode 100644 index 0000000..e09f267 --- /dev/null +++ b/backend/app/modules/pricing/repository.py @@ -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)) + diff --git a/backend/app/modules/pricing/router.py b/backend/app/modules/pricing/router.py new file mode 100644 index 0000000..d0db500 --- /dev/null +++ b/backend/app/modules/pricing/router.py @@ -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)) + diff --git a/backend/app/modules/pricing/schema.py b/backend/app/modules/pricing/schema.py new file mode 100644 index 0000000..a13b054 --- /dev/null +++ b/backend/app/modules/pricing/schema.py @@ -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 + diff --git a/backend/app/modules/pricing/service.py b/backend/app/modules/pricing/service.py new file mode 100644 index 0000000..21f6ee2 --- /dev/null +++ b/backend/app/modules/pricing/service.py @@ -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, + } + diff --git a/backend/app/modules/providers/repository.py b/backend/app/modules/providers/repository.py new file mode 100644 index 0000000..f169806 --- /dev/null +++ b/backend/app/modules/providers/repository.py @@ -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)) + diff --git a/backend/app/modules/providers/router.py b/backend/app/modules/providers/router.py new file mode 100644 index 0000000..8cc3733 --- /dev/null +++ b/backend/app/modules/providers/router.py @@ -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)) + diff --git a/backend/app/modules/providers/schema.py b/backend/app/modules/providers/schema.py new file mode 100644 index 0000000..a96ad37 --- /dev/null +++ b/backend/app/modules/providers/schema.py @@ -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 + diff --git a/backend/app/modules/providers/service.py b/backend/app/modules/providers/service.py new file mode 100644 index 0000000..35fc156 --- /dev/null +++ b/backend/app/modules/providers/service.py @@ -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, + } + diff --git a/backend/app/modules/system/repository.py b/backend/app/modules/system/repository.py new file mode 100644 index 0000000..b8b98e6 --- /dev/null +++ b/backend/app/modules/system/repository.py @@ -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()) + diff --git a/backend/app/modules/system/router.py b/backend/app/modules/system/router.py new file mode 100644 index 0000000..5accf55 --- /dev/null +++ b/backend/app/modules/system/router.py @@ -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()) + diff --git a/backend/app/modules/system/schema.py b/backend/app/modules/system/schema.py new file mode 100644 index 0000000..7efaba2 --- /dev/null +++ b/backend/app/modules/system/schema.py @@ -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 = "" + diff --git a/backend/app/modules/system/service.py b/backend/app/modules/system/service.py new file mode 100644 index 0000000..0746c73 --- /dev/null +++ b/backend/app/modules/system/service.py @@ -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() + ] + diff --git a/backend/app/modules/users/repository.py b/backend/app/modules/users/repository.py new file mode 100644 index 0000000..b8ffd4d --- /dev/null +++ b/backend/app/modules/users/repository.py @@ -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)) + diff --git a/backend/app/modules/users/router.py b/backend/app/modules/users/router.py new file mode 100644 index 0000000..a80b44b --- /dev/null +++ b/backend/app/modules/users/router.py @@ -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) + diff --git a/backend/app/modules/users/schema.py b/backend/app/modules/users/schema.py new file mode 100644 index 0000000..c433f94 --- /dev/null +++ b/backend/app/modules/users/schema.py @@ -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) + diff --git a/backend/app/modules/users/service.py b/backend/app/modules/users/service.py new file mode 100644 index 0000000..8e8a1f6 --- /dev/null +++ b/backend/app/modules/users/service.py @@ -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 "", + } + diff --git a/backend/app/modules/video_models/repository.py b/backend/app/modules/video_models/repository.py new file mode 100644 index 0000000..d5d4870 --- /dev/null +++ b/backend/app/modules/video_models/repository.py @@ -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} + diff --git a/backend/app/modules/video_models/router.py b/backend/app/modules/video_models/router.py new file mode 100644 index 0000000..5dfffa1 --- /dev/null +++ b/backend/app/modules/video_models/router.py @@ -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)) + diff --git a/backend/app/modules/video_models/schema.py b/backend/app/modules/video_models/schema.py new file mode 100644 index 0000000..9a7714c --- /dev/null +++ b/backend/app/modules/video_models/schema.py @@ -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 + diff --git a/backend/app/modules/video_models/service.py b/backend/app/modules/video_models/service.py new file mode 100644 index 0000000..f235ab9 --- /dev/null +++ b/backend/app/modules/video_models/service.py @@ -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) + diff --git a/backend/app/modules/video_tasks/repository.py b/backend/app/modules/video_tasks/repository.py new file mode 100644 index 0000000..09cd048 --- /dev/null +++ b/backend/app/modules/video_tasks/repository.py @@ -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()) + ) diff --git a/backend/app/modules/video_tasks/router.py b/backend/app/modules/video_tasks/router.py new file mode 100644 index 0000000..590de25 --- /dev/null +++ b/backend/app/modules/video_tasks/router.py @@ -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)) + diff --git a/backend/app/modules/video_tasks/schema.py b/backend/app/modules/video_tasks/schema.py new file mode 100644 index 0000000..a35f5b8 --- /dev/null +++ b/backend/app/modules/video_tasks/schema.py @@ -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) diff --git a/backend/app/modules/video_tasks/service.py b/backend/app/modules/video_tasks/service.py new file mode 100644 index 0000000..216868c --- /dev/null +++ b/backend/app/modules/video_tasks/service.py @@ -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 "" diff --git a/backend/app/modules/wallets/repository.py b/backend/app/modules/wallets/repository.py new file mode 100644 index 0000000..89c0860 --- /dev/null +++ b/backend/app/modules/wallets/repository.py @@ -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() + ) + diff --git a/backend/app/modules/wallets/router.py b/backend/app/modules/wallets/router.py new file mode 100644 index 0000000..7c546ea --- /dev/null +++ b/backend/app/modules/wallets/router.py @@ -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)) diff --git a/backend/app/modules/wallets/schema.py b/backend/app/modules/wallets/schema.py new file mode 100644 index 0000000..fc42916 --- /dev/null +++ b/backend/app/modules/wallets/schema.py @@ -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) diff --git a/backend/app/modules/wallets/service.py b/backend/app/modules/wallets/service.py new file mode 100644 index 0000000..be61dd4 --- /dev/null +++ b/backend/app/modules/wallets/service.py @@ -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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py new file mode 100644 index 0000000..22acd92 --- /dev/null +++ b/backend/app/workers/celery_app.py @@ -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 + diff --git a/backend/app/workers/tasks_video_finalize.py b/backend/app/workers/tasks_video_finalize.py new file mode 100644 index 0000000..869f072 --- /dev/null +++ b/backend/app/workers/tasks_video_finalize.py @@ -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 + diff --git a/backend/app/workers/tasks_video_poll.py b/backend/app/workers/tasks_video_poll.py new file mode 100644 index 0000000..ad247ee --- /dev/null +++ b/backend/app/workers/tasks_video_poll.py @@ -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 + diff --git a/backend/app/workers/tasks_video_submit.py b/backend/app/workers/tasks_video_submit.py new file mode 100644 index 0000000..f8962c7 --- /dev/null +++ b/backend/app/workers/tasks_video_submit.py @@ -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 + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0f40f38 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..235734c --- /dev/null +++ b/deploy/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd74b60 --- /dev/null +++ b/docker-compose.yml @@ -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: + diff --git a/docs/AI视频平台开发文档.md b/docs/AI视频平台开发文档.md new file mode 100644 index 0000000..16ecdb1 --- /dev/null +++ b/docs/AI视频平台开发文档.md @@ -0,0 +1,3461 @@ +# AI视频平台开发文档 + +## 1. 项目目标 + +### 1.1 项目定位 + +建设一个类似 `plotparty.ai` 的 AI 视频生成平台,面向普通用户和内容创作者,提供以下能力: + +- 用户注册、登录、充值、查看积分余额 +- 用户通过密钥兑换积分 +- 用户可编辑用户名、头像等个人资料 +- 用户可创建自己的邀请链接或邀请码 +- 用户提交 AI 视频生成任务 +- 用户上传图片、音频、视频等参考素材 +- 用户可查看自己全部视频生成记录,并可自行删除记录 +- 平台根据后台配置调用不同第三方视频模型 API +- 平台根据后台价格规则按秒对用户进行积分扣费 +- 管理员后台可配置多个视频模型供应商,并为每个前台视频模型绑定一个或多个供应商模型 +- 管理员后台可配置注册赠送积分、邀请奖励积分、邀请码功能是否开启 +- 管理员在后台配置 API、模型、积分价格、充值比例、套餐、公告和风控策略 +- 平台记录完整的任务、计费、充值、回调和操作日志 + +### 1.2 首期范围 + +首版建议聚焦下面 4 个核心闭环: + +1. 用户充值到账 +2. 用户发起视频生成任务 +3. 后端异步调用第三方模型并查询结果 +4. 管理员可动态调整模型、价格和充值配置 + +### 1.3 不在首版强制范围内 + +- 社区广场、作品公开发布、点赞评论 +- 多租户代理分销 +- 多级分销返佣 +- 自研模型推理集群 +- 视频在线剪辑器 +- 高级工作流编排 + +这些能力可以在首版稳定后迭代。 + +## 2. 技术选型 + +### 2.1 最终建议 + +后端使用 `Python`,数据库使用 `MySQL`。 + +### 2.2 选型理由 + +对于本项目,真正复杂的地方是: + +- 对接多个第三方 AI 视频 API +- 长任务异步调度 +- 充值、扣费、退款和幂等控制 +- 后台频繁调整业务规则 + +这类场景更偏向“业务编排”和“集成开发”,不是 CPU 密集型高性能计算,因此优先选择开发效率更高、AI 生态更成熟的 Python。 + +### 2.3 推荐技术栈 + +#### 后端 + +- `Python 3.12` +- `FastAPI` +- `SQLAlchemy 2.0` +- `Alembic` +- `Pydantic v2` +- `Celery` 或 `Dramatiq` +- `Redis` +- `MySQL 8.0` + +#### 前端 + +- `Next.js` +- `React` +- `TypeScript` +- `Tailwind CSS` +- 用户端可用 `shadcn/ui` +- 管理端可用 `Ant Design` + +#### 基础设施 + +- `Nginx` +- `Docker / Docker Compose` +- 对象存储:`MinIO / S3 / OSS / COS` +- 文件分发:`CDN` +- 监控:`Prometheus + Grafana` +- 错误追踪:`Sentry` + +### 2.4 架构决策 + +#### 服务形态 + +采用“前后端分离 + 单后端服务 + 异步任务 Worker”的结构。 + +#### API 风格 + +采用 `REST API`,首版不建议引入 GraphQL。 + +#### 鉴权策略 + +用户端采用 `JWT Access Token + Refresh Token`。 +后台管理员使用单独的管理员账号体系和 RBAC 权限控制。 + +#### 实时反馈方式 + +首版建议以“轮询任务状态”为主,后续可增加 `SSE` 推送。 + +#### 项目结构 + +后端采用“按业务模块划分”的 feature-first 结构,避免 controller/service/repository 全项目散落。 + +## 3. 系统总体架构 + +### 3.1 逻辑架构 + +系统分为以下几个部分: + +1. 用户前台 +2. 管理后台 +3. 业务 API 服务 +4. 异步任务服务 +5. MySQL 数据库 +6. Redis 缓存与队列 +7. 对象存储 +8. 第三方支付服务 +9. 第三方 AI 视频模型服务 + +### 3.2 推荐部署结构 + +```text +用户浏览器 / 管理后台 + | + Nginx + | + ------------------------- + | | +Next.js Frontend FastAPI Backend + | + ---------------------------------- + | | | + MySQL Redis Object Storage + | + Celery Worker + | + ---------------------------------- + | | | + OpenAI 官方格式 Seedance 格式 其他扩展格式 +``` + +### 3.3 核心原则 + +- 业务 API 不直接阻塞等待视频生成完成 +- 所有扣费必须有流水 +- 所有第三方回调必须幂等 +- 所有模型调用必须做抽象适配,不将业务逻辑直接耦合到某一家 API +- 所有后台配置变更必须保留操作日志 + +## 4. 角色与权限 + +### 4.1 用户角色 + +- 普通用户 +- 管理员 +- 超级管理员 + +### 4.2 普通用户能力 + +- 注册 / 登录 / 退出 +- 查看个人资料 +- 修改用户名、头像、昵称 +- 兑换积分密钥 +- 创建和管理自己的邀请码 / 邀请链接 +- 查看邀请数据和奖励记录 +- 上传素材 +- 创建视频生成任务 +- 查看任务列表和详情 +- 删除自己的任务记录 +- 下载生成结果 +- 查看积分余额和流水 +- 发起充值 + +### 4.3 管理员能力 + +- 管理用户 +- 查看充值订单 +- 查看视频任务 +- 配置积分兑换密钥 +- 配置注册赠送积分规则 +- 配置邀请奖励规则 +- 配置多个视频供应商账号 +- 配置供应商模型与平台模型 +- 配置价格规则 +- 配置充值套餐和充值赠送比例 +- 处理失败订单和人工补单 +- 查看回调日志、任务日志、系统日志 + +### 4.4 RBAC 权限模块 + +后台建议最少拆分以下权限: + +- `user:view` +- `user:edit` +- `order:view` +- `wallet:view` +- `wallet:manual_adjust` +- `task:view` +- `task:retry` +- `provider:view` +- `provider:edit` +- `pricing:view` +- `pricing:edit` +- `growth:view` +- `growth:edit` +- `redeem:view` +- `redeem:edit` +- `system:view` +- `system:edit` + +## 5. 核心业务流程 + +### 5.1 用户充值流程 + +```text +用户选择充值套餐 +-> 创建充值订单 +-> 跳转支付 +-> 第三方支付回调 +-> 校验签名与订单状态 +-> 幂等更新订单为已支付 +-> 增加用户积分钱包余额 +-> 写入钱包流水 +-> 返回支付结果 +``` + +#### 关键要求 + +- 支付回调必须验签 +- 订单状态更新必须幂等 +- 钱包积分增加必须和订单状态更新在同一事务中 +- 同一订单只能入账一次 + +### 5.2 视频生成流程 + +```text +用户提交生成参数 +-> 校验模型是否启用 +-> 计算预估积分费用 +-> 校验积分余额 +-> 冻结预扣积分 +-> 创建本地任务记录 +-> 投递异步任务 +-> Worker 调用第三方视频生成 API +-> 保存第三方任务ID +-> 定时轮询任务状态 +-> 成功后下载/转存结果 +-> 按规则完成最终结算 +-> 更新任务状态 +-> 用户查看并下载结果 +``` + +#### 推荐结算策略 + +为了兼容不同模型与不同供应商格式,建议使用: + +- 提交任务时:冻结预扣积分 +- 任务成功时:正式扣减积分 +- 任务失败时:解冻积分 +- 若平台后续需要按供应商实际成本二次修正:执行补扣或退回积分差额 + +这样可以同时满足: + +- 用户提交前能看到大致费用 +- 平台可以控制风险 +- 后期支持不同模型差异化积分定价 + +### 5.3 后台价格配置流程 + +```text +管理员配置模型价格规则 +-> 配置是否启用 +-> 选择平台视频模型 +-> 配置每秒积分价格 +-> 配置最低积分扣费 +-> 配置生效时间和版本 +-> 保存版本化价格配置 +-> 新任务按最新生效版本计费 +``` + +### 5.4 人工补单与钱包调整流程 + +管理员后台允许人工修正: + +- 充值成功但未到账 +- 任务失败需补偿 +- 手工赠送积分 +- 风控冻结后恢复 + +所有人工调整都必须: + +- 记录操作人 +- 记录原因 +- 记录前后积分 +- 写入钱包流水 +- 可审计 + +### 5.5 密钥兑换积分流程 + +```text +用户输入兑换密钥 +-> 后端校验密钥格式 +-> 查询兑换密钥状态 +-> 校验是否已使用、是否过期、是否禁用 +-> 对兑换密钥记录加锁 +-> 增加用户积分 +-> 写入钱包流水 +-> 标记兑换密钥为已使用 +-> 写入兑换记录 +-> 返回兑换结果 +``` + +#### 关键要求 + +- 一个兑换密钥默认只能使用一次 +- 必须使用数据库事务 + 行锁防止重复兑换 +- 后台可生成批量兑换密钥或手动导入兑换密钥 +- 所有兑换失败原因必须明确记录 + +### 5.6 新用户注册赠送积分流程 + +```text +用户完成注册 +-> 后端读取注册赠送规则 +-> 若规则开启,则给用户发放注册奖励积分 +-> 写入钱包流水 +-> 写入奖励发放记录 +``` + +#### 关键要求 + +- 每个用户只能获得一次注册赠送 +- 注册赠送是否开启、赠送多少积分由后台控制 +- 注册赠送积分应有单独业务类型,不能与充值积分混淆 + +### 5.7 邀请奖励流程 + +推荐将邀请奖励触发条件定义为: + +- 新用户通过邀请码或邀请链接注册成功 +- 该新用户首次发生有效积分消费 +- 邀请人获得一次邀请奖励积分 + +```text +新用户使用邀请码注册 +-> 建立邀请关系 +-> 新用户完成首次有效消费 +-> 系统检查该邀请关系是否已发奖 +-> 若未发奖且邀请奖励功能开启 +-> 向邀请人发放积分奖励 +-> 写入钱包流水 +-> 更新邀请关系奖励状态 +``` + +#### 关键要求 + +- 每个被邀请用户只能触发一次邀请奖励 +- 奖励应在“首次有效消费成功”后发放,而不是注册后立即发放 +- 有效消费建议定义为:首次 `final_points > 0` 且任务状态为 `succeeded` +- 后台可控制是否开启邀请奖励以及奖励多少积分 +- 必须防刷,不能允许同设备批量注册反复薅奖励 + +### 5.8 视频记录删除规则 + +用户删除视频记录时,不建议物理删除数据库记录,建议采用“用户侧软删除”: + +```text +用户点击删除任务记录 +-> 校验该任务属于当前用户 +-> 更新任务记录为用户不可见 +-> 保留后台审计信息和财务流水 +-> 前台默认不再展示该记录 +``` + +#### 关键要求 + +- 用户删除仅影响前台可见性,不影响后台审计 +- 已生成文件可按延迟清理策略处理,不建议立即物理删除 +- 充值、积分、奖励相关记录不得被用户删除 + +## 6. 第三方视频模型接入设计 + +### 6.1 基本原则 + +首版明确只支持两种视频 API 协议格式: + +- `openai_official_video` +- `seedance_video_generation` + +同时遵循以下原则: + +- 管理后台可以配置多个供应商账号 +- 一个供应商账号下可以配置多个供应商模型 +- 前台展示的是“平台视频模型”,不是直接暴露供应商模型 +- 一个平台视频模型可以绑定多个供应商模型,用于主备切换或故障回退 +- 业务层只面向统一适配器接口,不直接耦合 OpenAI 或 Seedance 原始协议 + +### 6.2 统一适配器接口 + +#### 推荐抽象接口 + +```python +class VideoProviderAdapter(Protocol): + api_format: str + + def submit_task(self, payload: dict) -> SubmitResult: ... + def query_task(self, external_task_id: str) -> QueryResult: ... + def download_result(self, external_task_id: str, variant: str = "video") -> bytes: ... + def cancel_task(self, external_task_id: str) -> CancelResult: ... + def remix_task(self, external_task_id: str, payload: dict) -> SubmitResult: ... + def normalize_webhook(self, payload: dict) -> CallbackResult: ... +``` + +### 6.3 OpenAI 官方视频格式适配说明 + +根据 OpenAI 官方文档,当前视频 API 的核心接口格式如下: + +- `POST /v1/videos` 创建视频生成任务 +- `GET /v1/videos/{video_id}` 查询任务状态 +- `GET /v1/videos/{video_id}/content` 下载最终视频内容 +- `POST /v1/videos/{video_id}/remix` 基于已有视频生成 remix +- 支持 webhook 事件:`video.completed`、`video.failed` + +OpenAI 官方格式的关键特点: + +- 创建接口使用 `multipart/form-data` +- 核心参数包括:`prompt`、`model`、`seconds`、`size` +- 可选 `input_reference` 图片作为首帧参考 +- 当前官方文档公开的模型包括 `sora-2`、`sora-2-pro` +- 任务状态主要包括 `queued`、`in_progress`、`completed`、`failed` +- 视频完成后通过 `/content` 下载,下载资产可能存在 `expires_at` +- 支持 `variant` 下载派生资源,例如缩略图或 spritesheet + +平台接入 OpenAI 官方格式时,后端需要注意: + +- 请求体不是 JSON,而是 `multipart/form-data` +- 参数校验要按 OpenAI 允许值做白名单校验 +- 完成后立即下载并转存到自己的对象存储 +- webhook 验签必须按 OpenAI 官方 webhook secret 做校验 +- 前台不要直接暴露 OpenAI 原始模型名和下载地址 + +#### OpenAI 官方格式示例 + +```bash +curl -X POST "https://api.openai.com/v1/videos" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: multipart/form-data" \ + -F 'prompt=A cinematic shot of a paper airplane flying through a neon city at night' \ + -F 'model=sora-2' \ + -F 'seconds=8' \ + -F 'size=1280x720' \ + -F 'input_reference=@frame.png;type=image/png' +``` + +典型返回结构: + +```json +{ + "id": "video_123", + "object": "video", + "model": "sora-2", + "status": "queued", + "progress": 0, + "seconds": "8", + "size": "1280x720" +} +``` + +### 6.4 Seedance 格式适配说明 + +根据你项目目录中的 `Seedance_2.0_客户使用手册.docx`,可确认其核心特点: + +- 通过 `POST /v1/video/generations` 创建异步生成任务 +- 通过 `GET /v1/video/generations/{id}` 查询状态 +- 支持 `seedance` 和 `seedance-fast` +- 支持文本、图片、视频、音频组合输入 +- 支持 `generate_audio`、`ratio`、`duration` +- 生成结果需要尽快下载,文档里提到结果链接存在有效期 + +因此平台内部要统一做以下事情: + +- 提交任务后保存第三方任务 ID +- 定时查询第三方任务状态 +- 成功后将结果文件转存到自己的对象存储 +- 不依赖第三方临时链接作为最终下载地址 + +#### Seedance 格式示例 + +```json +{ + "model": "seedance", + "content": [ + { + "type": "text", + "text": "夜晚的城市高架桥上,一辆银色跑车高速穿行,镜头跟拍,霓虹反光强烈。" + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/reference.png" + }, + "role": "reference_image" + } + ], + "generate_audio": true, + "ratio": "16:9", + "duration": 8 +} +``` + +典型查询接口: + +```text +GET /v1/video/generations/{task_id} +``` + +### 6.5 平台模型与供应商绑定设计 + +为了支持“多个供应商 + 每个前台视频单独定价”,数据结构上必须区分三层: + +1. 供应商账号:如 OpenAI 主账号、Seedance 主账号 +2. 供应商模型:如 `sora-2`、`sora-2-pro`、`seedance`、`seedance-fast` +3. 平台视频模型:用户前台看到的产品模型,如“标准视频”“高速视频”“高清视频” + +推荐后台工作流: + +1. 先新增多个供应商账号 +2. 为每个供应商账号录入一个协议格式:`openai_official_video` 或 `seedance_video_generation` +3. 在供应商账号下录入供应商模型 +4. 创建平台视频模型 +5. 将一个平台视频模型绑定一个或多个供应商模型 +6. 设置主供应商、优先级和故障切换顺序 + +首版建议使用最简单可控的优先级策略: + +- 同一个平台视频模型可以绑定多个供应商模型 +- 默认按优先级从高到低依次尝试 +- 主供应商失败、限流或超时后切换到下一个供应商 +- 前台价格不因供应商切换而变化,价格只绑定平台视频模型 + +### 6.6 模型差异抽象字段 + +后台配置中建议统一维护以下模型能力字段: + +- 是否支持文生视频 +- 是否支持图生视频 +- 是否支持首尾帧 +- 是否支持视频参考 +- 是否支持音频参考 +- 是否支持生成音频 +- 支持的分辨率 +- 支持的宽高比 +- 支持的时长范围 +- 默认时长 +- 单次最大文件数 +- 是否支持联网搜索 + +这样用户前台就能根据模型能力动态渲染表单。 + +## 7. 功能模块拆解 + +### 7.1 用户端 + +#### 账户系统 + +- 手机号 / 邮箱注册登录 +- 验证码登录 +- 密码登录 +- 忘记密码 +- 绑定手机号 / 邮箱 +- 设置用户名 +- 设置头像 +- 设置昵称 + +#### 钱包系统 + +- 当前积分余额 +- 冻结积分 +- 可用积分 +- 充值入口 +- 兑换码入口 +- 充值记录 +- 消费记录 +- 退款记录 +- 奖励记录 + +#### 素材系统 + +- 上传图片 +- 上传音频 +- 上传视频 +- 删除素材 +- 查看素材列表 + +#### 视频任务系统 + +- 创建任务 +- 查看任务进度 +- 查看失败原因 +- 查看结果视频 +- 再次生成 +- 复制参数重建任务 +- 删除自己的任务记录 + +#### 邀请系统 + +- 生成邀请码 +- 生成邀请链接 +- 查看邀请人数 +- 查看邀请奖励记录 +- 查看待奖励邀请用户 + +### 7.2 管理后台 + +#### 控制台 + +- 今日充值金额 +- 今日订单数 +- 今日任务数 +- 成功率 +- 用户增长 +- 模型调用分布 + +#### 用户管理 + +- 用户列表 +- 用户详情 +- 积分余额查看 +- 冻结/解冻用户 +- 人工调整积分 +- 查看邀请关系 +- 查看邀请码 + +#### 充值管理 + +- 充值订单列表 +- 订单状态筛选 +- 回调日志 +- 异常订单补偿 + +#### 兑换密钥管理 + +- 批量生成兑换密钥 +- 手动导入兑换密钥 +- 设置兑换积分数量 +- 设置有效期 +- 查看兑换记录 +- 禁用兑换密钥 + +#### 视频任务管理 + +- 任务列表 +- 任务状态筛选 +- 按模型筛选 +- 查看原始请求参数 +- 查看第三方返回 +- 重试任务 +- 标记补偿 + +#### 供应商管理 + +- 可配置多个供应商账号 +- 第三方平台名称 +- 协议格式类型 +- Base URL +- API Key +- Secret +- Webhook Secret +- 默认超时时间 +- 重试策略 +- 是否启用 + +#### 供应商模型管理 + +- 供应商模型名称 +- 供应商模型编码 +- 所属平台 +- 是否上架 +- 能力标签 +- 默认参数 + +#### 平台视频模型管理 + +- 平台模型名称 +- 前台展示名称 +- 绑定多个供应商模型 +- 默认路由供应商 +- 默认参数 +- 是否上架 + +#### 价格配置管理 + +- 按平台视频模型定价 +- 每秒积分价格 +- 最低积分扣费 +- 生效时间 +- 版本管理 + +#### 增长奖励配置管理 + +- 是否开启新用户注册送积分 +- 注册赠送积分数量 +- 是否开启邀请奖励 +- 邀请奖励积分数量 +- 邀请奖励触发条件说明 + +#### 充值配置管理 + +- 固定充值套餐 +- 赠送比例 +- 实付与到账换算比例 +- 活动时间范围 +- 是否启用 + +#### 系统配置管理 + +- 网站标题 +- 首页公告 +- 用户协议 +- 隐私政策 +- 上传大小限制 +- 允许的文件类型 + +## 8. 数据库设计 + +### 8.1 设计原则 + +- 使用 `MySQL 8.0` +- 人民币字段使用 `DECIMAL(10,2)`,禁止使用浮点 +- 积分字段使用 `BIGINT UNSIGNED` +- 主键使用 `BIGINT UNSIGNED` +- 对外展示 ID 使用字符串型 `public_id` +- 所有表保留 `created_at`、`updated_at` +- 关键业务表加 `deleted_at` 实现软删除时要谨慎,钱包流水表不建议软删除 + +### 8.2 核心表清单 + +首版建议至少包含以下表: + +1. `users` +2. `user_auths` +3. `admin_users` +4. `roles` +5. `permissions` +6. `admin_user_roles` +7. `wallets` +8. `wallet_transactions` +9. `growth_reward_rules` +10. `redeem_codes` +11. `invite_codes` +12. `invite_relations` +13. `recharge_plans` +14. `recharge_orders` +15. `payment_channels` +16. `provider_accounts` +17. `provider_models` +18. `video_models` +19. `video_model_supplier_bindings` +20. `pricing_rules` +21. `media_assets` +22. `video_generation_tasks` +23. `video_task_events` +24. `callback_logs` +25. `system_configs` +26. `operation_logs` + +### 8.3 关键表设计 + +#### users + +```sql +CREATE TABLE users ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + public_id VARCHAR(64) NOT NULL UNIQUE, + username VARCHAR(64) NULL DEFAULT NULL, + nickname VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + email VARCHAR(191) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + password_hash VARCHAR(255) NOT NULL DEFAULT '', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2禁用 3冻结', + last_login_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_users_username (username), + KEY idx_users_email (email), + KEY idx_users_mobile (mobile), + KEY idx_users_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### wallets + +```sql +CREATE TABLE wallets ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + balance_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + frozen_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + total_recharged_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + total_consumed_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + total_refunded_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_wallets_user_id (user_id), + CONSTRAINT fk_wallets_user_id FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### wallet_transactions + +```sql +CREATE TABLE wallet_transactions ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + transaction_no VARCHAR(64) NOT NULL UNIQUE, + user_id BIGINT UNSIGNED NOT NULL, + wallet_id BIGINT UNSIGNED NOT NULL, + biz_type VARCHAR(32) NOT NULL COMMENT 'recharge/redeem_code/signup_reward/invite_reward/freeze/consume/refund/unfreeze/manual_adjust', + direction VARCHAR(16) NOT NULL COMMENT 'in/out/freeze/unfreeze', + amount_points BIGINT UNSIGNED NOT NULL, + balance_before_points BIGINT UNSIGNED NOT NULL, + balance_after_points BIGINT UNSIGNED NOT NULL, + frozen_before_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + frozen_after_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + related_type VARCHAR(32) NOT NULL DEFAULT '', + related_id BIGINT UNSIGNED NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + operator_type VARCHAR(16) NOT NULL DEFAULT 'system' COMMENT 'system/admin/user', + operator_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_wallet_tx_user_id (user_id), + KEY idx_wallet_tx_biz_type (biz_type), + KEY idx_wallet_tx_related (related_type, related_id), + CONSTRAINT fk_wallet_tx_user_id FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_wallet_tx_wallet_id FOREIGN KEY (wallet_id) REFERENCES wallets(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### growth_reward_rules + +```sql +CREATE TABLE growth_reward_rules ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + rule_type VARCHAR(32) NOT NULL COMMENT 'signup_reward/invite_reward', + enabled TINYINT NOT NULL DEFAULT 0, + reward_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + trigger_condition VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'on_register/on_first_consume', + remark VARCHAR(255) NOT NULL DEFAULT '', + updated_by_admin_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_growth_reward_rules_type (rule_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### redeem_codes + +```sql +CREATE TABLE redeem_codes ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + batch_no VARCHAR(64) NOT NULL, + redeem_code VARCHAR(64) NOT NULL UNIQUE, + points BIGINT UNSIGNED NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'unused' COMMENT 'unused/used/expired/disabled', + used_by_user_id BIGINT UNSIGNED NULL, + wallet_transaction_id BIGINT UNSIGNED NULL, + expired_at DATETIME NULL, + used_at DATETIME NULL, + created_by_admin_id BIGINT UNSIGNED NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_redeem_codes_batch_no (batch_no), + KEY idx_redeem_codes_status (status), + KEY idx_redeem_codes_used_by_user_id (used_by_user_id), + CONSTRAINT fk_redeem_codes_used_by_user_id FOREIGN KEY (used_by_user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### invite_codes + +```sql +CREATE TABLE invite_codes ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + invite_code VARCHAR(32) NOT NULL UNIQUE, + invite_link VARCHAR(255) NOT NULL DEFAULT '', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', + is_default TINYINT NOT NULL DEFAULT 0, + max_use_count INT UNSIGNED NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_invite_codes_user_id (user_id), + KEY idx_invite_codes_status (status), + CONSTRAINT fk_invite_codes_user_id FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### invite_relations + +```sql +CREATE TABLE invite_relations ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + inviter_user_id BIGINT UNSIGNED NOT NULL, + invitee_user_id BIGINT UNSIGNED NOT NULL, + invite_code_id BIGINT UNSIGNED NOT NULL, + reward_status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/eligible/rewarded/invalid', + reward_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + first_consumed_task_id BIGINT UNSIGNED NULL, + first_consumed_at DATETIME NULL, + rewarded_at DATETIME NULL, + reward_wallet_transaction_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_invite_relations_invitee_user_id (invitee_user_id), + KEY idx_invite_relations_inviter_user_id (inviter_user_id), + KEY idx_invite_relations_reward_status (reward_status), + CONSTRAINT fk_invite_relations_inviter_user_id FOREIGN KEY (inviter_user_id) REFERENCES users(id), + CONSTRAINT fk_invite_relations_invitee_user_id FOREIGN KEY (invitee_user_id) REFERENCES users(id), + CONSTRAINT fk_invite_relations_invite_code_id FOREIGN KEY (invite_code_id) REFERENCES invite_codes(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### recharge_plans + +```sql +CREATE TABLE recharge_plans ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + pay_amount DECIMAL(10,2) NOT NULL, + point_ratio INT UNSIGNED NOT NULL DEFAULT 10 COMMENT '1元兑换多少积分', + give_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + bonus_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + status TINYINT NOT NULL DEFAULT 1, + start_at DATETIME NULL, + end_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_recharge_plans_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### recharge_orders + +```sql +CREATE TABLE recharge_orders ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + order_no VARCHAR(64) NOT NULL UNIQUE, + user_id BIGINT UNSIGNED NOT NULL, + recharge_plan_id BIGINT UNSIGNED NULL, + payment_channel_code VARCHAR(32) NOT NULL, + pay_amount DECIMAL(10,2) NOT NULL, + point_ratio_snapshot INT UNSIGNED NOT NULL, + give_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + bonus_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + arrival_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + currency VARCHAR(16) NOT NULL DEFAULT 'CNY', + status VARCHAR(32) NOT NULL COMMENT 'pending/paid/failed/cancelled/refunded', + third_party_order_no VARCHAR(100) NOT NULL DEFAULT '', + paid_at DATETIME NULL, + expired_at DATETIME NULL, + callback_payload JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_recharge_orders_user_id (user_id), + KEY idx_recharge_orders_status (status), + KEY idx_recharge_orders_paid_at (paid_at), + CONSTRAINT fk_recharge_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### provider_accounts + +```sql +CREATE TABLE provider_accounts ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + provider_code VARCHAR(32) NOT NULL UNIQUE, + provider_name VARCHAR(100) NOT NULL, + api_format VARCHAR(64) NOT NULL COMMENT 'openai_official_video/seedance_video_generation', + base_url VARCHAR(255) NOT NULL, + api_key_encrypted TEXT NOT NULL, + api_secret_encrypted TEXT NULL, + webhook_secret_encrypted TEXT NULL, + timeout_seconds INT NOT NULL DEFAULT 60, + max_retries INT NOT NULL DEFAULT 3, + status TINYINT NOT NULL DEFAULT 1, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_provider_accounts_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### provider_models + +```sql +CREATE TABLE provider_models ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + provider_account_id BIGINT UNSIGNED NOT NULL, + model_code VARCHAR(64) NOT NULL, + model_name VARCHAR(100) NOT NULL, + request_content_type VARCHAR(64) NOT NULL DEFAULT 'application/json', + scene_type VARCHAR(32) NOT NULL DEFAULT 'video_generation', + supports_text_to_video TINYINT NOT NULL DEFAULT 1, + supports_image_to_video TINYINT NOT NULL DEFAULT 0, + supports_video_reference TINYINT NOT NULL DEFAULT 0, + supports_audio_reference TINYINT NOT NULL DEFAULT 0, + supports_generate_audio TINYINT NOT NULL DEFAULT 0, + supports_remix TINYINT NOT NULL DEFAULT 0, + supports_webhook TINYINT NOT NULL DEFAULT 0, + min_duration INT NOT NULL DEFAULT 4, + max_duration INT NOT NULL DEFAULT 15, + status TINYINT NOT NULL DEFAULT 1, + default_ratio VARCHAR(20) NOT NULL DEFAULT '16:9', + default_resolution VARCHAR(20) NOT NULL DEFAULT '720p', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_provider_model (provider_account_id, model_code), + KEY idx_provider_models_status (status), + CONSTRAINT fk_provider_models_provider_account_id FOREIGN KEY (provider_account_id) REFERENCES provider_accounts(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### video_models + +```sql +CREATE TABLE video_models ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + model_key VARCHAR(64) NOT NULL UNIQUE, + model_name VARCHAR(100) NOT NULL, + frontend_title VARCHAR(100) NOT NULL, + frontend_description VARCHAR(255) NOT NULL DEFAULT '', + default_duration_seconds INT NOT NULL DEFAULT 8, + default_ratio VARCHAR(20) NOT NULL DEFAULT '16:9', + default_resolution VARCHAR(20) NOT NULL DEFAULT '720p', + status TINYINT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_video_models_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### video_model_supplier_bindings + +```sql +CREATE TABLE video_model_supplier_bindings ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + video_model_id BIGINT UNSIGNED NOT NULL, + provider_model_id BIGINT UNSIGNED NOT NULL, + routing_priority INT NOT NULL DEFAULT 100, + is_primary TINYINT NOT NULL DEFAULT 0, + status TINYINT NOT NULL DEFAULT 1, + timeout_seconds_override INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_video_model_provider_model (video_model_id, provider_model_id), + KEY idx_video_model_bindings_priority (video_model_id, routing_priority), + CONSTRAINT fk_video_model_bindings_video_model_id FOREIGN KEY (video_model_id) REFERENCES video_models(id), + CONSTRAINT fk_video_model_bindings_provider_model_id FOREIGN KEY (provider_model_id) REFERENCES provider_models(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### pricing_rules + +```sql +CREATE TABLE pricing_rules ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + rule_name VARCHAR(100) NOT NULL, + video_model_id BIGINT UNSIGNED NOT NULL, + billing_mode VARCHAR(32) NOT NULL DEFAULT 'per_second', + points_per_second INT UNSIGNED NOT NULL, + minimum_points INT UNSIGNED NOT NULL DEFAULT 0, + status TINYINT NOT NULL DEFAULT 1, + effective_at DATETIME NOT NULL, + expired_at DATETIME NULL, + version_no INT NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_pricing_rules_model_id (video_model_id), + KEY idx_pricing_rules_status (status), + KEY idx_pricing_rules_effective_at (effective_at), + CONSTRAINT fk_pricing_rules_video_model_id FOREIGN KEY (video_model_id) REFERENCES video_models(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### media_assets + +```sql +CREATE TABLE media_assets ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + asset_no VARCHAR(64) NOT NULL UNIQUE, + user_id BIGINT UNSIGNED NOT NULL, + media_type VARCHAR(16) NOT NULL COMMENT 'image/video/audio', + source_type VARCHAR(16) NOT NULL DEFAULT 'upload' COMMENT 'upload/generated/imported', + original_filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL DEFAULT '', + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + storage_bucket VARCHAR(100) NOT NULL, + storage_key VARCHAR(255) NOT NULL, + public_url VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_media_assets_user_id (user_id), + KEY idx_media_assets_media_type (media_type), + CONSTRAINT fk_media_assets_user_id FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### video_generation_tasks + +```sql +CREATE TABLE video_generation_tasks ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + task_no VARCHAR(64) NOT NULL UNIQUE, + user_id BIGINT UNSIGNED NOT NULL, + video_model_id BIGINT UNSIGNED NOT NULL, + provider_account_id BIGINT UNSIGNED NOT NULL, + provider_model_id BIGINT UNSIGNED NOT NULL, + provider_binding_id BIGINT UNSIGNED NULL, + pricing_rule_id BIGINT UNSIGNED NOT NULL, + external_task_id VARCHAR(100) NOT NULL DEFAULT '', + submit_mode VARCHAR(32) NOT NULL DEFAULT 'async', + task_status VARCHAR(32) NOT NULL COMMENT 'created/queued/running/succeeded/failed/cancelled', + generation_mode VARCHAR(32) NOT NULL COMMENT 'text_to_video/image_to_video/multimodal', + prompt_text TEXT NULL, + request_payload JSON NOT NULL, + response_payload JSON NULL, + duration_seconds INT NOT NULL DEFAULT 5, + ratio VARCHAR(20) NOT NULL DEFAULT '16:9', + resolution VARCHAR(20) NOT NULL DEFAULT '720p', + generate_audio TINYINT NOT NULL DEFAULT 0, + estimated_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + frozen_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + final_points BIGINT UNSIGNED NOT NULL DEFAULT 0, + supplier_cost_amount DECIMAL(10,4) NOT NULL DEFAULT 0.0000, + supplier_cost_currency VARCHAR(16) NOT NULL DEFAULT '', + result_asset_id BIGINT UNSIGNED NULL, + fail_reason VARCHAR(500) NOT NULL DEFAULT '', + submitted_at DATETIME NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + next_poll_at DATETIME NULL, + poll_count INT NOT NULL DEFAULT 0, + user_visible TINYINT NOT NULL DEFAULT 1, + user_deleted_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_video_tasks_user_id (user_id), + KEY idx_video_tasks_status (task_status), + KEY idx_video_tasks_external_task_id (external_task_id), + KEY idx_video_tasks_next_poll_at (next_poll_at), + KEY idx_video_tasks_user_visible (user_id, user_visible), + CONSTRAINT fk_video_tasks_user_id FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_video_tasks_video_model_id FOREIGN KEY (video_model_id) REFERENCES video_models(id), + CONSTRAINT fk_video_tasks_provider_account_id FOREIGN KEY (provider_account_id) REFERENCES provider_accounts(id), + CONSTRAINT fk_video_tasks_provider_model_id FOREIGN KEY (provider_model_id) REFERENCES provider_models(id), + CONSTRAINT fk_video_tasks_provider_binding_id FOREIGN KEY (provider_binding_id) REFERENCES video_model_supplier_bindings(id), + CONSTRAINT fk_video_tasks_pricing_rule_id FOREIGN KEY (pricing_rule_id) REFERENCES pricing_rules(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### video_task_events + +```sql +CREATE TABLE video_task_events ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + video_task_id BIGINT UNSIGNED NOT NULL, + event_type VARCHAR(32) NOT NULL, + event_message VARCHAR(255) NOT NULL DEFAULT '', + payload JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_video_task_events_task_id (video_task_id), + KEY idx_video_task_events_event_type (event_type), + CONSTRAINT fk_video_task_events_video_task_id FOREIGN KEY (video_task_id) REFERENCES video_generation_tasks(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### callback_logs + +```sql +CREATE TABLE callback_logs ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + source_type VARCHAR(32) NOT NULL COMMENT 'payment/provider', + source_code VARCHAR(32) NOT NULL, + related_no VARCHAR(64) NOT NULL DEFAULT '', + request_headers JSON NULL, + request_body JSON NULL, + verify_status VARCHAR(32) NOT NULL DEFAULT 'pending', + process_status VARCHAR(32) NOT NULL DEFAULT 'pending', + response_body TEXT NULL, + error_message VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_callback_logs_source (source_type, source_code), + KEY idx_callback_logs_related_no (related_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 8.4 索引建议 + +以下字段必须建立索引: + +- 用户登录查询:`email`、`mobile` +- 用户名查询:`username` +- 钱包流水列表:`user_id`、`created_at` +- 充值订单查询:`order_no`、`user_id`、`status` +- 兑换密钥查询:`redeem_code`、`status` +- 邀请码查询:`invite_code`、`user_id` +- 邀请关系查询:`invitee_user_id`、`inviter_user_id`、`reward_status` +- 任务轮询:`task_status`、`next_poll_at` +- 第三方回调:`external_task_id` +- 用户任务列表:`user_id`、`user_visible`、`created_at` +- 管理后台筛选:`video_model_id`、`provider_model_id`、`created_at` + +### 8.5 金额与积分字段规范 + +金额与积分相关统一规定: + +- 用户钱包单位:积分 +- 积分字段统一使用 `BIGINT UNSIGNED` +- 支付金额单位:元 +- 人民币字段统一使用 `DECIMAL(10,2)` +- 积分建议为整数,不使用小数积分 +- 建议后台配置基础兑换比例,例如 `1 元 = 100 积分` +- 业务内部结算不允许使用 `float` + +### 8.6 数据一致性要求 + +- 钱包积分更新必须使用事务 +- 钱包积分扣减必须带版本控制或 `select ... for update` +- 充值回调必须通过唯一订单号防重 +- 视频任务成功结算必须保证“状态更新 + 钱包流水”一致 +- 兑换密钥兑换必须通过行锁保证只成功一次 +- 注册赠送必须通过唯一用户校验只发放一次 +- 邀请奖励必须通过唯一 invitee 校验只发放一次 +- 供应商故障切换时不得重复提交同一任务到多个供应商,除非显式进入补偿重试流程 + +## 9. 后端模块设计 + +### 9.1 推荐目录结构 + +```text +backend/ + app/ + common/ + config/ + db/ + errors/ + middleware/ + security/ + utils/ + modules/ + auth/ + users/ + invites/ + redeem_codes/ + growth_rules/ + wallets/ + payments/ + providers/ + pricing/ + assets/ + video_tasks/ + admins/ + system/ + workers/ + scripts/ + tests/ + alembic/ +``` + +### 9.2 模块职责 + +#### auth + +- 注册登录 +- JWT 签发与刷新 +- 权限校验 + +#### users + +- 个人资料查询 +- 用户名与头像修改 +- 用户状态校验 + +#### invites + +- 邀请码生成 +- 邀请关系建立 +- 邀请奖励发放 +- 邀请统计查询 + +#### redeem_codes + +- 兑换密钥校验 +- 兑换事务处理 +- 兑换记录查询 + +#### growth_rules + +- 注册赠送规则读取 +- 邀请奖励规则读取 +- 后台增长规则维护 + +#### wallets + +- 钱包积分查询 +- 冻结和解冻 +- 积分扣减和退款 +- 人工调账 + +#### payments + +- 创建充值订单 +- 调起支付 +- 回调验签 +- 到账处理 + +#### providers + +- 多供应商账号配置 +- OpenAI 官方格式适配器 +- Seedance 格式适配器 +- API 调用封装 +- 回调标准化 +- 供应商故障切换 + +#### pricing + +- 价格规则查询 +- 积分价格计算器 +- 活动和赠送策略 + +#### assets + +- 上传凭证 +- 文件转存 +- 文件元数据记录 + +#### video_tasks + +- 创建任务 +- 调度 worker +- 状态查询 +- 结果保存 +- 最终结算 + +## 10. API 设计 + +### 10.1 设计规范 + +- 路径使用复数资源名 +- 响应统一包装 +- 错误响应统一结构 +- 列表接口统一分页 +- 异步任务创建返回 `202 Accepted` 或 `200 + taskId` + +### 10.2 推荐响应格式 + +#### 成功 + +```json +{ + "code": 0, + "message": "ok", + "data": {} +} +``` + +#### 失败 + +```json +{ + "code": 40001, + "message": "insufficient balance", + "requestId": "req_xxx", + "errors": [ + { + "field": "balance", + "reason": "not enough" + } + ] +} +``` + +### 10.3 用户端接口 + +#### 认证 + +- `POST /api/v1/auth/register` +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/refresh` +- `POST /api/v1/auth/logout` +- `GET /api/v1/auth/me` + +#### 个人资料 + +- `GET /api/v1/profile` +- `PUT /api/v1/profile` + +#### 钱包 + +- `GET /api/v1/wallet` +- `GET /api/v1/wallet/transactions` +- `POST /api/v1/wallet/recharge-orders` +- `GET /api/v1/wallet/recharge-orders` +- `POST /api/v1/wallet/redeem-codes/exchange` +- `GET /api/v1/wallet/redeem-records` + +#### 邀请 + +- `GET /api/v1/invite` +- `POST /api/v1/invite/codes` +- `GET /api/v1/invite/relations` +- `GET /api/v1/invite/rewards` + +#### 素材 + +- `POST /api/v1/assets/upload-token` +- `POST /api/v1/assets` +- `GET /api/v1/assets` +- `DELETE /api/v1/assets/{assetId}` + +#### 视频任务 + +- `GET /api/v1/video-models` +- `POST /api/v1/video-tasks` +- `GET /api/v1/video-tasks` +- `GET /api/v1/video-tasks/{taskId}` +- `POST /api/v1/video-tasks/{taskId}/retry` +- `POST /api/v1/video-tasks/{taskId}/cancel` +- `DELETE /api/v1/video-tasks/{taskId}` + +### 10.4 管理后台接口 + +#### 用户与钱包 + +- `GET /api/v1/admin/users` +- `GET /api/v1/admin/users/{userId}` +- `POST /api/v1/admin/users/{userId}/wallet-adjust` +- `GET /api/v1/admin/users/{userId}/invite-relations` + +#### 充值管理 + +- `GET /api/v1/admin/recharge-orders` +- `GET /api/v1/admin/recharge-orders/{orderId}` +- `POST /api/v1/admin/recharge-orders/{orderId}/repair` + +#### 兑换密钥与增长奖励管理 + +- `GET /api/v1/admin/redeem-codes` +- `POST /api/v1/admin/redeem-codes/batch-create` +- `POST /api/v1/admin/redeem-codes/import` +- `PUT /api/v1/admin/redeem-codes/{id}/disable` +- `GET /api/v1/admin/growth-rules` +- `PUT /api/v1/admin/growth-rules/signup` +- `PUT /api/v1/admin/growth-rules/invite` +- `GET /api/v1/admin/invite-relations` + +#### 平台和模型管理 + +- `GET /api/v1/admin/provider-accounts` +- `POST /api/v1/admin/provider-accounts` +- `PUT /api/v1/admin/provider-accounts/{id}` +- `GET /api/v1/admin/provider-models` +- `POST /api/v1/admin/provider-models` +- `PUT /api/v1/admin/provider-models/{id}` +- `GET /api/v1/admin/video-models` +- `POST /api/v1/admin/video-models` +- `PUT /api/v1/admin/video-models/{id}` +- `GET /api/v1/admin/video-model-bindings` +- `POST /api/v1/admin/video-model-bindings` +- `PUT /api/v1/admin/video-model-bindings/{id}` + +#### 价格管理 + +- `GET /api/v1/admin/pricing-rules` +- `POST /api/v1/admin/pricing-rules` +- `PUT /api/v1/admin/pricing-rules/{id}` +- `POST /api/v1/admin/pricing-rules/{id}/publish` + +#### 任务管理 + +- `GET /api/v1/admin/video-tasks` +- `GET /api/v1/admin/video-tasks/{id}` +- `POST /api/v1/admin/video-tasks/{id}/retry` +- `POST /api/v1/admin/video-tasks/{id}/refund` + +## 11. 价格与充值设计 + +### 11.1 积分充值体系 + +首版建议采用“人民币充值 -> 积分到账”的积分钱包制。 + +例如: + +- 用户支付 100 元 +- 后台基础兑换比例配置为 `1 元 = 100 积分` +- 则基础到账 10000 积分 +- 若活动赠送 1000 积分,则最终到账 11000 积分 + +因此要区分: + +- `pay_amount`:用户实付金额 +- `give_points`:基础到账积分 +- `bonus_points`:赠送积分 +- `arrival_points`:最终到账积分 + +### 11.2 价格规则设计 + +价格中心必须按“平台视频模型”单独配置价格。 + +首版统一采用: + +- `按秒计费` +- `单位为积分` +- `每个平台视频模型单独设置 points_per_second` + +例如: + +- 标准视频:`120 积分/秒` +- 高速视频:`80 积分/秒` +- 高清视频:`200 积分/秒` + +注意: + +- 前台用户选择的是平台视频模型 +- 后台真正路由到哪个供应商模型,不影响前台价格展示 +- 这样可以在不中断用户体验的情况下切换供应商 + +### 11.3 推荐计费公式 + +```text +最终扣费积分 = +max( + 最低积分扣费, + 请求时长秒数 * 每秒积分价格 +) +``` + +例如: + +- 某平台视频模型价格为 `120 积分/秒` +- 用户提交 `8 秒` +- 最低扣费为 `500 积分` +- 最终扣费 = `max(500, 8 * 120) = 960 积分` + +### 11.4 计费秒数标准 + +首版建议统一按“用户提交的请求时长”计费,而不是按供应商最终生成时长回写计费。 + +原因: + +- 用户在提交前就能明确看到积分消耗 +- OpenAI 与 Seedance 的返回格式不完全一致 +- 避免因供应商返回的时长轻微偏差产生争议 + +### 11.5 供应商格式差异下的参数校验 + +首版在前台选择平台视频模型后,后端仍需依据实际命中的供应商模型做二次参数校验: + +- OpenAI 官方格式: + - `seconds` 只允许官方支持的档位 + - `size` 必须是官方允许的分辨率 + - 有图参考时需使用 `multipart/form-data` +- Seedance 格式: + - `duration` 允许整数秒 + - `content` 为多模态数组 + - `generate_audio`、`ratio`、`tools` 等走 JSON 提交 + +### 11.6 首版建议 + +首版不要引入过于复杂的倍率系统,定价中心只保留: + +- 平台视频模型 +- 每秒积分价格 +- 最低积分扣费 +- 生效时间 +- 版本号 + +### 11.7 积分来源与优先级 + +平台首版建议明确区分积分来源: + +- 充值积分 +- 兑换密钥积分 +- 注册赠送积分 +- 邀请奖励积分 +- 人工补偿积分 + +建议所有积分都进入同一个钱包余额,但在流水中区分业务类型。 + +### 11.8 注册赠送与邀请奖励的发放规则 + +推荐规则如下: + +- 注册赠送: + - 注册成功后立即发放 + - 每个用户仅一次 +- 邀请奖励: + - 仅当被邀请用户首次成功消费后发放 + - 每个被邀请用户仅触发一次 + - 若邀请关系被判定异常,则不发放奖励 + +### 11.9 密钥兑换规则 + +推荐规则如下: + +- 默认一密一用 +- 支持设置过期时间 +- 支持后台批量生成 +- 支持后台单个禁用 +- 兑换后不可撤销,若需回收,应通过人工调账处理 + +## 12. 异步任务与调度设计 + +### 12.1 队列任务分类 + +- `submit_video_task` +- `poll_video_task_status` +- `finalize_video_task` +- `download_result_asset` +- `retry_failed_task` +- `grant_signup_reward` +- `grant_invite_reward` + +### 12.2 轮询策略 + +推荐指数退避轮询: + +- 第 1 次:10 秒后 +- 第 2 次:20 秒后 +- 第 3 次:30 秒后 +- 第 4 次后:60 秒固定 +- 最大轮询时间:根据模型 SLA 配置,例如 30 分钟 + +### 12.3 超时处理 + +若任务超时: + +- 标记任务为失败或超时 +- 释放冻结积分 +- 记录第三方超时原因 +- 可由管理员手动重试 + +## 13. 文件与对象存储设计 + +### 13.1 存储对象分类 + +- 用户上传原始图片 +- 用户上传音频 +- 用户上传视频参考 +- 生成结果视频 +- 缩略图和封面 + +### 13.2 推荐策略 + +- 浏览器直接上传对象存储,后端签发上传凭证 +- 上传完成后由后端写入 `media_assets` +- 生成结果统一转存到自有桶 +- 文件路径按日期和用户 ID 分层 + +示例: + +```text +uploads/user/10001/images/2026/04/17/xxx.png +uploads/user/10001/videos/2026/04/17/xxx.mp4 +generated/video/2026/04/17/task_xxx/result.mp4 +``` + +### 13.3 下载控制 + +- 用户自己的文件可查看 +- 生成结果可设置临时签名 URL +- 管理员可查看所有文件 + +## 14. 安全设计 + +### 14.1 必须实现 + +- HTTPS +- JWT 鉴权 +- 管理后台 RBAC +- 管理后台强密码策略 +- 管理后台建议开启二次验证 +- 支付回调验签 +- API Key 加密存储 +- 限流 +- 上传文件类型校验 +- 敏感操作审计日志 +- 密码使用 `Argon2id` 或 `bcrypt` 哈希存储 +- 下载地址使用签名 URL +- 所有关键写操作支持幂等控制 + +### 14.2 风控建议 + +- 单用户分钟级任务创建频率限制 +- 单用户每日消费上限 +- 可疑账号风控标签 +- 支付失败次数限制 +- 超大文件拒绝上传 +- 兑换密钥尝试次数限制 +- 邀请码尝试次数限制 +- 同 IP / 同设备注册频率限制 +- 同设备多账号邀请奖励限制 + +### 14.3 数据安全 + +- API Key 使用应用层加密后保存 +- 不在日志中输出完整秘钥 +- 对回调报文脱敏存储 +- 定期备份 MySQL 和对象存储元数据 +- 用户密码、Token、支付报文中的敏感字段必须脱敏 +- 生成视频原始供应商下载地址不直接暴露给前端 + +### 14.4 必须落实的安全基线 + +这部分不是建议,而是上线前必须落实: + +1. 注册、登录、兑换密钥、创建任务、支付回调全部记录 request id +2. 所有创建类接口都校验登录态和用户状态 +3. 所有查询类接口都校验资源归属,禁止越权查看他人任务 +4. `DELETE /video-tasks/{id}` 只能删除自己的记录,且为软删除 +5. 兑换密钥必须通过数据库事务加锁,防止并发重复兑换 +6. 邀请奖励发放必须检查是否已奖励过,不能重复发奖 +7. 后台接口必须进行角色权限校验,不能只依赖前端菜单隐藏 +8. 对象存储桶不得设置公开读写 +9. 上传文件必须做 MIME、扩展名、大小三重校验 +10. 生产环境必须关闭调试模式和详细堆栈输出 + +### 14.5 邀请与兑换的防刷要求 + +邀请奖励和兑换密钥是最容易被攻击的两个模块,必须重点处理: + +#### 兑换密钥防刷 + +- 单用户单位时间兑换失败次数限制 +- 单 IP 单位时间兑换失败次数限制 +- 兑换密钥使用后立即失效 +- 后台支持一键禁用批次 +- 对异常批量尝试行为进行告警 + +#### 邀请奖励防刷 + +- 同一被邀请用户只允许绑定一次邀请关系 +- 同设备、同 IP、同支付标识可作为风控参考 +- 邀请奖励必须延迟到首次有效消费后发放 +- 可配置最小有效消费积分阈值,例如消费满 100 积分才触发奖励 +- 若命中风控规则,可将奖励状态置为 `invalid` + +### 14.6 用户删除记录的安全要求 + +用户删除视频记录时必须满足: + +- 仅影响用户前台可见性 +- 不删除任务日志、积分流水、邀请奖励触发依据 +- 不删除后台审计记录 +- 管理后台仍可检索该记录 + +### 14.7 推荐安全中间件 + +后端建议至少接入: + +- 请求 ID 中间件 +- 统一异常处理中间件 +- JWT 鉴权中间件 +- RBAC 权限中间件 +- 频率限制中间件 +- 管理后台操作审计中间件 + +## 15. 监控与日志 + +### 15.1 必备日志 + +- 请求日志 +- 错误日志 +- 支付回调日志 +- 第三方 API 调用日志 +- 视频任务事件日志 +- 管理员操作日志 + +### 15.2 核心监控指标 + +- 注册用户数 +- 充值转化率 +- 任务提交成功率 +- 第三方接口成功率 +- 任务平均完成时长 +- 模型调用成本 +- 钱包积分扣费失败次数 + +### 15.3 告警建议 + +- 第三方接口连续失败 +- 支付回调异常激增 +- 队列积压 +- MySQL 连接数过高 +- Redis 内存过高 + +## 16. 开发阶段划分 + +### 16.1 Phase 1:MVP + +目标:打通最小商业闭环。 + +包含: + +- 用户登录注册 +- 钱包与充值 +- 兑换密钥 +- 注册赠送积分 +- 邀请码与邀请奖励 +- 两种供应商协议接入:`OpenAI 官方格式 + Seedance 格式` +- 文生视频和图生视频 +- 任务列表和详情 +- 管理后台配置模型和价格 + +### 16.2 Phase 2:增强版 + +包含: + +- 多模型接入 +- 音频参考和视频参考 +- 优惠活动和充值赠送 +- 管理台统计报表 +- 失败任务补偿 +- SSE 任务状态推送 + +### 16.3 Phase 3:平台化 + +包含: + +- 社区作品广场 +- API 对外开放 +- 渠道商体系 +- 团队工作区 +- 自动化风控与推荐 + +## 17. 建议开发排期 + +### 第 1 周 + +- 项目初始化 +- 用户认证 +- MySQL 基础表 +- 钱包系统 + +### 第 2 周 + +- 充值订单 +- 支付回调 +- 素材上传 +- 管理后台基础框架 + +### 第 3 周 + +- 视频任务创建 +- 第三方模型接入 +- Celery 轮询任务 +- 结果转存 + +### 第 4 周 + +- 价格配置 +- 后台模型管理 +- 任务管理 +- 错误处理与监控 + +### 第 5 周 + +- 联调测试 +- 压测 +- 修复异常流程 +- 预发布上线 + +## 18. 测试方案 + +### 18.1 单元测试 + +- 价格计算器 +- 钱包扣费逻辑 +- 充值入账逻辑 +- 兑换密钥兑换逻辑 +- 注册赠送逻辑 +- 邀请奖励发放逻辑 +- 第三方适配器 + +### 18.2 集成测试 + +- 登录注册 +- 充值下单 +- 密钥兑换 +- 邀请码注册 +- 支付回调 +- 任务创建到完成 +- 失败退回积分 + +### 18.3 关键用例 + +- 重复支付回调只入账一次 +- 任务失败后积分正确返还 +- 同一个兑换密钥不能重复兑换 +- 同一个被邀请用户不能重复触发邀请奖励 +- 第三方返回慢时任务不会重复提交 +- 多供应商绑定时主供应商失败可自动切换到备用供应商 +- OpenAI 官方格式和 Seedance 格式都能正确映射到统一任务模型 +- 管理员停用模型后前台不可选 + +## 19. 上线建议 + +### 19.1 生产环境组件 + +- `1 x Nginx` +- `1 x Next.js` +- `2 x FastAPI` +- `2 x Celery Worker` +- `1 x MySQL 主库` +- `1 x Redis` +- `1 x MinIO/OSS` + +### 19.2 备份策略 + +- MySQL 每日全量备份 +- 二进制日志保留至少 7 天 +- 对象存储开启版本控制或跨区域备份 + +### 19.3 上线前检查 + +- 环境变量是否完整 +- 支付回调域名是否正确 +- CORS 白名单是否正确 +- JWT 密钥是否已替换 +- API Key 是否加密保存 +- 监控和告警是否生效 + +## 20. 首版最终建议 + +### 20.1 技术结论 + +本项目首版推荐方案如下: + +- 后端:`Python + FastAPI` +- 数据库:`MySQL 8` +- 缓存与队列:`Redis` +- 异步任务:`Celery` +- 前端:`Next.js + React` +- 管理后台:`Next.js Admin` 或独立 `React + Ant Design` + +### 20.2 业务结论 + +首版优先实现: + +1. 钱包充值 +2. 视频生成 +3. 后台配置多供应商、平台视频模型与积分价格 +4. OpenAI 官方格式与 Seedance 格式双适配 +5. 日志与回调幂等 + +### 20.3 架构结论 + +一定要从第一天就做好这 5 件事: + +1. 第三方模型适配层 +2. 钱包流水审计 +3. 平台模型与供应商绑定层 +4. 任务异步化 +5. 后台动态积分价格配置 + +这五项决定后续能不能稳定扩展成真正的平台。 + +## 21. 开发落地规范 + +### 21.1 文档使用方式 + +这份文档应同时承担以下三个角色: + +- 产品需求说明书 +- 技术设计说明书 +- 开发实施说明书 + +开发时建议按下面顺序阅读: + +1. 先读 `1-6` 章,理解业务目标、供应商设计和计费方式 +2. 再读 `8-12` 章,确定数据库、接口、积分规则和任务调度 +3. 最后读 `21-31` 章,按页面、接口、状态机和验收标准落地开发 + +### 21.2 MVP 开发完成标准 + +满足以下条件,才可认为首版可上线测试: + +1. 用户可注册、登录、退出,并正常刷新登录态 +2. 用户可查看积分余额、充值套餐、充值记录、积分流水 +3. 用户支付成功后,积分能准确到账且回调幂等 +4. 用户可上传图片素材并发起视频任务 +5. 前台可展示平台视频模型列表,并显示每秒积分价格 +6. 用户可创建视频任务并看到预估扣费积分 +7. 后端可根据平台视频模型路由到供应商模型 +8. 平台至少支持 `OpenAI 官方格式` 和 `Seedance 格式` +9. 任务完成后能转存结果并在前台查看 +10. 任务失败后能退回冻结积分 +11. 用户可通过兑换密钥成功兑换积分 +12. 用户可修改用户名、头像等资料 +13. 邀请码注册、首次消费、邀请奖励链路可用 +14. 用户可查看并软删除自己的视频记录 +15. 管理后台可配置供应商账号、供应商模型、平台视频模型、模型绑定关系、价格规则、增长奖励规则、兑换密钥 +16. 所有关键业务有日志、操作记录和错误追踪 + +### 21.3 项目分仓建议 + +建议首版使用单仓库,结构如下: + +```text +AIVideo/ + backend/ + frontend-web/ + frontend-admin/ + docs/ + deploy/ + scripts/ + sql/ +``` + +如果团队规模较小,也可以采用: + +```text +AIVideo/ + apps/ + api/ + web/ + admin/ + packages/ + shared-types/ + deploy/ + docs/ +``` + +### 21.4 后端推荐文件清单 + +后端建议至少包含以下文件: + +```text +backend/ + app/ + main.py + common/ + config/settings.py + db/session.py + db/base.py + middleware/request_id.py + middleware/auth.py + errors/app_error.py + responses/api_response.py + security/jwt.py + security/encryptor.py + utils/time.py + utils/id_gen.py + modules/ + auth/ + router.py + service.py + schema.py + repository.py + users/ + router.py + service.py + schema.py + repository.py + invites/ + router.py + service.py + schema.py + repository.py + redeem_codes/ + router.py + service.py + schema.py + repository.py + growth_rules/ + router.py + service.py + schema.py + repository.py + wallets/ + router.py + service.py + schema.py + repository.py + payments/ + router.py + service.py + schema.py + repository.py + channels/ + alipay.py + wechat.py + assets/ + router.py + service.py + schema.py + repository.py + storage.py + providers/ + router.py + service.py + schema.py + repository.py + adapters/ + base.py + openai_video.py + seedance_video.py + mapper.py + video_models/ + router.py + service.py + schema.py + repository.py + pricing/ + router.py + service.py + schema.py + repository.py + calculator.py + video_tasks/ + router.py + service.py + schema.py + repository.py + orchestrator.py + admins/ + router.py + service.py + schema.py + repository.py + system/ + router.py + service.py + schema.py + repository.py + workers/ + celery_app.py + tasks_video_submit.py + tasks_video_poll.py + tasks_video_finalize.py + tasks_recharge_repair.py + tasks_signup_reward.py + tasks_invite_reward.py + models/ + user.py + wallet.py + growth_reward_rule.py + redeem_code.py + invite_code.py + invite_relation.py + recharge_order.py + provider_account.py + provider_model.py + video_model.py + pricing_rule.py + media_asset.py + video_generation_task.py + callback_log.py + tests/ + unit/ + integration/ + alembic/ + requirements.txt +``` + +### 21.5 前台与后台推荐文件清单 + +前台建议至少包含以下页面和模块: + +```text +frontend-web/ + src/ + app/ + login/page.tsx + register/page.tsx + workspace/create/page.tsx + workspace/tasks/page.tsx + workspace/tasks/[taskNo]/page.tsx + workspace/assets/page.tsx + wallet/page.tsx + wallet/recharge/page.tsx + wallet/redeem/page.tsx + invite/page.tsx + profile/page.tsx + components/ + video-model-select.tsx + create-task-form.tsx + asset-uploader.tsx + task-status-badge.tsx + points-balance-card.tsx + redeem-code-form.tsx + invite-summary-card.tsx + lib/ + api.ts + auth.ts + types.ts +``` + +后台建议至少包含以下页面和模块: + +```text +frontend-admin/ + src/ + app/ + admin/login/page.tsx + admin/dashboard/page.tsx + admin/users/page.tsx + admin/recharge-orders/page.tsx + admin/redeem-codes/page.tsx + admin/growth-rules/page.tsx + admin/invite-relations/page.tsx + admin/provider-accounts/page.tsx + admin/provider-models/page.tsx + admin/video-models/page.tsx + admin/video-model-bindings/page.tsx + admin/pricing-rules/page.tsx + admin/video-tasks/page.tsx + admin/callback-logs/page.tsx + admin/system-config/page.tsx + components/ + provider-account-form.tsx + provider-model-form.tsx + video-model-form.tsx + pricing-rule-form.tsx + redeem-code-batch-form.tsx + growth-rule-form.tsx + task-detail-drawer.tsx + lib/ + api.ts + auth.ts + table-columns.ts +``` + +## 22. 页面与路由详细清单 + +### 22.1 用户前台页面路由 + +建议前台路由如下: + +```text +/ +/login +/register +/forgot-password +/workspace/create +/workspace/tasks +/workspace/tasks/[taskNo] +/workspace/assets +/wallet +/wallet/recharge +/wallet/redeem +/wallet/orders +/wallet/transactions +/invite +/profile +``` + +### 22.2 用户前台页面说明 + +#### 首页 `/` + +页面目标: + +- 展示平台主卖点 +- 引导登录和充值 +- 引导进入创建页 + +页面模块: + +- 顶部导航 +- Banner +- 平台视频模型展示卡片 +- 常见问题 +- 页脚 + +#### 登录页 `/login` + +表单字段: + +- `account`:邮箱或手机号,必填 +- `password`:密码,必填 + +交互要求: + +- 登录成功跳转到 `/workspace/create` +- 登录失败显示统一错误提示 + +#### 注册页 `/register` + +表单字段: + +- `email` 或 `mobile` +- `password` +- `confirmPassword` +- `verificationCode` + +校验规则: + +- 密码长度至少 8 位 +- 两次密码必须一致 +- 邮箱或手机号必须未注册 + +#### 创建页 `/workspace/create` + +页面目标: + +- 选择平台视频模型 +- 输入提示词 +- 上传参考素材 +- 预估积分 +- 创建任务 + +页面字段: + +- `videoModelId`:平台视频模型 ID,必选 +- `prompt`:提示词,必填 +- `durationSeconds`:时长秒数,必填 +- `resolution`:分辨率,必选 +- `ratio`:宽高比,必选 +- `generateAudio`:是否生成音频 +- `referenceImageAssetIds`:参考图片列表 +- `referenceVideoAssetIds`:参考视频列表 +- `referenceAudioAssetIds`:参考音频列表 + +页面行为: + +- 选择平台视频模型后请求模型详情和价格规则 +- 输入时长后实时计算预估积分 +- 提交前再次向后端确认价格 +- 若积分不足,提示去充值 + +#### 任务列表页 `/workspace/tasks` + +表格字段: + +- 任务编号 +- 平台视频模型名称 +- 任务状态 +- 时长秒数 +- 预估积分 +- 最终积分 +- 创建时间 +- 操作 + +操作按钮: + +- 查看详情 +- 复制参数重建 +- 失败后重试 +- 删除记录 + +#### 任务详情页 `/workspace/tasks/[taskNo]` + +页面模块: + +- 任务基础信息 +- 原始请求参数 +- 任务时间线 +- 最终结果视频 +- 失败原因 +- 积分结算信息 + +#### 素材页 `/workspace/assets` + +页面功能: + +- 上传素材 +- 筛选图片/视频/音频 +- 删除素材 +- 复制素材 URL + +#### 钱包页 `/wallet` + +页面功能: + +- 当前积分 +- 冻结积分 +- 可用积分 +- 充值入口 +- 兑换入口 +- 最近流水 + +#### 充值页 `/wallet/recharge` + +页面功能: + +- 展示充值套餐 +- 展示每个套餐对应到账积分 +- 创建充值订单 +- 跳转支付 + +#### 兑换页 `/wallet/redeem` + +页面功能: + +- 输入兑换密钥 +- 查看兑换说明 +- 查看最近兑换记录 + +表单字段: + +- `redeemCode` + +#### 邀请页 `/invite` + +页面功能: + +- 展示自己的邀请码 +- 展示邀请链接 +- 一键复制邀请码 +- 一键复制邀请链接 +- 手动生成新邀请码 +- 查看邀请人数 +- 查看已奖励积分 +- 查看待奖励邀请关系 + +#### 个人中心 `/profile` + +页面功能: + +- 修改用户名 +- 修改昵称 +- 上传头像 +- 查看绑定信息 +- 查看账号状态 + +### 22.3 管理后台页面路由 + +建议后台路由如下: + +```text +/admin/login +/admin/dashboard +/admin/users +/admin/users/[id] +/admin/recharge-orders +/admin/recharge-orders/[id] +/admin/redeem-codes +/admin/growth-rules +/admin/invite-relations +/admin/provider-accounts +/admin/provider-accounts/create +/admin/provider-models +/admin/video-models +/admin/video-model-bindings +/admin/pricing-rules +/admin/video-tasks +/admin/video-tasks/[id] +/admin/callback-logs +/admin/system-config +/admin/operation-logs +``` + +### 22.4 管理后台页面说明 + +#### 仪表盘 `/admin/dashboard` + +指标卡片: + +- 今日充值金额 +- 今日充值订单数 +- 今日任务创建数 +- 今日任务成功率 +- 今日积分消耗 +- 供应商失败率 + +图表: + +- 最近 7 天充值趋势 +- 最近 7 天任务趋势 +- 各平台视频模型调用分布 +- 各供应商调用分布 + +#### 供应商账号页 `/admin/provider-accounts` + +列表字段: + +- 供应商名称 +- 协议格式 +- Base URL +- 状态 +- 超时秒数 +- 重试次数 +- 更新时间 + +创建/编辑字段: + +- `providerCode` +- `providerName` +- `apiFormat` +- `baseUrl` +- `apiKey` +- `apiSecret` +- `webhookSecret` +- `timeoutSeconds` +- `maxRetries` +- `status` +- `remark` + +#### 供应商模型页 `/admin/provider-models` + +列表字段: + +- 供应商 +- 模型编码 +- 模型名称 +- 是否支持图生视频 +- 是否支持音频参考 +- 是否支持 webhook +- 状态 + +创建/编辑字段: + +- `providerAccountId` +- `modelCode` +- `modelName` +- `requestContentType` +- `supportsTextToVideo` +- `supportsImageToVideo` +- `supportsVideoReference` +- `supportsAudioReference` +- `supportsGenerateAudio` +- `supportsRemix` +- `supportsWebhook` +- `minDuration` +- `maxDuration` +- `defaultRatio` +- `defaultResolution` +- `status` + +#### 平台视频模型页 `/admin/video-models` + +列表字段: + +- 平台模型名称 +- 前台展示名称 +- 默认时长 +- 默认分辨率 +- 状态 +- 排序 + +创建/编辑字段: + +- `modelKey` +- `modelName` +- `frontendTitle` +- `frontendDescription` +- `defaultDurationSeconds` +- `defaultRatio` +- `defaultResolution` +- `status` +- `sortOrder` + +#### 平台模型绑定页 `/admin/video-model-bindings` + +列表字段: + +- 平台视频模型 +- 供应商 +- 供应商模型 +- 是否主路由 +- 路由优先级 +- 状态 + +创建/编辑字段: + +- `videoModelId` +- `providerModelId` +- `routingPriority` +- `isPrimary` +- `status` +- `timeoutSecondsOverride` + +#### 价格规则页 `/admin/pricing-rules` + +列表字段: + +- 平台视频模型 +- 每秒积分价格 +- 最低积分 +- 生效时间 +- 失效时间 +- 版本号 +- 状态 + +创建/编辑字段: + +- `ruleName` +- `videoModelId` +- `pointsPerSecond` +- `minimumPoints` +- `effectiveAt` +- `expiredAt` +- `versionNo` +- `status` + +#### 兑换密钥页 `/admin/redeem-codes` + +列表字段: + +- 批次号 +- 兑换密钥 +- 对应积分 +- 状态 +- 使用人 +- 使用时间 +- 过期时间 + +创建/编辑字段: + +- `batchNo` +- `points` +- `quantity` +- `expiredAt` +- `remark` + +#### 增长奖励规则页 `/admin/growth-rules` + +字段: + +- `signupRewardEnabled` +- `signupRewardPoints` +- `inviteRewardEnabled` +- `inviteRewardPoints` +- `inviteRewardTrigger` + +#### 邀请关系页 `/admin/invite-relations` + +列表字段: + +- 邀请人 +- 被邀请用户 +- 邀请码 +- 注册时间 +- 首次消费时间 +- 奖励状态 +- 奖励积分 +- 奖励时间 + +#### 视频任务页 `/admin/video-tasks` + +列表字段: + +- 任务编号 +- 用户 ID +- 平台视频模型 +- 供应商 +- 供应商模型 +- 任务状态 +- 请求时长 +- 预估积分 +- 最终积分 +- 创建时间 + +详情页字段: + +- 原始提交参数 +- 标准化参数 +- 供应商提交报文 +- 供应商返回报文 +- 时间线 +- 回调日志 +- 结算记录 + +## 23. 统一 DTO 与接口字段规范 + +### 23.1 创建视频任务请求 DTO + +后端对外统一接收如下结构: + +```json +{ + "videoModelId": 1, + "prompt": "夜晚的未来城市上空,一架银色飞行器低空掠过,镜头跟拍。", + "durationSeconds": 8, + "resolution": "1280x720", + "ratio": "16:9", + "generateAudio": true, + "referenceImageAssetIds": [1001], + "referenceVideoAssetIds": [], + "referenceAudioAssetIds": [] +} +``` + +字段说明: + +- `videoModelId`:平台视频模型 ID,必填 +- `prompt`:提示词,必填,建议长度 1 到 4000 +- `durationSeconds`:请求时长,必填 +- `resolution`:分辨率,必填 +- `ratio`:宽高比,必填 +- `generateAudio`:是否生成音频,必填 +- `referenceImageAssetIds`:参考图片素材 ID 列表 +- `referenceVideoAssetIds`:参考视频素材 ID 列表 +- `referenceAudioAssetIds`:参考音频素材 ID 列表 + +### 23.2 创建视频任务响应 DTO + +```json +{ + "code": 0, + "message": "ok", + "data": { + "taskNo": "vt_202604170001", + "taskStatus": "queued", + "estimatedPoints": 960, + "frozenPoints": 960 + } +} +``` + +### 23.3 任务详情响应 DTO + +```json +{ + "code": 0, + "message": "ok", + "data": { + "taskNo": "vt_202604170001", + "taskStatus": "succeeded", + "videoModel": { + "id": 1, + "name": "标准视频" + }, + "provider": { + "providerCode": "openai-main", + "modelCode": "sora-2" + }, + "durationSeconds": 8, + "estimatedPoints": 960, + "finalPoints": 960, + "resultVideoUrl": "https://cdn.example.com/generated/xxx.mp4", + "failReason": "", + "createdAt": "2026-04-17T18:00:00+08:00", + "finishedAt": "2026-04-17T18:03:12+08:00" + } +} +``` + +### 23.4 创建充值订单请求 DTO + +```json +{ + "rechargePlanId": 1, + "paymentChannelCode": "alipay" +} +``` + +### 23.5 创建充值订单响应 DTO + +```json +{ + "code": 0, + "message": "ok", + "data": { + "orderNo": "rc_202604170001", + "payAmount": "100.00", + "arrivalPoints": 11000, + "payUrl": "https://payment.example.com/pay/xxx" + } +} +``` + +### 23.6 管理后台供应商账号请求 DTO + +```json +{ + "providerCode": "openai-main", + "providerName": "OpenAI 主账号", + "apiFormat": "openai_official_video", + "baseUrl": "https://api.openai.com", + "apiKey": "sk-xxx", + "apiSecret": "", + "webhookSecret": "whsec_xxx", + "timeoutSeconds": 120, + "maxRetries": 3, + "status": 1, + "remark": "生产主账号" +} +``` + +### 23.7 管理后台价格规则请求 DTO + +```json +{ + "ruleName": "标准视频默认价格", + "videoModelId": 1, + "pointsPerSecond": 120, + "minimumPoints": 500, + "effectiveAt": "2026-04-18 00:00:00", + "expiredAt": null, + "versionNo": 1, + "status": 1 +} +``` + +### 23.8 个人资料更新请求 DTO + +```json +{ + "username": "neo_director", + "nickname": "Neo", + "avatarUrl": "https://cdn.example.com/avatar/1001.png" +} +``` + +### 23.9 兑换密钥请求 DTO + +```json +{ + "redeemCode": "SPRING-2026-ABCD-1234" +} +``` + +### 23.10 邀请码创建响应 DTO + +```json +{ + "code": 0, + "message": "ok", + "data": { + "inviteCode": "NEO88", + "inviteLink": "https://example.com/register?inviteCode=NEO88" + } +} +``` + +### 23.11 注册请求 DTO 补充字段 + +若用户通过邀请码或邀请链接注册,注册请求体建议增加: + +```json +{ + "account": "user@example.com", + "password": "12345678", + "verificationCode": "123456", + "inviteCode": "NEO88" +} +``` + +## 24. 状态机与枚举规范 + +### 24.1 充值订单状态 + +推荐枚举: + +- `pending` +- `paid` +- `failed` +- `cancelled` +- `refunded` + +状态流转: + +```text +pending -> paid +pending -> failed +pending -> cancelled +paid -> refunded +``` + +### 24.2 视频任务状态 + +推荐枚举: + +- `created` +- `queued` +- `submitting` +- `submitted` +- `running` +- `succeeded` +- `failed` +- `cancelled` +- `timed_out` + +状态流转: + +```text +created -> queued +queued -> submitting +submitting -> submitted +submitted -> running +running -> succeeded +running -> failed +running -> timed_out +queued -> cancelled +running -> cancelled +``` + +### 24.3 钱包流水业务类型 + +推荐枚举: + +- `recharge` +- `redeem_code` +- `signup_reward` +- `invite_reward` +- `freeze` +- `consume` +- `unfreeze` +- `refund` +- `manual_adjust` + +### 24.4 供应商协议枚举 + +推荐枚举: + +- `openai_official_video` +- `seedance_video_generation` + +### 24.5 兑换密钥状态 + +推荐枚举: + +- `unused` +- `used` +- `expired` +- `disabled` + +### 24.6 邀请奖励状态 + +推荐枚举: + +- `pending` +- `eligible` +- `rewarded` +- `invalid` + +## 25. 供应商适配器实现细则 + +### 25.1 标准化内部请求结构 + +所有业务层进入适配器之前,统一整理成如下内部结构: + +```json +{ + "taskNo": "vt_202604170001", + "videoModelId": 1, + "providerAccountId": 10, + "providerModelId": 21, + "prompt": "夜晚的未来城市上空,一架银色飞行器低空掠过,镜头跟拍。", + "durationSeconds": 8, + "resolution": "1280x720", + "ratio": "16:9", + "generateAudio": true, + "referenceImages": [ + { + "assetId": 1001, + "url": "https://storage.example.com/uploads/1001.png" + } + ], + "referenceVideos": [], + "referenceAudios": [] +} +``` + +### 25.2 OpenAI 适配器映射规则 + +映射建议: + +- `prompt` -> `prompt` +- `providerModel.modelCode` -> `model` +- `durationSeconds` -> `seconds` +- `resolution` -> `size` +- `referenceImages[0].url` -> `input_reference` + +请求格式: + +- 使用 `multipart/form-data` +- 由适配器自行构造 multipart 边界和文件流 + +查询规则: + +- 使用 `GET /v1/videos/{video_id}` +- 当状态为 `completed` 时,再调用 `/content` 下载 + +### 25.3 Seedance 适配器映射规则 + +映射建议: + +- `providerModel.modelCode` -> `model` +- `prompt` -> `content[].text` +- `referenceImages` -> `content[].image_url` +- `referenceVideos` -> `content[].video_url` +- `referenceAudios` -> `content[].audio_url` +- `durationSeconds` -> `duration` +- `ratio` -> `ratio` +- `generateAudio` -> `generate_audio` + +请求格式: + +- 使用 `application/json` + +查询规则: + +- 使用 `GET /v1/video/generations/{id}` +- 当返回成功状态时读取视频 URL 并下载转存 + +### 25.4 标准化查询结果结构 + +适配器对外统一返回如下结构: + +```json +{ + "externalTaskId": "video_123", + "normalizedStatus": "running", + "progress": 50, + "resultUrl": "", + "resultExpiresAt": null, + "rawResponse": {} +} +``` + +### 25.5 供应商切换策略 + +首版建议按以下策略执行: + +1. 根据 `videoModelId` 查出绑定的供应商模型列表 +2. 按 `routingPriority` 升序排序 +3. 优先使用 `isPrimary = 1` 的供应商模型 +4. 若提交失败且属于可重试错误,则切到下一个供应商 +5. 若已经拿到外部任务 ID,则后续只轮询该供应商,不再切换 + +### 25.6 不允许切换的场景 + +以下场景不能自动切换供应商: + +- 已经成功提交到供应商并拿到任务 ID +- 供应商已进入 `running` +- 供应商已返回部分结果 + +### 25.7 建议保留的原始日志 + +建议新增供应商请求日志表或至少落文件日志,记录: + +- 任务编号 +- 供应商账号 +- 供应商模型 +- 请求 URL +- 请求方法 +- 请求头脱敏版本 +- 请求体脱敏版本 +- 响应体 +- HTTP 状态码 +- 请求耗时 + +## 26. 任务执行链路详细说明 + +### 26.1 创建任务事务 + +用户提交任务时,后端需在一个事务内完成: + +1. 校验用户状态 +2. 校验平台视频模型状态 +3. 查找当前生效的价格规则 +4. 计算预估积分 +5. 校验钱包可用积分 +6. 创建视频任务记录 +7. 冻结积分 +8. 写入钱包流水 +9. 提交事务 + +事务提交后再投递异步任务。 + +### 26.2 提交任务 Worker + +Worker 执行顺序: + +1. 根据 `taskNo` 读取任务 +2. 判断状态是否允许提交 +3. 根据 `videoModelId` 解析可用供应商绑定 +4. 构建标准化请求 DTO +5. 调用供应商适配器提交任务 +6. 保存 `externalTaskId` +7. 更新任务状态为 `submitted` +8. 写入任务事件 +9. 投递轮询任务 + +### 26.3 轮询任务 Worker + +Worker 执行顺序: + +1. 读取本地任务 +2. 若任务已结束,直接退出 +3. 调用对应适配器查询状态 +4. 写入原始响应 +5. 若状态仍在处理中,更新 `nextPollAt` +6. 若状态成功,投递结果下载任务 +7. 若状态失败,执行解冻与失败收尾 + +### 26.4 结果下载 Worker + +Worker 执行顺序: + +1. 使用适配器下载结果文件 +2. 上传到对象存储 +3. 创建 `media_assets` 记录 +4. 回写 `resultAssetId` +5. 触发最终结算 + +### 26.5 最终结算 Worker + +成功任务: + +1. 将冻结积分转为实际消费 +2. 写入消费流水 +3. 更新任务为 `succeeded` + +失败任务: + +1. 解冻积分 +2. 写入退回流水 +3. 更新任务为 `failed` + +### 26.6 注册赠送与邀请奖励触发 + +注册成功后: + +1. 检查注册赠送规则是否开启 +2. 检查用户是否已发放过注册赠送 +3. 发放注册奖励积分 +4. 写入钱包流水 + +任务消费成功后: + +1. 检查用户是否存在邀请关系 +2. 检查该邀请关系是否尚未奖励 +3. 检查是否满足首次有效消费条件 +4. 检查邀请奖励规则是否开启 +5. 给邀请人发放积分奖励 +6. 更新邀请关系状态为 `rewarded` + +### 26.7 幂等要求 + +以下操作必须具备幂等性: + +- 支付回调 +- 任务冻结积分 +- 任务最终扣费 +- 任务失败退回积分 +- 注册赠送发放 +- 邀请奖励发放 +- 兑换密钥使用 +- 结果转存写库 +- 供应商 webhook 处理 + +## 27. 环境变量与配置清单 + +### 27.1 后端环境变量 + +```env +APP_NAME=AIVideo +APP_ENV=development +APP_HOST=0.0.0.0 +APP_PORT=8000 +APP_DEBUG=true + +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=123456 +MYSQL_DATABASE=aivideo + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_DB=0 + +JWT_SECRET=replace_me +JWT_REFRESH_SECRET=replace_me_too +JWT_ACCESS_EXPIRE_MINUTES=120 +JWT_REFRESH_EXPIRE_DAYS=30 + +STORAGE_PROVIDER=minio +STORAGE_ENDPOINT=127.0.0.1:9000 +STORAGE_BUCKET=ai-video +STORAGE_ACCESS_KEY=minioadmin +STORAGE_SECRET_KEY=minioadmin +STORAGE_PUBLIC_BASE_URL=https://cdn.example.com + +POINT_EXCHANGE_RATIO=100 +REDEEM_CODE_FAIL_LIMIT_PER_HOUR=20 +INVITE_REWARD_MIN_CONSUME_POINTS=100 + +PAYMENT_NOTIFY_BASE_URL=https://api.example.com +VIDEO_NOTIFY_BASE_URL=https://api.example.com + +DATA_ENCRYPTION_KEY=replace_32_length_key +SENTRY_DSN= +``` + +### 27.2 前端环境变量 + +前台: + +```env +NEXT_PUBLIC_API_BASE_URL=https://api.example.com +NEXT_PUBLIC_APP_NAME=AIVideo +``` + +后台: + +```env +NEXT_PUBLIC_ADMIN_API_BASE_URL=https://api.example.com +NEXT_PUBLIC_ADMIN_APP_NAME=AIVideo Admin +``` + +### 27.3 系统配置表建议维护的键 + +建议把以下配置放入 `system_configs`: + +- `site.title` +- `site.notice` +- `wallet.point_exchange_ratio` +- `reward.signup.enabled` +- `reward.signup.points` +- `reward.invite.enabled` +- `reward.invite.points` +- `reward.invite.trigger` +- `reward.invite.min_consume_points` +- `invite.code.enabled` +- `upload.max_image_size_mb` +- `upload.max_video_size_mb` +- `upload.max_audio_size_mb` +- `task.default_poll_interval_seconds` +- `task.max_poll_minutes` +- `task.daily_create_limit` + +## 28. 本地开发流程 + +### 28.1 后端初始化步骤 + +```text +1. 创建 Python 虚拟环境 +2. 安装依赖 +3. 准备 .env +4. 初始化 MySQL 数据库 +5. 执行 Alembic migrate +6. 启动 FastAPI +7. 启动 Celery Worker +8. 启动 Celery Beat 或定时轮询服务 +``` + +### 28.2 前台初始化步骤 + +```text +1. 安装 Node.js 依赖 +2. 配置 .env.local +3. 启动 Next.js dev server +4. 连接后端调试登录、充值、创建任务 +``` + +### 28.3 后台初始化步骤 + +```text +1. 安装 Node.js 依赖 +2. 配置 .env.local +3. 创建管理员种子账号 +4. 启动后台项目 +5. 先录入增长奖励规则 +6. 再录入兑换密钥批次 +7. 再录入供应商账号 +8. 再录入供应商模型 +9. 再创建平台视频模型 +10. 再创建绑定关系和价格规则 +``` + +### 28.4 数据初始化顺序 + +建议种子数据按以下顺序初始化: + +1. 管理员角色与权限 +2. 管理员账号 +3. 增长奖励规则 +4. 支付渠道 +5. 兑换密钥批次 +6. 供应商账号 +7. 供应商模型 +8. 平台视频模型 +9. 平台模型绑定关系 +10. 价格规则 +11. 系统配置 + +## 29. 错误码规范 + +### 29.1 错误码分段 + +建议按模块划分错误码: + +- `10000-19999`:认证与用户 +- `20000-29999`:钱包与积分 +- `30000-39999`:充值与支付 +- `40000-49999`:素材与上传 +- `50000-59999`:视频任务 +- `60000-69999`:供应商与模型 +- `70000-74999`:邀请、兑换与增长奖励 +- `75000-79999`:后台权限与系统配置 + +### 29.2 常用错误码示例 + +```text +10001 未登录 +10002 Token 无效 +10003 用户被禁用 + +20001 积分不足 +20002 钱包不存在 +20003 冻结积分失败 +20004 兑换密钥不存在 +20005 兑换密钥已使用 +20006 兑换密钥已过期 + +30001 充值订单不存在 +30002 支付回调验签失败 +30003 订单已处理 + +40001 上传文件类型不支持 +40002 文件大小超限 +40003 素材不存在 + +50001 平台视频模型不存在 +50002 平台视频模型已下架 +50003 没有可用供应商 +50004 任务状态不允许当前操作 +50005 任务创建失败 +50006 任务记录不存在或无权限访问 + +60001 供应商账号不可用 +60002 供应商模型不可用 +60003 供应商接口超时 +60004 供应商接口限流 +60005 供应商返回格式异常 + +70001 邀请码不存在 +70002 邀请码不可用 +70003 邀请关系已绑定 +70004 邀请奖励已发放 +``` + +## 30. 开发任务拆分建议 + +### 30.1 后端任务拆分 + +后端至少拆成以下开发任务: + +1. 认证模块 +2. 用户与钱包模块 +3. 兑换密钥模块 +4. 邀请与奖励模块 +5. 充值订单与支付回调模块 +6. 素材上传模块 +7. 供应商账号与模型管理模块 +8. 平台视频模型与绑定模块 +9. 价格规则模块 +10. 视频任务模块 +11. OpenAI 适配器 +12. Seedance 适配器 +13. 异步任务模块 +14. 日志与监控模块 + +### 30.2 前台任务拆分 + +前台至少拆成以下开发任务: + +1. 登录注册 +2. 钱包与充值 +3. 兑换密钥 +4. 邀请中心 +5. 素材管理 +6. 创建视频任务 +7. 任务列表 +8. 任务详情 +9. 个人中心 + +### 30.3 后台任务拆分 + +后台至少拆成以下开发任务: + +1. 管理员登录 +2. 仪表盘 +3. 用户管理 +4. 充值订单管理 +5. 兑换密钥管理 +6. 增长奖励规则管理 +7. 邀请关系管理 +8. 供应商账号管理 +9. 供应商模型管理 +10. 平台视频模型管理 +11. 绑定关系管理 +12. 价格规则管理 +13. 视频任务管理 +14. 回调日志和操作日志 + +## 31. 开发验收清单 + +### 31.1 用户端验收 + +- 用户可正常登录退出 +- 用户可看到平台视频模型与价格 +- 用户可修改用户名和头像 +- 用户可上传素材 +- 用户可通过兑换密钥获得积分 +- 用户可看到邀请码和邀请链接 +- 用户可创建任务 +- 用户可轮询看到任务变化 +- 用户可看到结果视频 +- 用户可删除自己的任务记录 +- 用户可查看积分流水 + +### 31.2 后台验收 + +- 可新增多个供应商账号 +- 可新增多个供应商模型 +- 可新增平台视频模型 +- 可绑定多个供应商模型到同一个平台视频模型 +- 可配置每秒积分价格 +- 可配置注册赠送积分是否开启及积分数 +- 可配置邀请奖励是否开启及积分数 +- 可批量生成或导入兑换密钥 +- 可查看任务命中的实际供应商 +- 可查看邀请关系与奖励状态 +- 可查看回调日志与操作日志 + +### 31.3 技术验收 + +- OpenAI 官方格式能成功提交、查询和下载结果 +- Seedance 格式能成功提交、查询和下载结果 +- 支付回调幂等 +- 任务积分冻结与扣减幂等 +- 任务失败积分退回正确 +- 兑换密钥并发兑换时只成功一次 +- 注册赠送积分只发放一次 +- 邀请奖励只发放一次 +- 供应商切换策略符合预期 +- 日志、监控、错误追踪已接入 + +### 31.4 上线前人工回归项 + +- 创建 1 个 OpenAI 平台模型并完成生成 +- 创建 1 个 Seedance 平台模型并完成生成 +- 构造供应商主路由失败并验证自动切换 +- 构造支付重复回调并验证只到账一次 +- 构造任务失败并验证积分退回 +- 构造同一个兑换密钥重复兑换并验证只成功一次 +- 构造邀请注册和首次消费并验证奖励只发放一次 diff --git a/docs/Seedance_2.0_客户使用手册.docx b/docs/Seedance_2.0_客户使用手册.docx new file mode 100644 index 0000000000000000000000000000000000000000..91f483335f518fba75ac58028720eb7567dec466 GIT binary patch literal 31366 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 +# 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. + diff --git a/frontend-admin/CLAUDE.md b/frontend-admin/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend-admin/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend-admin/README.md b/frontend-admin/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend-admin/README.md @@ -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. diff --git a/frontend-admin/eslint.config.mjs b/frontend-admin/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend-admin/eslint.config.mjs @@ -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; diff --git a/frontend-admin/next.config.ts b/frontend-admin/next.config.ts new file mode 100644 index 0000000..c4f0d41 --- /dev/null +++ b/frontend-admin/next.config.ts @@ -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; diff --git a/frontend-admin/package.json b/frontend-admin/package.json new file mode 100644 index 0000000..b8dd409 --- /dev/null +++ b/frontend-admin/package.json @@ -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" + } +} diff --git a/frontend-admin/postcss.config.mjs b/frontend-admin/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend-admin/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend-admin/public/file.svg b/frontend-admin/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend-admin/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-admin/public/globe.svg b/frontend-admin/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend-admin/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-admin/public/next.svg b/frontend-admin/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend-admin/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-admin/public/vercel.svg b/frontend-admin/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend-admin/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-admin/public/window.svg b/frontend-admin/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend-admin/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-admin/src/app/admin/(secure)/callback-logs/page.tsx b/frontend-admin/src/app/admin/(secure)/callback-logs/page.tsx new file mode 100644 index 0000000..a28d066 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/callback-logs/page.tsx @@ -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("/api/v1/admin/callback-logs"), + }); + + return ( +
+

回调日志

+
+ {query.data?.map((item) => ( +
+
+ {item.sourceType} / {item.sourceCode} + +
+
+ 关联号:{item.relatedNo || "-"} · 验签:{item.verifyStatus} +
+ {item.errorMessage ?
错误:{item.errorMessage}
: null} +
+ ))} +
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/dashboard/page.tsx b/frontend-admin/src/app/admin/(secure)/dashboard/page.tsx new file mode 100644 index 0000000..531601f --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/dashboard/page.tsx @@ -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 ( +
+
+

用户总数

+
{data?.users ?? 0}
+
+
+

已支付订单

+
{data?.paidOrders ?? 0}
+
+
+

任务总数

+
{data?.tasks ?? 0}
+
+
+

成功率

+
{data?.successRate ?? 0}%
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/growth-rules/page.tsx b/frontend-admin/src/app/admin/(secure)/growth-rules/page.tsx new file mode 100644 index 0000000..c6c3b50 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/growth-rules/page.tsx @@ -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("/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 ( +
+
+

注册奖励

+
+ + + +
+
+ +
+

邀请奖励

+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/invite-relations/page.tsx b/frontend-admin/src/app/admin/(secure)/invite-relations/page.tsx new file mode 100644 index 0000000..f0a7379 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/invite-relations/page.tsx @@ -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("/api/v1/admin/invite-relations"), + }); + + return ( +
+

邀请关系

+
+ {query.data?.map((item) => ( +
+
+ + 邀请人 {item.inviterUserId} to 被邀请人 {item.inviteeUserId} + + +
+
奖励积分:{item.rewardPoints}
+
+ ))} +
+
+ ); +} diff --git a/frontend-admin/src/app/admin/(secure)/layout.tsx b/frontend-admin/src/app/admin/(secure)/layout.tsx new file mode 100644 index 0000000..e716995 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/layout.tsx @@ -0,0 +1,10 @@ +import { AdminShell } from "@/components/admin-shell"; + +export default function SecureAdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} + diff --git a/frontend-admin/src/app/admin/(secure)/pricing-rules/page.tsx b/frontend-admin/src/app/admin/(secure)/pricing-rules/page.tsx new file mode 100644 index 0000000..81ee6f9 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/pricing-rules/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +export default function PricingRulesPage() { + const [form, setForm] = useState({ + rule_name: "影院视频默认价格", + video_model_id: 1, + points_per_second: 160, + minimum_points: 600, + effective_at: new Date().toISOString(), + expired_at: null, + version_no: 2, + status: 1, + }); + const query = useQuery({ + queryKey: ["pricing-rules"], + queryFn: () => api.get("/api/v1/admin/pricing-rules"), + }); + const mutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/pricing-rules", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增价格规则

+
{JSON.stringify(form, null, 2)}
+ +
+
+

价格规则列表

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/provider-accounts/page.tsx b/frontend-admin/src/app/admin/(secure)/provider-accounts/page.tsx new file mode 100644 index 0000000..6674951 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/provider-accounts/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +export default function ProviderAccountsPage() { + const [form, setForm] = useState({ + provider_code: "openai-backup", + provider_name: "OpenAI 备用账号", + api_format: "openai_official_video", + base_url: "mock://openai", + api_key: "mock", + api_secret: "", + webhook_secret: "", + timeout_seconds: 120, + max_retries: 3, + status: 1, + remark: "backup route", + }); + const query = useQuery({ + queryKey: ["provider-accounts"], + queryFn: () => api.get("/api/v1/admin/provider-accounts"), + }); + const createMutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/provider-accounts", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增供应商账号

+
+ {Object.entries(form).map(([key, value]) => ( + + ))} + +
+
+
+

当前账号

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/provider-models/page.tsx b/frontend-admin/src/app/admin/(secure)/provider-models/page.tsx new file mode 100644 index 0000000..2b17e82 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/provider-models/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +export default function ProviderModelsPage() { + const [form, setForm] = useState({ + provider_account_id: 1, + model_code: "sora-2-pro", + model_name: "Sora 2 Pro", + request_content_type: "multipart/form-data", + supports_text_to_video: true, + supports_image_to_video: true, + supports_video_reference: false, + supports_audio_reference: false, + supports_generate_audio: true, + supports_remix: false, + supports_webhook: true, + min_duration: 4, + max_duration: 12, + default_ratio: "16:9", + default_resolution: "1280x720", + status: 1, + }); + const query = useQuery({ + queryKey: ["provider-models"], + queryFn: () => api.get("/api/v1/admin/provider-models"), + }); + const createMutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/provider-models", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增供应商模型

+
{JSON.stringify(form, null, 2)}
+ +
+
+

当前模型

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/recharge-orders/page.tsx b/frontend-admin/src/app/admin/(secure)/recharge-orders/page.tsx new file mode 100644 index 0000000..5672b48 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/recharge-orders/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type OrderRow = { + id: number; + orderNo: string; + userId: number; + payAmount: string; + arrivalPoints: number; + status: string; + paymentChannelCode: string; +}; + +export default function RechargeOrdersPage() { + const ordersQuery = useQuery({ + queryKey: ["admin-orders"], + queryFn: () => api.get("/api/v1/admin/recharge-orders"), + }); + const repairMutation = useMutation({ + mutationFn: (orderId: number) => api.post(`/api/v1/admin/recharge-orders/${orderId}/repair`), + onSuccess: () => ordersQuery.refetch(), + }); + + return ( +
+

充值订单

+
+ {ordersQuery.data?.map((order) => ( +
+
+ {order.orderNo} + +
+
+ 用户 {order.userId} · {order.payAmount} 元 · 到账 {order.arrivalPoints} 积分 +
+ +
+ ))} +
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/redeem-codes/page.tsx b/frontend-admin/src/app/admin/(secure)/redeem-codes/page.tsx new file mode 100644 index 0000000..0aca85d --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/redeem-codes/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type RedeemCodeRow = { + id: number; + batchNo: string; + redeemCode: string; + points: number; + status: string; +}; + +export default function RedeemCodesPage() { + const [form, setForm] = useState({ + batch_no: "OPS2026", + points: 800, + quantity: 3, + remark: "ops batch", + }); + const codesQuery = useQuery({ + queryKey: ["admin-redeem-codes"], + queryFn: () => api.get("/api/v1/admin/redeem-codes"), + }); + const createMutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/redeem-codes/batch-create", form), + onSuccess: () => codesQuery.refetch(), + }); + const disableMutation = useMutation({ + mutationFn: (id: number) => api.put(`/api/v1/admin/redeem-codes/${id}/disable`), + onSuccess: () => codesQuery.refetch(), + }); + + return ( +
+
+

批量生成兑换密钥

+
+ + + + +
+
+ +
+

兑换码列表

+
+ {codesQuery.data?.map((item) => ( +
+
+ {item.redeemCode} + +
+
{item.batchNo} · {item.points} 积分
+ +
+ ))} +
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/system-config/page.tsx b/frontend-admin/src/app/admin/(secure)/system-config/page.tsx new file mode 100644 index 0000000..9ac133e --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/system-config/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +type ConfigRow = { + configKey: string; + configValue: string; + groupName: string; +}; + +export default function SystemConfigPage() { + const [form, setForm] = useState({ + config_key: "site.notice", + config_value: "当前为本地联调环境。", + value_type: "string", + group_name: "site", + description: "公告", + is_public: true, + }); + const query = useQuery({ + queryKey: ["system-config"], + queryFn: () => api.get("/api/v1/admin/system-config"), + }); + const mutation = useMutation({ + mutationFn: () => api.put("/api/v1/admin/system-config", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

更新系统配置

+
+ {Object.entries(form).map(([key, value]) => ( + + ))} + +
+
+
+

配置清单

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} diff --git a/frontend-admin/src/app/admin/(secure)/users/page.tsx b/frontend-admin/src/app/admin/(secure)/users/page.tsx new file mode 100644 index 0000000..2562838 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/users/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +type UserRow = { + id: number; + publicId: string; + username: string; + nickname: string; + email: string; + status: number; + createdAt: string; +}; + +export default function UsersPage() { + const [adjustForm, setAdjustForm] = useState({ + userId: 1, + amountPoints: 100, + reason: "manual bonus", + }); + const usersQuery = useQuery({ + queryKey: ["admin-users"], + queryFn: () => api.get("/api/v1/admin/users"), + }); + const adjustMutation = useMutation({ + mutationFn: () => + api.post(`/api/v1/admin/users/${adjustForm.userId}/wallet-adjust`, adjustForm), + }); + + return ( +
+
+

用户列表

+
+ {usersQuery.data?.map((user) => ( +
+ {user.nickname || user.username || user.publicId} +
+ #{user.id} · {user.email} · 状态 {user.status} +
+
+ ))} +
+
+ +
+

人工调账

+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/video-model-bindings/page.tsx b/frontend-admin/src/app/admin/(secure)/video-model-bindings/page.tsx new file mode 100644 index 0000000..448f75b --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/video-model-bindings/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +export default function VideoModelBindingsPage() { + const [form, setForm] = useState({ + video_model_id: 1, + provider_model_id: 1, + routing_priority: 20, + is_primary: false, + status: 1, + timeout_seconds_override: 90, + }); + const query = useQuery({ + queryKey: ["video-model-bindings"], + queryFn: () => api.get("/api/v1/admin/video-model-bindings"), + }); + const mutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/video-model-bindings", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增绑定

+
{JSON.stringify(form, null, 2)}
+ +
+
+

绑定列表

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/video-models/page.tsx b/frontend-admin/src/app/admin/(secure)/video-models/page.tsx new file mode 100644 index 0000000..b0b3fdd --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/video-models/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; + +export default function VideoModelsPage() { + const [form, setForm] = useState({ + model_key: "cinema-pro", + model_name: "影院视频", + frontend_title: "影院视频", + frontend_description: "偏高质量的长镜头生成。", + default_duration_seconds: 8, + default_ratio: "16:9", + default_resolution: "1280x720", + status: 1, + sort_order: 40, + }); + const query = useQuery({ + queryKey: ["video-models-admin"], + queryFn: () => api.get("/api/v1/admin/video-models"), + }); + const mutation = useMutation({ + mutationFn: () => api.post("/api/v1/admin/video-models", form), + onSuccess: () => query.refetch(), + }); + + return ( +
+
+

新增平台视频模型

+
{JSON.stringify(form, null, 2)}
+ +
+
+

平台模型列表

+
{JSON.stringify(query.data ?? [], null, 2)}
+
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/(secure)/video-tasks/page.tsx b/frontend-admin/src/app/admin/(secure)/video-tasks/page.tsx new file mode 100644 index 0000000..e6f2573 --- /dev/null +++ b/frontend-admin/src/app/admin/(secure)/video-tasks/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type TaskRow = { + id: number; + taskNo: string; + taskStatus: string; + estimatedPoints: number; + finalPoints: number; + resultVideoUrl: string; +}; + +export default function VideoTasksPage() { + const query = useQuery({ + queryKey: ["admin-video-tasks"], + queryFn: () => api.get("/api/v1/admin/video-tasks"), + refetchInterval: 4_000, + }); + const retryMutation = useMutation({ + mutationFn: (taskId: number) => api.post(`/api/v1/admin/video-tasks/${taskId}/retry`), + onSuccess: () => query.refetch(), + }); + const refundMutation = useMutation({ + mutationFn: (taskId: number) => api.post(`/api/v1/admin/video-tasks/${taskId}/refund`), + onSuccess: () => query.refetch(), + }); + + return ( +
+

视频任务

+
+ {query.data?.map((task) => ( +
+
+ {task.taskNo} + +
+
+ 预估 {task.estimatedPoints} · 最终 {task.finalPoints} +
+
+ + +
+
+ ))} +
+
+ ); +} + diff --git a/frontend-admin/src/app/admin/login/page.tsx b/frontend-admin/src/app/admin/login/page.tsx new file mode 100644 index 0000000..36ba4d7 --- /dev/null +++ b/frontend-admin/src/app/admin/login/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { startTransition, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { api, ApiError } from "@/lib/api"; + +export default function AdminLoginPage() { + const router = useRouter(); + const [form, setForm] = useState({ + username: "admin", + password: "Admin@123456", + }); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(""); + try { + await api.post("/api/v1/admin/auth/login", form); + startTransition(() => router.replace("/admin/dashboard")); + } catch (err) { + setError(err instanceof ApiError ? err.message : "登录失败"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
Ops Console
+

+ 管好模型、价格、奖励、订单和任务链路。 +

+

+ 后台聚焦两件事:配置业务规则,以及处理异常链路。默认已创建管理员账号,适合直接联调整条 MVP。 +

+
+
+ +
+
+
AIVideo Admin
+

管理员登录

+
+ + + {error ?
{error}
: null} + +
+
+
+
+ ); +} + diff --git a/frontend-admin/src/app/favicon.ico b/frontend-admin/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/frontend-admin/src/app/globals.css b/frontend-admin/src/app/globals.css new file mode 100644 index 0000000..3ac7f98 --- /dev/null +++ b/frontend-admin/src/app/globals.css @@ -0,0 +1,309 @@ +@import "tailwindcss"; + +:root { + --bg: #f4f3ef; + --surface: rgba(255, 255, 255, 0.86); + --ink: #141b24; + --muted: #667281; + --line: rgba(20, 27, 36, 0.1); + --accent: #0d6b78; + --accent-soft: rgba(13, 107, 120, 0.12); + --success: #1b8d62; + --warn: #b9770e; + --danger: #bb3e3e; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + font-family: var(--font-body), sans-serif; + background: + radial-gradient(circle at top left, rgba(13, 107, 120, 0.13), transparent 24%), + radial-gradient(circle at bottom right, rgba(45, 69, 122, 0.1), transparent 22%), + linear-gradient(180deg, #faf8f2 0%, #efebe2 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.fullscreen-shell { + min-height: 100vh; + display: grid; + place-items: center; +} + +.login-grid, +.shell-grid { + min-height: 100vh; + display: grid; +} + +.login-grid { + grid-template-columns: 1fr 420px; +} + +.shell-grid { + grid-template-columns: 300px 1fr; +} + +.shell-sidebar { + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; + border-right: 1px solid var(--line); + background: rgba(255, 255, 255, 0.62); + backdrop-filter: blur(16px); +} + +.brand-kicker, +.header-kicker { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent); +} + +.brand-block h1 { + margin: 8px 0 12px; + font-size: 30px; + font-family: var(--font-display), sans-serif; +} + +.sidebar-nav { + display: grid; + gap: 8px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 15px; + border-radius: 16px; + color: var(--muted); +} + +.nav-item.active { + background: var(--accent-soft); + color: var(--ink); + font-weight: 700; +} + +.shell-main { + padding: 24px; +} + +.shell-content { + display: grid; + gap: 18px; +} + +.panel, +.profile-card, +.stat-card, +.pulse-card, +.auth-card { + border-radius: 22px; + border: 1px solid var(--line); + background: var(--surface); + box-shadow: 0 16px 52px rgba(20, 27, 36, 0.08); +} + +.panel, +.stat-card, +.auth-card { + padding: 22px; +} + +.profile-card { + padding: 16px; + margin-top: auto; + display: grid; + gap: 12px; +} + +.profile-name { + font-weight: 700; +} + +.profile-meta, +.muted { + color: var(--muted); +} + +.toolbar, +.row { + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + flex-wrap: wrap; +} + +.panel-grid, +.stats-grid, +.two-col-grid { + display: grid; + gap: 18px; +} + +.panel-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.stats-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.two-col-grid { + grid-template-columns: 1fr 1fr; +} + +.panel h3, +.stat-card h3, +.auth-card h3 { + margin: 0 0 14px; + font-size: 20px; + font-family: var(--font-display), sans-serif; +} + +.value { + font-size: 30px; + font-family: var(--font-display), sans-serif; +} + +.list-grid { + display: grid; + gap: 12px; +} + +.list-item { + padding: 15px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.8); +} + +.mini-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.form-stack { + display: grid; + gap: 14px; +} + +.field-label { + display: grid; + gap: 8px; + color: var(--muted); +} + +.field-label input, +.field-label select, +.field-label textarea { + width: 100%; + padding: 13px 15px; + border: 1px solid rgba(20, 27, 36, 0.14); + border-radius: 14px; + background: white; +} + +.field-label textarea { + min-height: 110px; + resize: vertical; +} + +.primary-button, +.ghost-button, +.danger-button { + border: 0; + border-radius: 14px; + padding: 12px 16px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; +} + +.primary-button { + background: linear-gradient(135deg, #0d6b78 0%, #1d94a4 100%); + color: white; +} + +.ghost-button { + background: rgba(20, 27, 36, 0.06); +} + +.danger-button { + background: rgba(187, 62, 62, 0.12); + color: var(--danger); +} + +.status-badge { + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.tone-soft { + background: rgba(20, 27, 36, 0.08); +} + +.tone-success { + background: rgba(27, 141, 98, 0.12); + color: var(--success); +} + +.tone-warn { + background: rgba(185, 119, 14, 0.12); + color: var(--warn); +} + +.tone-danger { + background: rgba(187, 62, 62, 0.12); + color: var(--danger); +} + +.tone-ghost { + background: rgba(102, 114, 129, 0.14); + color: var(--muted); +} + +.code-block { + margin: 0; + padding: 16px; + border-radius: 16px; + background: #101620; + color: #d8e1f5; + overflow: auto; + font-size: 13px; +} + +@media (max-width: 1120px) { + .login-grid, + .shell-grid, + .two-col-grid { + grid-template-columns: 1fr; + } +} + diff --git a/frontend-admin/src/app/layout.tsx b/frontend-admin/src/app/layout.tsx new file mode 100644 index 0000000..b6964aa --- /dev/null +++ b/frontend-admin/src/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { IBM_Plex_Sans, Space_Grotesk } from "next/font/google"; + +import { Providers } from "@/components/providers"; + +import "./globals.css"; + +const displayFont = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-display", +}); + +const bodyFont = IBM_Plex_Sans({ + subsets: ["latin"], + variable: "--font-body", + weight: ["400", "500", "600", "700"], +}); + +export const metadata: Metadata = { + title: "AIVideo Admin", + description: "AI 视频平台管理后台", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} + diff --git a/frontend-admin/src/app/page.tsx b/frontend-admin/src/app/page.tsx new file mode 100644 index 0000000..afb2301 --- /dev/null +++ b/frontend-admin/src/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/admin/dashboard"); +} + diff --git a/frontend-admin/src/components/admin-shell.tsx b/frontend-admin/src/components/admin-shell.tsx new file mode 100644 index 0000000..b041720 --- /dev/null +++ b/frontend-admin/src/components/admin-shell.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; +import { + Blocks, + ChartColumnBig, + Coins, + KeySquare, + Link2, + LogOut, + Package2, + Settings2, + Users, + Workflow, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { api } from "@/lib/api"; + +const navigation = [ + { href: "/admin/dashboard", label: "仪表盘", icon: ChartColumnBig }, + { href: "/admin/users", label: "用户管理", icon: Users }, + { href: "/admin/recharge-orders", label: "充值订单", icon: Coins }, + { href: "/admin/redeem-codes", label: "兑换密钥", icon: KeySquare }, + { href: "/admin/growth-rules", label: "增长奖励", icon: Link2 }, + { href: "/admin/invite-relations", label: "邀请关系", icon: Link2 }, + { href: "/admin/provider-accounts", label: "供应商账号", icon: Workflow }, + { href: "/admin/provider-models", label: "供应商模型", icon: Blocks }, + { href: "/admin/video-models", label: "平台模型", icon: Package2 }, + { href: "/admin/video-model-bindings", label: "模型绑定", icon: Workflow }, + { href: "/admin/pricing-rules", label: "价格规则", icon: Coins }, + { href: "/admin/video-tasks", label: "视频任务", icon: Workflow }, + { href: "/admin/callback-logs", label: "回调日志", icon: ChartColumnBig }, + { href: "/admin/system-config", label: "系统配置", icon: Settings2 }, +]; + +export function AdminShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const meQuery = useQuery({ + queryKey: ["admin-me"], + queryFn: () => api.get("/api/v1/admin/auth/me"), + }); + + useEffect(() => { + if (meQuery.error) { + router.replace("/admin/login"); + } + }, [meQuery.error, router]); + + if (meQuery.isLoading || !meQuery.data) { + return ( +
+
正在校验管理员身份...
+
+ ); + } + + return ( +
+ + +
+
{children}
+
+
+ ); +} + diff --git a/frontend-admin/src/components/providers.tsx b/frontend-admin/src/components/providers.tsx new file mode 100644 index 0000000..9442e1f --- /dev/null +++ b/frontend-admin/src/components/providers.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { useState } from "react"; + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 2_000, + }, + }, + }), + ); + + return ( + {children} + ); +} + diff --git a/frontend-admin/src/components/status-badge.tsx b/frontend-admin/src/components/status-badge.tsx new file mode 100644 index 0000000..8275e2e --- /dev/null +++ b/frontend-admin/src/components/status-badge.tsx @@ -0,0 +1,31 @@ +import clsx from "clsx"; + +const tones: Record = { + paid: "success", + pending: "soft", + succeeded: "success", + running: "warn", + failed: "danger", + unused: "success", + used: "ghost", + disabled: "danger", + rewarded: "success", +}; + +export function StatusBadge({ value }: { value: string }) { + const tone = tones[value] ?? "soft"; + return ( + + {value} + + ); +} + diff --git a/frontend-admin/src/lib/api.ts b/frontend-admin/src/lib/api.ts new file mode 100644 index 0000000..fb3aa17 --- /dev/null +++ b/frontend-admin/src/lib/api.ts @@ -0,0 +1,60 @@ +export type ApiEnvelope = { + code: number; + message: string; + data: T; +}; + +export class ApiError extends Error { + status: number; + details: unknown; + + constructor(message: string, status: number, details: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.details = details; + } +} + +const API_BASE_URL = + process.env.NEXT_PUBLIC_ADMIN_API_BASE_URL ?? "http://localhost:8000"; + +async function request( + path: string, + options: RequestInit = {}, +): Promise { + const isFormData = options.body instanceof FormData; + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + credentials: "include", + cache: "no-store", + headers: { + ...(isFormData ? {} : { "Content-Type": "application/json" }), + ...(options.headers ?? {}), + }, + }); + let payload: ApiEnvelope | null = null; + try { + payload = (await response.json()) as ApiEnvelope; + } catch { + payload = null; + } + if (!response.ok || !payload || payload.code !== 0) { + throw new ApiError(payload?.message ?? "Request failed", response.status, payload); + } + return payload.data; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { + method: "POST", + body: JSON.stringify(body ?? {}), + }), + put: (path: string, body?: unknown) => + request(path, { + method: "PUT", + body: JSON.stringify(body ?? {}), + }), +}; diff --git a/frontend-admin/tsconfig.json b/frontend-admin/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/frontend-admin/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/frontend-web/.gitignore b/frontend-web/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend-web/.gitignore @@ -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 diff --git a/frontend-web/AGENTS.md b/frontend-web/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend-web/AGENTS.md @@ -0,0 +1,5 @@ + +# 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. + diff --git a/frontend-web/CLAUDE.md b/frontend-web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend-web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend-web/README.md b/frontend-web/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend-web/README.md @@ -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. diff --git a/frontend-web/eslint.config.mjs b/frontend-web/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend-web/eslint.config.mjs @@ -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; diff --git a/frontend-web/next.config.ts b/frontend-web/next.config.ts new file mode 100644 index 0000000..c4f0d41 --- /dev/null +++ b/frontend-web/next.config.ts @@ -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; diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json new file mode 100644 index 0000000..df7ad92 --- /dev/null +++ b/frontend-web/package-lock.json @@ -0,0 +1,6571 @@ +{ + "name": "frontend-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend-web", + "version": "0.1.0", + "dependencies": { + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.4", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.0.tgz", + "integrity": "sha512-LDicyhrRFrIaheDYryeM2W8gWyZXnAs4zIr2WVPiOSeTmIu2RjR4x/9N0xLaRWZ+9hssBDGo3AadcohuzAvSvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.4", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend-web/package.json b/frontend-web/package.json new file mode 100644 index 0000000..007b413 --- /dev/null +++ b/frontend-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend-web", + "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" + } +} diff --git a/frontend-web/postcss.config.mjs b/frontend-web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend-web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend-web/public/file.svg b/frontend-web/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend-web/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/public/globe.svg b/frontend-web/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend-web/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/public/next.svg b/frontend-web/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend-web/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/public/vercel.svg b/frontend-web/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend-web/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/public/window.svg b/frontend-web/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend-web/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/app/(dashboard)/invite/page.tsx b/frontend-web/src/app/(dashboard)/invite/page.tsx new file mode 100644 index 0000000..6966e52 --- /dev/null +++ b/frontend-web/src/app/(dashboard)/invite/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type InviteSummary = { + inviteCode: string; + inviteLink: string; + invitedUsers: number; + rewardedUsers: number; + rewardedPoints: number; +}; + +type InviteRelation = { + inviteeUserId: number; + inviteeNickname: string; + rewardStatus: string; + rewardPoints: number; + createdAt: string; + rewardedAt: string | null; +}; + +export default function InvitePage() { + const summaryQuery = useQuery({ + queryKey: ["invite-summary"], + queryFn: () => api.get("/api/v1/invite"), + }); + const relationsQuery = useQuery({ + queryKey: ["invite-relations"], + queryFn: () => api.get("/api/v1/invite/relations"), + }); + const rewardsQuery = useQuery({ + queryKey: ["invite-rewards"], + queryFn: () => api.get("/api/v1/invite/rewards"), + }); + const createMutation = useMutation({ + mutationFn: () => api.post("/api/v1/invite/codes"), + onSuccess: () => summaryQuery.refetch(), + }); + + const summary = summaryQuery.data; + + return ( +
+
+
+
+

邀请中心

+

邀请码与邀请链接默认在首次访问时自动生成。

+
+ +
+
+
+ {summary?.inviteCode} +
邀请码
+
+
+ {summary?.invitedUsers ?? 0} +
邀请人数
+
+
+ {summary?.rewardedPoints ?? 0} +
累计奖励积分
+
+
+
+          {summary?.inviteLink}
+        
+
+ +
+

邀请关系

+
+ {relationsQuery.data?.map((item) => ( +
+
+ {item.inviteeNickname || `用户 #${item.inviteeUserId}`} + +
+
奖励积分:{item.rewardPoints}
+
+ ))} +
+ 已发奖记录 +
{rewardsQuery.data?.length ?? 0} 条
+
+
+
+
+ ); +} + diff --git a/frontend-web/src/app/(dashboard)/layout.tsx b/frontend-web/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..01e87e8 --- /dev/null +++ b/frontend-web/src/app/(dashboard)/layout.tsx @@ -0,0 +1,10 @@ +import { SiteShell } from "@/components/site-shell"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} + diff --git a/frontend-web/src/app/(dashboard)/profile/page.tsx b/frontend-web/src/app/(dashboard)/profile/page.tsx new file mode 100644 index 0000000..2081444 --- /dev/null +++ b/frontend-web/src/app/(dashboard)/profile/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +import { api } from "@/lib/api"; +import type { UserProfile } from "@/lib/types"; + +export default function ProfilePage() { + const profileQuery = useQuery({ + queryKey: ["profile"], + queryFn: () => api.get("/api/v1/profile"), + }); + const [form, setForm] = useState({ + username: "", + nickname: "", + avatarUrl: "", + }); + + useEffect(() => { + if (profileQuery.data) { + setForm({ + username: profileQuery.data.username, + nickname: profileQuery.data.nickname, + avatarUrl: profileQuery.data.avatarUrl, + }); + } + }, [profileQuery.data]); + + const mutation = useMutation({ + mutationFn: () => + api.put("/api/v1/profile", { + username: form.username, + nickname: form.nickname, + avatar_url: form.avatarUrl, + }), + onSuccess: () => profileQuery.refetch(), + }); + + return ( +
+
+

个人资料

+
+ + + + +
+
+ +
+

账户概览

+
+
+ {profileQuery.data?.publicId} +
公共用户 ID
+
+
+ {profileQuery.data?.email} +
注册邮箱
+
+
+ {profileQuery.data?.nickname} +
当前昵称
+
+
+
+
+ ); +} diff --git a/frontend-web/src/app/(dashboard)/wallet/page.tsx b/frontend-web/src/app/(dashboard)/wallet/page.tsx new file mode 100644 index 0000000..82e2683 --- /dev/null +++ b/frontend-web/src/app/(dashboard)/wallet/page.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; +import type { WalletSummary, WalletTransaction } from "@/lib/types"; + +export default function WalletOverviewPage() { + const walletQuery = useQuery({ + queryKey: ["wallet"], + queryFn: () => api.get("/api/v1/wallet"), + }); + const txQuery = useQuery({ + queryKey: ["wallet-transactions"], + queryFn: () => api.get("/api/v1/wallet/transactions"), + }); + + return ( +
+
+
+

余额

+
{walletQuery.data?.balancePoints ?? 0}
+
总积分
+
+
+

冻结

+
{walletQuery.data?.frozenPoints ?? 0}
+
任务预扣积分
+
+
+

可用

+
{walletQuery.data?.availablePoints ?? 0}
+
当前可消费
+
+
+ +
+

积分流水

+
+ {txQuery.data?.map((item) => ( +
+
+
+ {item.transactionNo} +
{item.remark}
+
+ +
+
+ {item.direction} · {item.amountPoints} 积分 · {item.createdAt} +
+
+ ))} +
+
+
+ ); +} + diff --git a/frontend-web/src/app/(dashboard)/wallet/recharge/page.tsx b/frontend-web/src/app/(dashboard)/wallet/recharge/page.tsx new file mode 100644 index 0000000..a012d84 --- /dev/null +++ b/frontend-web/src/app/(dashboard)/wallet/recharge/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { StatusBadge } from "@/components/status-badge"; +import { api } from "@/lib/api"; + +type RechargeOptions = { + plans: Array<{ + id: number; + name: string; + payAmount: string; + arrivalPoints: number; + bonusPoints: number; + }>; + channels: Array<{ + id: number; + channelCode: string; + channelName: string; + }>; +}; + +type RechargeOrder = { + orderNo: string; + payAmount: string; + arrivalPoints: number; + status: string; + paymentChannelCode: string; + paidAt: string | null; + createdAt: string; +}; + +export default function RechargePage() { + const [selectedPlanId, setSelectedPlanId] = useState(1); + const [selectedChannel, setSelectedChannel] = useState("alipay"); + const optionsQuery = useQuery({ + queryKey: ["recharge-options"], + queryFn: () => api.get("/api/v1/wallet/recharge-options"), + }); + const ordersQuery = useQuery({ + queryKey: ["recharge-orders"], + queryFn: () => api.get("/api/v1/wallet/recharge-orders"), + }); + + const createMutation = useMutation({ + mutationFn: () => + api.post("/api/v1/wallet/recharge-orders", { + rechargePlanId: selectedPlanId, + paymentChannelCode: selectedChannel, + }), + onSuccess: async (order) => { + await api.get(`/api/v1/payments/mock-pay?orderNo=${order.orderNo}`); + await ordersQuery.refetch(); + }, + }); + + return ( +
+
+

充值中心

+
+ {optionsQuery.data?.plans.map((plan) => ( + + ))} +
+ + + + +
+ +
+

充值记录

+
+ {ordersQuery.data?.map((item) => ( +
+
+ {item.orderNo} + +
+
+ {item.payAmount} 元 to {item.arrivalPoints} 积分 · {item.paymentChannelCode} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend-web/src/app/(dashboard)/wallet/redeem/page.tsx b/frontend-web/src/app/(dashboard)/wallet/redeem/page.tsx new file mode 100644 index 0000000..a0e6d89 --- /dev/null +++ b/frontend-web/src/app/(dashboard)/wallet/redeem/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { StatusBadge } from "@/components/status-badge"; +import { api, ApiError } from "@/lib/api"; + +type RedeemRecord = { + redeemCode: string; + points: number; + usedAt: string | null; +}; + +export default function RedeemPage() { + const [redeemCode, setRedeemCode] = useState("SPRING-2026-ABCD-1234"); + const [message, setMessage] = useState(""); + const recordsQuery = useQuery({ + queryKey: ["redeem-records"], + queryFn: () => api.get("/api/v1/wallet/redeem-records"), + }); + + const mutation = useMutation({ + mutationFn: () => + api.post("/api/v1/wallet/redeem-codes/exchange", { + redeemCode, + }), + onSuccess: () => { + setMessage("兑换成功"); + recordsQuery.refetch(); + }, + onError(error) { + setMessage(error instanceof ApiError ? error.message : "兑换失败"); + }, + }); + + return ( +
+
+

兑换密钥

+
+ + + {message ?
{message}
: null} +
+
+ +
+

兑换记录

+
+ {recordsQuery.data?.map((item) => ( +
+
+ {item.redeemCode} + +
+
{item.points} 积分
+
+ ))} +
+
+
+ ); +} + diff --git a/frontend-web/src/app/(dashboard)/workspace/assets/page.tsx b/frontend-web/src/app/(dashboard)/workspace/assets/page.tsx new file mode 100644 index 0000000..17ea54f --- /dev/null +++ b/frontend-web/src/app/(dashboard)/workspace/assets/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { api } from "@/lib/api"; +import type { AssetItem } from "@/lib/types"; + +export default function AssetsPage() { + const [mediaType, setMediaType] = useState("image"); + const assetsQuery = useQuery({ + queryKey: ["asset-list"], + queryFn: () => api.get("/api/v1/assets"), + }); + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("mediaType", mediaType); + return api.post("/api/v1/assets", formData); + }, + onSuccess: () => assetsQuery.refetch(), + }); + + const deleteMutation = useMutation({ + mutationFn: (assetId: number) => api.del(`/api/v1/assets/${assetId}`), + onSuccess: () => assetsQuery.refetch(), + }); + + return ( +
+
+

上传素材

+
+ + +
+
+ +
+

已上传素材

+
+ {assetsQuery.data?.map((asset) => ( +
+ {asset.originalFilename} +
+ {asset.mediaType} · {Math.round(asset.fileSize / 1024)} KB +
+
+ + 查看文件 + + +
+
+ ))} +
+
+
+ ); +} + diff --git a/frontend-web/src/app/(dashboard)/workspace/create/page.tsx b/frontend-web/src/app/(dashboard)/workspace/create/page.tsx new file mode 100644 index 0000000..b29927a --- /dev/null +++ b/frontend-web/src/app/(dashboard)/workspace/create/page.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import Link from "next/link"; + +import { api, ApiError } from "@/lib/api"; +import type { AssetItem, VideoModel, WalletSummary } from "@/lib/types"; + +export default function CreateTaskPage() { + const [form, setForm] = useState({ + videoModelId: 0, + prompt: + "夜晚的未来城市高架桥上,一辆银色跑车高速穿行,镜头跟拍,霓虹反光强烈。", + durationSeconds: 8, + resolution: "1280x720", + ratio: "16:9", + generateAudio: true, + referenceImageAssetIds: [] as number[], + }); + const [message, setMessage] = useState(""); + + const videoModelsQuery = useQuery({ + queryKey: ["video-models"], + queryFn: () => api.get("/api/v1/video-models"), + }); + const walletQuery = useQuery({ + queryKey: ["wallet-summary"], + queryFn: () => api.get("/api/v1/wallet"), + }); + const assetsQuery = useQuery({ + queryKey: ["assets"], + queryFn: () => api.get("/api/v1/assets"), + }); + + const selectedModel = useMemo( + () => + videoModelsQuery.data?.find((item) => item.id === form.videoModelId) ?? + videoModelsQuery.data?.[0], + [form.videoModelId, videoModelsQuery.data], + ); + + const createMutation = useMutation({ + mutationFn: () => + api.post<{ + taskNo: string; + taskStatus: string; + estimatedPoints: number; + frozenPoints: number; + }>("/api/v1/video-tasks", { + ...form, + videoModelId: form.videoModelId || selectedModel?.id, + referenceVideoAssetIds: [], + referenceAudioAssetIds: [], + }), + onSuccess(data) { + setMessage( + `任务 ${data.taskNo} 已提交,预估扣费 ${data.estimatedPoints} 积分。`, + ); + }, + onError(error) { + setMessage(error instanceof ApiError ? error.message : "创建失败"); + }, + }); + + return ( +
+
+
+
+

创建视频任务

+

选择平台模型、提示词和参考素材,提交后系统会自动冻结积分并开始轮询。

+
+ + 查看任务列表 + +
+ +
+ +