feat(security): 增加 Telegram 验证功能及相关配置

This commit is contained in:
Jeff Gu committed 2026-04-13 21:38:11 +08:00
1 parent bd76a89ba1
commit 7a19d88dc1
9 files changed
+472 -100

No files matched your search

+1 -1
View File
@@ -249,7 +249,7 @@ export const apiClient = {
}), }),
sendTelegramTestMessage: (payload: Pick<SystemConfig['adminSecurity'], 'telegramBotToken' | 'telegramChatId'>) => sendTelegramTestMessage: (payload: Pick<SystemConfig['adminSecurity'], 'telegramBotToken' | 'telegramChatId'>) =>
request<ApiResponse<boolean>>('/api/admin/system/telegram/test', { request<ApiResponse<Pick<SystemConfig['adminSecurity'], 'verifiedFingerprint' | 'verifiedAt'>>>('/api/admin/system/telegram/test', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}), }),
+24
View File
@@ -8,6 +8,8 @@ export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = {
enabled: false, enabled: false,
telegramBotToken: "", telegramBotToken: "",
telegramChatId: "", telegramChatId: "",
verifiedFingerprint: "",
verifiedAt: null,
}; };
export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = { export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = {
@@ -36,6 +38,10 @@ export const normalizeAdminSecurityConfig = (value?: Partial<AdminSecurityConfig
enabled: value?.enabled === true, enabled: value?.enabled === true,
telegramBotToken: normalizeString(value?.telegramBotToken), telegramBotToken: normalizeString(value?.telegramBotToken),
telegramChatId: normalizeString(value?.telegramChatId), telegramChatId: normalizeString(value?.telegramChatId),
verifiedFingerprint: normalizeString(value?.verifiedFingerprint),
verifiedAt: typeof value?.verifiedAt === "string" && value.verifiedAt.trim().length > 0
? value.verifiedAt.trim()
: null,
}; };
}; };
@@ -60,3 +66,21 @@ export const isTelegramSecurityEnabled = (config?: Partial<AdminSecurityConfig>
&& typeof config.telegramChatId === "string" && typeof config.telegramChatId === "string"
&& config.telegramChatId.trim().length > 0; && config.telegramChatId.trim().length > 0;
}; };
export const isTelegramSecurityVerified = (config?: Partial<AdminSecurityConfig> | 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,
};
};
+152 -10
View File
@@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client"; import { apiClient } from "@/api/client";
import { Channel, PricingConfig, PricingModel, Token } from "@/types"; import { Channel, PricingConfig, PricingModel, Token } from "@/types";
@@ -45,6 +45,9 @@ type TokenRef = {
const PRICING_DECIMALS = 6; const PRICING_DECIMALS = 6;
const PRICING_INPUT_STEP = "0.000001"; const PRICING_INPUT_STEP = "0.000001";
const DEFAULT_REQUEST_VALUE = 1; const DEFAULT_REQUEST_VALUE = 1;
const AUTO_SAVE_DELAY_MS = 800;
type SaveSource = "auto" | "manual";
type SaveState = "idle" | "saving" | "saved" | "error" | "invalid";
type BuildPricingRowsOptions = { type BuildPricingRowsOptions = {
sortConfiguredFirst?: boolean; sortConfiguredFirst?: boolean;
}; };
@@ -91,6 +94,17 @@ const normalizePricingNumber = (value: unknown, fallback = 0): number => {
return Number(parsed.toFixed(PRICING_DECIMALS)); 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<PricingModel> | null): PricingRow => ({ const normalizePricingEntry = (pricing?: Partial<PricingModel> | null): PricingRow => ({
model: "", model: "",
input: normalizePricingNumber(pricing?.input, 0), input: normalizePricingNumber(pricing?.input, 0),
@@ -311,10 +325,15 @@ export function Pricing() {
const [selectedChannels, setSelectedChannels] = useState<string[]>([]); const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [selectedTokens, setSelectedTokens] = useState<string[]>([]); const [selectedTokens, setSelectedTokens] = useState<string[]>([]);
const [selectedTypes, setSelectedTypes] = useState<string[]>([]); const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
const [saveState, setSaveState] = useState<SaveState>("idle");
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
const deferredSearchQuery = useDeferredValue(searchQuery); const deferredSearchQuery = useDeferredValue(searchQuery);
const { addToast } = useToast(); const { addToast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasHydratedRef = useRef(false);
const lastSavedSignatureRef = useRef(JSON.stringify({}));
const pricingQuery = useQuery({ const pricingQuery = useQuery({
queryKey: ["pricing"], queryKey: ["pricing"],
@@ -354,16 +373,40 @@ export function Pricing() {
useEffect(() => { useEffect(() => {
setPricingRows(baseRows); setPricingRows(baseRows);
setJsonValue(JSON.stringify(pricingQuery.data || {}, null, 2)); 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]); }, [baseRows, pricingQuery.data]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async (config: PricingConfig) => apiClient.savePricing(config), mutationFn: async ({ config }: { config: PricingConfig; source: SaveSource; signature: string }) =>
onSuccess: () => { apiClient.savePricing(config),
queryClient.invalidateQueries({ queryKey: ["pricing"] }); onMutate: ({ source }) => {
addToast("定价配置已保存", "success"); if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (source === "auto") {
setSaveState("saving");
}
}, },
onError: (error: Error) => { onSuccess: (_response, variables) => {
addToast(`保存失败:${error.message}`, "error"); 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 = () => { const handleSave = () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (editMode === "cards") { if (editMode === "cards") {
saveMutation.mutate(buildConfigFromRows(pricingRows)); const config = buildConfigFromRows(pricingRows);
saveMutation.mutate({
config,
source: "manual",
signature: JSON.stringify(config),
});
return; return;
} }
try { try {
const parsed = JSON.parse(jsonValue) as PricingConfig; 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 { } catch {
setSaveState("invalid");
addToast("JSON 格式错误", "error"); addToast("JSON 格式错误", "error");
} }
}; };
@@ -570,13 +699,26 @@ export function Pricing() {
}; };
const isFetching = pricingQuery.isFetching || channelsQuery.isFetching || tokensQuery.isFetching; 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 ( return (
<PageContainer <PageContainer
title="定价管理" title="定价管理"
description="以模型卡片集中管理全局定价,默认覆盖当前系统模型,并支持按渠道、令牌、类型与关键词快速筛选。" description="展示和配置系统模型价格"
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="hidden text-xs text-muted-foreground md:inline">{saveHint}</span>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}> <Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} /> <RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button> </Button>
+219 -69
View File
@@ -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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bot, Check, FileText, Globe, Shield, SlidersHorizontal } from "lucide-react"; 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 { useToast } from "@/components/ui/use-toast";
import { BILLING_CONFIG_QUERY_KEY } from "@/hooks/use-billing-config"; import { BILLING_CONFIG_QUERY_KEY } from "@/hooks/use-billing-config";
import { SYSTEM_CONFIG_QUERY_KEY, useSystemConfig } from "@/hooks/use-system-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 { 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 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 => { const buildAbsoluteUrl = (pathname: string): string => {
if (typeof window === "undefined") { 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); const baseUrl = API_BASE_URL ? new URL(API_BASE_URL, window.location.origin) : new URL(window.location.origin);
return new URL(pathname, baseUrl).toString(); 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() { export function SystemSettings() {
const [systemConfig, setSystemConfig] = useState(DEFAULT_SYSTEM_CONFIG); const [systemConfig, setSystemConfig] = useState(DEFAULT_SYSTEM_CONFIG);
const [saveState, setSaveState] = useState<SaveState>("idle");
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
const { addToast } = useToast(); const { addToast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const systemConfigQuery = useSystemConfig(); const systemConfigQuery = useSystemConfig();
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | 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(() => { useEffect(() => {
if (systemConfigQuery.data) { currentSignatureRef.current = currentSignature;
setSystemConfig(normalizeSystemConfig(systemConfigQuery.data)); }, [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({ const saveSystemConfigMutation = useMutation({
mutationFn: async () => apiClient.saveSystemConfig(normalizeSystemConfig(systemConfig)), mutationFn: async ({ config }: { config: SystemConfig; source: SaveSource; signature: string }) =>
onSuccess: () => { apiClient.saveSystemConfig(config),
queryClient.invalidateQueries({ queryKey: SYSTEM_CONFIG_QUERY_KEY }); onMutate: ({ source }) => {
queryClient.invalidateQueries({ queryKey: BILLING_CONFIG_QUERY_KEY }); if (autoSaveTimerRef.current) {
addToast("系统设置已保存", "success"); clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (source === "auto") {
setSaveState("saving");
}
}, },
onError: (error: Error) => { onSuccess: (response, variables) => {
addToast(`保存失败:${error.message}`, "error"); 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({ const telegramTestMutation = useMutation({
mutationFn: async () => mutationFn: async () =>
apiClient.sendTelegramTestMessage({ apiClient.sendTelegramTestMessage({
telegramBotToken: systemConfig.adminSecurity.telegramBotToken, telegramBotToken: normalizedSystemConfig.adminSecurity.telegramBotToken,
telegramChatId: systemConfig.adminSecurity.telegramChatId, 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"); addToast("测试消息已发送,请到 Telegram 查收", "success");
}, },
onError: (error: Error) => { 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( const docsLinks = useMemo(
() => [ () => [
{ label: "Swagger UI", href: buildAbsoluteUrl("/api/docs") }, { 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 = const telegramConfigComplete =
systemConfig.adminSecurity.telegramBotToken.trim().length > 0 && normalizedSystemConfig.adminSecurity.telegramBotToken.trim().length > 0 &&
systemConfig.adminSecurity.telegramChatId.trim().length > 0; normalizedSystemConfig.adminSecurity.telegramChatId.trim().length > 0;
const securityEnabled = systemConfig.adminSecurity.enabled && telegramConfigComplete; 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 ( return (
<PageContainer <PageContainer
title="系统设置" title="系统设置"
description="统一管理全局系统设置。" description="统一管理全局系统设置"
actions={ actions={
<Button <div className="flex items-center gap-3">
size="sm" <span className="text-xs text-muted-foreground">{saveHint}</span>
onClick={() => saveSystemConfigMutation.mutate()} <Button
disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading} size="sm"
> onClick={handleManualSave}
<Check className="mr-1 h-4 w-4" /> disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading}
保存 >
</Button> <Check className="mr-1 h-4 w-4" />
保存
</Button>
</div>
} }
> >
<Card className="space-y-6 border-0 p-6"> <Card className="space-y-6 border-0 p-6">
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 rounded-lg bg-primary/10 p-2 text-primary"> <div className="mt-0.5 rounded-lg bg-muted-foreground/10 p-2 text-muted-foreground">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h3 className="font-bold tracking-tight">安全设置</h3> <h3 className="font-bold tracking-tight">安全设置</h3>
<p className="text-sm text-muted-foreground">
绑定 Telegram 后自动开启登录验证和通知功能。 {!telegramSecurityVerified && telegramConfigComplete ? (
</p> <p className="text-sm text-amber-600 dark:text-amber-300">
当前 Telegram 配置尚未验证,安全开关保持关闭。
</p>
) : (
<p className="text-sm text-green-600 dark:text-green-300">Telegram 配置已验证,安全开关已开启。</p>
)}
</div> </div>
<Switch <Switch
checked={systemConfig.adminSecurity.enabled} checked={normalizedSystemConfig.adminSecurity.enabled}
disabled={!telegramConfigComplete || !telegramSecurityVerified}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setSystemConfig((current) => ({ setSystemConfig((current) => ({
...current, ...current,
@@ -133,16 +301,8 @@ export function SystemSettings() {
id="telegramBotToken" id="telegramBotToken"
type="password" type="password"
placeholder="123456789:AA..." placeholder="123456789:AA..."
value={systemConfig.adminSecurity.telegramBotToken} value={normalizedSystemConfig.adminSecurity.telegramBotToken}
onChange={(event) => onChange={(event) => updateTelegramField("telegramBotToken", event.target.value)}
setSystemConfig((current) => ({
...current,
adminSecurity: {
...current.adminSecurity,
telegramBotToken: event.target.value,
},
}))
}
/> />
</div> </div>
@@ -153,16 +313,8 @@ export function SystemSettings() {
<Input <Input
id="telegramChatId" id="telegramChatId"
placeholder="-1001234567890" placeholder="-1001234567890"
value={systemConfig.adminSecurity.telegramChatId} value={normalizedSystemConfig.adminSecurity.telegramChatId}
onChange={(event) => onChange={(event) => updateTelegramField("telegramChatId", event.target.value)}
setSystemConfig((current) => ({
...current,
adminSecurity: {
...current.adminSecurity,
telegramChatId: event.target.value,
},
}))
}
/> />
</div> </div>
</div> </div>
@@ -176,10 +328,10 @@ export function SystemSettings() {
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{!telegramConfigComplete {!telegramConfigComplete
? "请确保 Telegram Bot Token 和 Chat ID 已正确填写,测试通过后将启用登录验证和通知功能。" ? "请先填写完整的 Bot Token 和 Chat ID。"
: securityEnabled : telegramSecurityVerified
? "当前已开启 Telegram 登录验证,管理员必须输入验证码才能登录。" ? `最近一次验证通过时间:${normalizedSystemConfig.adminSecurity.verifiedAt || "--"}`
: "Telegram 已配置但尚未启用。可以先发送测试消息确认,再决定是否开启登录验证。"} : "测试通过后,安全开关才会解锁。"}
</p> </p>
</div> </div>
@@ -199,22 +351,22 @@ export function SystemSettings() {
<section className="flex items-center gap-3"> <section className="flex items-center gap-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 rounded-lg bg-primary/10 p-2 text-primary"> <div className="mt-0.5 rounded-lg bg-muted-foreground/10 p-2 text-muted-foreground">
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-bold tracking-tight">显示精度设置</h3> <h3 className="font-bold tracking-tight">显示精度设置</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
页面显示金额保留 {systemConfig.displayDecimals} 位小数, 示例:$ 页面显示金额保留 {normalizedSystemConfig.displayDecimals} 位小数,示例:$
{(0).toFixed(systemConfig.displayDecimals)} {(0).toFixed(normalizedSystemConfig.displayDecimals)}
{currentPrecisionOption ? `(当前:${currentPrecisionOption.label})` : ""} {currentPrecisionOption ? `(当前:${currentPrecisionOption.label})` : ""}
</p> </p>
</div> </div>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<div className="inline-flex flex-wrap border rounded-md"> <div className="inline-flex flex-wrap rounded-md border">
{PRECISION_OPTIONS.map((option) => { {PRECISION_OPTIONS.map((option) => {
const isActive = systemConfig.displayDecimals === option.value; const isActive = normalizedSystemConfig.displayDecimals === option.value;
return ( return (
<Button <Button
key={option.value} key={option.value}
@@ -239,7 +391,7 @@ export function SystemSettings() {
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 rounded-lg bg-primary/10 p-2 text-primary"> <div className="mt-0.5 rounded-lg bg-muted-foreground/10 p-2 text-muted-foreground">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -251,7 +403,7 @@ export function SystemSettings() {
</p> </p>
</div> </div>
<Switch <Switch
checked={systemConfig.apiDocs.enabled} checked={normalizedSystemConfig.apiDocs.enabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setSystemConfig((current) => ({ setSystemConfig((current) => ({
...current, ...current,
@@ -266,7 +418,7 @@ export function SystemSettings() {
</div> </div>
</div> </div>
{systemConfig.apiDocs.enabled ? ( {normalizedSystemConfig.apiDocs.enabled ? (
<div className="grid gap-3 lg:grid-cols-3"> <div className="grid gap-3 lg:grid-cols-3">
{docsLinks.map((item) => ( {docsLinks.map((item) => (
<div <div
@@ -285,9 +437,7 @@ export function SystemSettings() {
</div> </div>
))} ))}
</div> </div>
) : ( ) : null}
""
)}
</section> </section>
</Card> </Card>
</PageContainer> </PageContainer>
+2
View File
@@ -46,6 +46,8 @@ export interface AdminSecurityConfig {
enabled: boolean enabled: boolean
telegramBotToken: string telegramBotToken: string
telegramChatId: string telegramChatId: string
verifiedFingerprint: string
verifiedAt: string | null
} }
export interface ApiDocsConfig { export interface ApiDocsConfig {
+13 -15
View File
@@ -39,8 +39,8 @@ const formatTimestampInTimezone = (date: Date, timezone: string): string => {
const parts = formatter.formatToParts(date); const parts = formatter.formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
// (${timezone})
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second} (${timezone})`; return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second}`;
}; };
const getRequestMetadata = (c: Context<HonoCustomType>): LoginRequestMetadata => { const getRequestMetadata = (c: Context<HonoCustomType>): LoginRequestMetadata => {
@@ -134,14 +134,13 @@ const buildBaseMessageLines = (
metadata: LoginRequestMetadata, metadata: LoginRequestMetadata,
occurredAt: Date occurredAt: Date
): Array<string> => { ): Array<string> => {
const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE; // const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE;
return [ return [
`时间(客户端):${formatTimestampInTimezone(occurredAt, clientTimezone)}`, // `时间(客户端):${formatTimestampInTimezone(occurredAt, clientTimezone)}`,
`时间(系统):${formatTimestampInTimezone(occurredAt, DEFAULT_SYSTEM_TIMEZONE)}`, `时间:${formatTimestampInTimezone(occurredAt, DEFAULT_SYSTEM_TIMEZONE)}`,
`IP:${metadata.clientIp || "未知 IP"}`, `位置:${metadata.clientIp || "未知 IP"} (${getLocationText(metadata)})`,
`位置:${getLocationText(metadata)}`, // `节点:${metadata.colo || "未知节点"}${metadata.timezone ? ` · ${metadata.timezone}` : ""}`,
`节点:${metadata.colo || "未知节点"}${metadata.timezone ? ` · ${metadata.timezone}` : ""}`,
]; ];
}; };
@@ -357,9 +356,8 @@ export const sendAdminLoginCodeNotification = async (
await sendTelegramMessage( await sendTelegramMessage(
securityConfig, securityConfig,
createTelegramMessage([ createTelegramMessage([
"【One API 管理后台】", "🔐 One API Workers 登录验证",
`登录验证码:${code}`, `${code} 验证码 5 分钟内有效,过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`,
`过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`,
...buildBaseMessageLines(metadata, occurredAt), ...buildBaseMessageLines(metadata, occurredAt),
]) ])
); );
@@ -377,8 +375,8 @@ export const sendAdminLoginResultNotification = async (
await sendTelegramMessage( await sendTelegramMessage(
securityConfig, securityConfig,
createTelegramMessage([ createTelegramMessage([
"【One API 管理后台】", "🔐 One API Workers 登录提醒",
`登录${status === "success" ? "成功" : "失败"}`, `您的账户在新设备上登录${status === "success" ? "成功" : "失败"}`,
reason ? `原因:${reason}` : "", reason ? `原因:${reason}` : "",
...buildBaseMessageLines(metadata, occurredAt), ...buildBaseMessageLines(metadata, occurredAt),
]) ])
@@ -395,8 +393,8 @@ export const sendTelegramTestNotification = async (
await sendTelegramMessage( await sendTelegramMessage(
securityConfig, securityConfig,
createTelegramMessage([ createTelegramMessage([
"【One API 管理后台】", "🔐 One API Workers Telegram 测试",
"Telegram 绑定测试成功", "绑定测试成功",
...buildBaseMessageLines(metadata, occurredAt), ...buildBaseMessageLines(metadata, occurredAt),
]) ])
); );
+16 -2
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import { import {
buildTelegramVerificationFingerprint,
getSystemConfig, getSystemConfig,
isTelegramSecurityEnabled, isTelegramSecurityEnabled,
normalizeAdminSecurityConfig, normalizeAdminSecurityConfig,
@@ -16,6 +17,8 @@ const adminSecurityConfigSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
telegramBotToken: z.string(), telegramBotToken: z.string(),
telegramChatId: z.string(), telegramChatId: z.string(),
verifiedFingerprint: z.string(),
verifiedAt: z.nullable(z.string()),
}); });
const apiDocsConfigSchema = z.object({ const apiDocsConfigSchema = z.object({
@@ -33,6 +36,11 @@ const telegramTestRequestSchema = z.object({
telegramChatId: z.string().trim().min(1, "Chat ID 不能为空"), telegramChatId: z.string().trim().min(1, "Chat ID 不能为空"),
}); });
const telegramTestResponseSchema = z.object({
verifiedFingerprint: z.string(),
verifiedAt: z.string(),
});
const ensureSystemConfigValid = (config: SystemConfig) => { const ensureSystemConfigValid = (config: SystemConfig) => {
if ( if (
config.adminSecurity.enabled config.adminSecurity.enabled
@@ -114,7 +122,7 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute {
}, },
}, },
responses: { responses: {
...CommonSuccessfulResponse(z.boolean()), ...CommonSuccessfulResponse(telegramTestResponseSchema),
...CommonErrorResponse, ...CommonErrorResponse,
}, },
}; };
@@ -148,7 +156,13 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute {
return { return {
success: true, success: true,
data: true, data: {
verifiedFingerprint: buildTelegramVerificationFingerprint(
securityConfig.telegramBotToken,
securityConfig.telegramChatId
),
verifiedAt: new Date().toISOString(),
},
message: "Telegram test message sent successfully", message: "Telegram test message sent successfully",
} as CommonResponse; } as CommonResponse;
} }
+43 -3
View File
@@ -11,6 +11,8 @@ export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = {
enabled: false, enabled: false,
telegramBotToken: "", telegramBotToken: "",
telegramChatId: "", telegramChatId: "",
verifiedFingerprint: "",
verifiedAt: null,
}; };
export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = { export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = {
@@ -31,13 +33,51 @@ const normalizeString = (value: unknown): string => {
return typeof value === "string" ? value.trim() : ""; 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 = ( export const normalizeAdminSecurityConfig = (
value: Partial<AdminSecurityConfig> | null | undefined value: Partial<AdminSecurityConfig> | null | undefined
): AdminSecurityConfig => { ): 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 { return {
enabled: normalizeBoolean(value?.enabled), enabled: normalizeBoolean(value?.enabled) && isVerified,
telegramBotToken: normalizeString(value?.telegramBotToken), telegramBotToken,
telegramChatId: normalizeString(value?.telegramChatId), telegramChatId,
verifiedFingerprint: isVerified ? verifiedFingerprint : "",
verifiedAt: isVerified ? verifiedAt : null,
}; };
}; };
Vendored
+2
View File
@@ -124,6 +124,8 @@ type AdminSecurityConfig = {
enabled: boolean; enabled: boolean;
telegramBotToken: string; telegramBotToken: string;
telegramChatId: string; telegramChatId: string;
verifiedFingerprint: string;
verifiedAt: string | null;
} }
type ApiDocsConfig = { type ApiDocsConfig = {