feat(auth): 新增系统设置功能

- 实现管理员登录和验证码验证功能
- 添加管理员登录流程,支持通过 Telegram 验证
- 实现登录挑战和会话管理,确保安全性
- 更新前端状态管理,处理登录和登出逻辑
- 增加系统配置管理,支持 Telegram 安全设置
- 新增数据库表以存储登录挑战和会话信息
This commit is contained in:
Jeff Gu committed 2026-04-13 19:07:16 +08:00
1 parent 1c9bd91aed
commit bd76a89ba1
21 files changed
+1820 -78

No files matched your search

+9
View File
@@ -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
View File
@@ -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,
}),
}
+158 -27
View File
@@ -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>
+4 -2
View File
@@ -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?.();
};
+19
View File
@@ -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,
});
}
+70
View File
@@ -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";
};
+3 -2
View File
@@ -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) {
+62
View File
@@ -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;
};
+295
View File
@@ -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
View File
@@ -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 })
}
},
+23
View File
@@ -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
+235
View File
@@ -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;
}
}
+403
View File
@@ -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),
])
);
};
+7 -13
View File
@@ -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
View File
@@ -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)
+155
View File
@@ -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;
}
}
+1
View File
@@ -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",
}
+26
View File
@@ -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;
+16
View File
@@ -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
+121
View File
@@ -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;
};
Vendored
+35
View File
@@ -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;
}