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 { ApiTest } from "./pages/ApiTest";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { SystemSettings } from "./pages/SystemSettings";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -144,6 +145,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SystemSettings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
||||
+59
-10
@@ -9,9 +9,12 @@ import {
|
||||
AnalyticsBreakdownDimension,
|
||||
AnalyticsRange,
|
||||
BillingConfig,
|
||||
SystemConfig,
|
||||
AdminLoginResponse,
|
||||
UsageLogFilters,
|
||||
UsageLogSearchData,
|
||||
} from '@/types'
|
||||
import { clearAdminCredentials, getStoredAdminCredential } from '@/lib/admin-auth'
|
||||
|
||||
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 onError: ((error: ApiError) => void) | null = null
|
||||
|
||||
type RequestBehavior = {
|
||||
skipUnauthorizedHandler?: boolean
|
||||
}
|
||||
|
||||
export function setErrorHandlers(handlers: {
|
||||
onUnauthorized?: () => void
|
||||
onError?: (error: ApiError) => void
|
||||
@@ -48,16 +55,16 @@ async function requestInterceptor(config: RequestInit): Promise<RequestInit> {
|
||||
}
|
||||
|
||||
// 添加 admin token
|
||||
const adminToken = localStorage.getItem('adminToken')
|
||||
if (adminToken) {
|
||||
headers.set('x-admin-token', adminToken)
|
||||
const credential = getStoredAdminCredential()
|
||||
if (credential) {
|
||||
headers.set(credential.headerName, credential.token)
|
||||
}
|
||||
|
||||
return { ...config, headers }
|
||||
}
|
||||
|
||||
// 响应拦截器
|
||||
async function responseInterceptor(response: Response): Promise<any> {
|
||||
async function responseInterceptor(response: Response, behavior: RequestBehavior = {}): Promise<any> {
|
||||
if (!response.ok) {
|
||||
let errorMessage: string
|
||||
|
||||
@@ -84,10 +91,11 @@ async function responseInterceptor(response: Response): Promise<any> {
|
||||
// 处理特定状态码
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
// 未授权 - 清除 token 并触发回调
|
||||
localStorage.removeItem('adminToken')
|
||||
onUnauthorized?.()
|
||||
error.message = '认证已过期,请重新登录'
|
||||
if (!behavior.skipUnauthorizedHandler) {
|
||||
clearAdminCredentials()
|
||||
onUnauthorized?.()
|
||||
error.message = '认证已过期,请重新登录'
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
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}`
|
||||
|
||||
try {
|
||||
const config = await requestInterceptor(options)
|
||||
const response = await fetch(url, config)
|
||||
return responseInterceptor(response)
|
||||
return responseInterceptor(response, behavior)
|
||||
} catch (error) {
|
||||
// 处理网络错误
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
@@ -228,6 +240,20 @@ export const apiClient = {
|
||||
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
|
||||
getAnalyticsOverview: (range: AnalyticsRange) =>
|
||||
request<ApiResponse<AnalyticsOverviewData>>(`/api/admin/analytics/overview?range=${encodeURIComponent(range)}`, { method: 'GET' }),
|
||||
@@ -292,4 +318,27 @@ export const apiClient = {
|
||||
|
||||
// Auth check
|
||||
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 { useToast } from '@/components/ui/use-toast'
|
||||
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 {
|
||||
children: ReactNode
|
||||
@@ -24,31 +25,117 @@ interface AppLayoutProps {
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const navigate = useNavigate()
|
||||
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 [isLoading, setIsLoading] = useState(false)
|
||||
const [authStep, setAuthStep] = useState<'token' | 'verification'>('token')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isMobileNavOpen, setIsMobileNavOpen] = 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 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) => {
|
||||
e.preventDefault()
|
||||
setAuthError('')
|
||||
setIsLoading(true)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await login(adminToken)
|
||||
setAdminToken('')
|
||||
setIsMobileNavOpen(false)
|
||||
addToast('登录成功', 'success')
|
||||
navigate('/dashboard', { replace: true })
|
||||
if (authStep === 'token') {
|
||||
const loginResult = await startLogin(adminToken)
|
||||
|
||||
if (loginResult.requiresVerification) {
|
||||
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) {
|
||||
setAuthError(error instanceof Error ? error.message : '管理员令牌无效')
|
||||
} 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 (
|
||||
<div className="flex">
|
||||
{isAuthenticated && !isAuthLoading && (
|
||||
@@ -108,28 +195,58 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
)}
|
||||
|
||||
{/* Auth Dialog */}
|
||||
<Dialog open={showAuthModal} onOpenChange={(open) => !open && closeAuthModal()}>
|
||||
<Dialog open={showAuthModal} onOpenChange={(open) => !open && handleCloseAuthModal()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理员身份验证</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入管理员令牌以访问管理功能
|
||||
{authStep === 'token'
|
||||
? '请输入管理员令牌以访问管理功能'
|
||||
: '验证码已发送到 Telegram,请输入 6 位数字验证码完成登录'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleAuthSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="block" htmlFor="adminToken">管理员令牌</Label>
|
||||
<Input
|
||||
id="adminToken"
|
||||
type="password"
|
||||
placeholder="请输入管理员令牌"
|
||||
value={adminToken}
|
||||
onChange={(e) => setAdminToken(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{authStep === 'token' ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="block" htmlFor="adminToken">管理员令牌</Label>
|
||||
<Input
|
||||
id="adminToken"
|
||||
type="password"
|
||||
placeholder="请输入管理员令牌"
|
||||
value={adminToken}
|
||||
onChange={(e) => setAdminToken(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</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 && (
|
||||
<Alert variant="destructive">
|
||||
@@ -142,13 +259,27 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeAuthModal}
|
||||
onClick={authStep === 'verification'
|
||||
? () => {
|
||||
setAuthStep('token')
|
||||
setVerificationCode('')
|
||||
setChallengeId(null)
|
||||
setChallengeExpiresAt(null)
|
||||
setAuthError('')
|
||||
}
|
||||
: handleCloseAuthModal}
|
||||
className='mr-0'
|
||||
>
|
||||
取消
|
||||
{authStep === 'verification' ? '返回上一步' : '取消'}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? '验证中...' : '登录'}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? authStep === 'verification'
|
||||
? '验证中...'
|
||||
: '发送中...'
|
||||
: authStep === 'verification'
|
||||
? '验证并登录'
|
||||
: '登录'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
X,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
SlidersHorizontal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
@@ -31,6 +32,7 @@ const navItems: NavItem[] = [
|
||||
{ title: "渠道管理", href: "/channels", icon: LinkIcon },
|
||||
{ title: "令牌管理", href: "/tokens", icon: Key },
|
||||
{ title: "定价管理", href: "/pricing", icon: DollarSign },
|
||||
{ title: "系统设置", href: "/settings", icon: SlidersHorizontal },
|
||||
{ title: "API 测试", href: "/api-test", icon: TestTube2 },
|
||||
];
|
||||
|
||||
@@ -77,8 +79,8 @@ export function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/", { replace: true });
|
||||
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_VERSION = 1;
|
||||
const ADMIN_TOKEN_KEY = "adminToken";
|
||||
|
||||
export interface LocalCacheEntry<T> {
|
||||
version: number;
|
||||
@@ -15,7 +16,7 @@ const getAdminTokenScope = (): string => {
|
||||
return "server";
|
||||
}
|
||||
|
||||
const adminToken = window.localStorage.getItem(ADMIN_TOKEN_KEY) || "anonymous";
|
||||
const adminToken = getAdminCredentialScopeToken();
|
||||
let hash = 2166136261;
|
||||
|
||||
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 { apiClient } from '@/api/client'
|
||||
import { clearScopedCacheByPrefix } from '@/lib/local-cache'
|
||||
import { type AdminLoginResponse } from '@/types'
|
||||
import {
|
||||
clearAdminCredentials,
|
||||
getStoredAdminCredential,
|
||||
storeAdminSessionToken,
|
||||
} from '@/lib/admin-auth'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
showAuthModal: boolean
|
||||
login: (token: string) => Promise<void>
|
||||
logout: () => void
|
||||
startLogin: (token: string) => Promise<AdminLoginResponse>
|
||||
verifyLogin: (challengeId: string, code: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
checkAuth: () => Promise<void>
|
||||
openAuthModal: () => void
|
||||
closeAuthModal: () => void
|
||||
@@ -20,18 +27,31 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
error: null,
|
||||
showAuthModal: false,
|
||||
|
||||
login: async (token: string) => {
|
||||
startLogin: async (token: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
// Store token
|
||||
localStorage.setItem('adminToken', token)
|
||||
const response = await apiClient.startAdminLogin(token)
|
||||
const loginResult = response.data as AdminLoginResponse
|
||||
|
||||
// Verify token by making a test request
|
||||
await apiClient.checkAuth()
|
||||
if (!loginResult) {
|
||||
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) {
|
||||
localStorage.removeItem('adminToken')
|
||||
clearAdminCredentials()
|
||||
set({
|
||||
isAuthenticated: 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('usage-logs:')
|
||||
localStorage.removeItem('adminToken')
|
||||
clearAdminCredentials()
|
||||
set({ isAuthenticated: false, error: null })
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = localStorage.getItem('adminToken')
|
||||
if (!token) {
|
||||
if (!getStoredAdminCredential()) {
|
||||
set({ isAuthenticated: false, isLoading: false })
|
||||
return
|
||||
}
|
||||
@@ -60,7 +109,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
await apiClient.checkAuth()
|
||||
set({ isAuthenticated: true, isLoading: false })
|
||||
} catch (error) {
|
||||
localStorage.removeItem('adminToken')
|
||||
clearAdminCredentials()
|
||||
set({ isAuthenticated: false, isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -42,6 +42,29 @@ export interface BillingConfig {
|
||||
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 {
|
||||
input?: 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 { z } from "zod";
|
||||
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
import { BillingConfig, normalizeBillingConfig } from "../billing";
|
||||
import { getJsonSetting, saveSetting } from "../utils";
|
||||
import { getSystemConfig, saveSystemConfig } from "../system-config";
|
||||
|
||||
const billingConfigSchema = z.object({
|
||||
displayDecimals: z.number().int().min(0).max(9),
|
||||
@@ -22,14 +21,9 @@ export class BillingConfigGetEndpoint extends OpenAPIRoute {
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const billingConfig = await getJsonSetting<BillingConfig>(
|
||||
c,
|
||||
CONSTANTS.BILLING_CONFIG_KEY
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizeBillingConfig(billingConfig),
|
||||
data: normalizeBillingConfig(await getSystemConfig(c)),
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -55,13 +49,13 @@ export class BillingConfigUpdateEndpoint extends OpenAPIRoute {
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const body = await c.req.json<BillingConfig>();
|
||||
const currentSystemConfig = await getSystemConfig(c);
|
||||
const config = normalizeBillingConfig(body);
|
||||
|
||||
await saveSetting(
|
||||
c,
|
||||
CONSTANTS.BILLING_CONFIG_KEY,
|
||||
JSON.stringify(config)
|
||||
);
|
||||
await saveSystemConfig(c, {
|
||||
...currentSystemConfig,
|
||||
displayDecimals: config.displayDecimals,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
+56
-10
@@ -14,6 +14,16 @@ import {
|
||||
import {
|
||||
BillingConfigGetEndpoint, BillingConfigUpdateEndpoint
|
||||
} from "./billing_api"
|
||||
import {
|
||||
SystemConfigGetEndpoint,
|
||||
SystemConfigUpdateEndpoint,
|
||||
TelegramTestMessageEndpoint,
|
||||
} from "./system_api"
|
||||
import {
|
||||
AdminLoginStartEndpoint,
|
||||
AdminLoginVerifyEndpoint,
|
||||
AdminLogoutEndpoint,
|
||||
} from "./auth_api"
|
||||
import {
|
||||
AnalyticsOverviewEndpoint,
|
||||
AnalyticsTrendEndpoint,
|
||||
@@ -21,28 +31,61 @@ import {
|
||||
AnalyticsEventsEndpoint,
|
||||
UsageLogSearchEndpoint,
|
||||
} from "./analytics_api"
|
||||
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"
|
||||
import { getAdminSessionHeaderName, validateAdminSession } from "./auth_shared"
|
||||
|
||||
const app = new Hono<HonoCustomType>()
|
||||
export const api = fromHono(app)
|
||||
|
||||
// Authentication Middleware - using environment variable
|
||||
app.use('/api/admin/*', async (c, next) => {
|
||||
const token = c.req.header('x-admin-token');
|
||||
const adminToken = c.env.ADMIN_TOKEN;
|
||||
|
||||
if (!token || !adminToken || token !== adminToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
const PUBLIC_AUTH_ROUTES = new Set([
|
||||
"/api/admin/auth/login",
|
||||
"/api/admin/auth/verify",
|
||||
]);
|
||||
|
||||
app.use('/api/admin/*', async (c, next) => {
|
||||
await db.ensureReady(c);
|
||||
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)
|
||||
|
||||
// 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.post("/api/admin/channel/:key", ChannelUpsertEndpoint)
|
||||
api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint)
|
||||
@@ -59,6 +102,9 @@ api.get("/api/admin/pricing", PricingGetEndpoint)
|
||||
api.post("/api/admin/pricing", PricingUpdateEndpoint)
|
||||
api.get("/api/admin/billing/config", BillingConfigGetEndpoint)
|
||||
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
|
||||
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",
|
||||
MODEL_PRICING_KEY: "MODEL_PRICING_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,
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { cors } from 'hono/cors';
|
||||
import { api as providerApi } from './providers'
|
||||
import { api as adminApi } from './admin'
|
||||
import { fromHono } from 'chanfana';
|
||||
import db from './db';
|
||||
import { getSystemConfig } from './system-config';
|
||||
|
||||
const FRONTEND_ENTRY = '/'
|
||||
const FRONTEND_STATIC_PATHS = new Set([
|
||||
@@ -22,6 +24,11 @@ const FRONTEND_STATIC_PREFIXES = [
|
||||
'/src/',
|
||||
]
|
||||
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>
|
||||
|
||||
function isApiRequest(pathname: string): boolean {
|
||||
@@ -96,6 +103,15 @@ openapi.use('/*', cors());
|
||||
app.use('*', async (c, next) => {
|
||||
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)) {
|
||||
await next()
|
||||
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;
|
||||
}
|
||||
|
||||
type AdminSecurityConfig = {
|
||||
enabled: boolean;
|
||||
telegramBotToken: string;
|
||||
telegramChatId: string;
|
||||
}
|
||||
|
||||
type ApiDocsConfig = {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type SystemConfig = BillingConfig & {
|
||||
adminSecurity: AdminSecurityConfig;
|
||||
apiDocs: ApiDocsConfig;
|
||||
}
|
||||
|
||||
type ApiTokenData = {
|
||||
name: string;
|
||||
channel_keys: string[];
|
||||
@@ -131,3 +146,23 @@ type RequestTrackingState = {
|
||||
upstreamStatus?: number;
|
||||
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