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'>) =>
|
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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in new issue
Block a user