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
+462 -90

No files matched your search

+1 -1
View File
@@ -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),
}),
+24
View File
@@ -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,
};
};
+151 -9
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 { 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) => {
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>
+210 -60
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 { 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) => {
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={
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{saveHint}</span>
<Button
size="sm"
onClick={() => saveSystemConfigMutation.mutate()}
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 后自动开启登录验证和通知功能。
{!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>
+2
View File
@@ -46,6 +46,8 @@ export interface AdminSecurityConfig {
enabled: boolean
telegramBotToken: string
telegramChatId: string
verifiedFingerprint: string
verifiedAt: string | null
}
export interface ApiDocsConfig {
+13 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
};
Vendored
+2
View File
@@ -124,6 +124,8 @@ type AdminSecurityConfig = {
enabled: boolean;
telegramBotToken: string;
telegramChatId: string;
verifiedFingerprint: string;
verifiedAt: string | null;
}
type ApiDocsConfig = {