feat(auth): 新增系统设置功能
- 实现管理员登录和验证码验证功能 - 添加管理员登录流程,支持通过 Telegram 验证 - 实现登录挑战和会话管理,确保安全性 - 更新前端状态管理,处理登录和登出逻辑 - 增加系统配置管理,支持 Telegram 安全设置 - 新增数据库表以存储登录挑战和会话信息
This commit is contained in:
1 parent
1c9bd91aed
commit
bd76a89ba1
21 files changed
+1820
-78
No files matched your search
@@ -12,6 +12,7 @@ import { Tokens } from "./pages/Tokens";
|
|||||||
import { Pricing } from "./pages/Pricing";
|
import { Pricing } from "./pages/Pricing";
|
||||||
import { ApiTest } from "./pages/ApiTest";
|
import { ApiTest } from "./pages/ApiTest";
|
||||||
import { NotFound } from "./pages/NotFound";
|
import { NotFound } from "./pages/NotFound";
|
||||||
|
import { SystemSettings } from "./pages/SystemSettings";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -144,6 +145,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SystemSettings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
+59
-10
@@ -9,9 +9,12 @@ import {
|
|||||||
AnalyticsBreakdownDimension,
|
AnalyticsBreakdownDimension,
|
||||||
AnalyticsRange,
|
AnalyticsRange,
|
||||||
BillingConfig,
|
BillingConfig,
|
||||||
|
SystemConfig,
|
||||||
|
AdminLoginResponse,
|
||||||
UsageLogFilters,
|
UsageLogFilters,
|
||||||
UsageLogSearchData,
|
UsageLogSearchData,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
import { clearAdminCredentials, getStoredAdminCredential } from '@/lib/admin-auth'
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
|
||||||
|
|
||||||
@@ -30,6 +33,10 @@ export class ApiError extends Error {
|
|||||||
let onUnauthorized: (() => void) | null = null
|
let onUnauthorized: (() => void) | null = null
|
||||||
let onError: ((error: ApiError) => void) | null = null
|
let onError: ((error: ApiError) => void) | null = null
|
||||||
|
|
||||||
|
type RequestBehavior = {
|
||||||
|
skipUnauthorizedHandler?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function setErrorHandlers(handlers: {
|
export function setErrorHandlers(handlers: {
|
||||||
onUnauthorized?: () => void
|
onUnauthorized?: () => void
|
||||||
onError?: (error: ApiError) => void
|
onError?: (error: ApiError) => void
|
||||||
@@ -48,16 +55,16 @@ async function requestInterceptor(config: RequestInit): Promise<RequestInit> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加 admin token
|
// 添加 admin token
|
||||||
const adminToken = localStorage.getItem('adminToken')
|
const credential = getStoredAdminCredential()
|
||||||
if (adminToken) {
|
if (credential) {
|
||||||
headers.set('x-admin-token', adminToken)
|
headers.set(credential.headerName, credential.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...config, headers }
|
return { ...config, headers }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
async function responseInterceptor(response: Response): Promise<any> {
|
async function responseInterceptor(response: Response, behavior: RequestBehavior = {}): Promise<any> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage: string
|
let errorMessage: string
|
||||||
|
|
||||||
@@ -84,10 +91,11 @@ async function responseInterceptor(response: Response): Promise<any> {
|
|||||||
// 处理特定状态码
|
// 处理特定状态码
|
||||||
switch (response.status) {
|
switch (response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
// 未授权 - 清除 token 并触发回调
|
if (!behavior.skipUnauthorizedHandler) {
|
||||||
localStorage.removeItem('adminToken')
|
clearAdminCredentials()
|
||||||
onUnauthorized?.()
|
onUnauthorized?.()
|
||||||
error.message = '认证已过期,请重新登录'
|
error.message = '认证已过期,请重新登录'
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 403:
|
case 403:
|
||||||
error.message = '没有权限执行此操作'
|
error.message = '没有权限执行此操作'
|
||||||
@@ -148,13 +156,17 @@ function buildQueryString(params: Record<string, string | number | undefined>):
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 统一请求方法
|
// 统一请求方法
|
||||||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
behavior: RequestBehavior = {},
|
||||||
|
): Promise<T> {
|
||||||
const url = `${BASE_URL}${endpoint}`
|
const url = `${BASE_URL}${endpoint}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await requestInterceptor(options)
|
const config = await requestInterceptor(options)
|
||||||
const response = await fetch(url, config)
|
const response = await fetch(url, config)
|
||||||
return responseInterceptor(response)
|
return responseInterceptor(response, behavior)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 处理网络错误
|
// 处理网络错误
|
||||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||||
@@ -228,6 +240,20 @@ export const apiClient = {
|
|||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getSystemConfig: () => request<ApiResponse<SystemConfig>>('/api/admin/system/config', { method: 'GET' }),
|
||||||
|
|
||||||
|
saveSystemConfig: (config: SystemConfig) =>
|
||||||
|
request<ApiResponse<SystemConfig>>('/api/admin/system/config', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendTelegramTestMessage: (payload: Pick<SystemConfig['adminSecurity'], 'telegramBotToken' | 'telegramChatId'>) =>
|
||||||
|
request<ApiResponse<boolean>>('/api/admin/system/telegram/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
// Analytics APIs
|
// Analytics APIs
|
||||||
getAnalyticsOverview: (range: AnalyticsRange) =>
|
getAnalyticsOverview: (range: AnalyticsRange) =>
|
||||||
request<ApiResponse<AnalyticsOverviewData>>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }),
|
request<ApiResponse<AnalyticsOverviewData>>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }),
|
||||||
@@ -292,4 +318,27 @@ export const apiClient = {
|
|||||||
|
|
||||||
// Auth check
|
// Auth check
|
||||||
checkAuth: () => request<ApiResponse>('/api/admin/channel', { method: 'GET' }),
|
checkAuth: () => request<ApiResponse>('/api/admin/channel', { method: 'GET' }),
|
||||||
|
|
||||||
|
startAdminLogin: (token: string) =>
|
||||||
|
request<ApiResponse<AdminLoginResponse>>('/api/admin/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
}, {
|
||||||
|
skipUnauthorizedHandler: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
verifyAdminLogin: (challengeId: string, code: string) =>
|
||||||
|
request<ApiResponse<AdminLoginResponse>>('/api/admin/auth/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ challengeId, code }),
|
||||||
|
}, {
|
||||||
|
skipUnauthorizedHandler: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
logoutAdmin: () =>
|
||||||
|
request<ApiResponse<boolean>>('/api/admin/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
}, {
|
||||||
|
skipUnauthorizedHandler: true,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,8 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Menu, Zap } from 'lucide-react'
|
import { Menu, RotateCcw, Zap } from 'lucide-react'
|
||||||
|
import { parseUtcTimestamp } from '@/lib/utils'
|
||||||
|
|
||||||
interface AppLayoutProps {
|
interface AppLayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -24,31 +25,117 @@ interface AppLayoutProps {
|
|||||||
export function AppLayout({ children }: AppLayoutProps) {
|
export function AppLayout({ children }: AppLayoutProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [adminToken, setAdminToken] = useState('')
|
const [adminToken, setAdminToken] = useState('')
|
||||||
|
const [verificationCode, setVerificationCode] = useState('')
|
||||||
|
const [challengeId, setChallengeId] = useState<string | null>(null)
|
||||||
|
const [challengeExpiresAt, setChallengeExpiresAt] = useState<string | null>(null)
|
||||||
const [authError, setAuthError] = useState('')
|
const [authError, setAuthError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [authStep, setAuthStep] = useState<'token' | 'verification'>('token')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false)
|
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false)
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||||
const { login, showAuthModal, closeAuthModal, isAuthenticated, isLoading: isAuthLoading } = useAuthStore()
|
const {
|
||||||
|
startLogin,
|
||||||
|
verifyLogin,
|
||||||
|
showAuthModal,
|
||||||
|
closeAuthModal,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading: isAuthLoading,
|
||||||
|
} = useAuthStore()
|
||||||
const { addToast } = useToast()
|
const { addToast } = useToast()
|
||||||
|
|
||||||
|
const resetAuthDialog = () => {
|
||||||
|
setAdminToken('')
|
||||||
|
setVerificationCode('')
|
||||||
|
setChallengeId(null)
|
||||||
|
setChallengeExpiresAt(null)
|
||||||
|
setAuthError('')
|
||||||
|
setAuthStep('token')
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseAuthModal = () => {
|
||||||
|
resetAuthDialog()
|
||||||
|
closeAuthModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalizeLogin = () => {
|
||||||
|
resetAuthDialog()
|
||||||
|
setIsMobileNavOpen(false)
|
||||||
|
addToast('登录成功', 'success')
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
const handleAuthSubmit = async (e: React.FormEvent) => {
|
const handleAuthSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setAuthError('')
|
setAuthError('')
|
||||||
setIsLoading(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(adminToken)
|
if (authStep === 'token') {
|
||||||
setAdminToken('')
|
const loginResult = await startLogin(adminToken)
|
||||||
setIsMobileNavOpen(false)
|
|
||||||
addToast('登录成功', 'success')
|
if (loginResult.requiresVerification) {
|
||||||
navigate('/dashboard', { replace: true })
|
setAuthStep('verification')
|
||||||
|
setChallengeId(loginResult.challengeId)
|
||||||
|
setChallengeExpiresAt(loginResult.challengeExpiresAt)
|
||||||
|
setVerificationCode('')
|
||||||
|
addToast('验证码已发送到 Telegram', 'success')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!challengeId) {
|
||||||
|
throw new Error('验证码会话不存在,请重新获取')
|
||||||
|
}
|
||||||
|
|
||||||
|
await verifyLogin(challengeId, verificationCode)
|
||||||
|
finalizeLogin()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAuthError(error instanceof Error ? error.message : '管理员令牌无效')
|
setAuthError(error instanceof Error ? error.message : '管理员令牌无效')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResendVerificationCode = async () => {
|
||||||
|
if (!adminToken.trim()) {
|
||||||
|
setAuthError('请先重新输入管理员令牌')
|
||||||
|
setAuthStep('token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginResult = await startLogin(adminToken)
|
||||||
|
if (!loginResult.requiresVerification) {
|
||||||
|
finalizeLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthStep('verification')
|
||||||
|
setChallengeId(loginResult.challengeId)
|
||||||
|
setChallengeExpiresAt(loginResult.challengeExpiresAt)
|
||||||
|
setVerificationCode('')
|
||||||
|
addToast('验证码已重新发送到 Telegram', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
setAuthError(error instanceof Error ? error.message : '验证码发送失败')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeExpiresText = (() => {
|
||||||
|
const date = challengeExpiresAt ? parseUtcTimestamp(challengeExpiresAt) : null
|
||||||
|
return date
|
||||||
|
? date.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
: ''
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{isAuthenticated && !isAuthLoading && (
|
{isAuthenticated && !isAuthLoading && (
|
||||||
@@ -108,28 +195,58 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auth Dialog */}
|
{/* Auth Dialog */}
|
||||||
<Dialog open={showAuthModal} onOpenChange={(open) => !open && closeAuthModal()}>
|
<Dialog open={showAuthModal} onOpenChange={(open) => !open && handleCloseAuthModal()}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>管理员身份验证</DialogTitle>
|
<DialogTitle>管理员身份验证</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
请输入管理员令牌以访问管理功能
|
{authStep === 'token'
|
||||||
|
? '请输入管理员令牌以访问管理功能'
|
||||||
|
: '验证码已发送到 Telegram,请输入 6 位数字验证码完成登录'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleAuthSubmit}>
|
<form onSubmit={handleAuthSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
{authStep === 'token' ? (
|
||||||
<Label className="block" htmlFor="adminToken">管理员令牌</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label className="block" htmlFor="adminToken">管理员令牌</Label>
|
||||||
id="adminToken"
|
<Input
|
||||||
type="password"
|
id="adminToken"
|
||||||
placeholder="请输入管理员令牌"
|
type="password"
|
||||||
value={adminToken}
|
placeholder="请输入管理员令牌"
|
||||||
onChange={(e) => setAdminToken(e.target.value)}
|
value={adminToken}
|
||||||
required
|
onChange={(e) => setAdminToken(e.target.value)}
|
||||||
/>
|
required
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="block" htmlFor="verificationCode">Telegram 验证码</Label>
|
||||||
|
<Input
|
||||||
|
id="verificationCode"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="请输入 6 位验证码"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{challengeExpiresText ? `验证码有效期至 ${challengeExpiresText}` : '验证码 5 分钟内有效'}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 text-foreground hover:text-muted-foreground"
|
||||||
|
onClick={handleResendVerificationCode}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
重新发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{authError && (
|
{authError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
@@ -142,13 +259,27 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={closeAuthModal}
|
onClick={authStep === 'verification'
|
||||||
|
? () => {
|
||||||
|
setAuthStep('token')
|
||||||
|
setVerificationCode('')
|
||||||
|
setChallengeId(null)
|
||||||
|
setChallengeExpiresAt(null)
|
||||||
|
setAuthError('')
|
||||||
|
}
|
||||||
|
: handleCloseAuthModal}
|
||||||
className='mr-0'
|
className='mr-0'
|
||||||
>
|
>
|
||||||
取消
|
{authStep === 'verification' ? '返回上一步' : '取消'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isLoading ? '验证中...' : '登录'}
|
{isSubmitting
|
||||||
|
? authStep === 'verification'
|
||||||
|
? '验证中...'
|
||||||
|
: '发送中...'
|
||||||
|
: authStep === 'verification'
|
||||||
|
? '验证并登录'
|
||||||
|
: '登录'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
|
SlidersHorizontal,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
@@ -31,6 +32,7 @@ const navItems: NavItem[] = [
|
|||||||
{ title: "渠道管理", href: "/channels", icon: LinkIcon },
|
{ title: "渠道管理", href: "/channels", icon: LinkIcon },
|
||||||
{ title: "令牌管理", href: "/tokens", icon: Key },
|
{ title: "令牌管理", href: "/tokens", icon: Key },
|
||||||
{ title: "定价管理", href: "/pricing", icon: DollarSign },
|
{ title: "定价管理", href: "/pricing", icon: DollarSign },
|
||||||
|
{ title: "系统设置", href: "/settings", icon: SlidersHorizontal },
|
||||||
{ title: "API 测试", href: "/api-test", icon: TestTube2 },
|
{ title: "API 测试", href: "/api-test", icon: TestTube2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -77,8 +79,8 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
logout();
|
await logout();
|
||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import { SystemConfig } from "@/types";
|
||||||
|
import { DEFAULT_SYSTEM_CONFIG, normalizeSystemConfig } from "@/lib/system-config";
|
||||||
|
|
||||||
|
export const SYSTEM_CONFIG_QUERY_KEY = ["system-config"] as const;
|
||||||
|
|
||||||
|
export function useSystemConfig() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SYSTEM_CONFIG_QUERY_KEY,
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.getSystemConfig();
|
||||||
|
return normalizeSystemConfig(response.data as SystemConfig | undefined);
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
placeholderData: DEFAULT_SYSTEM_CONFIG,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export const ADMIN_SESSION_TOKEN_KEY = "adminSessionToken";
|
||||||
|
export const LEGACY_ADMIN_TOKEN_KEY = "adminToken";
|
||||||
|
export const ADMIN_SESSION_HEADER = "x-admin-session";
|
||||||
|
export const LEGACY_ADMIN_TOKEN_HEADER = "x-admin-token";
|
||||||
|
|
||||||
|
const canUseLocalStorage = (): boolean => {
|
||||||
|
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStoredAdminSessionToken = (): string | null => {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = window.localStorage.getItem(ADMIN_SESSION_TOKEN_KEY);
|
||||||
|
return value && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStoredLegacyAdminToken = (): string | null => {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = window.localStorage.getItem(LEGACY_ADMIN_TOKEN_KEY);
|
||||||
|
return value && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStoredAdminCredential = (): { headerName: string; token: string } | null => {
|
||||||
|
const sessionToken = getStoredAdminSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
return {
|
||||||
|
headerName: ADMIN_SESSION_HEADER,
|
||||||
|
token: sessionToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyToken = getStoredLegacyAdminToken();
|
||||||
|
if (legacyToken) {
|
||||||
|
return {
|
||||||
|
headerName: LEGACY_ADMIN_TOKEN_HEADER,
|
||||||
|
token: legacyToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeAdminSessionToken = (token: string): void => {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.removeItem(LEGACY_ADMIN_TOKEN_KEY);
|
||||||
|
window.localStorage.setItem(ADMIN_SESSION_TOKEN_KEY, token);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAdminCredentials = (): void => {
|
||||||
|
if (!canUseLocalStorage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.removeItem(ADMIN_SESSION_TOKEN_KEY);
|
||||||
|
window.localStorage.removeItem(LEGACY_ADMIN_TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdminCredentialScopeToken = (): string => {
|
||||||
|
return getStoredAdminSessionToken()
|
||||||
|
|| getStoredLegacyAdminToken()
|
||||||
|
|| "anonymous";
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { getAdminCredentialScopeToken } from "@/lib/admin-auth";
|
||||||
|
|
||||||
const CACHE_PREFIX = "one-api-cache";
|
const CACHE_PREFIX = "one-api-cache";
|
||||||
const CACHE_VERSION = 1;
|
const CACHE_VERSION = 1;
|
||||||
const ADMIN_TOKEN_KEY = "adminToken";
|
|
||||||
|
|
||||||
export interface LocalCacheEntry<T> {
|
export interface LocalCacheEntry<T> {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -15,7 +16,7 @@ const getAdminTokenScope = (): string => {
|
|||||||
return "server";
|
return "server";
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminToken = window.localStorage.getItem(ADMIN_TOKEN_KEY) || "anonymous";
|
const adminToken = getAdminCredentialScopeToken();
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
|
|
||||||
for (let index = 0; index < adminToken.length; index += 1) {
|
for (let index = 0; index < adminToken.length; index += 1) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { type AdminSecurityConfig, type ApiDocsConfig, type SystemConfig } from "@/types";
|
||||||
|
import {
|
||||||
|
DEFAULT_BILLING_DISPLAY_DECIMALS,
|
||||||
|
normalizeBillingDisplayDecimals,
|
||||||
|
} from "@/lib/billing";
|
||||||
|
|
||||||
|
export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = {
|
||||||
|
enabled: false,
|
||||||
|
telegramBotToken: "",
|
||||||
|
telegramChatId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = {
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SYSTEM_CONFIG: SystemConfig = {
|
||||||
|
displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS,
|
||||||
|
adminSecurity: DEFAULT_ADMIN_SECURITY_CONFIG,
|
||||||
|
apiDocs: DEFAULT_API_DOCS_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRECISION_OPTIONS = [
|
||||||
|
{ label: "低", value: 2 },
|
||||||
|
{ label: "中", value: 4 },
|
||||||
|
{ label: "高", value: 6 },
|
||||||
|
{ label: "超高", value: 9 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const normalizeString = (value: unknown): string => {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeAdminSecurityConfig = (value?: Partial<AdminSecurityConfig> | null): AdminSecurityConfig => {
|
||||||
|
return {
|
||||||
|
enabled: value?.enabled === true,
|
||||||
|
telegramBotToken: normalizeString(value?.telegramBotToken),
|
||||||
|
telegramChatId: normalizeString(value?.telegramChatId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeApiDocsConfig = (value?: Partial<ApiDocsConfig> | null): ApiDocsConfig => {
|
||||||
|
return {
|
||||||
|
enabled: value?.enabled ?? DEFAULT_API_DOCS_CONFIG.enabled,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeSystemConfig = (value?: Partial<SystemConfig> | null): SystemConfig => {
|
||||||
|
return {
|
||||||
|
displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals),
|
||||||
|
adminSecurity: normalizeAdminSecurityConfig(value?.adminSecurity),
|
||||||
|
apiDocs: normalizeApiDocsConfig(value?.apiDocs),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTelegramSecurityEnabled = (config?: Partial<AdminSecurityConfig> | null): boolean => {
|
||||||
|
return config?.enabled === true
|
||||||
|
&& typeof config.telegramBotToken === "string"
|
||||||
|
&& config.telegramBotToken.trim().length > 0
|
||||||
|
&& typeof config.telegramChatId === "string"
|
||||||
|
&& config.telegramChatId.trim().length > 0;
|
||||||
|
};
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Bot, Check, FileText, Globe, Shield, SlidersHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { BILLING_CONFIG_QUERY_KEY } from "@/hooks/use-billing-config";
|
||||||
|
import { SYSTEM_CONFIG_QUERY_KEY, useSystemConfig } from "@/hooks/use-system-config";
|
||||||
|
import { DEFAULT_SYSTEM_CONFIG, normalizeSystemConfig, PRECISION_OPTIONS } from "@/lib/system-config";
|
||||||
|
import { PageContainer } from "@/components/ui/page-container";
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
|
|
||||||
|
const buildAbsoluteUrl = (pathname: string): string => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = API_BASE_URL ? new URL(API_BASE_URL, window.location.origin) : new URL(window.location.origin);
|
||||||
|
|
||||||
|
return new URL(pathname, baseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SystemSettings() {
|
||||||
|
const [systemConfig, setSystemConfig] = useState(DEFAULT_SYSTEM_CONFIG);
|
||||||
|
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const systemConfigQuery = useSystemConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemConfigQuery.data) {
|
||||||
|
setSystemConfig(normalizeSystemConfig(systemConfigQuery.data));
|
||||||
|
}
|
||||||
|
}, [systemConfigQuery.data]);
|
||||||
|
|
||||||
|
const saveSystemConfigMutation = useMutation({
|
||||||
|
mutationFn: async () => apiClient.saveSystemConfig(normalizeSystemConfig(systemConfig)),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: SYSTEM_CONFIG_QUERY_KEY });
|
||||||
|
queryClient.invalidateQueries({ queryKey: BILLING_CONFIG_QUERY_KEY });
|
||||||
|
addToast("系统设置已保存", "success");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(`保存失败:${error.message}`, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const telegramTestMutation = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
apiClient.sendTelegramTestMessage({
|
||||||
|
telegramBotToken: systemConfig.adminSecurity.telegramBotToken,
|
||||||
|
telegramChatId: systemConfig.adminSecurity.telegramChatId,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
addToast("测试消息已发送,请到 Telegram 查收", "success");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(`测试失败:${error.message}`, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const docsLinks = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Swagger UI", href: buildAbsoluteUrl("/api/docs") },
|
||||||
|
{ label: "ReDoc", href: buildAbsoluteUrl("/api/redocs") },
|
||||||
|
{ label: "OpenAPI JSON", href: buildAbsoluteUrl("/api/openapi.json") },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPrecisionOption = PRECISION_OPTIONS.find((option) => option.value === systemConfig.displayDecimals);
|
||||||
|
const telegramConfigComplete =
|
||||||
|
systemConfig.adminSecurity.telegramBotToken.trim().length > 0 &&
|
||||||
|
systemConfig.adminSecurity.telegramChatId.trim().length > 0;
|
||||||
|
const securityEnabled = systemConfig.adminSecurity.enabled && telegramConfigComplete;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="系统设置"
|
||||||
|
description="统一管理全局系统设置。"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => saveSystemConfigMutation.mutate()}
|
||||||
|
disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading}
|
||||||
|
>
|
||||||
|
<Check className="mr-1 h-4 w-4" />
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Card className="space-y-6 border-0 p-6">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 rounded-lg bg-primary/10 p-2 text-primary">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold tracking-tight">安全设置</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
绑定 Telegram 后自动开启登录验证和通知功能。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={systemConfig.adminSecurity.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSystemConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
adminSecurity: {
|
||||||
|
...current.adminSecurity,
|
||||||
|
enabled: checked,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="telegramBotToken">
|
||||||
|
Bot Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="telegramBotToken"
|
||||||
|
type="password"
|
||||||
|
placeholder="123456789:AA..."
|
||||||
|
value={systemConfig.adminSecurity.telegramBotToken}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSystemConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
adminSecurity: {
|
||||||
|
...current.adminSecurity,
|
||||||
|
telegramBotToken: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="telegramChatId">
|
||||||
|
Chat ID
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="telegramChatId"
|
||||||
|
placeholder="-1001234567890"
|
||||||
|
value={systemConfig.adminSecurity.telegramChatId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSystemConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
adminSecurity: {
|
||||||
|
...current.adminSecurity,
|
||||||
|
telegramChatId: event.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-muted/50 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
发送测试消息
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{!telegramConfigComplete
|
||||||
|
? "请确保 Telegram Bot Token 和 Chat ID 已正确填写,测试通过后将启用登录验证和通知功能。"
|
||||||
|
: securityEnabled
|
||||||
|
? "当前已开启 Telegram 登录验证,管理员必须输入验证码才能登录。"
|
||||||
|
: "Telegram 已配置但尚未启用。可以先发送测试消息确认,再决定是否开启登录验证。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => telegramTestMutation.mutate()}
|
||||||
|
disabled={telegramTestMutation.isPending || !telegramConfigComplete}
|
||||||
|
>
|
||||||
|
{telegramTestMutation.isPending ? "发送中..." : "发送测试消息"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
|
||||||
|
<section className="flex items-center gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 rounded-lg bg-primary/10 p-2 text-primary">
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-bold tracking-tight">显示精度设置</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
页面显示金额保留 {systemConfig.displayDecimals} 位小数, 示例:$
|
||||||
|
{(0).toFixed(systemConfig.displayDecimals)}
|
||||||
|
{currentPrecisionOption ? `(当前:${currentPrecisionOption.label})` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="inline-flex flex-wrap border rounded-md">
|
||||||
|
{PRECISION_OPTIONS.map((option) => {
|
||||||
|
const isActive = systemConfig.displayDecimals === option.value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={isActive ? "default" : "outline"}
|
||||||
|
onClick={() =>
|
||||||
|
setSystemConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
displayDecimals: option.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="h-8 border-0"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 rounded-lg bg-primary/10 p-2 text-primary">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold tracking-tight">开启接口文档</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
开启后自动暴露 Swagger、ReDoc 与 OpenAPI JSON;关闭后对应路由会直接返回 404。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={systemConfig.apiDocs.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSystemConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
apiDocs: {
|
||||||
|
...current.apiDocs,
|
||||||
|
enabled: checked,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{systemConfig.apiDocs.enabled ? (
|
||||||
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
|
{docsLinks.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.href}
|
||||||
|
className="flex flex-col gap-3 rounded-md border px-4 py-3 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{item.label}</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-xs text-muted-foreground">{item.href}</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" className="h-8 px-3">
|
||||||
|
<a href={item.href} target="_blank" rel="noreferrer">
|
||||||
|
打开
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
+63
-14
@@ -1,14 +1,21 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { clearScopedCacheByPrefix } from '@/lib/local-cache'
|
import { clearScopedCacheByPrefix } from '@/lib/local-cache'
|
||||||
|
import { type AdminLoginResponse } from '@/types'
|
||||||
|
import {
|
||||||
|
clearAdminCredentials,
|
||||||
|
getStoredAdminCredential,
|
||||||
|
storeAdminSessionToken,
|
||||||
|
} from '@/lib/admin-auth'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
showAuthModal: boolean
|
showAuthModal: boolean
|
||||||
login: (token: string) => Promise<void>
|
startLogin: (token: string) => Promise<AdminLoginResponse>
|
||||||
logout: () => void
|
verifyLogin: (challengeId: string, code: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
checkAuth: () => Promise<void>
|
checkAuth: () => Promise<void>
|
||||||
openAuthModal: () => void
|
openAuthModal: () => void
|
||||||
closeAuthModal: () => void
|
closeAuthModal: () => void
|
||||||
@@ -20,18 +27,31 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
showAuthModal: false,
|
showAuthModal: false,
|
||||||
|
|
||||||
login: async (token: string) => {
|
startLogin: async (token: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Store token
|
const response = await apiClient.startAdminLogin(token)
|
||||||
localStorage.setItem('adminToken', token)
|
const loginResult = response.data as AdminLoginResponse
|
||||||
|
|
||||||
// Verify token by making a test request
|
if (!loginResult) {
|
||||||
await apiClient.checkAuth()
|
throw new Error('登录响应无效')
|
||||||
|
}
|
||||||
|
|
||||||
set({ isAuthenticated: true, isLoading: false, showAuthModal: false })
|
if (loginResult.sessionToken) {
|
||||||
|
storeAdminSessionToken(loginResult.sessionToken)
|
||||||
|
set({ isAuthenticated: true, isLoading: false, showAuthModal: false })
|
||||||
|
return loginResult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginResult.requiresVerification || !loginResult.challengeId) {
|
||||||
|
throw new Error('登录响应缺少验证码挑战信息')
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isAuthenticated: false, isLoading: false })
|
||||||
|
return loginResult
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
localStorage.removeItem('adminToken')
|
clearAdminCredentials()
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -41,16 +61,45 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
verifyLogin: async (challengeId: string, code: string) => {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.verifyAdminLogin(challengeId, code)
|
||||||
|
const loginResult = response.data as AdminLoginResponse
|
||||||
|
|
||||||
|
if (!loginResult.sessionToken) {
|
||||||
|
throw new Error('登录响应缺少会话令牌')
|
||||||
|
}
|
||||||
|
|
||||||
|
storeAdminSessionToken(loginResult.sessionToken)
|
||||||
|
set({ isAuthenticated: true, isLoading: false, showAuthModal: false })
|
||||||
|
} catch (error) {
|
||||||
|
clearAdminCredentials()
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Verification failed',
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.logoutAdmin()
|
||||||
|
} catch {
|
||||||
|
// ignore logout errors and clear local state anyway
|
||||||
|
}
|
||||||
|
|
||||||
clearScopedCacheByPrefix('analytics:')
|
clearScopedCacheByPrefix('analytics:')
|
||||||
clearScopedCacheByPrefix('usage-logs:')
|
clearScopedCacheByPrefix('usage-logs:')
|
||||||
localStorage.removeItem('adminToken')
|
clearAdminCredentials()
|
||||||
set({ isAuthenticated: false, error: null })
|
set({ isAuthenticated: false, error: null })
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
const token = localStorage.getItem('adminToken')
|
if (!getStoredAdminCredential()) {
|
||||||
if (!token) {
|
|
||||||
set({ isAuthenticated: false, isLoading: false })
|
set({ isAuthenticated: false, isLoading: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,7 +109,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
await apiClient.checkAuth()
|
await apiClient.checkAuth()
|
||||||
set({ isAuthenticated: true, isLoading: false })
|
set({ isAuthenticated: true, isLoading: false })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
localStorage.removeItem('adminToken')
|
clearAdminCredentials()
|
||||||
set({ isAuthenticated: false, isLoading: false })
|
set({ isAuthenticated: false, isLoading: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,29 @@ export interface BillingConfig {
|
|||||||
displayDecimals: number
|
displayDecimals: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminSecurityConfig {
|
||||||
|
enabled: boolean
|
||||||
|
telegramBotToken: string
|
||||||
|
telegramChatId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiDocsConfig {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemConfig extends BillingConfig {
|
||||||
|
adminSecurity: AdminSecurityConfig
|
||||||
|
apiDocs: ApiDocsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResponse {
|
||||||
|
requiresVerification: boolean
|
||||||
|
challengeId: string | null
|
||||||
|
challengeExpiresAt: string | null
|
||||||
|
sessionToken: string | null
|
||||||
|
sessionExpiresAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingModel {
|
export interface PricingModel {
|
||||||
input?: number
|
input?: number
|
||||||
output?: number
|
output?: number
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { OpenAPIRoute } from "chanfana";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||||
|
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config";
|
||||||
|
import {
|
||||||
|
createAdminLoginChallenge,
|
||||||
|
createAdminSession,
|
||||||
|
deleteAdminLoginChallenge,
|
||||||
|
getAdminSessionHeaderName,
|
||||||
|
invalidateAdminSession,
|
||||||
|
sendAdminLoginCodeNotification,
|
||||||
|
sendAdminLoginResultNotification,
|
||||||
|
verifyAdminLoginChallenge,
|
||||||
|
} from "./auth_shared";
|
||||||
|
|
||||||
|
const loginStartRequestSchema = z.object({
|
||||||
|
token: z.string().trim().min(1, "管理员令牌不能为空"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginStartResponseSchema = z.object({
|
||||||
|
requiresVerification: z.boolean(),
|
||||||
|
challengeId: z.nullable(z.string()),
|
||||||
|
challengeExpiresAt: z.nullable(z.string()),
|
||||||
|
sessionToken: z.nullable(z.string()),
|
||||||
|
sessionExpiresAt: z.nullable(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginVerifyRequestSchema = z.object({
|
||||||
|
challengeId: z.string().trim().min(1, "challengeId 不能为空"),
|
||||||
|
code: z.string().trim().regex(/^\d{6}$/, "验证码必须为 6 位数字"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildDirectLoginResponse = async (
|
||||||
|
c: Context<HonoCustomType>
|
||||||
|
) => {
|
||||||
|
const session = await createAdminSession(c);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
requiresVerification: false,
|
||||||
|
challengeId: null,
|
||||||
|
challengeExpiresAt: null,
|
||||||
|
sessionToken: session.sessionToken,
|
||||||
|
sessionExpiresAt: session.expiresAt,
|
||||||
|
},
|
||||||
|
message: "Login successful",
|
||||||
|
} as CommonResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AdminLoginStartEndpoint extends OpenAPIRoute {
|
||||||
|
schema = {
|
||||||
|
tags: ["Admin API"],
|
||||||
|
summary: "Start admin login",
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: loginStartRequestSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
...CommonSuccessfulResponse(loginStartResponseSchema),
|
||||||
|
...CommonErrorResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async handle(c: Context<HonoCustomType>) {
|
||||||
|
const parsedBody = loginStartRequestSchema.safeParse(
|
||||||
|
await c.req.json<{ token: string }>()
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return c.text(parsedBody.error.issues[0]?.message || "管理员令牌不能为空", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data;
|
||||||
|
const systemConfig = await getSystemConfig(c);
|
||||||
|
const securityConfig = systemConfig.adminSecurity;
|
||||||
|
const securityEnabled = isTelegramSecurityEnabled(securityConfig);
|
||||||
|
|
||||||
|
if (!c.env.ADMIN_TOKEN || body.token !== c.env.ADMIN_TOKEN) {
|
||||||
|
if (securityEnabled) {
|
||||||
|
await sendAdminLoginResultNotification(
|
||||||
|
securityConfig,
|
||||||
|
c,
|
||||||
|
"failure",
|
||||||
|
"管理员令牌错误"
|
||||||
|
).catch((error) => {
|
||||||
|
console.error("Failed to send Telegram login failure notification:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.text("管理员令牌无效", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!securityEnabled) {
|
||||||
|
return buildDirectLoginResponse(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = await createAdminLoginChallenge(c);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendAdminLoginCodeNotification(
|
||||||
|
securityConfig,
|
||||||
|
c,
|
||||||
|
challenge.code,
|
||||||
|
challenge.expiresAt
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await deleteAdminLoginChallenge(c, challenge.challengeId);
|
||||||
|
|
||||||
|
return c.text(
|
||||||
|
`Telegram 发送验证码失败:${error instanceof Error ? error.message : "unknown error"}`,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
requiresVerification: true,
|
||||||
|
challengeId: challenge.challengeId,
|
||||||
|
challengeExpiresAt: challenge.expiresAt,
|
||||||
|
sessionToken: null,
|
||||||
|
sessionExpiresAt: null,
|
||||||
|
},
|
||||||
|
message: "Verification code sent",
|
||||||
|
} as CommonResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminLoginVerifyEndpoint extends OpenAPIRoute {
|
||||||
|
schema = {
|
||||||
|
tags: ["Admin API"],
|
||||||
|
summary: "Verify admin login code",
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: loginVerifyRequestSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
...CommonSuccessfulResponse(loginStartResponseSchema),
|
||||||
|
...CommonErrorResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async handle(c: Context<HonoCustomType>) {
|
||||||
|
const parsedBody = loginVerifyRequestSchema.safeParse(
|
||||||
|
await c.req.json<{ challengeId: string; code: string }>()
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return c.text(parsedBody.error.issues[0]?.message || "验证码格式无效", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data;
|
||||||
|
const systemConfig = await getSystemConfig(c);
|
||||||
|
const securityConfig = systemConfig.adminSecurity;
|
||||||
|
|
||||||
|
if (!isTelegramSecurityEnabled(securityConfig)) {
|
||||||
|
return c.text("当前未开启 Telegram 登录验证", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyAdminLoginChallenge(
|
||||||
|
c,
|
||||||
|
body.challengeId,
|
||||||
|
body.code
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
await sendAdminLoginResultNotification(
|
||||||
|
securityConfig,
|
||||||
|
c,
|
||||||
|
"failure",
|
||||||
|
result.reason
|
||||||
|
).catch((error) => {
|
||||||
|
console.error("Failed to send Telegram login failure notification:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.text(result.reason || "验证码验证失败", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createAdminSession(c);
|
||||||
|
|
||||||
|
await sendAdminLoginResultNotification(
|
||||||
|
securityConfig,
|
||||||
|
c,
|
||||||
|
"success"
|
||||||
|
).catch((error) => {
|
||||||
|
console.error("Failed to send Telegram login success notification:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
requiresVerification: false,
|
||||||
|
challengeId: null,
|
||||||
|
challengeExpiresAt: null,
|
||||||
|
sessionToken: session.sessionToken,
|
||||||
|
sessionExpiresAt: session.expiresAt,
|
||||||
|
},
|
||||||
|
message: "Login verified successfully",
|
||||||
|
} as CommonResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminLogoutEndpoint extends OpenAPIRoute {
|
||||||
|
schema = {
|
||||||
|
tags: ["Admin API"],
|
||||||
|
summary: "Logout admin session",
|
||||||
|
responses: {
|
||||||
|
...CommonSuccessfulResponse(z.boolean()),
|
||||||
|
...CommonErrorResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async handle(c: Context<HonoCustomType>) {
|
||||||
|
const sessionToken = c.req.header(getAdminSessionHeaderName());
|
||||||
|
|
||||||
|
await invalidateAdminSession(c, sessionToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: true,
|
||||||
|
message: "Logout successful",
|
||||||
|
} as CommonResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
|
||||||
|
const ADMIN_SESSION_HEADER = "x-admin-session";
|
||||||
|
const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
const ADMIN_LOGIN_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS = 5;
|
||||||
|
const DEFAULT_SYSTEM_TIMEZONE = "Asia/Shanghai";
|
||||||
|
|
||||||
|
type LoginRequestMetadata = {
|
||||||
|
clientIp: string;
|
||||||
|
country: string;
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
colo: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstNonEmpty = (...values: Array<string | null | undefined>): string => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestampInTimezone = (date: Date, timezone: string): string => {
|
||||||
|
const formatter = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour12: false,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||||
|
|
||||||
|
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second} (${timezone})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequestMetadata = (c: Context<HonoCustomType>): LoginRequestMetadata => {
|
||||||
|
const requestCf = (c.req.raw.cf || {}) as Partial<IncomingRequestCfProperties<unknown>>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientIp: firstNonEmpty(
|
||||||
|
c.req.header("cf-connecting-ip"),
|
||||||
|
c.req.header("x-real-ip"),
|
||||||
|
c.req.header("x-forwarded-for")?.split(",")[0]
|
||||||
|
),
|
||||||
|
country: firstNonEmpty(requestCf.country),
|
||||||
|
region: firstNonEmpty(requestCf.region, requestCf.regionCode),
|
||||||
|
city: firstNonEmpty(requestCf.city),
|
||||||
|
colo: firstNonEmpty(requestCf.colo),
|
||||||
|
timezone: firstNonEmpty(requestCf.timezone),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationText = (metadata: LoginRequestMetadata): string => {
|
||||||
|
const location = [metadata.country, metadata.region, metadata.city]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ");
|
||||||
|
|
||||||
|
return location || "未知位置";
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTelegramMessage = (
|
||||||
|
lines: Array<string>
|
||||||
|
): string => {
|
||||||
|
return lines.filter(Boolean).join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateNumericCode = (): string => {
|
||||||
|
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateSessionToken = (): string => {
|
||||||
|
return `${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSha256Hex = async (value: string): Promise<string> => {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupExpiredArtifacts = async (c: Context<HonoCustomType>) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
c.env.DB.prepare(
|
||||||
|
`DELETE FROM admin_login_challenge WHERE expires_at <= ?`
|
||||||
|
).bind(now).run(),
|
||||||
|
c.env.DB.prepare(
|
||||||
|
`DELETE FROM admin_session WHERE expires_at <= ?`
|
||||||
|
).bind(now).run(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTelegramMessage = async (
|
||||||
|
securityConfig: AdminSecurityConfig,
|
||||||
|
text: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.telegram.org/bot${securityConfig.telegramBotToken}/sendMessage`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: securityConfig.telegramChatId,
|
||||||
|
text,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = await response.json()
|
||||||
|
.catch(() => ({})) as { ok?: boolean; description?: string };
|
||||||
|
|
||||||
|
if (!response.ok || payload.ok !== true) {
|
||||||
|
throw new Error(payload.description || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBaseMessageLines = (
|
||||||
|
metadata: LoginRequestMetadata,
|
||||||
|
occurredAt: Date
|
||||||
|
): Array<string> => {
|
||||||
|
const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`时间(客户端):${formatTimestampInTimezone(occurredAt, clientTimezone)}`,
|
||||||
|
`时间(系统):${formatTimestampInTimezone(occurredAt, DEFAULT_SYSTEM_TIMEZONE)}`,
|
||||||
|
`IP:${metadata.clientIp || "未知 IP"}`,
|
||||||
|
`位置:${getLocationText(metadata)}`,
|
||||||
|
`节点:${metadata.colo || "未知节点"}${metadata.timezone ? ` · ${metadata.timezone}` : ""}`,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdminSessionHeaderName = (): string => {
|
||||||
|
return ADMIN_SESSION_HEADER;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAdminSession = async (
|
||||||
|
c: Context<HonoCustomType>
|
||||||
|
): Promise<{ sessionToken: string; expiresAt: string }> => {
|
||||||
|
await cleanupExpiredArtifacts(c);
|
||||||
|
|
||||||
|
const sessionToken = generateSessionToken();
|
||||||
|
const tokenHash = await toSha256Hex(sessionToken);
|
||||||
|
const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_MS).toISOString();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO admin_session (token_hash, expires_at)
|
||||||
|
VALUES (?, ?)`
|
||||||
|
).bind(tokenHash, expiresAt).run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionToken,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invalidateAdminSession = async (
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
sessionToken: string | null | undefined
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!sessionToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = await toSha256Hex(sessionToken);
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`DELETE FROM admin_session WHERE token_hash = ?`
|
||||||
|
).bind(tokenHash).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateAdminSession = async (
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
sessionToken: string | null | undefined
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!sessionToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupExpiredArtifacts(c);
|
||||||
|
|
||||||
|
const tokenHash = await toSha256Hex(sessionToken);
|
||||||
|
const session = await c.env.DB.prepare(
|
||||||
|
`SELECT token_hash, expires_at FROM admin_session WHERE token_hash = ?`
|
||||||
|
).bind(tokenHash).first<Pick<AdminSessionRow, "token_hash" | "expires_at">>();
|
||||||
|
|
||||||
|
if (!session?.token_hash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.parse(session.expires_at) <= Date.now()) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`DELETE FROM admin_session WHERE token_hash = ?`
|
||||||
|
).bind(tokenHash).run();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE admin_session
|
||||||
|
SET last_used_at = datetime('now'),
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE token_hash = ?`
|
||||||
|
).bind(tokenHash).run();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAdminLoginChallenge = async (
|
||||||
|
c: Context<HonoCustomType>
|
||||||
|
): Promise<{ challengeId: string; code: string; expiresAt: string }> => {
|
||||||
|
await cleanupExpiredArtifacts(c);
|
||||||
|
|
||||||
|
const challengeId = crypto.randomUUID();
|
||||||
|
const code = generateNumericCode();
|
||||||
|
const codeHash = await toSha256Hex(code);
|
||||||
|
const expiresAt = new Date(Date.now() + ADMIN_LOGIN_CHALLENGE_TTL_MS).toISOString();
|
||||||
|
const metadata = getRequestMetadata(c);
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO admin_login_challenge (
|
||||||
|
id,
|
||||||
|
code_hash,
|
||||||
|
expires_at,
|
||||||
|
attempts,
|
||||||
|
max_attempts,
|
||||||
|
request_ip,
|
||||||
|
request_country,
|
||||||
|
request_region,
|
||||||
|
request_city,
|
||||||
|
request_colo,
|
||||||
|
request_timezone
|
||||||
|
) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).bind(
|
||||||
|
challengeId,
|
||||||
|
codeHash,
|
||||||
|
expiresAt,
|
||||||
|
ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS,
|
||||||
|
metadata.clientIp,
|
||||||
|
metadata.country,
|
||||||
|
metadata.region,
|
||||||
|
metadata.city,
|
||||||
|
metadata.colo,
|
||||||
|
metadata.timezone
|
||||||
|
).run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
challengeId,
|
||||||
|
code,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAdminLoginChallenge = async (
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
challengeId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`DELETE FROM admin_login_challenge WHERE id = ?`
|
||||||
|
).bind(challengeId).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyAdminLoginChallenge = async (
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
challengeId: string,
|
||||||
|
code: string
|
||||||
|
): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}> => {
|
||||||
|
await cleanupExpiredArtifacts(c);
|
||||||
|
|
||||||
|
const challenge = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM admin_login_challenge WHERE id = ?`
|
||||||
|
).bind(challengeId).first<AdminLoginChallengeRow>();
|
||||||
|
|
||||||
|
if (!challenge?.id) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "验证码已失效,请重新获取",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.parse(challenge.expires_at) <= Date.now()) {
|
||||||
|
await deleteAdminLoginChallenge(c, challengeId);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "验证码已过期,请重新获取",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((challenge.attempts || 0) >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) {
|
||||||
|
await deleteAdminLoginChallenge(c, challengeId);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "验证码尝试次数过多,请重新获取",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeHash = await toSha256Hex(code);
|
||||||
|
|
||||||
|
if (codeHash !== challenge.code_hash) {
|
||||||
|
const nextAttempts = (challenge.attempts || 0) + 1;
|
||||||
|
|
||||||
|
if (nextAttempts >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) {
|
||||||
|
await deleteAdminLoginChallenge(c, challengeId);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "验证码错误,已达到最大尝试次数",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE admin_login_challenge
|
||||||
|
SET attempts = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
).bind(nextAttempts, challengeId).run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "验证码错误",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAdminLoginChallenge(c, challengeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendAdminLoginCodeNotification = async (
|
||||||
|
securityConfig: AdminSecurityConfig,
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
code: string,
|
||||||
|
expiresAt: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const metadata = getRequestMetadata(c);
|
||||||
|
const occurredAt = new Date();
|
||||||
|
const expiresDate = new Date(expiresAt);
|
||||||
|
const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE;
|
||||||
|
|
||||||
|
await sendTelegramMessage(
|
||||||
|
securityConfig,
|
||||||
|
createTelegramMessage([
|
||||||
|
"【One API 管理后台】",
|
||||||
|
`登录验证码:${code}`,
|
||||||
|
`过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`,
|
||||||
|
...buildBaseMessageLines(metadata, occurredAt),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendAdminLoginResultNotification = async (
|
||||||
|
securityConfig: AdminSecurityConfig,
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
status: "success" | "failure",
|
||||||
|
reason?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const metadata = getRequestMetadata(c);
|
||||||
|
const occurredAt = new Date();
|
||||||
|
|
||||||
|
await sendTelegramMessage(
|
||||||
|
securityConfig,
|
||||||
|
createTelegramMessage([
|
||||||
|
"【One API 管理后台】",
|
||||||
|
`登录${status === "success" ? "成功" : "失败"}`,
|
||||||
|
reason ? `原因:${reason}` : "",
|
||||||
|
...buildBaseMessageLines(metadata, occurredAt),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendTelegramTestNotification = async (
|
||||||
|
securityConfig: AdminSecurityConfig,
|
||||||
|
c: Context<HonoCustomType>
|
||||||
|
): Promise<void> => {
|
||||||
|
const metadata = getRequestMetadata(c);
|
||||||
|
const occurredAt = new Date();
|
||||||
|
|
||||||
|
await sendTelegramMessage(
|
||||||
|
securityConfig,
|
||||||
|
createTelegramMessage([
|
||||||
|
"【One API 管理后台】",
|
||||||
|
"Telegram 绑定测试成功",
|
||||||
|
...buildBaseMessageLines(metadata, occurredAt),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,10 +2,9 @@ import { Context } from "hono";
|
|||||||
import { OpenAPIRoute } from "chanfana";
|
import { OpenAPIRoute } from "chanfana";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { CONSTANTS } from "../constants";
|
|
||||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||||
import { BillingConfig, normalizeBillingConfig } from "../billing";
|
import { BillingConfig, normalizeBillingConfig } from "../billing";
|
||||||
import { getJsonSetting, saveSetting } from "../utils";
|
import { getSystemConfig, saveSystemConfig } from "../system-config";
|
||||||
|
|
||||||
const billingConfigSchema = z.object({
|
const billingConfigSchema = z.object({
|
||||||
displayDecimals: z.number().int().min(0).max(9),
|
displayDecimals: z.number().int().min(0).max(9),
|
||||||
@@ -22,14 +21,9 @@ export class BillingConfigGetEndpoint extends OpenAPIRoute {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async handle(c: Context<HonoCustomType>) {
|
async handle(c: Context<HonoCustomType>) {
|
||||||
const billingConfig = await getJsonSetting<BillingConfig>(
|
|
||||||
c,
|
|
||||||
CONSTANTS.BILLING_CONFIG_KEY
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: normalizeBillingConfig(billingConfig),
|
data: normalizeBillingConfig(await getSystemConfig(c)),
|
||||||
} as CommonResponse;
|
} as CommonResponse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,13 +49,13 @@ export class BillingConfigUpdateEndpoint extends OpenAPIRoute {
|
|||||||
|
|
||||||
async handle(c: Context<HonoCustomType>) {
|
async handle(c: Context<HonoCustomType>) {
|
||||||
const body = await c.req.json<BillingConfig>();
|
const body = await c.req.json<BillingConfig>();
|
||||||
|
const currentSystemConfig = await getSystemConfig(c);
|
||||||
const config = normalizeBillingConfig(body);
|
const config = normalizeBillingConfig(body);
|
||||||
|
|
||||||
await saveSetting(
|
await saveSystemConfig(c, {
|
||||||
c,
|
...currentSystemConfig,
|
||||||
CONSTANTS.BILLING_CONFIG_KEY,
|
displayDecimals: config.displayDecimals,
|
||||||
JSON.stringify(config)
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
+56
-10
@@ -14,6 +14,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
BillingConfigGetEndpoint, BillingConfigUpdateEndpoint
|
BillingConfigGetEndpoint, BillingConfigUpdateEndpoint
|
||||||
} from "./billing_api"
|
} from "./billing_api"
|
||||||
|
import {
|
||||||
|
SystemConfigGetEndpoint,
|
||||||
|
SystemConfigUpdateEndpoint,
|
||||||
|
TelegramTestMessageEndpoint,
|
||||||
|
} from "./system_api"
|
||||||
|
import {
|
||||||
|
AdminLoginStartEndpoint,
|
||||||
|
AdminLoginVerifyEndpoint,
|
||||||
|
AdminLogoutEndpoint,
|
||||||
|
} from "./auth_api"
|
||||||
import {
|
import {
|
||||||
AnalyticsOverviewEndpoint,
|
AnalyticsOverviewEndpoint,
|
||||||
AnalyticsTrendEndpoint,
|
AnalyticsTrendEndpoint,
|
||||||
@@ -21,28 +31,61 @@ import {
|
|||||||
AnalyticsEventsEndpoint,
|
AnalyticsEventsEndpoint,
|
||||||
UsageLogSearchEndpoint,
|
UsageLogSearchEndpoint,
|
||||||
} from "./analytics_api"
|
} from "./analytics_api"
|
||||||
|
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"
|
||||||
|
import { getAdminSessionHeaderName, validateAdminSession } from "./auth_shared"
|
||||||
|
|
||||||
const app = new Hono<HonoCustomType>()
|
const app = new Hono<HonoCustomType>()
|
||||||
export const api = fromHono(app)
|
export const api = fromHono(app)
|
||||||
|
|
||||||
// Authentication Middleware - using environment variable
|
const PUBLIC_AUTH_ROUTES = new Set([
|
||||||
app.use('/api/admin/*', async (c, next) => {
|
"/api/admin/auth/login",
|
||||||
const token = c.req.header('x-admin-token');
|
"/api/admin/auth/verify",
|
||||||
const adminToken = c.env.ADMIN_TOKEN;
|
]);
|
||||||
|
|
||||||
if (!token || !adminToken || token !== adminToken) {
|
|
||||||
return c.text("Unauthorized", 401);
|
|
||||||
}
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/api/admin/*', async (c, next) => {
|
app.use('/api/admin/*', async (c, next) => {
|
||||||
await db.ensureReady(c);
|
await db.ensureReady(c);
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Authentication Middleware - using environment variable or admin session
|
||||||
|
app.use('/api/admin/*', async (c, next) => {
|
||||||
|
if (PUBLIC_AUTH_ROUTES.has(c.req.path)) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = c.req.header(getAdminSessionHeaderName());
|
||||||
|
|
||||||
|
if (await validateAdminSession(c, sessionToken)) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemConfig = await getSystemConfig(c);
|
||||||
|
const securityEnabled = isTelegramSecurityEnabled(systemConfig.adminSecurity);
|
||||||
|
const token = c.req.header('x-admin-token');
|
||||||
|
const adminToken = c.env.ADMIN_TOKEN;
|
||||||
|
|
||||||
|
if (!securityEnabled && token && adminToken && token === adminToken) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.text(
|
||||||
|
securityEnabled
|
||||||
|
? "Telegram 登录验证已开启,请先完成验证码登录"
|
||||||
|
: "Unauthorized",
|
||||||
|
401
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
api.post("/api/admin/db_initialize", DBInitializeEndpoint)
|
api.post("/api/admin/db_initialize", DBInitializeEndpoint)
|
||||||
|
|
||||||
|
// Authentication routes
|
||||||
|
api.post("/api/admin/auth/login", AdminLoginStartEndpoint)
|
||||||
|
api.post("/api/admin/auth/verify", AdminLoginVerifyEndpoint)
|
||||||
|
api.post("/api/admin/auth/logout", AdminLogoutEndpoint)
|
||||||
|
|
||||||
api.get("/api/admin/channel", ChannelGetEndpoint)
|
api.get("/api/admin/channel", ChannelGetEndpoint)
|
||||||
api.post("/api/admin/channel/:key", ChannelUpsertEndpoint)
|
api.post("/api/admin/channel/:key", ChannelUpsertEndpoint)
|
||||||
api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint)
|
api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint)
|
||||||
@@ -59,6 +102,9 @@ api.get("/api/admin/pricing", PricingGetEndpoint)
|
|||||||
api.post("/api/admin/pricing", PricingUpdateEndpoint)
|
api.post("/api/admin/pricing", PricingUpdateEndpoint)
|
||||||
api.get("/api/admin/billing/config", BillingConfigGetEndpoint)
|
api.get("/api/admin/billing/config", BillingConfigGetEndpoint)
|
||||||
api.post("/api/admin/billing/config", BillingConfigUpdateEndpoint)
|
api.post("/api/admin/billing/config", BillingConfigUpdateEndpoint)
|
||||||
|
api.get("/api/admin/system/config", SystemConfigGetEndpoint)
|
||||||
|
api.post("/api/admin/system/config", SystemConfigUpdateEndpoint)
|
||||||
|
api.post("/api/admin/system/telegram/test", TelegramTestMessageEndpoint)
|
||||||
|
|
||||||
// Analytics management routes
|
// Analytics management routes
|
||||||
api.get("/api/admin/analytics/overview", AnalyticsOverviewEndpoint)
|
api.get("/api/admin/analytics/overview", AnalyticsOverviewEndpoint)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { OpenAPIRoute } from "chanfana";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||||
|
import {
|
||||||
|
getSystemConfig,
|
||||||
|
isTelegramSecurityEnabled,
|
||||||
|
normalizeAdminSecurityConfig,
|
||||||
|
normalizeSystemConfig,
|
||||||
|
saveSystemConfig,
|
||||||
|
} from "../system-config";
|
||||||
|
import { sendTelegramTestNotification } from "./auth_shared";
|
||||||
|
|
||||||
|
const adminSecurityConfigSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
telegramBotToken: z.string(),
|
||||||
|
telegramChatId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiDocsConfigSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const systemConfigSchema = z.object({
|
||||||
|
displayDecimals: z.number().int().min(0).max(9),
|
||||||
|
adminSecurity: adminSecurityConfigSchema,
|
||||||
|
apiDocs: apiDocsConfigSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const telegramTestRequestSchema = z.object({
|
||||||
|
telegramBotToken: z.string().trim().min(1, "Bot Token 不能为空"),
|
||||||
|
telegramChatId: z.string().trim().min(1, "Chat ID 不能为空"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureSystemConfigValid = (config: SystemConfig) => {
|
||||||
|
if (
|
||||||
|
config.adminSecurity.enabled
|
||||||
|
&& !isTelegramSecurityEnabled(config.adminSecurity)
|
||||||
|
) {
|
||||||
|
throw new Error("开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SystemConfigGetEndpoint extends OpenAPIRoute {
|
||||||
|
schema = {
|
||||||
|
tags: ["Admin API"],
|
||||||
|
summary: "Get system configuration",
|
||||||
|
responses: {
|
||||||
|
...CommonSuccessfulResponse(systemConfigSchema),
|
||||||
|
...CommonErrorResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async handle(c: Context<HonoCustomType>) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await getSystemConfig(c),
|
||||||
|
} as CommonResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemConfigUpdateEndpoint extends OpenAPIRoute {
|
||||||
|
schema = {
|
||||||
|
tags: ["Admin API"],
|
||||||
|
summary: "Update system configuration",
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: systemConfigSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
...CommonSuccessfulResponse(systemConfigSchema),
|
||||||
|
...CommonErrorResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async handle(c: Context<HonoCustomType>) {
|
||||||
|
const body = await c.req.json<SystemConfig>();
|
||||||
|
const config = normalizeSystemConfig(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSystemConfigValid(config);
|
||||||
|
} catch (error) {
|
||||||
|
return c.text(
|
||||||
|
error instanceof Error ? error.message : "系统设置无效",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await saveSystemConfig(c, config),
|
||||||
|
message: "System config updated successfully",
|
||||||
|
} as CommonResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramTestMessageEndpoint extends OpenAPIRoute {
|
||||||
|
schema = {
|
||||||
|
tags: ["Admin API"],
|
||||||
|
summary: "Send Telegram test notification",
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: telegramTestRequestSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
...CommonSuccessfulResponse(z.boolean()),
|
||||||
|
...CommonErrorResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async handle(c: Context<HonoCustomType>) {
|
||||||
|
const parsedBody = telegramTestRequestSchema.safeParse(
|
||||||
|
await c.req.json<Pick<AdminSecurityConfig, "telegramBotToken" | "telegramChatId">>()
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return c.text(parsedBody.error.issues[0]?.message || "Telegram 配置无效", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data;
|
||||||
|
const securityConfig = {
|
||||||
|
...normalizeAdminSecurityConfig(body),
|
||||||
|
enabled: true,
|
||||||
|
} satisfies AdminSecurityConfig;
|
||||||
|
|
||||||
|
if (!isTelegramSecurityEnabled(securityConfig)) {
|
||||||
|
return c.text("请先填写有效的 Bot Token 和 Chat ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendTelegramTestNotification(securityConfig, c);
|
||||||
|
} catch (error) {
|
||||||
|
return c.text(
|
||||||
|
`Telegram 测试消息发送失败:${error instanceof Error ? error.message : "unknown error"}`,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: true,
|
||||||
|
message: "Telegram test message sent successfully",
|
||||||
|
} as CommonResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@ export const CONSTANTS = {
|
|||||||
DB_VERSION: "v0.0.6",
|
DB_VERSION: "v0.0.6",
|
||||||
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
|
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
|
||||||
BILLING_CONFIG_KEY: "BILLING_CONFIG",
|
BILLING_CONFIG_KEY: "BILLING_CONFIG",
|
||||||
|
SYSTEM_CONFIG_KEY: "SYSTEM_CONFIG",
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,32 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_login_challenge (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
max_attempts INTEGER DEFAULT 5,
|
||||||
|
request_ip TEXT DEFAULT '',
|
||||||
|
request_country TEXT DEFAULT '',
|
||||||
|
request_region TEXT DEFAULT '',
|
||||||
|
request_city TEXT DEFAULT '',
|
||||||
|
request_colo TEXT DEFAULT '',
|
||||||
|
request_timezone TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_session (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_login_challenge_expires_at
|
||||||
|
ON admin_login_challenge (expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_session_expires_at
|
||||||
|
ON admin_session (expires_at);
|
||||||
`
|
`
|
||||||
|
|
||||||
let dbReadyPromise: Promise<void> | null = null;
|
let dbReadyPromise: Promise<void> | null = null;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { cors } from 'hono/cors';
|
|||||||
import { api as providerApi } from './providers'
|
import { api as providerApi } from './providers'
|
||||||
import { api as adminApi } from './admin'
|
import { api as adminApi } from './admin'
|
||||||
import { fromHono } from 'chanfana';
|
import { fromHono } from 'chanfana';
|
||||||
|
import db from './db';
|
||||||
|
import { getSystemConfig } from './system-config';
|
||||||
|
|
||||||
const FRONTEND_ENTRY = '/'
|
const FRONTEND_ENTRY = '/'
|
||||||
const FRONTEND_STATIC_PATHS = new Set([
|
const FRONTEND_STATIC_PATHS = new Set([
|
||||||
@@ -22,6 +24,11 @@ const FRONTEND_STATIC_PREFIXES = [
|
|||||||
'/src/',
|
'/src/',
|
||||||
]
|
]
|
||||||
const LOCAL_DEV_HOSTNAMES = new Set(['0.0.0.0', '127.0.0.1', '::1', 'localhost'])
|
const LOCAL_DEV_HOSTNAMES = new Set(['0.0.0.0', '127.0.0.1', '::1', 'localhost'])
|
||||||
|
const API_DOC_ROUTE_PATHS = new Set([
|
||||||
|
'/api/docs',
|
||||||
|
'/api/redocs',
|
||||||
|
'/api/openapi.json',
|
||||||
|
])
|
||||||
type AppContext = Context<HonoCustomType>
|
type AppContext = Context<HonoCustomType>
|
||||||
|
|
||||||
function isApiRequest(pathname: string): boolean {
|
function isApiRequest(pathname: string): boolean {
|
||||||
@@ -96,6 +103,15 @@ openapi.use('/*', cors());
|
|||||||
app.use('*', async (c, next) => {
|
app.use('*', async (c, next) => {
|
||||||
const requestUrl = new URL(c.req.url)
|
const requestUrl = new URL(c.req.url)
|
||||||
|
|
||||||
|
if (API_DOC_ROUTE_PATHS.has(requestUrl.pathname)) {
|
||||||
|
await db.ensureReady(c);
|
||||||
|
|
||||||
|
const systemConfig = await getSystemConfig(c);
|
||||||
|
if (!systemConfig.apiDocs.enabled) {
|
||||||
|
return c.notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isApiRequest(requestUrl.pathname)) {
|
if (isApiRequest(requestUrl.pathname)) {
|
||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_BILLING_DISPLAY_DECIMALS,
|
||||||
|
normalizeBillingDisplayDecimals,
|
||||||
|
} from "./billing";
|
||||||
|
import { CONSTANTS } from "./constants";
|
||||||
|
import { getJsonSetting, saveSetting } from "./utils";
|
||||||
|
|
||||||
|
export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = {
|
||||||
|
enabled: false,
|
||||||
|
telegramBotToken: "",
|
||||||
|
telegramChatId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = {
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SYSTEM_CONFIG: SystemConfig = {
|
||||||
|
displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS,
|
||||||
|
adminSecurity: DEFAULT_ADMIN_SECURITY_CONFIG,
|
||||||
|
apiDocs: DEFAULT_API_DOCS_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBoolean = (value: unknown, fallback = false): boolean => {
|
||||||
|
return typeof value === "boolean" ? value : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeString = (value: unknown): string => {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeAdminSecurityConfig = (
|
||||||
|
value: Partial<AdminSecurityConfig> | null | undefined
|
||||||
|
): AdminSecurityConfig => {
|
||||||
|
return {
|
||||||
|
enabled: normalizeBoolean(value?.enabled),
|
||||||
|
telegramBotToken: normalizeString(value?.telegramBotToken),
|
||||||
|
telegramChatId: normalizeString(value?.telegramChatId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeApiDocsConfig = (
|
||||||
|
value: Partial<ApiDocsConfig> | null | undefined
|
||||||
|
): ApiDocsConfig => {
|
||||||
|
return {
|
||||||
|
enabled: normalizeBoolean(value?.enabled, DEFAULT_API_DOCS_CONFIG.enabled),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeSystemConfig = (
|
||||||
|
value: Partial<SystemConfig> | null | undefined
|
||||||
|
): SystemConfig => {
|
||||||
|
return {
|
||||||
|
displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals),
|
||||||
|
adminSecurity: normalizeAdminSecurityConfig(value?.adminSecurity),
|
||||||
|
apiDocs: normalizeApiDocsConfig(value?.apiDocs),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTelegramSecurityEnabled = (
|
||||||
|
value: AdminSecurityConfig | SystemConfig | null | undefined
|
||||||
|
): boolean => {
|
||||||
|
const security = value && "adminSecurity" in value
|
||||||
|
? value.adminSecurity
|
||||||
|
: value;
|
||||||
|
|
||||||
|
if (!security) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return security.enabled
|
||||||
|
&& security.telegramBotToken.length > 0
|
||||||
|
&& security.telegramChatId.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemConfig = async (
|
||||||
|
c: Context<HonoCustomType>
|
||||||
|
): Promise<SystemConfig> => {
|
||||||
|
const systemConfig = await getJsonSetting<SystemConfig>(
|
||||||
|
c,
|
||||||
|
CONSTANTS.SYSTEM_CONFIG_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
if (systemConfig) {
|
||||||
|
return normalizeSystemConfig(systemConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyBillingConfig = await getJsonSetting<BillingConfig>(
|
||||||
|
c,
|
||||||
|
CONSTANTS.BILLING_CONFIG_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeSystemConfig({
|
||||||
|
displayDecimals: legacyBillingConfig?.displayDecimals,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveSystemConfig = async (
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
value: Partial<SystemConfig> | null | undefined
|
||||||
|
): Promise<SystemConfig> => {
|
||||||
|
const config = normalizeSystemConfig(value);
|
||||||
|
|
||||||
|
await saveSetting(
|
||||||
|
c,
|
||||||
|
CONSTANTS.SYSTEM_CONFIG_KEY,
|
||||||
|
JSON.stringify(config)
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveSetting(
|
||||||
|
c,
|
||||||
|
CONSTANTS.BILLING_CONFIG_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
displayDecimals: config.displayDecimals,
|
||||||
|
} satisfies BillingConfig)
|
||||||
|
);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
@@ -120,6 +120,21 @@ type BillingConfig = {
|
|||||||
displayDecimals: number;
|
displayDecimals: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminSecurityConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
telegramBotToken: string;
|
||||||
|
telegramChatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiDocsConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemConfig = BillingConfig & {
|
||||||
|
adminSecurity: AdminSecurityConfig;
|
||||||
|
apiDocs: ApiDocsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
type ApiTokenData = {
|
type ApiTokenData = {
|
||||||
name: string;
|
name: string;
|
||||||
channel_keys: string[];
|
channel_keys: string[];
|
||||||
@@ -131,3 +146,23 @@ type RequestTrackingState = {
|
|||||||
upstreamStatus?: number;
|
upstreamStatus?: number;
|
||||||
errorSummary?: string;
|
errorSummary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminLoginChallengeRow = BaseDbRow & {
|
||||||
|
id: string;
|
||||||
|
code_hash: string;
|
||||||
|
expires_at: string;
|
||||||
|
attempts: number;
|
||||||
|
max_attempts: number;
|
||||||
|
request_ip: string;
|
||||||
|
request_country: string;
|
||||||
|
request_region: string;
|
||||||
|
request_city: string;
|
||||||
|
request_colo: string;
|
||||||
|
request_timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSessionRow = BaseDbRow & {
|
||||||
|
token_hash: string;
|
||||||
|
expires_at: string;
|
||||||
|
last_used_at: string;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user