feat(security): 增加 Telegram 验证功能及相关配置
This commit is contained in:
1 parent
bd76a89ba1
commit
7a19d88dc1
9 files changed
+472
-100
No files matched your search
@@ -249,7 +249,7 @@ export const apiClient = {
|
||||
}),
|
||||
|
||||
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',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
@@ -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<AdminSecurityConfig
|
||||
enabled: value?.enabled === true,
|
||||
telegramBotToken: normalizeString(value?.telegramBotToken),
|
||||
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"
|
||||
&& 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
@@ -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<PricingModel> | null): PricingRow => ({
|
||||
model: "",
|
||||
input: normalizePricingNumber(pricing?.input, 0),
|
||||
@@ -311,10 +325,15 @@ export function Pricing() {
|
||||
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
|
||||
const [selectedTokens, setSelectedTokens] = 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 { addToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<PageContainer
|
||||
title="定价管理"
|
||||
description="以模型卡片集中管理全局定价,默认覆盖当前系统模型,并支持按渠道、令牌、类型与关键词快速筛选。"
|
||||
description="展示和配置系统模型价格"
|
||||
actions={
|
||||
<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}>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
|
||||
@@ -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<SaveState>("idle");
|
||||
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
|
||||
|
||||
const { addToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
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(() => {
|
||||
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 (
|
||||
<PageContainer
|
||||
title="系统设置"
|
||||
description="统一管理全局系统设置。"
|
||||
description="统一管理全局系统设置"
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveSystemConfigMutation.mutate()}
|
||||
disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading}
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
保存
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">{saveHint}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleManualSave}
|
||||
disabled={saveSystemConfigMutation.isPending || systemConfigQuery.isLoading}
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<div className="mt-0.5 rounded-lg bg-muted-foreground/10 p-2 text-muted-foreground">
|
||||
<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>
|
||||
|
||||
{!telegramSecurityVerified && telegramConfigComplete ? (
|
||||
<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>
|
||||
<Switch
|
||||
checked={systemConfig.adminSecurity.enabled}
|
||||
checked={normalizedSystemConfig.adminSecurity.enabled}
|
||||
disabled={!telegramConfigComplete || !telegramSecurityVerified}
|
||||
onCheckedChange={(checked) =>
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,16 +313,8 @@ export function SystemSettings() {
|
||||
<Input
|
||||
id="telegramChatId"
|
||||
placeholder="-1001234567890"
|
||||
value={systemConfig.adminSecurity.telegramChatId}
|
||||
onChange={(event) =>
|
||||
setSystemConfig((current) => ({
|
||||
...current,
|
||||
adminSecurity: {
|
||||
...current.adminSecurity,
|
||||
telegramChatId: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
value={normalizedSystemConfig.adminSecurity.telegramChatId}
|
||||
onChange={(event) => updateTelegramField("telegramChatId", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,10 +328,10 @@ export function SystemSettings() {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{!telegramConfigComplete
|
||||
? "请确保 Telegram Bot Token 和 Chat ID 已正确填写,测试通过后将启用登录验证和通知功能。"
|
||||
: securityEnabled
|
||||
? "当前已开启 Telegram 登录验证,管理员必须输入验证码才能登录。"
|
||||
: "Telegram 已配置但尚未启用。可以先发送测试消息确认,再决定是否开启登录验证。"}
|
||||
? "请先填写完整的 Bot Token 和 Chat ID。"
|
||||
: telegramSecurityVerified
|
||||
? `最近一次验证通过时间:${normalizedSystemConfig.adminSecurity.verifiedAt || "--"}`
|
||||
: "测试通过后,安全开关才会解锁。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -199,22 +351,22 @@ export function SystemSettings() {
|
||||
|
||||
<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">
|
||||
<div className="mt-0.5 rounded-lg bg-muted-foreground/10 p-2 text-muted-foreground">
|
||||
<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)}
|
||||
页面显示金额保留 {normalizedSystemConfig.displayDecimals} 位小数,示例:$
|
||||
{(0).toFixed(normalizedSystemConfig.displayDecimals)}
|
||||
{currentPrecisionOption ? `(当前:${currentPrecisionOption.label})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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) => {
|
||||
const isActive = systemConfig.displayDecimals === option.value;
|
||||
const isActive = normalizedSystemConfig.displayDecimals === option.value;
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
@@ -239,7 +391,7 @@ export function SystemSettings() {
|
||||
|
||||
<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">
|
||||
<div className="mt-0.5 rounded-lg bg-muted-foreground/10 p-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -251,7 +403,7 @@ export function SystemSettings() {
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={systemConfig.apiDocs.enabled}
|
||||
checked={normalizedSystemConfig.apiDocs.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSystemConfig((current) => ({
|
||||
...current,
|
||||
@@ -266,7 +418,7 @@ export function SystemSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{systemConfig.apiDocs.enabled ? (
|
||||
{normalizedSystemConfig.apiDocs.enabled ? (
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{docsLinks.map((item) => (
|
||||
<div
|
||||
@@ -285,9 +437,7 @@ export function SystemSettings() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface AdminSecurityConfig {
|
||||
enabled: boolean
|
||||
telegramBotToken: string
|
||||
telegramChatId: string
|
||||
verifiedFingerprint: string
|
||||
verifiedAt: string | null
|
||||
}
|
||||
|
||||
export interface ApiDocsConfig {
|
||||
|
||||
+13
-15
@@ -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<HonoCustomType>): LoginRequestMetadata => {
|
||||
@@ -134,14 +134,13 @@ const buildBaseMessageLines = (
|
||||
metadata: LoginRequestMetadata,
|
||||
occurredAt: Date
|
||||
): Array<string> => {
|
||||
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),
|
||||
])
|
||||
);
|
||||
|
||||
+16
-2
@@ -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;
|
||||
}
|
||||
|
||||
+43
-3
@@ -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<AdminSecurityConfig> | 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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user