feat(market-data): 优化同步并增加完整性检查
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const requestJson = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
||||
|
||||
import {
|
||||
getIntegrityCheck,
|
||||
getLatestIntegrityCheck,
|
||||
triggerIntegrityCheck,
|
||||
} from "./sync.api"
|
||||
|
||||
describe("sync API adapters", () => {
|
||||
beforeEach(() => {
|
||||
requestJson.mockReset()
|
||||
})
|
||||
|
||||
it("triggers one read-only integrity check through the POST endpoint", async () => {
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await triggerIntegrityCheck(signal)
|
||||
|
||||
expect(requestJson).toHaveBeenCalledWith(
|
||||
"/api/v1/market-data/integrity-checks",
|
||||
{
|
||||
method: "POST",
|
||||
signal,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("uses the latest report pagination contract", async () => {
|
||||
await getLatestIntegrityCheck({ page: 2, pageSize: 20 })
|
||||
|
||||
const [input, init] = requestJson.mock.calls[0] as [
|
||||
string,
|
||||
{ signal?: AbortSignal },
|
||||
]
|
||||
const params = new URL(input, "http://localhost").searchParams
|
||||
|
||||
expect(input).toContain("/api/v1/market-data/integrity-checks/latest?")
|
||||
expect(params.get("page")).toBe("2")
|
||||
expect(params.get("page_size")).toBe("20")
|
||||
expect(init).toEqual({ signal: undefined })
|
||||
})
|
||||
|
||||
it("encodes a check id while keeping the paged check contract", async () => {
|
||||
await getIntegrityCheck("check/with spaces", { page: 3, pageSize: 50 })
|
||||
|
||||
const [input] = requestJson.mock.calls[0] as [string]
|
||||
const params = new URL(input, "http://localhost").searchParams
|
||||
|
||||
expect(input).toContain(
|
||||
"/api/v1/market-data/integrity-checks/check%2Fwith%20spaces?",
|
||||
)
|
||||
expect(params.get("page")).toBe("3")
|
||||
expect(params.get("page_size")).toBe("50")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { requestJson } from "@/shared/api/request-json"
|
||||
|
||||
import {
|
||||
defaultMarketIntegrityQuery,
|
||||
type IntegrityCheckAccepted,
|
||||
type MarketIntegrityCheck,
|
||||
type MarketIntegrityQuery,
|
||||
} from "./sync.types"
|
||||
|
||||
const integrityChecksPath = "/api/v1/market-data/integrity-checks"
|
||||
|
||||
export function triggerIntegrityCheck(signal?: AbortSignal) {
|
||||
return requestJson<IntegrityCheckAccepted>(integrityChecksPath, {
|
||||
method: "POST",
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLatestIntegrityCheck(
|
||||
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestJson<MarketIntegrityCheck>(
|
||||
`${integrityChecksPath}/latest?${buildQuery(query)}`,
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export function getIntegrityCheck(
|
||||
checkId: string,
|
||||
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestJson<MarketIntegrityCheck>(
|
||||
`${integrityChecksPath}/${encodeURIComponent(checkId)}?${buildQuery(query)}`,
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
function buildQuery(query: MarketIntegrityQuery) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(query.page),
|
||||
page_size: String(query.pageSize),
|
||||
})
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export const getMarketIntegrityCheck = getIntegrityCheck
|
||||
export const getLatestMarketIntegrityCheck = getLatestIntegrityCheck
|
||||
export const triggerMarketIntegrityCheck = triggerIntegrityCheck
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { ApiError } from "@/shared/api/request-json"
|
||||
|
||||
const queryHooks = vi.hoisted(() => ({
|
||||
useMutation: vi.fn(),
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(),
|
||||
}))
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getIntegrityCheck: vi.fn(),
|
||||
getLatestIntegrityCheck: vi.fn(),
|
||||
triggerIntegrityCheck: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => queryHooks)
|
||||
vi.mock("./sync.api", () => api)
|
||||
|
||||
import {
|
||||
marketIntegrityCheckQueryKey,
|
||||
marketIntegrityLatestQueryKey,
|
||||
useIntegrityCheck,
|
||||
useLatestIntegrityCheck,
|
||||
useTriggerIntegrityCheck,
|
||||
} from "./sync.query"
|
||||
|
||||
interface QueryOptionsForTest {
|
||||
queryKey: readonly unknown[]
|
||||
enabled?: boolean
|
||||
refetchInterval?: (query: {
|
||||
state: { data?: { status?: string } }
|
||||
}) => number | false
|
||||
}
|
||||
|
||||
interface MutationOptionsForTest {
|
||||
onError?: (error: unknown) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
describe("sync query hooks", () => {
|
||||
const queryClient = { invalidateQueries: vi.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryHooks.useQuery.mockImplementation((options) => options)
|
||||
queryHooks.useQueryClient.mockReturnValue(queryClient)
|
||||
queryHooks.useMutation.mockImplementation((options) => options)
|
||||
})
|
||||
|
||||
it("keeps latest and check keys under marketIntegrity", () => {
|
||||
expect(marketIntegrityLatestQueryKey({ page: 2, pageSize: 20 })).toEqual([
|
||||
"marketIntegrity",
|
||||
"latest",
|
||||
2,
|
||||
20,
|
||||
])
|
||||
expect(
|
||||
marketIntegrityCheckQueryKey("check-1", { page: 3, pageSize: 50 }),
|
||||
).toEqual(["marketIntegrity", "check", "check-1", 3, 50])
|
||||
})
|
||||
|
||||
it("polls only while a check response is running", () => {
|
||||
useLatestIntegrityCheck({ page: 1, pageSize: 10 })
|
||||
useIntegrityCheck("check-1", { page: 1, pageSize: 10 })
|
||||
const latest = queryHooks.useQuery.mock.calls[0]?.[0] as QueryOptionsForTest
|
||||
const check = queryHooks.useQuery.mock.calls[1]?.[0] as QueryOptionsForTest
|
||||
|
||||
expect(latest.queryKey).toEqual(["marketIntegrity", "latest", 1, 10])
|
||||
expect(check.queryKey).toEqual([
|
||||
"marketIntegrity",
|
||||
"check",
|
||||
"check-1",
|
||||
1,
|
||||
10,
|
||||
])
|
||||
expect(check.enabled).toBe(true)
|
||||
if (!check.refetchInterval) throw new Error("polling callback is missing")
|
||||
expect(
|
||||
check.refetchInterval({ state: { data: { status: "running" } } }),
|
||||
).toBe(1500)
|
||||
expect(
|
||||
check.refetchInterval({ state: { data: { status: "passed" } } }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("disables a check query without an active id", () => {
|
||||
useIntegrityCheck(null)
|
||||
const check = queryHooks.useQuery.mock.calls[0]?.[0] as QueryOptionsForTest
|
||||
|
||||
expect(check.enabled).toBe(false)
|
||||
expect(check.queryKey).toEqual(["marketIntegrity", "check", "none", 1, 10])
|
||||
})
|
||||
|
||||
it("invalidates latest reports after success and on a 409 conflict", () => {
|
||||
useTriggerIntegrityCheck()
|
||||
const mutation = queryHooks.useMutation.mock
|
||||
.calls[0]?.[0] as MutationOptionsForTest
|
||||
|
||||
mutation.onSuccess?.()
|
||||
mutation.onError?.(new ApiError(409, "already running"))
|
||||
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(1, {
|
||||
queryKey: ["marketIntegrity", "latest"],
|
||||
})
|
||||
expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(2, {
|
||||
queryKey: ["marketIntegrity", "latest"],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query"
|
||||
|
||||
import { ApiError } from "@/shared/api/request-json"
|
||||
|
||||
import {
|
||||
getIntegrityCheck,
|
||||
getLatestIntegrityCheck,
|
||||
triggerIntegrityCheck,
|
||||
} from "./sync.api"
|
||||
import {
|
||||
defaultMarketIntegrityQuery,
|
||||
type IntegrityCheckAccepted,
|
||||
type MarketIntegrityCheck,
|
||||
type MarketIntegrityQuery,
|
||||
} from "./sync.types"
|
||||
|
||||
export const marketIntegrityQueryKey = ["marketIntegrity"] as const
|
||||
export const marketIntegrityLatestQueryPrefix = [
|
||||
"marketIntegrity",
|
||||
"latest",
|
||||
] as const
|
||||
export const marketIntegrityLatestQueryKey = (
|
||||
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
|
||||
) => [...marketIntegrityLatestQueryPrefix, query.page, query.pageSize] as const
|
||||
export const marketIntegrityCheckQueryKey = (
|
||||
checkId: string,
|
||||
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
|
||||
) => ["marketIntegrity", "check", checkId, query.page, query.pageSize] as const
|
||||
|
||||
const MARKET_INTEGRITY_POLL_INTERVAL_MS = 1500
|
||||
|
||||
export function useLatestIntegrityCheck(
|
||||
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
|
||||
) {
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getLatestIntegrityCheck(query, signal),
|
||||
queryKey: marketIntegrityLatestQueryKey(query),
|
||||
})
|
||||
}
|
||||
|
||||
export function useIntegrityCheck(
|
||||
checkId: string | null,
|
||||
query: MarketIntegrityQuery = defaultMarketIntegrityQuery,
|
||||
) {
|
||||
return useQuery({
|
||||
enabled: Boolean(checkId),
|
||||
queryFn: ({ signal }) => getIntegrityCheck(checkId ?? "", query, signal),
|
||||
queryKey: marketIntegrityCheckQueryKey(checkId ?? "none", query),
|
||||
refetchInterval: (currentQuery) =>
|
||||
currentQuery.state.data?.status === "running"
|
||||
? MARKET_INTEGRITY_POLL_INTERVAL_MS
|
||||
: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTriggerIntegrityCheck() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ signal }: { signal?: AbortSignal } = {}) =>
|
||||
triggerIntegrityCheck(signal),
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
void invalidateLatestIntegrityChecks(queryClient)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateLatestIntegrityChecks(queryClient)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function invalidateLatestIntegrityChecks(queryClient: QueryClient) {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: marketIntegrityLatestQueryPrefix,
|
||||
})
|
||||
}
|
||||
|
||||
export const useLatestMarketIntegrityCheck = useLatestIntegrityCheck
|
||||
export const useMarketIntegrityCheck = useIntegrityCheck
|
||||
export const useTriggerMarketIntegrityCheck = useTriggerIntegrityCheck
|
||||
|
||||
export type MarketIntegrityMutation = ReturnType<
|
||||
typeof useTriggerIntegrityCheck
|
||||
>
|
||||
export type MarketIntegrityQueryResult = ReturnType<typeof useIntegrityCheck>
|
||||
export type MarketIntegrityAccepted = IntegrityCheckAccepted
|
||||
export type MarketIntegrityData = MarketIntegrityCheck
|
||||
@@ -0,0 +1,45 @@
|
||||
export type MarketIntegrityStatus =
|
||||
"no_data" | "running" | "passed" | "issues_found" | "failed"
|
||||
|
||||
export interface MarketIntegrityQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface IntegrityCheckAccepted {
|
||||
check_id: string
|
||||
status: "running"
|
||||
window_start: string
|
||||
window_end: string
|
||||
}
|
||||
|
||||
export interface IntegrityIssue {
|
||||
issue_key: string
|
||||
item_kind: string
|
||||
item_key: string
|
||||
issue_type: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface MarketIntegrityCheck {
|
||||
check_id: string | null
|
||||
status: MarketIntegrityStatus
|
||||
window_start: string | null
|
||||
window_end: string | null
|
||||
target_count: number
|
||||
checked_count: number
|
||||
issue_count: number
|
||||
error_type: string | null
|
||||
error_message: string | null
|
||||
created_at: string | null
|
||||
finished_at: string | null
|
||||
page: number
|
||||
page_size: number
|
||||
issues_total: number
|
||||
issues: IntegrityIssue[]
|
||||
}
|
||||
|
||||
export const defaultMarketIntegrityQuery: MarketIntegrityQuery = {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { ApiError } from "@/shared/api/request-json"
|
||||
|
||||
import { SyncPage } from "./sync-page"
|
||||
import type {
|
||||
IntegrityIssue,
|
||||
MarketIntegrityCheck,
|
||||
MarketIntegrityStatus,
|
||||
} from "../api/sync.types"
|
||||
|
||||
const syncHooks = vi.hoisted(() => ({
|
||||
useIntegrityCheck: vi.fn(),
|
||||
useLatestIntegrityCheck: vi.fn(),
|
||||
useTriggerIntegrityCheck: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../api/sync.query", () => syncHooks)
|
||||
|
||||
const issue: IntegrityIssue = {
|
||||
issue_key: "issue-1",
|
||||
item_kind: "bar",
|
||||
item_key: "000001.SZ:2026-08-08",
|
||||
issue_type: "missing_csv_row",
|
||||
message: "仅报告 PostgreSQL 与 CSV 的差异,不会自动修复。",
|
||||
}
|
||||
|
||||
function buildReport(
|
||||
status: MarketIntegrityStatus,
|
||||
overrides: Partial<MarketIntegrityCheck> = {},
|
||||
): MarketIntegrityCheck {
|
||||
const hasRun = status !== "no_data"
|
||||
return {
|
||||
check_id: hasRun ? "check-1" : null,
|
||||
status,
|
||||
window_start: hasRun ? "2026-08-01" : null,
|
||||
window_end: hasRun ? "2026-08-08" : null,
|
||||
target_count: 10,
|
||||
checked_count: 10,
|
||||
issue_count: status === "issues_found" ? 1 : 0,
|
||||
error_type: null,
|
||||
error_message: null,
|
||||
created_at: "2026-08-08T09:00:00+08:00",
|
||||
finished_at: "2026-08-08T09:02:00+08:00",
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
issues_total: status === "issues_found" ? 1 : 0,
|
||||
issues: status === "issues_found" ? [issue] : [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function configureHooks(
|
||||
latestData: MarketIntegrityCheck | undefined,
|
||||
options: {
|
||||
checkData?: MarketIntegrityCheck
|
||||
latestError?: unknown
|
||||
triggerError?: unknown
|
||||
} = {},
|
||||
) {
|
||||
const refetchLatest = vi.fn()
|
||||
const refetchCheck = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const reset = vi.fn()
|
||||
|
||||
syncHooks.useLatestIntegrityCheck.mockReturnValue({
|
||||
data: latestData,
|
||||
error: options.latestError,
|
||||
isError: Boolean(options.latestError),
|
||||
isPending: false,
|
||||
refetch: refetchLatest,
|
||||
})
|
||||
syncHooks.useIntegrityCheck.mockReturnValue({
|
||||
data: options.checkData,
|
||||
error: undefined,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
refetch: refetchCheck,
|
||||
})
|
||||
syncHooks.useTriggerIntegrityCheck.mockReturnValue({
|
||||
data: undefined,
|
||||
error: options.triggerError,
|
||||
isError: Boolean(options.triggerError),
|
||||
isPending: false,
|
||||
mutate,
|
||||
reset,
|
||||
})
|
||||
|
||||
return { mutate, refetchCheck, refetchLatest, reset }
|
||||
}
|
||||
|
||||
describe("SyncPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
configureHooks(buildReport("no_data"))
|
||||
})
|
||||
|
||||
it("explains the no-data state and starts a check once", () => {
|
||||
const { mutate } = configureHooks(buildReport("no_data"))
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
expect(screen.getByText("暂无检查记录")).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/只报告,不自动修复/).length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始完整性检查" }))
|
||||
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("shows running progress and disables duplicate triggers", () => {
|
||||
configureHooks(buildReport("running", { checked_count: 4 }))
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
expect(screen.getByText("完整性检查进行中")).toBeInTheDocument()
|
||||
expect(screen.getByText("4 / 10")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "检查进行中" })).toBeDisabled()
|
||||
})
|
||||
|
||||
it("recovers a running check id from the latest report after refresh", async () => {
|
||||
configureHooks(buildReport("running"), {
|
||||
checkData: buildReport("running", { checked_count: 3 }),
|
||||
})
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(syncHooks.useIntegrityCheck).toHaveBeenCalledWith("check-1", {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("refreshes latest once after a running check reaches a terminal state", async () => {
|
||||
const { refetchLatest } = configureHooks(buildReport("running"), {
|
||||
checkData: buildReport("passed"),
|
||||
})
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refetchLatest).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it("renders a passed report without an issue table", () => {
|
||||
configureHooks(buildReport("passed"))
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
expect(screen.getByText("检查通过")).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/未发现 PostgreSQL\/CSV 不一致/)).toHaveLength(2)
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders paginated issue details and the safe-report notice", () => {
|
||||
configureHooks(buildReport("issues_found"))
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
expect(
|
||||
screen.getByRole("table", { name: "完整性检查问题报告" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("bar")).toBeInTheDocument()
|
||||
expect(screen.getByText("000001.SZ:2026-08-08")).toBeInTheDocument()
|
||||
expect(screen.getByText("missing_csv_row")).toBeInTheDocument()
|
||||
expect(screen.getByText(/只展示对象和差异说明/)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole("contentinfo", { name: "表格分页" }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows a failed report and offers a retry entry", () => {
|
||||
const { mutate } = configureHooks(
|
||||
buildReport("failed", {
|
||||
error_type: "check_error",
|
||||
error_message: "存储暂时不可用",
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
expect(screen.getByText("检查未完成")).toBeInTheDocument()
|
||||
expect(screen.getByText(/不能据此判断市场数据已经损坏/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新发起检查" }))
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("makes 503 and network failures visible with a retry button", () => {
|
||||
const { refetchLatest } = configureHooks(undefined, {
|
||||
latestError: new ApiError(503, "unavailable"),
|
||||
})
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
expect(screen.getByText("完整性检查结果暂时不可用")).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
||||
expect(refetchLatest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("explains a 409 and lets the page take over the running report", () => {
|
||||
configureHooks(buildReport("running"), {
|
||||
triggerError: new ApiError(409, "conflict"),
|
||||
})
|
||||
|
||||
render(<SyncPage />)
|
||||
|
||||
expect(screen.getByText(/已刷新并接管该检查/)).toBeInTheDocument()
|
||||
expect(screen.getByText("完整性检查进行中")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,665 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Database,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { PageLayout } from "@/app/layout/page-layout"
|
||||
import { ApiError } from "@/shared/api/request-json"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card"
|
||||
import { Pagination } from "@/shared/ui/pagination"
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/shared/ui/progress"
|
||||
|
||||
import {
|
||||
useIntegrityCheck,
|
||||
useLatestIntegrityCheck,
|
||||
useTriggerIntegrityCheck,
|
||||
} from "../api/sync.query"
|
||||
import {
|
||||
defaultMarketIntegrityQuery,
|
||||
type IntegrityCheckAccepted,
|
||||
type MarketIntegrityCheck,
|
||||
} from "../api/sync.types"
|
||||
|
||||
export function SyncPage() {
|
||||
const [triggeredCheckId, setTriggeredCheckId] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(defaultMarketIntegrityQuery.page)
|
||||
const [pageSize, setPageSize] = useState(defaultMarketIntegrityQuery.pageSize)
|
||||
const query = { page, pageSize }
|
||||
const latest = useLatestIntegrityCheck(query)
|
||||
const latestRunningCheckId =
|
||||
latest.data?.status === "running" ? latest.data.check_id : null
|
||||
const activeCheckId = triggeredCheckId ?? latestRunningCheckId
|
||||
const check = useIntegrityCheck(activeCheckId, query)
|
||||
const trigger = useTriggerIntegrityCheck()
|
||||
const { refetch: refetchLatest } = latest
|
||||
const terminalRefreshId = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCheckId || !check.data || check.data.status === "running") {
|
||||
if (!activeCheckId) terminalRefreshId.current = null
|
||||
return
|
||||
}
|
||||
if (terminalRefreshId.current === activeCheckId) return
|
||||
|
||||
terminalRefreshId.current = activeCheckId
|
||||
void refetchLatest()
|
||||
}, [activeCheckId, check.data, refetchLatest])
|
||||
|
||||
const acceptedReport = trigger.data
|
||||
? acceptedCheckAsReport(trigger.data)
|
||||
: undefined
|
||||
const latestMatchesActive =
|
||||
!activeCheckId || latest.data?.check_id === activeCheckId
|
||||
const report =
|
||||
check.data ??
|
||||
(activeCheckId && latestMatchesActive ? latest.data : undefined) ??
|
||||
(trigger.data ? acceptedReport : latest.data)
|
||||
|
||||
function startCheck() {
|
||||
setPage(1)
|
||||
trigger.reset()
|
||||
trigger.mutate(
|
||||
{},
|
||||
{
|
||||
onError: (error) => {
|
||||
if (getErrorStatus(error) === 409) setTriggeredCheckId(null)
|
||||
},
|
||||
onSuccess: (accepted) => setTriggeredCheckId(accepted.check_id),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function retryActiveCheck() {
|
||||
void check.refetch()
|
||||
}
|
||||
|
||||
function retryLatest() {
|
||||
void latest.refetch()
|
||||
}
|
||||
|
||||
const triggerStatus = getErrorStatus(trigger.error)
|
||||
const triggerError =
|
||||
trigger.isError && triggerStatus !== 409 ? trigger.error : null
|
||||
const queryPending = activeCheckId ? check.isPending : latest.isPending
|
||||
const queryError = activeCheckId ? check.error : latest.error
|
||||
const queryHasData = Boolean(report)
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mx-auto w-full max-w-6xl space-y-4">
|
||||
<header className="space-y-1">
|
||||
<p className="text-xs font-medium tracking-[0.08em] text-muted-foreground uppercase">
|
||||
只读数据校验
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
市场数据完整性检查
|
||||
</h1>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
检查 PostgreSQL 与 CSV 快照中的市场数据是否一致,帮助定位数据问题。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ReadOnlyNotice />
|
||||
|
||||
{triggerStatus === 409 ? <ConflictNotice /> : null}
|
||||
|
||||
{triggerError ? (
|
||||
<RequestError
|
||||
description={getTriggerErrorDescription(triggerError)}
|
||||
onRetry={startCheck}
|
||||
title="完整性检查暂时无法发起"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!queryHasData && queryPending ? <LoadingState /> : null}
|
||||
|
||||
{!queryPending && queryError ? (
|
||||
<RequestError
|
||||
description={getQueryErrorDescription(queryError)}
|
||||
onRetry={activeCheckId ? retryActiveCheck : retryLatest}
|
||||
title="完整性检查结果暂时不可用"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{report?.status === "no_data" ? (
|
||||
<NoDataState isPending={trigger.isPending} onStart={startCheck} />
|
||||
) : null}
|
||||
|
||||
{report?.status === "running" ? <RunningState report={report} /> : null}
|
||||
|
||||
{report?.status === "passed" ? (
|
||||
<PassedState
|
||||
isPending={trigger.isPending}
|
||||
onStart={startCheck}
|
||||
report={report}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{report?.status === "issues_found" ? (
|
||||
<IssuesFoundState
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(nextPageSize) => {
|
||||
setPage(1)
|
||||
setPageSize(nextPageSize)
|
||||
}}
|
||||
report={report}
|
||||
selectedPage={page}
|
||||
selectedPageSize={pageSize}
|
||||
isPending={trigger.isPending}
|
||||
onStart={startCheck}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{report?.status === "failed" ? (
|
||||
<FailedState
|
||||
isPending={trigger.isPending}
|
||||
onStart={startCheck}
|
||||
report={report}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadOnlyNotice() {
|
||||
return (
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardContent className="flex gap-3 p-4">
|
||||
<ShieldCheck
|
||||
className="mt-0.5 size-5 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">只读检查,安全报告</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
本检查只比较 PostgreSQL 与 CSV,不访问
|
||||
Tushare,不修改市场事实。发现差异时只报告,不自动修复。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ConflictNotice() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 p-3 text-sm text-foreground"
|
||||
>
|
||||
<RefreshCw
|
||||
className="mt-0.5 size-4 shrink-0 text-warning"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>已有完整性检查正在运行,页面已刷新并接管该检查的进度。</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<Card aria-label="正在加载完整性检查" role="status">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="h-6 w-52 animate-pulse rounded-md bg-muted" />
|
||||
<div className="h-4 w-80 max-w-full animate-pulse rounded-md bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-20 animate-pulse rounded-md bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NoDataState({
|
||||
isPending,
|
||||
onStart,
|
||||
}: {
|
||||
isPending: boolean
|
||||
onStart: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Database className="size-5 text-primary" aria-hidden="true" />
|
||||
暂无检查记录
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
当前没有可查看的完整性检查。发起后,系统会比较最近完成同步窗口中的
|
||||
PostgreSQL 与 CSV 数据。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button disabled={isPending} onClick={onStart}>
|
||||
<ClipboardCheck aria-hidden="true" />
|
||||
{isPending ? "正在发起检查…" : "开始完整性检查"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
检查只读数据并生成报告,不会访问 Tushare,也不会自动修复。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RunningState({ report }: { report: MarketIntegrityCheck }) {
|
||||
const percentage = progressPercentage(
|
||||
report.checked_count,
|
||||
report.target_count,
|
||||
)
|
||||
return (
|
||||
<Card aria-live="polite">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<ClipboardCheck
|
||||
className="size-5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
完整性检查进行中
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
检查完成后会自动停止刷新;运行期间不会修改 PostgreSQL 或 CSV
|
||||
数据。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">运行中</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<Progress aria-label="完整性检查进度" max={100} value={percentage}>
|
||||
<ProgressLabel>检查进度</ProgressLabel>
|
||||
<ProgressValue>
|
||||
{() => `${report.checked_count}/${report.target_count}`}
|
||||
</ProgressValue>
|
||||
</Progress>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Metric
|
||||
label="已检查对象"
|
||||
value={`${report.checked_count} / ${report.target_count}`}
|
||||
/>
|
||||
<Metric label="当前问题数" value={String(report.issue_count)} />
|
||||
<Metric label="检查窗口" value={formatWindow(report)} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
|
||||
<Timestamp label="开始时间" value={report.created_at} />
|
||||
<Button disabled variant="outline">
|
||||
检查进行中
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function PassedState({
|
||||
isPending,
|
||||
onStart,
|
||||
report,
|
||||
}: {
|
||||
isPending: boolean
|
||||
onStart: () => void
|
||||
report: MarketIntegrityCheck
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<CheckCircle2
|
||||
className="size-5 text-success"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
检查通过
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
未发现 PostgreSQL/CSV 不一致,本次检查只报告结果,没有修改数据。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="border-transparent bg-success text-success-foreground">
|
||||
已通过
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border border-success/30 bg-success/10 p-4 text-sm">
|
||||
未发现 PostgreSQL/CSV 不一致。
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Metric
|
||||
label="已检查对象"
|
||||
value={`${report.checked_count} / ${report.target_count}`}
|
||||
/>
|
||||
<Metric label="问题数" value={String(report.issue_count)} />
|
||||
<Metric label="检查窗口" value={formatWindow(report)} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<Timestamp label="开始时间" value={report.created_at} />
|
||||
<Timestamp label="完成时间" value={report.finished_at} />
|
||||
</div>
|
||||
<Button disabled={isPending} onClick={onStart} variant="outline">
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{isPending ? "正在发起…" : "再次检查"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function IssuesFoundState({
|
||||
isPending,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onStart,
|
||||
report,
|
||||
selectedPage,
|
||||
selectedPageSize,
|
||||
}: {
|
||||
isPending: boolean
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
onStart: () => void
|
||||
report: MarketIntegrityCheck
|
||||
selectedPage: number
|
||||
selectedPageSize: number
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<AlertTriangle
|
||||
className="size-5 text-warning"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
发现数据差异
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
检查完成并发现 {report.issue_count}{" "}
|
||||
个问题。以下内容仅供定位,系统不会自动修复。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="border-transparent bg-warning text-warning-foreground">
|
||||
{report.issue_count} 个问题
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Metric
|
||||
label="已检查对象"
|
||||
value={`${report.checked_count} / ${report.target_count}`}
|
||||
/>
|
||||
<Metric label="问题数" value={String(report.issue_count)} />
|
||||
<Metric label="检查窗口" value={formatWindow(report)} />
|
||||
</div>
|
||||
<div className="rounded-md border border-warning/40 bg-warning/10 p-4 text-sm">
|
||||
安全说明:问题报告只展示对象和差异说明,不会执行修复或重新同步。
|
||||
</div>
|
||||
<IssueTable report={report} />
|
||||
<Pagination
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
page={selectedPage}
|
||||
pageSize={selectedPageSize}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
total={report.issues_total}
|
||||
/>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<Timestamp label="开始时间" value={report.created_at} />
|
||||
<Timestamp label="完成时间" value={report.finished_at} />
|
||||
</div>
|
||||
<Button disabled={isPending} onClick={onStart} variant="outline">
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{isPending ? "正在发起…" : "再次检查"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function IssueTable({ report }: { report: MarketIntegrityCheck }) {
|
||||
if (report.issues.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border p-4 text-sm text-muted-foreground">
|
||||
当前页没有问题记录。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full min-w-[680px] text-left text-sm">
|
||||
<caption className="sr-only">完整性检查问题报告</caption>
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
对象类型
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
对象 key
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
问题类型
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
安全说明
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{report.issues.map((issue) => (
|
||||
<tr key={issue.issue_key} className="align-top">
|
||||
<td className="px-3 py-3 font-medium">{issue.item_kind}</td>
|
||||
<td className="break-all px-3 py-3 font-mono text-xs">
|
||||
{issue.item_key}
|
||||
</td>
|
||||
<td className="px-3 py-3">{issue.issue_type}</td>
|
||||
<td className="max-w-md px-3 py-3 text-muted-foreground">
|
||||
{issue.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FailedState({
|
||||
isPending,
|
||||
onStart,
|
||||
report,
|
||||
}: {
|
||||
isPending: boolean
|
||||
onStart: () => void
|
||||
report: MarketIntegrityCheck
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<AlertTriangle
|
||||
className="size-5 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
检查未完成
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
本次检查遇到安全错误,不能据此判断市场数据已经损坏。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="destructive">失败</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm"
|
||||
>
|
||||
<p className="font-medium">未判断市场数据状态</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{report.error_type
|
||||
? `错误类型:${report.error_type}`
|
||||
: "检查服务暂时无法完成比较。"}
|
||||
</p>
|
||||
{report.error_message ? (
|
||||
<p className="mt-1 text-muted-foreground">{report.error_message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Metric
|
||||
label="已检查对象"
|
||||
value={`${report.checked_count} / ${report.target_count}`}
|
||||
/>
|
||||
<Metric label="问题数" value={String(report.issue_count)} />
|
||||
<Metric label="检查窗口" value={formatWindow(report)} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
<Timestamp label="开始时间" value={report.created_at} />
|
||||
<Timestamp label="结束时间" value={report.finished_at} />
|
||||
</div>
|
||||
<Button disabled={isPending} onClick={onStart}>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{isPending ? "正在重新发起…" : "重新发起检查"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RequestError({
|
||||
description,
|
||||
onRetry,
|
||||
title,
|
||||
}: {
|
||||
description: string
|
||||
onRetry: () => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<Card role="alert">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<AlertTriangle
|
||||
className="size-5 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
<RefreshCw aria-hidden="true" />
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 break-words font-medium tabular-nums">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Timestamp({ label, value }: { label: string; value: string | null }) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{label}:
|
||||
<time className="text-foreground" dateTime={value ?? undefined}>
|
||||
{value ?? "—"}
|
||||
</time>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function formatWindow(report: MarketIntegrityCheck) {
|
||||
if (!report.window_start || !report.window_end) return "—"
|
||||
return `${report.window_start} 至 ${report.window_end}`
|
||||
}
|
||||
|
||||
function progressPercentage(checked: number, target: number) {
|
||||
if (target <= 0) return 0
|
||||
return Math.min(100, Math.max(0, (checked / target) * 100))
|
||||
}
|
||||
|
||||
function acceptedCheckAsReport(
|
||||
accepted: IntegrityCheckAccepted,
|
||||
): MarketIntegrityCheck {
|
||||
return {
|
||||
check_id: accepted.check_id,
|
||||
status: "running",
|
||||
window_start: accepted.window_start,
|
||||
window_end: accepted.window_end,
|
||||
target_count: 0,
|
||||
checked_count: 0,
|
||||
issue_count: 0,
|
||||
error_type: null,
|
||||
error_message: null,
|
||||
created_at: null,
|
||||
finished_at: null,
|
||||
page: 1,
|
||||
page_size: defaultMarketIntegrityQuery.pageSize,
|
||||
issues_total: 0,
|
||||
issues: [],
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown) {
|
||||
if (error instanceof ApiError) return error.status
|
||||
if (typeof error === "object" && error !== null && "status" in error) {
|
||||
const status = error.status
|
||||
return typeof status === "number" ? status : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getQueryErrorDescription(error: unknown) {
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 503) {
|
||||
return "检查结果存储暂时不可用,未将这次请求解释为市场数据损坏。"
|
||||
}
|
||||
return "检查结果暂时无法获取,可能是网络或服务异常。请稍后重试。"
|
||||
}
|
||||
|
||||
function getTriggerErrorDescription(error: unknown) {
|
||||
const status = getErrorStatus(error)
|
||||
if (status === 422) {
|
||||
return "当前没有可供比较的已完成市场数据窗口,请先完成一次市场数据同步。"
|
||||
}
|
||||
if (status === 503) {
|
||||
return "检查服务暂时不可用,未发起新的检查。请稍后重试。"
|
||||
}
|
||||
return "检查请求可能没有到达服务,请确认网络后重试。"
|
||||
}
|
||||
Reference in New Issue
Block a user