1、后台系统设置settings页面增加一个开启关闭网站的按钮

2、如果网站关闭,前台页面所有请求暂时跳转单独的页如果网站关闭,前台页面所有请求暂时跳转单独的页面,页面内容 系统正在升级相关信息
3、要提供一个开发人员可以查看真实网站内容的入口
This commit is contained in:
2026-07-11 10:58:48 +08:00
parent 111c74c7c6
commit d2a30c58ca
11 changed files with 461 additions and 5 deletions
+11
View File
@@ -1,7 +1,18 @@
# VideoGen 代码架构指南
> 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。
# AI 工作规则(最高优先级)
当你执行开发任务时:
1. 不要扫描整个 Repository。
2. 不要执行全量目录探索。
3. 仅阅读本任务涉及的目录。
4. 优先按照本文档中的"快速定位表"寻找文件。
5. 如果无法定位文件,再进行有限范围搜索。
6. 单次最多搜索一级目录。
7. 不允许重复搜索已经访问过的目录。
8. 阅读完目标文件后立即开始修改代码。
---
## 一、整体架构
+4
View File
@@ -224,6 +224,10 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
await api.put(`/admin/system-configs/${id}`, { value });
}
export async function regenerateDevToken(): Promise<{ token: string }> {
return api.post('/admin/system-configs/regenerate-dev-token');
}
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
return api.get('/admin/resource-capacity/global');
}
+85 -2
View File
@@ -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<string, any> = {};
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<string, SystemConfig[]> = {
'站点信息': 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 = () => {
</div>
<Form form={form} layout="vertical">
{/* 网站访问控制 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
访
</Typography.Text>
{siteEnabledConfig ? (
<Form.Item
name="site_enabled"
label="网站访问开关"
valuePropName="checked"
extra="关闭后前台所有用户将被重定向到维护页面(管理员仍可登录后台管理)"
>
<Switch checkedChildren="网站开启" unCheckedChildren="网站关闭" />
</Form.Item>
) : (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
site_enabled
</Typography.Text>
)}
{devTokenConfig && (
<Form.Item
label="开发者访问令牌"
extra="网站关闭时,在维护页面输入此令牌可正常访问前台内容。令牌仅管理员可见。"
>
<Space.Compact style={{ width: '100%' }}>
<Input.Password
name="site_dev_access_token"
readOnly
value={devTokenConfig.value}
style={{ fontFamily: 'monospace' }}
/>
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(devTokenConfig.value);
message.success('已复制到剪贴板');
}}
>
</Button>
<Popconfirm
title="重新生成令牌?"
description="重新生成后旧令牌将立即失效,需要重新复制新令牌。"
onConfirm={handleRegenerateDevToken}
okText="确认"
cancelText="取消"
>
<Button icon={<ReloadOutlined />} danger></Button>
</Popconfirm>
</Space.Compact>
</Form.Item>
)}
</div>
{Object.entries(groupedConfigs).map(([group, items]) => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
+35
View File
@@ -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")
+7
View File
@@ -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",
}
+38 -1
View File
@@ -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)
+125
View File
@@ -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": "系统正在升级维护,请稍后再试",
}
},
)
+39 -1
View File
@@ -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 (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
return (
<ConfigProvider
locale={zhCN}
@@ -93,6 +130,7 @@ const App = () => {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/join-team" element={<JoinTeamPage />} />
<Route path="/private-portrait-authorized" element={<PrivatePortraitAuthorizeResult />} />
<Route
+13
View File
@@ -66,6 +66,12 @@ export async function apiRequest<T>(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<T>(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;
+1 -1
View File
@@ -290,7 +290,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
return res.token;
}
// ── Site Info ─────────────────────────────────────────────
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string }> {
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);
}
+103
View File
@@ -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 (
<div
style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #f8fafc 0%, #eef2ff 100%)',
padding: 24,
textAlign: 'center',
position: 'relative',
}}
>
{/* 开发者入口 — 右下角 */}
<div style={{ position: 'absolute', right: 20, bottom: 20 }}>
{!showDevInput ? (
<Typography.Link
onClick={() => setShowDevInput(true)}
style={{ fontSize: 12, color: '#94a3b8' }}
>
<SafetyOutlined />
</Typography.Link>
) : (
<Space.Compact>
<Input.Password
placeholder="输入开发者访问令牌"
value={devToken}
onChange={(e) => setDevToken(e.target.value)}
onPressEnter={handleDevAccess}
style={{ width: 200 }}
/>
<Button type="primary" onClick={handleDevAccess}>
</Button>
</Space.Compact>
)}
</div>
{/* 主内容 */}
<div
style={{
width: 80,
height: 80,
borderRadius: 20,
background: 'rgba(99, 102, 241, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 32,
}}
>
<ToolOutlined style={{ fontSize: 40, color: '#6366f1' }} />
</div>
{siteName && (
<Typography.Text type="secondary" style={{ fontSize: 13, marginBottom: 8, letterSpacing: 1 }}>
{siteName}
</Typography.Text>
)}
<Typography.Title level={2} style={{ marginBottom: 8, color: '#1e293b' }}>
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: 15, maxWidth: 400 }}>
<br />
</Typography.Paragraph>
</div>
);
};
export default MaintenancePage;