diff --git a/CODEBASE.md b/CODEBASE.md index 9cc25c3f..e0320b0b 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -1,7 +1,18 @@ # VideoGen 代码架构指南 > 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。 +# AI 工作规则(最高优先级) +当你执行开发任务时: + +1. 不要扫描整个 Repository。 +2. 不要执行全量目录探索。 +3. 仅阅读本任务涉及的目录。 +4. 优先按照本文档中的"快速定位表"寻找文件。 +5. 如果无法定位文件,再进行有限范围搜索。 +6. 单次最多搜索一级目录。 +7. 不允许重复搜索已经访问过的目录。 +8. 阅读完目标文件后立即开始修改代码。 --- ## 一、整体架构 diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index ec4c4191..7576e2e5 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -224,6 +224,10 @@ export async function updateSystemConfig(id: string, value: string): Promise { + return api.post('/admin/system-configs/regenerate-dev-token'); +} + export async function getGlobalResourceCapacity(): Promise { return api.get('/admin/resource-capacity/global'); } diff --git a/video-gen-admin/src/pages/AdminSettings.tsx b/video-gen-admin/src/pages/AdminSettings.tsx index 3afe6226..cdcb32d9 100644 --- a/video-gen-admin/src/pages/AdminSettings.tsx +++ b/video-gen-admin/src/pages/AdminSettings.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; import { - Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload, + Button, Card, Form, Input, InputNumber, message, Popconfirm, Select, Space, Switch, Typography, Upload, } from 'antd'; import { SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, + PoweroffOutlined, ReloadOutlined, CopyOutlined, } from '@ant-design/icons'; import { getGlobalResourceCapacity, @@ -12,6 +13,7 @@ import { updateSystemConfig, uploadLogo, uploadPdf, + regenerateDevToken, } from '../api'; import type { ResourceCapacityUnit, SystemConfig } from '../types'; @@ -42,6 +44,8 @@ const AdminSettings: React.FC = () => { setConfigs(data); const formValues: Record = {}; data.forEach(c => { formValues[c.key] = c.value; }); + // 网站开关布尔值转换 + formValues.site_enabled = siteEnabledConfig?.value !== 'false'; formValues.resource_capacity_enabled = capacity.enabled; formValues.resource_capacity_limit_value = capacity.limitValue || '1.000'; formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB'; @@ -63,6 +67,13 @@ const AdminSettings: React.FC = () => { await updateSystemConfig(config.id, String(newVal ?? '')); } } + // 保存网站开关(布尔值转为字符串) + if (siteEnabledConfig) { + const newEnabled = values.site_enabled ? 'true' : 'false'; + if (newEnabled !== siteEnabledConfig.value) { + await updateSystemConfig(siteEnabledConfig.id, newEnabled); + } + } await saveGlobalResourceCapacity({ enabled: !!values.resource_capacity_enabled, limitValue: String(values.resource_capacity_limit_value ?? '1.000'), @@ -77,6 +88,20 @@ const AdminSettings: React.FC = () => { } }; + const handleRegenerateDevToken = async () => { + try { + const result = await regenerateDevToken(); + message.success('开发者令牌已重新生成'); + await load(); + // 更新显示的令牌 + if (result?.token) { + form.setFieldsValue({ site_dev_access_token: result.token }); + } + } catch (e: any) { + message.error(e?.message || '重新生成失败'); + } + }; + const handleUpload = async (file: File, configKey: string) => { setUploading(configKey); try { @@ -118,8 +143,11 @@ const AdminSettings: React.FC = () => { return false; }; + const siteEnabledConfig = configs.find(c => c.key === 'site_enabled'); + const devTokenConfig = configs.find(c => c.key === 'site_dev_access_token'); + const groupedConfigs: Record = { - '站点信息': configs.filter(c => c.key.startsWith('site_')), + '站点信息': configs.filter(c => c.key.startsWith('site_') && c.key !== 'site_enabled' && c.key !== 'site_dev_access_token'), '协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'), 'SEO 设置': configs.filter(c => c.key.startsWith('seo_')), '用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')), @@ -297,6 +325,61 @@ const AdminSettings: React.FC = () => {
+ {/* 网站访问控制 */} +
+ + 访问控制 + + {siteEnabledConfig ? ( + + + + ) : ( + + 系统配置 site_enabled 未初始化,请通过数据库手动添加。 + + )} + + {devTokenConfig && ( + + + + + + + + + + )} +
+ {Object.entries(groupedConfigs).map(([group, items]) => (
diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index f37562c2..861a0c5d 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -1622,6 +1622,41 @@ async def update_system_config( return config +@router.post("/system-configs/regenerate-dev-token") +async def regenerate_dev_token( + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """重新生成开发者访问令牌。""" + import secrets + result = await db.execute( + select(SystemConfig).where(SystemConfig.key == "site_dev_access_token").limit(1) + ) + config = result.scalar_one_or_none() + new_token = secrets.token_urlsafe(32) + if config: + config.value = new_token + else: + config = SystemConfig( + id=generate_id(), + key="site_dev_access_token", + value=new_token, + description="开发者访问令牌(网站关闭时用于绕过限制)", + ) + db.add(config) + await db.flush() + await log_operation( + db, + admin.id, + admin.username, + "重新生成开发者访问令牌", + "POST", + "/admin/system-configs/regenerate-dev-token", + ) + await db.commit() + return {"token": new_token} + + # ── Operation Logs ────────────────────────────────────── @router.get("/operation-logs") diff --git a/video-gen-api/app/api/v1/auth.py b/video-gen-api/app/api/v1/auth.py index 0a16d8cb..09d8eb97 100644 --- a/video-gen-api/app/api/v1/auth.py +++ b/video-gen-api/app/api/v1/auth.py @@ -345,12 +345,19 @@ async def get_site_info(db: AsyncSession = Depends(get_db)): base_url = settings.BASE_URL.rstrip("/") return f"{base_url}{path}" + # 读取网站开关状态(不存在时默认开启) + site_enabled_result = await db.execute( + select(SystemConfig.value).where(SystemConfig.key == "site_enabled").limit(1) + ) + site_enabled_value = site_enabled_result.scalar_one_or_none() + return { "site_name": info.get("site_name", "VideoGen.AI"), "site_logo": to_full_url(info.get("site_logo")), "user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")), "site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"), "operation_manual": info.get("operation_manual", ""), + "site_enabled": site_enabled_value != "false", } diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py index 6fe1afae..7782bed9 100644 --- a/video-gen-api/app/main.py +++ b/video-gen-api/app/main.py @@ -16,6 +16,7 @@ from app.middleware.logging import RequestLoggingMiddleware from app.middleware.anti_crawler import AntiCrawlerMiddleware from app.middleware.rate_limit import RateLimitMiddleware from app.middleware.request_encrypt import RequestEncryptMiddleware +from app.middleware.site_status import SiteStatusMiddleware from app.services.log_config import decrypt_data logging.basicConfig(level=logging.INFO if settings.DEBUG else logging.WARNING) @@ -29,6 +30,7 @@ async def lifespan(app: FastAPI): os.makedirs(settings.UPLOAD_LOCAL_PATH, exist_ok=True) await init_database() await init_redis() + await _ensure_site_configs() # await _seed_data() # Start task queue (handles both video and image generation) @@ -513,6 +515,40 @@ async def _seed_data(): await db.commit() +async def _ensure_site_configs() -> None: + """确保网站开关相关配置项存在(不存在则自动创建)。""" + import secrets + from app.models.base import async_session + from app.models.system_config import SystemConfig + from app.utils.id_gen import generate_id + from sqlalchemy import select + + async with async_session() as db: + # site_enabled + existing = await db.execute( + select(SystemConfig).where(SystemConfig.key == "site_enabled").limit(1) + ) + if not existing.scalar_one_or_none(): + db.add(SystemConfig( + id=generate_id(), + key="site_enabled", + value="true", + description="网站访问开关(true=开启,false=关闭)", + )) + # site_dev_access_token + existing_token = await db.execute( + select(SystemConfig).where(SystemConfig.key == "site_dev_access_token").limit(1) + ) + if not existing_token.scalar_one_or_none(): + db.add(SystemConfig( + id=generate_id(), + key="site_dev_access_token", + value=secrets.token_urlsafe(32), + description="开发者访问令牌(网站关闭时用于绕过限制)", + )) + await db.commit() + + def create_app() -> FastAPI: application = FastAPI( title=settings.APP_NAME, @@ -522,7 +558,8 @@ def create_app() -> FastAPI: redoc_url="/internal/api-redoc", ) - # Middleware (outermost first) + # Middleware (outermost first) — SiteStatus 放最外层,早于其他中间件拦截 + application.add_middleware(SiteStatusMiddleware) application.add_middleware(RequestLoggingMiddleware) application.add_middleware(AntiCrawlerMiddleware) application.add_middleware(RateLimitMiddleware) diff --git a/video-gen-api/app/middleware/site_status.py b/video-gen-api/app/middleware/site_status.py new file mode 100644 index 00000000..98800baa --- /dev/null +++ b/video-gen-api/app/middleware/site_status.py @@ -0,0 +1,125 @@ +import logging +import time + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import JSONResponse + +logger = logging.getLogger("videogen") + +# 网站关闭时仍然放行的路径前缀 +_WHITELIST_PREFIXES = ( + "/api/auth/", # 登录/注册/短信/验证码 + "/api/admin/", # 后台管理(确保管理员能登录并重新开启网站) + "/api/captcha/", # 图形验证码 + "/internal/", # 健康检查/文档 + "/uploads/", # 静态文件 + "/api/generation-records/callback", # 火山引擎回调 + "/api/payments/wechat/callback", # 微信回调 + "/api/payments/alipay/callback", # 支付宝回调 +) + +# 完全匹配的白名单路径 +_WHITELIST_EXACT = ( + "/api/auth/site-info", + "/internal/health", +) + +# site_enabled 缓存有效期(秒) +_CACHE_TTL = 5.0 + + +class SiteStatusMiddleware(BaseHTTPMiddleware): + """网站访问开关中间件。 + + 检查 SystemConfig 中 site_enabled 的值: + - 配置不存在或值不为 "false" → 放行 + - 值为 "false" 且不在白名单 → 检查开发者令牌,不匹配则返回 503 { code: "SITE_CLOSED" } + """ + + def __init__(self, app): + super().__init__(app) + self._cache_enabled = True # 缓存的站点状态(True=开启) + self._cache_at: float = 0.0 # 缓存时间戳 + + async def _is_site_enabled(self) -> bool: + """查询 site_enabled 配置,带短缓存避免每个请求查 DB。""" + now = time.time() + if now - self._cache_at < _CACHE_TTL: + return self._cache_enabled + + try: + from app.models.base import async_session + from app.models.system_config import SystemConfig + from sqlalchemy import select + + async with async_session() as db: + result = await db.execute( + select(SystemConfig.value).where(SystemConfig.key == "site_enabled").limit(1) + ) + value = result.scalar_one_or_none() + self._cache_enabled = value != "false" + self._cache_at = now + except Exception: + # 查询异常时默认放行,避免数据库故障导致全站不可用 + logger.exception("Failed to read site_enabled config, defaulting to enabled") + self._cache_enabled = True + self._cache_at = now + + return self._cache_enabled + + @staticmethod + def _is_whitelisted(path: str) -> bool: + """检查路径是否在白名单中。""" + if path in _WHITELIST_EXACT: + return True + return any(path.startswith(prefix) for prefix in _WHITELIST_PREFIXES) + + @staticmethod + async def _check_dev_token(token: str) -> bool: + """校验开发者访问令牌是否匹配配置。""" + try: + from app.models.base import async_session + from app.models.system_config import SystemConfig + from sqlalchemy import select + + async with async_session() as db: + result = await db.execute( + select(SystemConfig.value) + .where(SystemConfig.key == "site_dev_access_token") + .limit(1) + ) + expected = result.scalar_one_or_none() + return expected is not None and token == expected + except Exception: + logger.exception("Failed to verify dev access token") + return False + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + path = request.url.path + + # 白名单直接放行 + if self._is_whitelisted(path): + return await call_next(request) + + # 网站开启时放行 + if await self._is_site_enabled(): + return await call_next(request) + + # 网站已关闭 — 校验开发者令牌 + token = request.query_params.get("dev_access") or request.headers.get("x-dev-access") + if token and await self._check_dev_token(token): + return await call_next(request) + + # 拦截请求,返回维护状态 + return JSONResponse( + status_code=503, + content={ + "detail": { + "code": "SITE_CLOSED", + "message": "系统正在升级维护,请稍后再试", + } + }, + ) diff --git a/video-gen-app/src/App.tsx b/video-gen-app/src/App.tsx index d166aefc..f1abaee7 100644 --- a/video-gen-app/src/App.tsx +++ b/video-gen-app/src/App.tsx @@ -1,10 +1,11 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { ConfigProvider, App as AntApp, Spin } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn'); +import MaintenancePage from './pages/MaintenancePage'; import AppLayout from './components/Layout/AppLayout'; import LoginPage from './pages/LoginPage'; import ProjectsPage from './pages/ProjectsPage'; @@ -61,11 +62,47 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const App = () => { const { checkAuth } = useAuthStore(); + const [siteReady, setSiteReady] = useState(false); useEffect(() => { + // 处理 URL 中的开发者访问令牌 + const params = new URLSearchParams(window.location.search); + const devToken = params.get('dev_access'); + if (devToken) { + localStorage.setItem('dev_access_token', devToken); + // 清除 URL 中的令牌参数 + params.delete('dev_access'); + const newSearch = params.toString(); + window.history.replaceState({}, '', window.location.pathname + (newSearch ? `?${newSearch}` : '')); + } + + // 检查网站状态(仅当用户没有有效 dev token 时) checkAuth(); + if (!localStorage.getItem('dev_access_token')) { + import('./api').then(({ getSiteInfo }) => { + getSiteInfo() + .then((info) => { + if (!info.siteEnabled && !window.location.pathname.startsWith('/maintenance')) { + window.location.href = '/maintenance'; + } + }) + .catch(() => { /* 请求失败时默认放行 */ }) + .finally(() => setSiteReady(true)); + }); + } else { + setSiteReady(true); + } }, []); + // 网站状态检查中显示 loading + if (!siteReady && !localStorage.getItem('dev_access_token')) { + return ( +
+ +
+ ); + } + return ( { } /> + } /> } /> } /> (path: string, options: RequestOptions = {}): if (token) headers['Authorization'] = `Bearer ${token}`; } + // 开发者访问令牌(网站关闭时用于绕过限制) + const devAccessToken = localStorage.getItem('dev_access_token'); + if (devAccessToken) { + headers['X-Dev-Access'] = devAccessToken; + } + let bodyStr: string | undefined; if (body !== undefined) { const json = JSON.stringify(body); @@ -110,6 +116,13 @@ export async function apiRequest(path: string, options: RequestOptions = {}): // Handle error responses if (!res.ok) { + // 网站关闭 → 跳转到维护页面 + if (res.status === 503 && parsed?.detail?.code === 'SITE_CLOSED') { + if (!window.location.pathname.startsWith('/maintenance')) { + window.location.href = '/maintenance'; + } + throw new Error(parsed?.detail?.message || '系统正在升级维护'); + } let msg = parsed?.detail?.message || parsed?.message || `请求失败 (${res.status})`; if (typeof parsed?.detail === 'string') { msg = parsed.detail; diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 8e11da08..6c591651 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -290,7 +290,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise { +export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; siteEnabled: boolean }> { if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有', operationManual: '' }; return api.get('/auth/site-info', false); } diff --git a/video-gen-app/src/pages/MaintenancePage.tsx b/video-gen-app/src/pages/MaintenancePage.tsx new file mode 100644 index 00000000..c0d8eef1 --- /dev/null +++ b/video-gen-app/src/pages/MaintenancePage.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Input, Space, Typography, message } from 'antd'; +import { ToolOutlined, SafetyOutlined } from '@ant-design/icons'; +import { getSiteInfo } from '../api'; + +const MaintenancePage: React.FC = () => { + const [devToken, setDevToken] = useState(''); + const [showDevInput, setShowDevInput] = useState(false); + const [siteName, setSiteName] = useState(''); + + useEffect(() => { + // site-info 在网站关闭时仍可访问(白名单) + getSiteInfo().then((info) => { + if (info.siteName) setSiteName(info.siteName); + }).catch(() => {}); + }, []); + + const handleDevAccess = () => { + const token = devToken.trim(); + if (!token) { + message.warning('请输入开发者访问令牌'); + return; + } + localStorage.setItem('dev_access_token', token); + message.success('开发者访问已启用,正在刷新...'); + window.location.href = '/'; + }; + + return ( +
+ {/* 开发者入口 — 右下角 */} +
+ {!showDevInput ? ( + setShowDevInput(true)} + style={{ fontSize: 12, color: '#94a3b8' }} + > + 开发者入口 + + ) : ( + + setDevToken(e.target.value)} + onPressEnter={handleDevAccess} + style={{ width: 200 }} + /> + + + )} +
+ + {/* 主内容 */} +
+ +
+ + {siteName && ( + + {siteName} + + )} + + + 系统正在升级维护 + + + + 为了提供更好的服务,系统正在进行升级维护。 +
+ 请稍后再来,感谢您的理解与支持! +
+
+ ); +}; + +export default MaintenancePage;