From 7a19d88dc1377b1a4c228cce21f5758eeda29f25 Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Mon, 13 Apr 2026 21:38:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E5=A2=9E=E5=8A=A0=20Telegram?= =?UTF-8?q?=20=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/client.ts | 2 +- frontend/src/lib/system-config.ts | 24 +++ frontend/src/pages/Pricing.tsx | 162 ++++++++++++++- frontend/src/pages/SystemSettings.tsx | 288 ++++++++++++++++++++------ frontend/src/types/index.ts | 2 + src/admin/auth_shared.ts | 28 ++- src/admin/system_api.ts | 18 +- src/system-config.ts | 46 +++- type.d.ts | 2 + 9 files changed, 472 insertions(+), 100 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e042009..47f323f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -249,7 +249,7 @@ export const apiClient = { }), sendTelegramTestMessage: (payload: Pick) => - request>('/api/admin/system/telegram/test', { + request>>('/api/admin/system/telegram/test', { method: 'POST', body: JSON.stringify(payload), }), diff --git a/frontend/src/lib/system-config.ts b/frontend/src/lib/system-config.ts index 851d814..87ea319 100644 --- a/frontend/src/lib/system-config.ts +++ b/frontend/src/lib/system-config.ts @@ -8,6 +8,8 @@ export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = { enabled: false, telegramBotToken: "", telegramChatId: "", + verifiedFingerprint: "", + verifiedAt: null, }; export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = { @@ -36,6 +38,10 @@ export const normalizeAdminSecurityConfig = (value?: Partial 0 + ? value.verifiedAt.trim() + : null, }; }; @@ -60,3 +66,21 @@ export const isTelegramSecurityEnabled = (config?: Partial && typeof config.telegramChatId === "string" && config.telegramChatId.trim().length > 0; }; + +export const isTelegramSecurityVerified = (config?: Partial | null): boolean => { + return typeof config?.verifiedFingerprint === "string" + && config.verifiedFingerprint.trim().length > 0 + && typeof config?.verifiedAt === "string" + && config.verifiedAt.trim().length > 0; +}; + +export const clearTelegramSecurityVerification = ( + config: AdminSecurityConfig, +): AdminSecurityConfig => { + return { + ...config, + enabled: false, + verifiedFingerprint: "", + verifiedAt: null, + }; +}; diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx index 8e93ca4..d814ff5 100644 --- a/frontend/src/pages/Pricing.tsx +++ b/frontend/src/pages/Pricing.tsx @@ -1,4 +1,4 @@ -import { useDeferredValue, useEffect, useMemo, useState } from "react"; +import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiClient } from "@/api/client"; import { Channel, PricingConfig, PricingModel, Token } from "@/types"; @@ -45,6 +45,9 @@ type TokenRef = { const PRICING_DECIMALS = 6; const PRICING_INPUT_STEP = "0.000001"; const DEFAULT_REQUEST_VALUE = 1; +const AUTO_SAVE_DELAY_MS = 800; +type SaveSource = "auto" | "manual"; +type SaveState = "idle" | "saving" | "saved" | "error" | "invalid"; type BuildPricingRowsOptions = { sortConfiguredFirst?: boolean; }; @@ -91,6 +94,17 @@ const normalizePricingNumber = (value: unknown, fallback = 0): number => { return Number(parsed.toFixed(PRICING_DECIMALS)); }; +const formatSaveTime = (value: Date | null): string => { + if (!value) { + return "--:--:--"; + } + + const hours = String(value.getHours()).padStart(2, "0"); + const minutes = String(value.getMinutes()).padStart(2, "0"); + const seconds = String(value.getSeconds()).padStart(2, "0"); + return `${hours}:${minutes}:${seconds}`; +}; + const normalizePricingEntry = (pricing?: Partial | null): PricingRow => ({ model: "", input: normalizePricingNumber(pricing?.input, 0), @@ -311,10 +325,15 @@ export function Pricing() { const [selectedChannels, setSelectedChannels] = useState([]); const [selectedTokens, setSelectedTokens] = useState([]); const [selectedTypes, setSelectedTypes] = useState([]); + const [saveState, setSaveState] = useState("idle"); + const [lastSavedAt, setLastSavedAt] = useState(null); const deferredSearchQuery = useDeferredValue(searchQuery); const { addToast } = useToast(); const queryClient = useQueryClient(); + const autoSaveTimerRef = useRef | null>(null); + const hasHydratedRef = useRef(false); + const lastSavedSignatureRef = useRef(JSON.stringify({})); const pricingQuery = useQuery({ queryKey: ["pricing"], @@ -354,16 +373,40 @@ export function Pricing() { useEffect(() => { setPricingRows(baseRows); setJsonValue(JSON.stringify(pricingQuery.data || {}, null, 2)); + lastSavedSignatureRef.current = JSON.stringify(normalizePricingConfig(pricingQuery.data || {})); + hasHydratedRef.current = true; + setSaveState("idle"); + setLastSavedAt(new Date()); }, [baseRows, pricingQuery.data]); const saveMutation = useMutation({ - mutationFn: async (config: PricingConfig) => apiClient.savePricing(config), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["pricing"] }); - addToast("定价配置已保存", "success"); + mutationFn: async ({ config }: { config: PricingConfig; source: SaveSource; signature: string }) => + apiClient.savePricing(config), + onMutate: ({ source }) => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + + if (source === "auto") { + setSaveState("saving"); + } }, - onError: (error: Error) => { - addToast(`保存失败:${error.message}`, "error"); + onSuccess: (_response, variables) => { + lastSavedSignatureRef.current = variables.signature; + queryClient.setQueryData(["pricing"], variables.config); + setSaveState("saved"); + setLastSavedAt(new Date()); + + if (variables.source === "manual") { + addToast("定价配置已保存", "success"); + } + }, + onError: (error: Error, variables) => { + setSaveState("error"); + if (variables.source === "manual") { + addToast(`保存失败:${error.message}`, "error"); + } }, }); @@ -531,16 +574,102 @@ export function Pricing() { ); }; + const normalizedJsonConfig = useMemo(() => { + if (editMode !== "json") { + return null; + } + + try { + return normalizePricingConfig(JSON.parse(jsonValue) as PricingConfig); + } catch { + return null; + } + }, [editMode, jsonValue]); + + const currentPricingConfig = useMemo(() => { + return editMode === "cards" + ? buildConfigFromRows(pricingRows) + : normalizedJsonConfig; + }, [editMode, normalizedJsonConfig, pricingRows]); + + const currentPricingSignature = useMemo(() => { + return currentPricingConfig ? JSON.stringify(currentPricingConfig) : null; + }, [currentPricingConfig]); + + useEffect(() => { + if (!hasHydratedRef.current) { + return; + } + + if (editMode === "json" && normalizedJsonConfig === null) { + setSaveState("invalid"); + return; + } + + if (!currentPricingSignature) { + return; + } + + if (saveMutation.isPending) { + return; + } + + if (currentPricingSignature === lastSavedSignatureRef.current) { + if (saveState === "saving") { + setSaveState("saved"); + } + return; + } + + autoSaveTimerRef.current = setTimeout(() => { + saveMutation.mutate({ + config: currentPricingConfig!, + source: "auto", + signature: currentPricingSignature, + }); + }, AUTO_SAVE_DELAY_MS); + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + }; + }, [ + currentPricingConfig, + currentPricingSignature, + editMode, + normalizedJsonConfig, + saveMutation, + saveState, + ]); + const handleSave = () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + if (editMode === "cards") { - saveMutation.mutate(buildConfigFromRows(pricingRows)); + const config = buildConfigFromRows(pricingRows); + saveMutation.mutate({ + config, + source: "manual", + signature: JSON.stringify(config), + }); return; } try { const parsed = JSON.parse(jsonValue) as PricingConfig; - saveMutation.mutate(normalizePricingConfig(parsed)); + const config = normalizePricingConfig(parsed); + saveMutation.mutate({ + config, + source: "manual", + signature: JSON.stringify(config), + }); } catch { + setSaveState("invalid"); addToast("JSON 格式错误", "error"); } }; @@ -570,13 +699,26 @@ export function Pricing() { }; const isFetching = pricingQuery.isFetching || channelsQuery.isFetching || tokensQuery.isFetching; + const saveHint = (() => { + switch (saveState) { + case "saving": + return "自动保存中..."; + case "error": + return "自动保存失败"; + case "invalid": + return "JSON 无法自动保存"; + default: + return `保存于 ${formatSaveTime(lastSavedAt)}`; + } + })(); return ( + {saveHint} diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index ce67b39..d1f2d2c 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Bot, Check, FileText, Globe, Shield, SlidersHorizontal } from "lucide-react"; @@ -10,10 +10,21 @@ 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"; +import { + DEFAULT_SYSTEM_CONFIG, + PRECISION_OPTIONS, + clearTelegramSecurityVerification, + isTelegramSecurityVerified, + normalizeSystemConfig, +} from "@/lib/system-config"; +import type { SystemConfig } from "@/types"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; +const AUTO_SAVE_DELAY_MS = 800; + +type SaveSource = "auto" | "manual"; +type SaveState = "idle" | "saving" | "saved" | "error"; const buildAbsoluteUrl = (pathname: string): string => { if (typeof window === "undefined") { @@ -21,42 +32,115 @@ const buildAbsoluteUrl = (pathname: string): string => { } const baseUrl = API_BASE_URL ? new URL(API_BASE_URL, window.location.origin) : new URL(window.location.origin); - return new URL(pathname, baseUrl).toString(); }; +const buildSystemConfigSignature = (config: SystemConfig): string => { + return JSON.stringify(normalizeSystemConfig(config)); +}; + +const formatSaveTime = (value: Date | null): string => { + if (!value) { + return "--:--:--"; + } + + const hours = String(value.getHours()).padStart(2, "0"); + const minutes = String(value.getMinutes()).padStart(2, "0"); + const seconds = String(value.getSeconds()).padStart(2, "0"); + return `${hours}:${minutes}:${seconds}`; +}; + export function SystemSettings() { const [systemConfig, setSystemConfig] = useState(DEFAULT_SYSTEM_CONFIG); + const [saveState, setSaveState] = useState("idle"); + const [lastSavedAt, setLastSavedAt] = useState(null); const { addToast } = useToast(); const queryClient = useQueryClient(); const systemConfigQuery = useSystemConfig(); + const autoSaveTimerRef = useRef | null>(null); + const hasHydratedRef = useRef(false); + const lastSavedSignatureRef = useRef(buildSystemConfigSignature(DEFAULT_SYSTEM_CONFIG)); + const currentSignatureRef = useRef(buildSystemConfigSignature(DEFAULT_SYSTEM_CONFIG)); + + const normalizedSystemConfig = useMemo(() => normalizeSystemConfig(systemConfig), [systemConfig]); + const currentSignature = useMemo(() => buildSystemConfigSignature(normalizedSystemConfig), [normalizedSystemConfig]); useEffect(() => { - if (systemConfigQuery.data) { - setSystemConfig(normalizeSystemConfig(systemConfigQuery.data)); + currentSignatureRef.current = currentSignature; + }, [currentSignature]); + + useEffect(() => { + if (!systemConfigQuery.isFetched || !systemConfigQuery.data) { + return; } - }, [systemConfigQuery.data]); + + const normalized = normalizeSystemConfig(systemConfigQuery.data); + lastSavedSignatureRef.current = buildSystemConfigSignature(normalized); + hasHydratedRef.current = true; + setSaveState("idle"); + setLastSavedAt(new Date()); + setSystemConfig(normalized); + }, [systemConfigQuery.data, systemConfigQuery.isFetched]); 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"); + mutationFn: async ({ config }: { config: SystemConfig; source: SaveSource; signature: string }) => + apiClient.saveSystemConfig(config), + onMutate: ({ source }) => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + + if (source === "auto") { + setSaveState("saving"); + } }, - onError: (error: Error) => { - addToast(`保存失败:${error.message}`, "error"); + onSuccess: (response, variables) => { + const savedConfig = normalizeSystemConfig((response.data as SystemConfig | undefined) ?? variables.config); + const savedSignature = buildSystemConfigSignature(savedConfig); + + lastSavedSignatureRef.current = savedSignature; + queryClient.setQueryData(SYSTEM_CONFIG_QUERY_KEY, savedConfig); + queryClient.setQueryData(BILLING_CONFIG_QUERY_KEY, { + displayDecimals: savedConfig.displayDecimals, + }); + + if (variables.signature === currentSignatureRef.current) { + setSystemConfig(savedConfig); + } + + setSaveState("saved"); + setLastSavedAt(new Date()); + + if (variables.source === "manual") { + addToast("系统设置已保存", "success"); + } + }, + onError: (error: Error, variables) => { + setSaveState("error"); + if (variables.source === "manual") { + addToast(`保存失败:${error.message}`, "error"); + } }, }); const telegramTestMutation = useMutation({ mutationFn: async () => apiClient.sendTelegramTestMessage({ - telegramBotToken: systemConfig.adminSecurity.telegramBotToken, - telegramChatId: systemConfig.adminSecurity.telegramChatId, + telegramBotToken: normalizedSystemConfig.adminSecurity.telegramBotToken, + telegramChatId: normalizedSystemConfig.adminSecurity.telegramChatId, }), - onSuccess: () => { + onSuccess: (response) => { + const verification = response.data; + setSystemConfig((current) => ({ + ...current, + adminSecurity: { + ...current.adminSecurity, + verifiedFingerprint: verification?.verifiedFingerprint || "", + verifiedAt: verification?.verifiedAt || null, + }, + })); addToast("测试消息已发送,请到 Telegram 查收", "success"); }, onError: (error: Error) => { @@ -64,6 +148,38 @@ export function SystemSettings() { }, }); + useEffect(() => { + if (!hasHydratedRef.current) { + return; + } + + if (saveSystemConfigMutation.isPending) { + return; + } + + if (currentSignature === lastSavedSignatureRef.current) { + if (saveState === "saving") { + setSaveState("saved"); + } + return; + } + + autoSaveTimerRef.current = setTimeout(() => { + saveSystemConfigMutation.mutate({ + config: normalizedSystemConfig, + source: "auto", + signature: currentSignature, + }); + }, AUTO_SAVE_DELAY_MS); + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + }; + }, [currentSignature, normalizedSystemConfig, saveState, saveSystemConfigMutation]); + const docsLinks = useMemo( () => [ { label: "Swagger UI", href: buildAbsoluteUrl("/api/docs") }, @@ -73,43 +189,95 @@ export function SystemSettings() { [], ); - const currentPrecisionOption = PRECISION_OPTIONS.find((option) => option.value === systemConfig.displayDecimals); + const currentPrecisionOption = PRECISION_OPTIONS.find( + (option) => option.value === normalizedSystemConfig.displayDecimals, + ); const telegramConfigComplete = - systemConfig.adminSecurity.telegramBotToken.trim().length > 0 && - systemConfig.adminSecurity.telegramChatId.trim().length > 0; - const securityEnabled = systemConfig.adminSecurity.enabled && telegramConfigComplete; + normalizedSystemConfig.adminSecurity.telegramBotToken.trim().length > 0 && + normalizedSystemConfig.adminSecurity.telegramChatId.trim().length > 0; + const telegramSecurityVerified = isTelegramSecurityVerified(normalizedSystemConfig.adminSecurity); + + const saveHint = (() => { + switch (saveState) { + case "saving": + return "自动保存中..."; + case "error": + return "自动保存失败"; + default: + return `保存于 ${formatSaveTime(lastSavedAt)}`; + } + })(); + + const updateTelegramField = (field: "telegramBotToken" | "telegramChatId", value: string) => { + setSystemConfig((current) => { + const previousValue = current.adminSecurity[field]; + if (previousValue === value) { + return current; + } + + return { + ...current, + adminSecurity: clearTelegramSecurityVerification({ + ...current.adminSecurity, + [field]: value, + }), + }; + }); + }; + + const handleManualSave = () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + + saveSystemConfigMutation.mutate({ + config: normalizedSystemConfig, + source: "manual", + signature: currentSignature, + }); + }; return ( saveSystemConfigMutation.mutate()} - disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading} - > - - 保存 - +
+ {saveHint} + +
} >
-
+

安全设置

-

- 绑定 Telegram 后自动开启登录验证和通知功能。 -

+ + {!telegramSecurityVerified && telegramConfigComplete ? ( +

+ 当前 Telegram 配置尚未验证,安全开关保持关闭。 +

+ ) : ( +

Telegram 配置已验证,安全开关已开启。

+ )}
setSystemConfig((current) => ({ ...current, @@ -133,16 +301,8 @@ export function SystemSettings() { id="telegramBotToken" type="password" placeholder="123456789:AA..." - value={systemConfig.adminSecurity.telegramBotToken} - onChange={(event) => - setSystemConfig((current) => ({ - ...current, - adminSecurity: { - ...current.adminSecurity, - telegramBotToken: event.target.value, - }, - })) - } + value={normalizedSystemConfig.adminSecurity.telegramBotToken} + onChange={(event) => updateTelegramField("telegramBotToken", event.target.value)} />
@@ -153,16 +313,8 @@ export function SystemSettings() { - setSystemConfig((current) => ({ - ...current, - adminSecurity: { - ...current.adminSecurity, - telegramChatId: event.target.value, - }, - })) - } + value={normalizedSystemConfig.adminSecurity.telegramChatId} + onChange={(event) => updateTelegramField("telegramChatId", event.target.value)} />
@@ -176,10 +328,10 @@ export function SystemSettings() {

{!telegramConfigComplete - ? "请确保 Telegram Bot Token 和 Chat ID 已正确填写,测试通过后将启用登录验证和通知功能。" - : securityEnabled - ? "当前已开启 Telegram 登录验证,管理员必须输入验证码才能登录。" - : "Telegram 已配置但尚未启用。可以先发送测试消息确认,再决定是否开启登录验证。"} + ? "请先填写完整的 Bot Token 和 Chat ID。" + : telegramSecurityVerified + ? `最近一次验证通过时间:${normalizedSystemConfig.adminSecurity.verifiedAt || "--"}` + : "测试通过后,安全开关才会解锁。"}

@@ -199,22 +351,22 @@ export function SystemSettings() {
-
+

显示精度设置

- 页面显示金额保留 {systemConfig.displayDecimals} 位小数, 示例:$ - {(0).toFixed(systemConfig.displayDecimals)} + 页面显示金额保留 {normalizedSystemConfig.displayDecimals} 位小数,示例:$ + {(0).toFixed(normalizedSystemConfig.displayDecimals)} {currentPrecisionOption ? `(当前:${currentPrecisionOption.label})` : ""}

-
+
{PRECISION_OPTIONS.map((option) => { - const isActive = systemConfig.displayDecimals === option.value; + const isActive = normalizedSystemConfig.displayDecimals === option.value; return (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a950a7e..060c235 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -46,6 +46,8 @@ export interface AdminSecurityConfig { enabled: boolean telegramBotToken: string telegramChatId: string + verifiedFingerprint: string + verifiedAt: string | null } export interface ApiDocsConfig { diff --git a/src/admin/auth_shared.ts b/src/admin/auth_shared.ts index 2977178..9a22769 100644 --- a/src/admin/auth_shared.ts +++ b/src/admin/auth_shared.ts @@ -39,8 +39,8 @@ const formatTimestampInTimezone = (date: Date, timezone: string): string => { 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})`; + // (${timezone}) + return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second}`; }; const getRequestMetadata = (c: Context): LoginRequestMetadata => { @@ -134,14 +134,13 @@ const buildBaseMessageLines = ( metadata: LoginRequestMetadata, occurredAt: Date ): Array => { - const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE; + // 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}` : ""}`, + // `时间(客户端):${formatTimestampInTimezone(occurredAt, clientTimezone)}`, + `时间:${formatTimestampInTimezone(occurredAt, DEFAULT_SYSTEM_TIMEZONE)}`, + `位置:${metadata.clientIp || "未知 IP"} (${getLocationText(metadata)})`, + // `节点:${metadata.colo || "未知节点"}${metadata.timezone ? ` · ${metadata.timezone}` : ""}`, ]; }; @@ -357,9 +356,8 @@ export const sendAdminLoginCodeNotification = async ( await sendTelegramMessage( securityConfig, createTelegramMessage([ - "【One API 管理后台】", - `登录验证码:${code}`, - `过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`, + "🔐 One API Workers 登录验证", + `${code} 验证码 5 分钟内有效,过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`, ...buildBaseMessageLines(metadata, occurredAt), ]) ); @@ -377,8 +375,8 @@ export const sendAdminLoginResultNotification = async ( await sendTelegramMessage( securityConfig, createTelegramMessage([ - "【One API 管理后台】", - `登录${status === "success" ? "成功" : "失败"}`, + "🔐 One API Workers 登录提醒", + `您的账户在新设备上登录${status === "success" ? "成功" : "失败"}`, reason ? `原因:${reason}` : "", ...buildBaseMessageLines(metadata, occurredAt), ]) @@ -395,8 +393,8 @@ export const sendTelegramTestNotification = async ( await sendTelegramMessage( securityConfig, createTelegramMessage([ - "【One API 管理后台】", - "Telegram 绑定测试成功", + "🔐 One API Workers Telegram 测试", + "绑定测试成功", ...buildBaseMessageLines(metadata, occurredAt), ]) ); diff --git a/src/admin/system_api.ts b/src/admin/system_api.ts index 410c4a5..d05eed4 100644 --- a/src/admin/system_api.ts +++ b/src/admin/system_api.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; import { + buildTelegramVerificationFingerprint, getSystemConfig, isTelegramSecurityEnabled, normalizeAdminSecurityConfig, @@ -16,6 +17,8 @@ const adminSecurityConfigSchema = z.object({ enabled: z.boolean(), telegramBotToken: z.string(), telegramChatId: z.string(), + verifiedFingerprint: z.string(), + verifiedAt: z.nullable(z.string()), }); const apiDocsConfigSchema = z.object({ @@ -33,6 +36,11 @@ const telegramTestRequestSchema = z.object({ telegramChatId: z.string().trim().min(1, "Chat ID 不能为空"), }); +const telegramTestResponseSchema = z.object({ + verifiedFingerprint: z.string(), + verifiedAt: z.string(), +}); + const ensureSystemConfigValid = (config: SystemConfig) => { if ( config.adminSecurity.enabled @@ -114,7 +122,7 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute { }, }, responses: { - ...CommonSuccessfulResponse(z.boolean()), + ...CommonSuccessfulResponse(telegramTestResponseSchema), ...CommonErrorResponse, }, }; @@ -148,7 +156,13 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute { return { success: true, - data: true, + data: { + verifiedFingerprint: buildTelegramVerificationFingerprint( + securityConfig.telegramBotToken, + securityConfig.telegramChatId + ), + verifiedAt: new Date().toISOString(), + }, message: "Telegram test message sent successfully", } as CommonResponse; } diff --git a/src/system-config.ts b/src/system-config.ts index fbe9a93..d9b8d93 100644 --- a/src/system-config.ts +++ b/src/system-config.ts @@ -11,6 +11,8 @@ export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = { enabled: false, telegramBotToken: "", telegramChatId: "", + verifiedFingerprint: "", + verifiedAt: null, }; export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = { @@ -31,13 +33,51 @@ const normalizeString = (value: unknown): string => { return typeof value === "string" ? value.trim() : ""; }; +const normalizeNullableString = (value: unknown): string | null => { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +}; + +export const buildTelegramVerificationFingerprint = ( + telegramBotToken: string, + telegramChatId: string +): string => { + const source = `${telegramBotToken.trim()}::${telegramChatId.trim()}`; + let hash = 2166136261; + + for (let index = 0; index < source.length; index += 1) { + hash ^= source.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return source ? `tgv1-${(hash >>> 0).toString(16)}` : ""; +}; + export const normalizeAdminSecurityConfig = ( value: Partial | null | undefined ): AdminSecurityConfig => { + const telegramBotToken = normalizeString(value?.telegramBotToken); + const telegramChatId = normalizeString(value?.telegramChatId); + const expectedFingerprint = buildTelegramVerificationFingerprint( + telegramBotToken, + telegramChatId + ); + const verifiedFingerprint = normalizeString(value?.verifiedFingerprint); + const verifiedAt = normalizeNullableString(value?.verifiedAt); + const isVerified = Boolean( + expectedFingerprint + && verifiedFingerprint + && verifiedFingerprint === expectedFingerprint + && verifiedAt + ); + return { - enabled: normalizeBoolean(value?.enabled), - telegramBotToken: normalizeString(value?.telegramBotToken), - telegramChatId: normalizeString(value?.telegramChatId), + enabled: normalizeBoolean(value?.enabled) && isVerified, + telegramBotToken, + telegramChatId, + verifiedFingerprint: isVerified ? verifiedFingerprint : "", + verifiedAt: isVerified ? verifiedAt : null, }; }; diff --git a/type.d.ts b/type.d.ts index bf78fe4..f7958c0 100644 --- a/type.d.ts +++ b/type.d.ts @@ -124,6 +124,8 @@ type AdminSecurityConfig = { enabled: boolean; telegramBotToken: string; telegramChatId: string; + verifiedFingerprint: string; + verifiedAt: string | null; } type ApiDocsConfig = {