Przeglądaj źródła

feat(frontend): extend Zod validation to remaining query/mutation hooks

Adds Zod schemas for client/inbound/xray/node-probe endpoints and wires
useNodeMutations, useClients, useInbounds, useXraySetting, useDatepicker
through parseMsg. Drops the duplicated per-file ApiMsg<T> interfaces and
the local ClientRecord / OutboundTrafficRow / XraySettingsValue / DefaultsPayload
declarations in favour of schema-inferred types re-exported from the
new src/schemas/ modules.

API boundary now validates: clients list/paged, clients onlines,
clients lastOnline, clients get/hydrate, inbounds slim, inbounds get,
inbounds options, defaultSettings, xray config, xray outbounds traffic,
xray testOutbound, xray getXrayResult, getDefaultJsonConfig, nodes probe,
nodes test. Mutation responses that consume obj (bulkAdjust, delDepleted,
nodes probe / test) get response validation; pass-through mutations stay
agnostic. NodeFormModal type-aligned to Msg<ProbeResult>.
MHSanaei 19 godzin temu
rodzic
commit
d00ddc3f58

+ 16 - 21
frontend/src/api/queries/useNodeMutations.ts

@@ -1,21 +1,12 @@
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 
-import { HttpUtil } from '@/utils';
+import { HttpUtil, Msg } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  msg?: string;
-  obj?: T;
-}
-
-export interface ProbeResult {
-  status: string;
-  latencyMs?: number;
-  xrayVersion?: string;
-  error?: string;
-}
+export type { ProbeResult };
 
 export function useNodeMutations() {
   const queryClient = useQueryClient();
@@ -23,31 +14,33 @@ export function useNodeMutations() {
 
   const createMut = useMutation({
     mutationFn: (payload: Partial<NodeRecord>) =>
-      HttpUtil.post('/panel/api/nodes/add', payload) as Promise<ApiMsg>,
+      HttpUtil.post('/panel/api/nodes/add', payload),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const updateMut = useMutation({
     mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
-      HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/nodes/update/${id}`, payload),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const removeMut = useMutation({
     mutationFn: (id: number) =>
-      HttpUtil.post(`/panel/api/nodes/del/${id}`) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/nodes/del/${id}`),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const setEnableMut = useMutation({
     mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
-      HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
   const probeMut = useMutation({
-    mutationFn: (id: number) =>
-      HttpUtil.post(`/panel/api/nodes/probe/${id}`) as Promise<ApiMsg<ProbeResult>>,
+    mutationFn: async (id: number): Promise<Msg<ProbeResult>> => {
+      const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
+      return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
+    },
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
   });
 
@@ -57,7 +50,9 @@ export function useNodeMutations() {
     remove: (id: number) => removeMut.mutateAsync(id),
     setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
     probe: (id: number) => probeMut.mutateAsync(id),
-    testConnection: (payload: Partial<NodeRecord>) =>
-      HttpUtil.post('/panel/api/nodes/test', payload) as Promise<ApiMsg<ProbeResult>>,
+    testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
+      const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
+      return parseMsg(raw, ProbeResultSchema, 'nodes/test');
+    },
   };
 }

+ 59 - 95
frontend/src/hooks/useClients.ts

@@ -1,55 +1,30 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 
-import { HttpUtil } from '@/utils';
+import { HttpUtil, Msg } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
+import {
+  ClientHydrateSchema,
+  ClientPageResponseSchema,
+  InboundOptionsSchema,
+  OnlinesSchema,
+  BulkAdjustResultSchema,
+  DelDepletedResultSchema,
+  type ClientHydrate,
+  type ClientRecord,
+  type ClientTraffic,
+  type ClientsSummary,
+  type ClientPageResponse,
+  type InboundOption,
+  type BulkAdjustResult,
+} from '@/schemas/client';
+import { DefaultsPayloadSchema } from '@/schemas/defaults';
+
+export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
 
 const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
 
-export interface ClientTraffic {
-  up?: number;
-  down?: number;
-  total?: number;
-  expiryTime?: number;
-  enable?: boolean;
-  lastOnline?: number;
-}
-
-export interface ClientRecord {
-  email: string;
-  subId?: string;
-  uuid?: string;
-  password?: string;
-  auth?: string;
-  flow?: string;
-  totalGB?: number;
-  expiryTime?: number;
-  limitIp?: number;
-  tgId?: number | string;
-  comment?: string;
-  enable?: boolean;
-  inboundIds?: number[];
-  traffic?: ClientTraffic;
-  reverse?: { tag?: string };
-  createdAt?: number;
-  updatedAt?: number;
-  [key: string]: unknown;
-}
-
-export interface InboundOption {
-  id: number;
-  remark?: string;
-  protocol?: string;
-  port?: number;
-  tlsFlowCapable?: boolean;
-}
-
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  msg?: string;
-  obj?: T;
-}
-
 interface SubSettings {
   enable: boolean;
   subURI: string;
@@ -68,24 +43,6 @@ export interface ClientQueryParams {
   order?: 'ascend' | 'descend';
 }
 
-export interface ClientsSummary {
-  total: number;
-  active: number;
-  online: string[];
-  depleted: string[];
-  expiring: string[];
-  deactive: string[];
-}
-
-interface ClientPageResponse {
-  items: ClientRecord[];
-  total: number;
-  filtered: number;
-  page: number;
-  pageSize: number;
-  summary?: ClientsSummary;
-}
-
 const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
 const DEFAULT_SUMMARY: ClientsSummary = {
   total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
@@ -106,21 +63,25 @@ function buildQS(p: ClientQueryParams): string {
 
 async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
   const qs = buildQS(params);
-  const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }) as ApiMsg<ClientPageResponse>;
+  const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
   if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
-  return msg.obj;
+  const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
+  if (!validated.obj) throw new Error('Empty clients response');
+  return validated.obj;
 }
 
 async function fetchInboundOptions(): Promise<InboundOption[]> {
-  const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }) as ApiMsg<InboundOption[]>;
+  const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchDefaults(): Promise<Record<string, unknown>> {
-  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<Record<string, unknown>>;
+  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
-  return msg.obj || {};
+  const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
+  return validated.obj || {};
 }
 
 export function useClients() {
@@ -168,9 +129,10 @@ export function useClients() {
   const onlinesQuery = useQuery({
     queryKey: keys.clients.onlines(),
     queryFn: async () => {
-      const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
+      const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
       if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
-      return Array.isArray(msg.obj) ? msg.obj : [];
+      const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
+      return Array.isArray(validated.obj) ? validated.obj : [];
     },
     staleTime: Infinity,
   });
@@ -208,22 +170,23 @@ export function useClients() {
     await invalidateAll();
   }, [invalidateAll]);
 
-  const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
+  const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
     if (!email) return null;
-    const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
+    const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
     if (!msg?.success || !msg.obj) return null;
-    return msg.obj;
+    const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
+    return validated.obj;
   }, []);
 
   const createMut = useMutation({
     mutationFn: (payload: unknown) =>
-      HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const updateMut = useMutation({
     mutationFn: ({ email, client }: { email: string; client: unknown }) =>
-      HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
@@ -232,7 +195,7 @@ export function useClients() {
       const url = keepTraffic
         ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
         : `/panel/api/clients/del/${encodeURIComponent(email)}`;
-      return HttpUtil.post(url) as Promise<ApiMsg>;
+      return HttpUtil.post(url);
     },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
@@ -242,7 +205,7 @@ export function useClients() {
       const suffix = keepTraffic ? '?keepTraffic=1' : '';
       const results = await Promise.all(emails.map((email) => {
         const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
-        return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
+        return HttpUtil.post(url, undefined, { silent: true });
       }));
       return results;
     },
@@ -250,54 +213,55 @@ export function useClients() {
   });
 
   const bulkAdjustMut = useMutation({
-    mutationFn: (payload: { emails: string[]; addDays: number; addBytes: number }) =>
-      HttpUtil.post(
-        '/panel/api/clients/bulkAdjust',
-        payload,
-        JSON_HEADERS,
-      ) as Promise<ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>>,
+    mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
+      return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
+    },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const attachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const detachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const resetTrafficMut = useMutation({
     mutationFn: (email: string) =>
-      HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`) as Promise<ApiMsg>,
+      HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const resetAllTrafficsMut = useMutation({
-    mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics') as Promise<ApiMsg>,
+    mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const delDepletedMut = useMutation({
-    mutationFn: () => HttpUtil.post('/panel/api/clients/delDepleted') as Promise<ApiMsg<{ deleted?: number }>>,
+    mutationFn: async () => {
+      const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
+      return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
+    },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
   const update = useCallback((email: string, client: unknown) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return updateMut.mutateAsync({ email, client });
   }, [updateMut]);
   const remove = useCallback((email: string, keepTraffic = false) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return removeMut.mutateAsync({ email, keepTraffic });
   }, [removeMut]);
   const removeMany = useCallback((emails: string[], keepTraffic = false) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as ApiMsg[]);
+    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as Msg<unknown>[]);
     return removeManyMut.mutateAsync({ emails, keepTraffic });
   }, [removeManyMut]);
   const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
@@ -305,15 +269,15 @@ export function useClients() {
     return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
   }, [bulkAdjustMut]);
   const attach = useCallback((email: string, inboundIds: number[]) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return attachMut.mutateAsync({ email, inboundIds });
   }, [attachMut]);
   const detach = useCallback((email: string, inboundIds: number[]) => {
-    if (!email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return detachMut.mutateAsync({ email, inboundIds });
   }, [detachMut]);
   const resetTraffic = useCallback((client: ClientRecord) => {
-    if (!client?.email) return Promise.resolve(null as unknown as ApiMsg);
+    if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
     return resetTrafficMut.mutateAsync(client.email);
   }, [resetTrafficMut]);
   const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);

+ 5 - 5
frontend/src/hooks/useDatepicker.ts

@@ -1,5 +1,7 @@
 import { useEffect, useState } from 'react';
 import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { DefaultsPayloadSchema } from '@/schemas/defaults';
 
 type Calendar = 'gregorian' | 'jalalian';
 
@@ -20,12 +22,10 @@ async function loadOnce(): Promise<void> {
   }
   pending = (async () => {
     try {
-      const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
-        success?: boolean;
-        obj?: { datepicker?: Calendar };
-      };
+      const msg = await HttpUtil.post('/panel/setting/defaultSettings');
       if (msg?.success) {
-        cachedValue = msg.obj?.datepicker || 'gregorian';
+        const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
+        cachedValue = validated.obj?.datepicker || 'gregorian';
         notify(cachedValue);
       }
     } finally {

+ 40 - 60
frontend/src/hooks/useXraySetting.ts

@@ -1,30 +1,25 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { z } from 'zod';
 
-import { HttpUtil, PromiseUtil } from '@/utils';
+import { HttpUtil, Msg, PromiseUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
+import {
+  OutboundTrafficListSchema,
+  OutboundTestResultSchema,
+  XrayConfigPayloadSchema,
+  XraySettingsValueSchema,
+  type OutboundTestResult,
+  type OutboundTrafficRow,
+} from '@/schemas/xray';
 
 const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 
-export interface OutboundTrafficRow {
-  tag: string;
-  up: number;
-  down: number;
-}
+export type { OutboundTrafficRow, OutboundTestResult };
 
-export interface OutboundTestResult {
-  success: boolean;
-  delay?: number;
-  error?: string;
-  mode?: string;
-  ttfbMs?: number;
-  tlsMs?: number;
-  connectMs?: number;
-  dnsMs?: number;
-  statusCode?: number;
-  endpoints?: { address: string; delay?: number; success: boolean; error?: string }[];
-}
+export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
 
 export interface OutboundTestState {
   testing?: boolean;
@@ -32,23 +27,6 @@ export interface OutboundTestState {
   mode?: string;
 }
 
-export interface XraySettingsValue {
-  inbounds?: unknown[];
-  outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[];
-  routing?: {
-    rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[];
-    balancers?: unknown[];
-    domainStrategy?: string;
-  };
-  dns?: { tag?: string; servers?: unknown[] };
-  log?: Record<string, unknown>;
-  policy?: { system?: Record<string, boolean> };
-  observatory?: unknown;
-  burstObservatory?: unknown;
-  fakedns?: unknown;
-  [key: string]: unknown;
-}
-
 export type SetTemplate = (
   next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
 ) => void;
@@ -84,35 +62,32 @@ export interface UseXraySettingResult {
   restartXray: () => Promise<void>;
 }
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  obj?: T;
-  msg?: string;
-}
-
-interface XrayConfigPayload {
-  xraySetting: XraySettingsValue;
-  inboundTags?: string[];
-  clientReverseTags?: string[];
-  outboundTestUrl?: string;
-}
+type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
 
 async function fetchXrayConfig(): Promise<XrayConfigPayload> {
-  const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg<string>;
+  const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
   if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
+  let parsed: unknown;
   try {
-    return JSON.parse(msg.obj) as XrayConfigPayload;
+    parsed = JSON.parse(msg.obj);
   } catch (e) {
     const err = e as Error;
     throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
   }
+  const result = XrayConfigPayloadSchema.safeParse(parsed);
+  if (!result.success) {
+    console.warn('[zod] xray/ config payload failed validation', result.error.issues);
+    return parsed as XrayConfigPayload;
+  }
+  return result.data;
 }
 
 async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
-  const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg<OutboundTrafficRow[]>;
+  const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 export function useXraySetting(): UseXraySettingResult {
@@ -219,7 +194,7 @@ export function useXraySetting(): UseXraySettingResult {
       HttpUtil.post('/panel/xray/update', {
         xraySetting: xraySettingRef.current,
         outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL,
-      }) as Promise<ApiMsg>,
+      }),
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() });
     },
@@ -227,7 +202,7 @@ export function useXraySetting(): UseXraySettingResult {
 
   const resetTrafficMut = useMutation({
     mutationFn: (tag: string) =>
-      HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise<ApiMsg>,
+      HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
     },
@@ -235,17 +210,21 @@ export function useXraySetting(): UseXraySettingResult {
 
   const restartMut = useMutation({
     mutationFn: async () => {
-      const msg = await HttpUtil.post('/panel/api/server/restartXrayService') as ApiMsg;
+      const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
       if (!msg?.success) return msg;
       await PromiseUtil.sleep(500);
-      const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg<string>;
-      if (r?.success) setRestartResult(r.obj || '');
+      const r = await HttpUtil.get('/panel/xray/getXrayResult');
+      const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
+      if (validated?.success) setRestartResult(validated.obj || '');
       return msg;
     },
   });
 
   const resetDefaultMut = useMutation({
-    mutationFn: async () => HttpUtil.get('/panel/setting/getDefaultJsonConfig') as Promise<ApiMsg<XraySettingsValue>>,
+    mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
+      const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
+      return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
+    },
     onSuccess: (msg) => {
       if (msg?.success && msg.obj) {
         const cloned = JSON.parse(JSON.stringify(msg.obj));
@@ -269,15 +248,16 @@ export function useXraySetting(): UseXraySettingResult {
         [index]: { testing: true, result: null, mode },
       }));
       try {
-        const msg = await HttpUtil.post('/panel/xray/testOutbound', {
+        const raw = await HttpUtil.post('/panel/xray/testOutbound', {
           outbound: JSON.stringify(outbound),
           allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
           mode,
-        }) as ApiMsg<OutboundTestResult>;
+        });
+        const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
         if (msg?.success && msg.obj) {
           setOutboundTestStates((prev) => ({
             ...prev,
-            [index]: { testing: false, result: msg.obj as OutboundTestResult },
+            [index]: { testing: false, result: msg.obj },
           }));
           return msg.obj;
         }

+ 19 - 31
frontend/src/pages/inbounds/useInbounds.ts

@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 
 import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
 import { DBInbound } from '@/models/dbinbound';
 import { Protocols } from '@/models/inbound';
 import { setDatepicker } from '@/hooks/useDatepicker';
 import { keys } from '@/api/queryKeys';
+import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
+import { OnlinesSchema } from '@/schemas/client';
+import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
 
 export interface SubSettings {
   enable: boolean;
@@ -27,27 +31,6 @@ interface ClientRollup {
   comments: Map<string, string>;
 }
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  obj?: T;
-  msg?: string;
-}
-
-interface DefaultsPayload {
-  expireDiff?: number;
-  trafficDiff?: number;
-  tgBotEnable?: boolean;
-  subEnable?: boolean;
-  subTitle?: string;
-  subURI?: string;
-  subJsonURI?: string;
-  subJsonEnable?: boolean;
-  pageSize?: number;
-  remarkModel?: string;
-  datepicker?: string;
-  ipLimitEnable?: boolean;
-}
-
 const TRACKED_PROTOCOLS = [
   Protocols.VMESS,
   Protocols.VLESS,
@@ -57,27 +40,31 @@ const TRACKED_PROTOCOLS = [
 ];
 
 async function fetchSlimInbounds(): Promise<unknown[]> {
-  const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>;
+  const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchOnlineClients(): Promise<string[]> {
-  const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
+  const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
-  return Array.isArray(msg.obj) ? msg.obj : [];
+  const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
+  return Array.isArray(validated.obj) ? validated.obj : [];
 }
 
 async function fetchLastOnlineMap(): Promise<Record<string, number>> {
-  const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>;
+  const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
-  return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {};
+  const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
+  return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {};
 }
 
 async function fetchDefaultSettings(): Promise<DefaultsPayload> {
-  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>;
+  const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
-  return (msg.obj as DefaultsPayload) || {};
+  const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
+  return validated.obj ?? {};
 }
 
 export function useInbounds() {
@@ -272,8 +259,9 @@ export function useInbounds() {
   const hydrateInbound = useCallback(async (id: number) => {
     const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
     if (!msg?.success || !msg.obj) return null;
-    const full = msg.obj as { id: number; protocol: string };
-    const dbInbound = new DBInbound(full) as DBInboundInstance;
+    const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
+    if (!validated.obj) return null;
+    const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
     setDbInbounds((prev) => {
       const next = prev.map((row) => (
         (row as unknown as { id: number }).id === id ? dbInbound : row

+ 4 - 13
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -14,27 +14,18 @@ import {
   message,
 } from 'antd';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import type { Msg } from '@/utils';
+import type { ProbeResult } from '@/schemas/node';
 import './NodeFormModal.css';
 
 type Mode = 'add' | 'edit';
 
-interface ApiMsg<T = unknown> {
-  success?: boolean;
-  msg?: string;
-  obj?: T;
-}
-
 interface NodeFormModalProps {
   open: boolean;
   mode: Mode;
   node: NodeRecord | null;
-  testConnection: (payload: Partial<NodeRecord>) => Promise<ApiMsg<{
-    status: string;
-    latencyMs?: number;
-    xrayVersion?: string;
-    error?: string;
-  }>>;
-  save: (payload: Partial<NodeRecord>) => Promise<ApiMsg>;
+  testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
+  save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
   onOpenChange: (open: boolean) => void;
 }
 

+ 1 - 1
frontend/src/pages/xray/OutboundsTab.tsx

@@ -130,7 +130,7 @@ export default function OutboundsTab({
   const [existingTags, setExistingTags] = useState<string[]>([]);
 
   const outbounds = useMemo(
-    () => (templateSettings?.outbounds || []) as OutboundRow[],
+    () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
     [templateSettings?.outbounds],
   );
 

+ 84 - 0
frontend/src/schemas/client.ts

@@ -0,0 +1,84 @@
+import { z } from 'zod';
+
+export const ClientTrafficSchema = z.object({
+  up: z.number().optional(),
+  down: z.number().optional(),
+  total: z.number().optional(),
+  expiryTime: z.number().optional(),
+  enable: z.boolean().optional(),
+  lastOnline: z.number().optional(),
+});
+
+export const ClientRecordSchema = z.object({
+  email: z.string(),
+  subId: z.string().optional(),
+  uuid: z.string().optional(),
+  password: z.string().optional(),
+  auth: z.string().optional(),
+  flow: z.string().optional(),
+  totalGB: z.number().optional(),
+  expiryTime: z.number().optional(),
+  limitIp: z.number().optional(),
+  tgId: z.union([z.number(), z.string()]).optional(),
+  comment: z.string().optional(),
+  enable: z.boolean().optional(),
+  inboundIds: z.array(z.number()).optional(),
+  traffic: ClientTrafficSchema.optional(),
+  reverse: z.object({ tag: z.string().optional() }).loose().optional(),
+  createdAt: z.number().optional(),
+  updatedAt: z.number().optional(),
+}).loose();
+
+export const InboundOptionSchema = z.object({
+  id: z.number(),
+  remark: z.string().optional(),
+  protocol: z.string().optional(),
+  port: z.number().optional(),
+  tlsFlowCapable: z.boolean().optional(),
+}).loose();
+
+export const InboundOptionsSchema = z.array(InboundOptionSchema);
+
+export const ClientsSummarySchema = z.object({
+  total: z.number(),
+  active: z.number(),
+  online: z.array(z.string()),
+  depleted: z.array(z.string()),
+  expiring: z.array(z.string()),
+  deactive: z.array(z.string()),
+});
+
+export const ClientPageResponseSchema = z.object({
+  items: z.array(ClientRecordSchema),
+  total: z.number(),
+  filtered: z.number(),
+  page: z.number(),
+  pageSize: z.number(),
+  summary: ClientsSummarySchema.optional(),
+});
+
+export const ClientHydrateSchema = z.object({
+  client: ClientRecordSchema,
+  inboundIds: z.array(z.number()),
+});
+
+export const BulkAdjustResultSchema = z.object({
+  adjusted: z.number(),
+  skipped: z
+    .array(z.object({ email: z.string(), reason: z.string() }))
+    .optional(),
+});
+
+export const DelDepletedResultSchema = z.object({
+  deleted: z.number().optional(),
+});
+
+export const OnlinesSchema = z.array(z.string());
+
+export type ClientRecord = z.infer<typeof ClientRecordSchema>;
+export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
+export type InboundOption = z.infer<typeof InboundOptionSchema>;
+export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
+export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
+export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
+export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;

+ 18 - 0
frontend/src/schemas/defaults.ts

@@ -0,0 +1,18 @@
+import { z } from 'zod';
+
+export const DefaultsPayloadSchema = z.object({
+  expireDiff: z.number().optional(),
+  trafficDiff: z.number().optional(),
+  tgBotEnable: z.boolean().optional(),
+  subEnable: z.boolean().optional(),
+  subTitle: z.string().optional(),
+  subURI: z.string().optional(),
+  subJsonURI: z.string().optional(),
+  subJsonEnable: z.boolean().optional(),
+  pageSize: z.number().optional(),
+  remarkModel: z.string().optional(),
+  datepicker: z.enum(['gregorian', 'jalalian']).optional(),
+  ipLimitEnable: z.boolean().optional(),
+}).loose();
+
+export type DefaultsPayload = z.infer<typeof DefaultsPayloadSchema>;

+ 19 - 0
frontend/src/schemas/inbound.ts

@@ -0,0 +1,19 @@
+import { z } from 'zod';
+
+export const SlimInboundSchema = z.object({
+  id: z.number(),
+  protocol: z.string(),
+}).loose();
+
+export const SlimInboundListSchema = z.array(SlimInboundSchema);
+
+export const InboundDetailSchema = z.object({
+  id: z.number(),
+  protocol: z.string(),
+}).loose();
+
+export const LastOnlineMapSchema = z.record(z.string(), z.number());
+
+export type SlimInbound = z.infer<typeof SlimInboundSchema>;
+export type InboundDetail = z.infer<typeof InboundDetailSchema>;
+export type LastOnlineMap = z.infer<typeof LastOnlineMapSchema>;

+ 8 - 0
frontend/src/schemas/node.ts

@@ -28,4 +28,12 @@ export const NodeRecordSchema = z.object({
 
 export const NodeListSchema = z.array(NodeRecordSchema);
 
+export const ProbeResultSchema = z.object({
+  status: z.string(),
+  latencyMs: z.number().optional(),
+  xrayVersion: z.string().optional(),
+  error: z.string().optional(),
+}).loose();
+
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;
+export type ProbeResult = z.infer<typeof ProbeResultSchema>;

+ 77 - 0
frontend/src/schemas/xray.ts

@@ -0,0 +1,77 @@
+import { z } from 'zod';
+
+export const XraySettingsValueSchema = z.object({
+  inbounds: z.array(z.unknown()).optional(),
+  outbounds: z
+    .array(
+      z.object({
+        tag: z.string().optional(),
+        protocol: z.string().optional(),
+        settings: z.unknown().optional(),
+        streamSettings: z.unknown().optional(),
+      }).loose(),
+    )
+    .optional(),
+  routing: z.object({
+    rules: z.array(z.object({
+      type: z.string().optional(),
+      outboundTag: z.string().optional(),
+      balancerTag: z.string().optional(),
+    }).loose()).optional(),
+    balancers: z.array(z.unknown()).optional(),
+    domainStrategy: z.string().optional(),
+  }).loose().optional(),
+  dns: z.object({
+    tag: z.string().optional(),
+    servers: z.array(z.unknown()).optional(),
+  }).loose().optional(),
+  log: z.record(z.string(), z.unknown()).optional(),
+  policy: z.object({
+    system: z.record(z.string(), z.boolean()).optional(),
+  }).loose().optional(),
+  observatory: z.unknown().optional(),
+  burstObservatory: z.unknown().optional(),
+  fakedns: z.unknown().optional(),
+}).loose();
+
+export const XrayConfigPayloadSchema = z.object({
+  xraySetting: XraySettingsValueSchema,
+  inboundTags: z.array(z.string()).optional(),
+  clientReverseTags: z.array(z.string()).optional(),
+  outboundTestUrl: z.string().optional(),
+}).loose();
+
+export const OutboundTrafficRowSchema = z.object({
+  tag: z.string(),
+  up: z.number(),
+  down: z.number(),
+});
+
+export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
+
+export const OutboundTestResultSchema = z.object({
+  success: z.boolean(),
+  delay: z.number().optional(),
+  error: z.string().optional(),
+  mode: z.string().optional(),
+  ttfbMs: z.number().optional(),
+  tlsMs: z.number().optional(),
+  connectMs: z.number().optional(),
+  dnsMs: z.number().optional(),
+  statusCode: z.number().optional(),
+  endpoints: z
+    .array(
+      z.object({
+        address: z.string(),
+        delay: z.number().optional(),
+        success: z.boolean(),
+        error: z.string().optional(),
+      }).loose(),
+    )
+    .optional(),
+}).loose();
+
+export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
+export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
+export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
+export type OutboundTestResult = z.infer<typeof OutboundTestResultSchema>;