diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 639125a..5a1d6cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Install dependencies run: yarn install --immutable + - name: Type check + run: yarn type-check + - name: Lint server run: yarn workspace @my-first-nest/server eslint "{src,database,test}/**/*.ts" diff --git a/apps/server/package.json b/apps/server/package.json index 8ed838b..f25c62b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -12,6 +12,7 @@ "start": "nest start", "start:debug": "nest start --debug --watch", "start:prod": "cross-env NODE_ENV=production node dist/src/main", + "type-check": "tsc -b", "migration:generate": "ts-node database/migration.ts generate", "migration:revert": "ts-node database/migration.ts revert", "db:init": "ts-node -r tsconfig-paths/register database/manage.ts init", diff --git a/apps/server/src/config/configuration.interface.ts b/apps/server/src/config/configuration.interface.ts index 9e615dd..3b1fe55 100644 --- a/apps/server/src/config/configuration.interface.ts +++ b/apps/server/src/config/configuration.interface.ts @@ -1,54 +1,81 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm'; export interface ServerConfig { + /** HTTP 监听端口 */ port?: number; + /** 全局 API 前缀,例如 `api` */ apiPrefix?: string; + /** 请求超时时间,单位秒 */ timeout?: number; } export interface SwaggerConfig { + /** 是否启用 Swagger */ enabled?: boolean; + /** Swagger UI 路径,不含前导斜杠 */ path?: string; + /** 文档标题 */ title?: string; + /** 文档描述 */ description?: string; + /** 文档版本号 */ version?: string; } export interface JwtConfig { + /** 签名和校验 JWT 的密钥 */ secret?: string; + /** access token 过期时间,单位秒 */ accessExpiresIn?: number; + /** refresh token 过期时间,单位秒 */ refreshExpiresIn?: number; } export interface SnowflakeConfig { + /** 工作节点 ID,范围 0-31 */ workerId: number; + /** 数据中心 ID,范围 0-31 */ datacenterId: number; } export interface RedisConfig { + /** Redis 连接 URL,与 host 二选一 */ url?: string; + /** Redis 主机,与 url 二选一 */ host?: string; + /** Redis 端口 */ port?: number; + /** Redis 密码 */ password?: string; + /** Redis DB 索引,范围 0-15 */ db?: number; + /** 缓存默认 TTL,单位秒;0 表示不过期 */ defaultTtl?: number; + /** 缓存和队列的 Key 前缀 */ keyPrefix?: string; } export interface ThrottlerConfig { - /** 默认时间窗口(毫秒) */ + /** 默认限流窗口,单位毫秒 */ ttl: number; /** 窗口内最大请求数 */ limit: number; } export interface AppConfig { + /** HTTP 服务配置 */ server?: ServerConfig; + /** Swagger 文档配置 */ swagger?: SwaggerConfig; + /** TypeORM 数据库配置 */ database?: TypeOrmModuleOptions; + /** JWT 鉴权配置 */ jwt?: JwtConfig; + /** 雪花 ID 配置 */ snowflake?: SnowflakeConfig; + /** Redis 连接和缓存配置 */ redis?: RedisConfig; + /** 全局限流配置 */ throttler?: ThrottlerConfig; } diff --git a/apps/server/src/shared/caching/cache.module.ts b/apps/server/src/shared/caching/cache.module.ts index 64d3c89..885a0e7 100644 --- a/apps/server/src/shared/caching/cache.module.ts +++ b/apps/server/src/shared/caching/cache.module.ts @@ -60,7 +60,14 @@ const buildRedisUrl = (redis: { }); return { - stores: [new Keyv({ store: keyvRedis, namespace: redis.keyPrefix })], + // KeyvRedis 自己会加 namespace::,这里再开 useKeyPrefix 会变成 prefix:prefix::key + stores: [ + new Keyv({ + store: keyvRedis, + namespace: redis.keyPrefix, + useKeyPrefix: false, + }), + ], ttl, }; }, diff --git a/apps/server/src/shared/caching/cache.service.ts b/apps/server/src/shared/caching/cache.service.ts index c11ef1d..b37e3cc 100644 --- a/apps/server/src/shared/caching/cache.service.ts +++ b/apps/server/src/shared/caching/cache.service.ts @@ -56,24 +56,38 @@ export class CacheService { const separator = redisStore.keyPrefixSeparator; const formatKey = (key: string) => namespace ? `${namespace}${separator}${key}` : key; + const ttlMs = ttlSeconds * 1000; + const serializedValue = JSON.stringify({ + value: expectedValue, + ...(ttlMs > 0 ? { expires: Date.now() + ttlMs } : {}), + }); const result = await client.eval( ` - if redis.call("GET", KEYS[1]) ~= ARGV[1] then + local raw = redis.call("GET", KEYS[1]) + if not raw then return 0 end + -- Keyv 存的是 {"value":"...","expires":...},也兼容历史裸字符串 + if raw ~= ARGV[1] then + local ok, data = pcall(cjson.decode, raw) + if (not ok) or data["value"] ~= ARGV[1] then + return 0 + end + end + redis.call("DEL", KEYS[1]) - if tonumber(ARGV[2]) > 0 then - redis.call("SET", KEYS[2], ARGV[1], "PX", ARGV[2]) + if tonumber(ARGV[3]) > 0 then + redis.call("SET", KEYS[2], ARGV[2], "PX", ARGV[3]) else - redis.call("SET", KEYS[2], ARGV[1]) + redis.call("SET", KEYS[2], ARGV[2]) end return 1 `, { keys: [formatKey(oldKey), formatKey(newKey)], - arguments: [expectedValue, String(ttlSeconds * 1000)], + arguments: [expectedValue, serializedValue, String(ttlMs)], }, ); diff --git a/apps/server/src/shared/jobs/board/job-board.auth.middleware.ts b/apps/server/src/shared/jobs/board/job-board.auth.middleware.ts index 890079f..8bb4b0a 100644 --- a/apps/server/src/shared/jobs/board/job-board.auth.middleware.ts +++ b/apps/server/src/shared/jobs/board/job-board.auth.middleware.ts @@ -5,6 +5,9 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { NextFunction, Request, Response } from 'express'; +type JobBoardRequest = Pick; +type JobBoardResponse = Pick; + @Injectable() export class JobBoardAuthMiddleware implements NestMiddleware { constructor( @@ -12,7 +15,7 @@ export class JobBoardAuthMiddleware implements NestMiddleware { private readonly usersService: UsersService, ) {} - async use(req: Request, res: Response, next: NextFunction) { + async use(req: JobBoardRequest, res: JobBoardResponse, next: NextFunction) { const tokenResult = this.extractToken(req); if (!tokenResult) { this.reject(res); @@ -33,7 +36,7 @@ export class JobBoardAuthMiddleware implements NestMiddleware { } } - private extractToken(req: Request) { + private extractToken(req: JobBoardRequest) { const bearerToken = this.extractBearerToken(req.headers.authorization); if (bearerToken) { return { token: bearerToken, tokenType: TokenType.ACCESS }; @@ -53,7 +56,7 @@ export class JobBoardAuthMiddleware implements NestMiddleware { return token || null; } - private reject(res: Response) { + private reject(res: JobBoardResponse) { res.status(401).json({ statusCode: 401, message: 'Unauthorized', diff --git a/apps/server/src/shared/jobs/board/job-board.module.ts b/apps/server/src/shared/jobs/board/job-board.module.ts index 73ea928..c9ff11c 100644 --- a/apps/server/src/shared/jobs/board/job-board.module.ts +++ b/apps/server/src/shared/jobs/board/job-board.module.ts @@ -10,7 +10,7 @@ import { createBullBoard } from '@bull-board/api'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; import { ExpressAdapter } from '@bull-board/express'; import { Queue } from 'bullmq'; -import { Express } from 'express'; +import type { Express, RequestHandler } from 'express'; import { DEFAULT_JOB_QUEUE } from '../constants/job.constants'; import { JobQueueModule } from '../queue/job-queue.module'; import { IBullJobData } from '../types/job.types'; @@ -55,7 +55,11 @@ export class JobBoardModule implements OnModuleInit { const app: Express = this.httpAdapterHost.httpAdapter.getInstance(); - app.use(JOB_BOARD_PATH, this.authMiddleware.use.bind(this.authMiddleware)); - app.use(JOB_BOARD_PATH, serverAdapter.getRouter()); + const boardAuthMiddleware: RequestHandler = (req, res, next) => + this.authMiddleware.use(req, res, next); + const boardRouter = serverAdapter.getRouter() as RequestHandler; + + app.use(JOB_BOARD_PATH, boardAuthMiddleware); + app.use(JOB_BOARD_PATH, boardRouter); } } diff --git a/apps/server/src/shared/jobs/services/job.service.ts b/apps/server/src/shared/jobs/services/job.service.ts index faefbd4..bc8feaf 100644 --- a/apps/server/src/shared/jobs/services/job.service.ts +++ b/apps/server/src/shared/jobs/services/job.service.ts @@ -55,7 +55,7 @@ export class JobService { payload: input.payload, }, { - jobId: run.id, + jobId: `job-${run.id}`, delayMs, attempts: maxAttempts, backoffMs: input.backoffMs, diff --git a/apps/server/tests/e2e/auth.e2e-spec.ts b/apps/server/tests/e2e/auth.e2e-spec.ts index a97d176..52c48f2 100644 --- a/apps/server/tests/e2e/auth.e2e-spec.ts +++ b/apps/server/tests/e2e/auth.e2e-spec.ts @@ -2,6 +2,11 @@ import { TestHelper } from './helpers/test-helper'; import request from 'supertest'; import { describe, expect, it, beforeAll, afterAll, beforeEach } from 'vitest'; +const getSetCookieHeaders = (headers: Record) => { + const setCookie = headers['set-cookie']; + return Array.isArray(setCookie) ? setCookie : []; +}; + describe('Auth (e2e)', () => { let helper: TestHelper; @@ -83,8 +88,7 @@ describe('Auth (e2e)', () => { expect(res.body).toHaveProperty('expiresAt'); // should set refreshToken cookie - const cookies = res.headers['set-cookie'] as string[]; - expect(cookies).toBeDefined(); + const cookies = getSetCookieHeaders(res.headers); expect(cookies.some((c) => c.startsWith('refreshToken='))).toBe(true); }); @@ -121,8 +125,7 @@ describe('Auth (e2e)', () => { expect(res.body).toHaveProperty('expiresAt'); // should set new refreshToken cookie - const cookies = res.headers['set-cookie'] as string[]; - expect(cookies).toBeDefined(); + const cookies = getSetCookieHeaders(res.headers); expect(cookies.some((c) => c.startsWith('refreshToken='))).toBe(true); }); @@ -153,8 +156,7 @@ describe('Auth (e2e)', () => { expect(res.body.message).toBe('Logout successfully'); // should clear refreshToken cookie - const cookies = res.headers['set-cookie'] as string[]; - expect(cookies).toBeDefined(); + const cookies = getSetCookieHeaders(res.headers); expect(cookies.some((c) => c.includes('refreshToken=;'))).toBe(true); }); }); diff --git a/apps/server/tests/unit/modules/background-tasks/handlers/export-report.handler.spec.ts b/apps/server/tests/unit/modules/background-tasks/handlers/export-report.handler.spec.ts index 4fd69a7..92bd23f 100644 --- a/apps/server/tests/unit/modules/background-tasks/handlers/export-report.handler.spec.ts +++ b/apps/server/tests/unit/modules/background-tasks/handlers/export-report.handler.spec.ts @@ -1,4 +1,7 @@ -import { ExportReportHandler } from '@/modules/background-tasks/handlers/export-report.handler'; +import { + ExportReportHandler, + IExportReportPayload, +} from '@/modules/background-tasks/handlers/export-report.handler'; import { JOB_NAMES } from '@/shared/jobs/constants/job.constants'; import { JobRegistryService } from '@/shared/jobs/registry/job-registry.service'; import { IJobContext } from '@/shared/jobs/types/job.types'; @@ -23,7 +26,7 @@ describe('ExportReportHandler', () => { expect(handler.name).toBe(JOB_NAMES.EXPORT_REPORT); const updateProgress = vi.fn(() => Promise.resolve()); - const ctx: IJobContext = { + const ctx: IJobContext = { jobId: 'job-1', name: JOB_NAMES.EXPORT_REPORT, payload: { title: 'report', steps: 2, stepDelayMs: 100 }, diff --git a/apps/server/tests/unit/modules/background-tasks/handlers/flaky-retry.handler.spec.ts b/apps/server/tests/unit/modules/background-tasks/handlers/flaky-retry.handler.spec.ts index 381c50d..028ed98 100644 --- a/apps/server/tests/unit/modules/background-tasks/handlers/flaky-retry.handler.spec.ts +++ b/apps/server/tests/unit/modules/background-tasks/handlers/flaky-retry.handler.spec.ts @@ -1,10 +1,16 @@ -import { FlakyRetryHandler } from '@/modules/background-tasks/handlers/flaky-retry.handler'; +import { + FlakyRetryHandler, + IFlakyRetryPayload, +} from '@/modules/background-tasks/handlers/flaky-retry.handler'; import { JOB_NAMES } from '@/shared/jobs/constants/job.constants'; import { JobRegistryService } from '@/shared/jobs/registry/job-registry.service'; import { IJobContext } from '@/shared/jobs/types/job.types'; import { describe, expect, it, vi } from 'vitest'; -const createCtx = (attemptsMade: number, failTimes = 2): IJobContext => ({ +const createCtx = ( + attemptsMade: number, + failTimes = 2, +): IJobContext => ({ jobId: 'job-1', name: JOB_NAMES.FLAKY_RETRY, payload: { failTimes }, diff --git a/apps/server/tests/unit/shared/caching/cache.service.spec.ts b/apps/server/tests/unit/shared/caching/cache.service.spec.ts index 6e346c4..1cfffcb 100644 --- a/apps/server/tests/unit/shared/caching/cache.service.spec.ts +++ b/apps/server/tests/unit/shared/caching/cache.service.spec.ts @@ -1,5 +1,5 @@ import { CacheService } from '@/shared/caching/cache.service'; -import { describe, expect, it, MockInstance, vi } from 'vitest'; +import { afterEach, describe, expect, it, MockInstance, vi } from 'vitest'; type MockCache = { del: MockInstance<(key: string) => Promise>; @@ -70,6 +70,10 @@ const createServiceWithRedis = (redisStore: Record | null) => { }; describe('CacheService', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('should delegate get set del and wrap to cache manager', async () => { const cache = createCache(); const service = new CacheService(cache as never); @@ -133,6 +137,9 @@ describe('CacheService', () => { }); it('should rotate refresh token via lua script', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const redisClient = createMockRedisClient(); const { service } = createServiceWithRedis(redisClient); @@ -146,11 +153,18 @@ describe('CacheService', () => { expect(result).toBe(true); expect(redisClient.evalFn).toHaveBeenCalledWith(expect.any(String), { keys: ['old-token', 'new-token'], - arguments: ['expected-value', '60000'], + arguments: [ + 'expected-value', + '{"value":"expected-value","expires":1767225660000}', + '60000', + ], }); }); it('should rotate refresh token with namespace', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const redisClient = createMockRedisClient({ keyPrefixSeparator: ':', namespace: 'myapp', @@ -161,7 +175,7 @@ describe('CacheService', () => { expect(redisClient.evalFn).toHaveBeenCalledWith(expect.any(String), { keys: ['myapp:old', 'myapp:new'], - arguments: ['val', '60000'], + arguments: ['val', '{"value":"val","expires":1767225660000}', '60000'], }); }); diff --git a/apps/server/tests/unit/shared/jobs/board/job-board.auth.middleware.spec.ts b/apps/server/tests/unit/shared/jobs/board/job-board.auth.middleware.spec.ts index 65acc33..9a7fb97 100644 --- a/apps/server/tests/unit/shared/jobs/board/job-board.auth.middleware.spec.ts +++ b/apps/server/tests/unit/shared/jobs/board/job-board.auth.middleware.spec.ts @@ -22,11 +22,16 @@ const createMiddleware = () => { }; const createResponse = () => { + const status = vi.fn(); + const json = vi.fn(); const response = { - status: vi.fn(() => response), - json: vi.fn(() => response), + status, + json, } as unknown as Response; - return response; + status.mockReturnValue(response); + json.mockReturnValue(response); + + return { response, status }; }; const createRequest = (authorization?: string) => { @@ -50,18 +55,18 @@ describe('JobBoardAuthMiddleware', () => { it('should reject request without bearer token', async () => { const { middleware } = createMiddleware(); - const response = createResponse(); + const { response, status } = createResponse(); const next = vi.fn(); await middleware.use(createRequest(), response, next); - expect(response.status).toHaveBeenCalledWith(401); + expect(status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); it('should reject refresh token', async () => { const { middleware, jwtService } = createMiddleware(); - const response = createResponse(); + const { response, status } = createResponse(); const next = vi.fn(); jwtService.verify.mockReturnValue({ @@ -71,13 +76,13 @@ describe('JobBoardAuthMiddleware', () => { await middleware.use(createRequest('Bearer token'), response, next); - expect(response.status).toHaveBeenCalledWith(401); + expect(status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); it('should allow valid refresh token cookie for browser board access', async () => { const { middleware, jwtService, usersService } = createMiddleware(); - const response = createResponse(); + const { response } = createResponse(); const next = vi.fn(); jwtService.verify.mockReturnValue({ @@ -94,7 +99,7 @@ describe('JobBoardAuthMiddleware', () => { it('should reject when user does not exist', async () => { const { middleware, jwtService, usersService } = createMiddleware(); - const response = createResponse(); + const { response, status } = createResponse(); const next = vi.fn(); jwtService.verify.mockReturnValue({ @@ -105,13 +110,13 @@ describe('JobBoardAuthMiddleware', () => { await middleware.use(createRequest('Bearer token'), response, next); - expect(response.status).toHaveBeenCalledWith(401); + expect(status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); it('should allow valid access token', async () => { const { middleware, jwtService, usersService } = createMiddleware(); - const response = createResponse(); + const { response } = createResponse(); const next = vi.fn(); jwtService.verify.mockReturnValue({ diff --git a/apps/server/tests/unit/shared/jobs/jobs.controller.spec.ts b/apps/server/tests/unit/shared/jobs/jobs.controller.spec.ts index d8e83e8..b836dab 100644 --- a/apps/server/tests/unit/shared/jobs/jobs.controller.spec.ts +++ b/apps/server/tests/unit/shared/jobs/jobs.controller.spec.ts @@ -11,7 +11,9 @@ import { Request, Response } from 'express'; import { Subject } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const createView = (status = JOB_STATUS.ACTIVE): IJobRunView => ({ +const createView = ( + status: IJobRunView['status'] = JOB_STATUS.ACTIVE, +): IJobRunView => ({ id: 'job-1', name: 'export-report', queueName: 'default', @@ -49,12 +51,18 @@ const createController = () => { }; const createResponse = () => { - return { - setHeader: vi.fn(), - flushHeaders: vi.fn(), - write: vi.fn(), - end: vi.fn(), + const setHeader = vi.fn(); + const flushHeaders = vi.fn(); + const write = vi.fn(); + const end = vi.fn(); + const response = { + setHeader, + flushHeaders, + write, + end, } as unknown as Response; + + return { response, setHeader, write, end }; }; const createRequest = () => { @@ -76,7 +84,7 @@ describe('JobsController', () => { it('should stream snapshot and close after terminal event', async () => { const { controller, jobService, events } = createController(); const subject = new Subject(); - const response = createResponse(); + const { response, setHeader, write, end } = createResponse(); const { request } = createRequest(); jobService.getById.mockResolvedValue(createView()); @@ -84,11 +92,8 @@ describe('JobsController', () => { await controller.getEvents('job-1', request, response); - expect(response.setHeader).toHaveBeenCalledWith( - 'Content-Type', - 'text/event-stream', - ); - expect(response.write).toHaveBeenCalledWith( + expect(setHeader).toHaveBeenCalledWith('Content-Type', 'text/event-stream'); + expect(write).toHaveBeenCalledWith( expect.stringContaining('event: job.snapshot'), ); @@ -97,7 +102,7 @@ describe('JobsController', () => { event: JOB_SSE_EVENT.UPDATED, data: createView(), }); - expect(response.write).toHaveBeenCalledWith( + expect(write).toHaveBeenCalledWith( expect.stringContaining('event: job.updated'), ); @@ -107,20 +112,20 @@ describe('JobsController', () => { data: createView(JOB_STATUS.COMPLETED), }); - expect(response.write).toHaveBeenCalledWith( + expect(write).toHaveBeenCalledWith( expect.stringContaining('event: job.completed'), ); - expect(response.end).toHaveBeenCalledTimes(1); + expect(end).toHaveBeenCalledTimes(1); }); it('should subscribe before sending snapshot so mid-read events are not lost', async () => { const { controller, jobService, events } = createController(); const subject = new Subject(); - const response = createResponse(); + const { response, write } = createResponse(); const { request } = createRequest(); const writeOrder: string[] = []; - jobService.getById.mockImplementation(async () => { + jobService.getById.mockImplementation(() => { subject.next({ id: '1', event: JOB_SSE_EVENT.UPDATED, @@ -129,10 +134,10 @@ describe('JobsController', () => { return createView(); }); events.subscribe.mockReturnValue(subject.asObservable()); - response.write = vi.fn((chunk: string) => { + write.mockImplementation((chunk: string) => { writeOrder.push(chunk); return true; - }) as Response['write']; + }); await controller.getEvents('job-1', request, response); diff --git a/apps/server/tests/unit/shared/jobs/records/job-record.service.spec.ts b/apps/server/tests/unit/shared/jobs/records/job-record.service.spec.ts index 067eaa9..647dda3 100644 --- a/apps/server/tests/unit/shared/jobs/records/job-record.service.spec.ts +++ b/apps/server/tests/unit/shared/jobs/records/job-record.service.spec.ts @@ -5,8 +5,11 @@ import { } from '@/shared/jobs/constants/job.constants'; import { JobRun } from '@/shared/jobs/records/entities/job-run.entity'; import { JobRecordService } from '@/shared/jobs/records/job-record.service'; +import type { IJobSseEvent } from '@/shared/jobs/types/job.types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +type JobEventInput = Omit; + const createRun = (overrides: Partial = {}) => { const run = new JobRun(); run.id = 'job-1'; @@ -52,7 +55,7 @@ const createService = () => { findAndCount: vi.fn(), }; const events = { - publish: vi.fn(), + publish: vi.fn<(event: JobEventInput) => void>(), }; const service = new JobRecordService(repository as never, events as never); @@ -117,13 +120,14 @@ describe('JobRecordService', () => { await service.updateProgress('job-1', 60); - expect(events.publish).toHaveBeenCalledWith({ + const [publishedEvent] = events.publish.mock.calls[0]; + expect(publishedEvent).toMatchObject({ event: 'job.updated', - data: expect.objectContaining({ + data: { id: 'job-1', status: JOB_STATUS.ACTIVE, progress: 60, - }), + }, }); }); @@ -168,13 +172,14 @@ describe('JobRecordService', () => { expect(run.attemptsMade).toBe(3); expect(run.errorMessage).toBeNull(); expect(run.finishedAt).toBeInstanceOf(Date); - expect(events.publish).toHaveBeenCalledWith({ + const [publishedEvent] = events.publish.mock.calls[0]; + expect(publishedEvent).toMatchObject({ event: 'job.completed', - data: expect.objectContaining({ + data: { id: 'job-1', status: JOB_STATUS.COMPLETED, progress: 100, - }), + }, }); }); @@ -196,12 +201,13 @@ describe('JobRecordService', () => { finishedAt: expect.any(Date) as Date, }), ); - expect(events.publish).toHaveBeenCalledWith({ + const [publishedEvent] = events.publish.mock.calls[0]; + expect(publishedEvent).toMatchObject({ event: 'job.failed', - data: expect.objectContaining({ + data: { id: 'job-1', status: JOB_STATUS.FAILED, - }), + }, }); }); @@ -218,13 +224,14 @@ describe('JobRecordService', () => { attemptsMade: 1, errorMessage: 'temp fail', }); - expect(events.publish).toHaveBeenCalledWith({ + const [publishedEvent] = events.publish.mock.calls[0]; + expect(publishedEvent).toMatchObject({ event: 'job.updated', - data: expect.objectContaining({ + data: { id: 'job-1', status: JOB_STATUS.QUEUED, attemptsMade: 1, - }), + }, }); }); @@ -238,12 +245,13 @@ describe('JobRecordService', () => { await service.markCancelledIfCancellable('job-1'); - expect(events.publish).toHaveBeenCalledWith({ + const [publishedEvent] = events.publish.mock.calls[0]; + expect(publishedEvent).toMatchObject({ event: 'job.cancelled', - data: expect.objectContaining({ + data: { id: 'job-1', status: JOB_STATUS.CANCELLED, - }), + }, }); }); diff --git a/apps/server/tests/unit/shared/jobs/services/job.service.spec.ts b/apps/server/tests/unit/shared/jobs/services/job.service.spec.ts index d462b6f..8018ee1 100644 --- a/apps/server/tests/unit/shared/jobs/services/job.service.spec.ts +++ b/apps/server/tests/unit/shared/jobs/services/job.service.spec.ts @@ -126,7 +126,7 @@ describe('JobService', () => { payload: { title: 'report' }, }, expect.objectContaining({ - jobId: 'job-1', + jobId: 'job-job-1', attempts: 1, }), ); @@ -160,6 +160,23 @@ describe('JobService', () => { ); }); + it('should prefix numeric record id when using it as BullMQ custom job id', async () => { + const { service, registry, records, queue } = createService(); + const run = createRun({ id: '42' }); + + registry.has.mockReturnValue(true); + records.createQueued.mockResolvedValue(run); + queue.enqueue.mockResolvedValue({ id: 'job-42' }); + records.getViewOrFail.mockResolvedValue(createView({ id: '42' })); + + await service.submit({ name: 'export-report' }); + + expect(queue.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ jobId: '42' }), + expect.objectContaining({ jobId: 'job-42' }), + ); + }); + it('should mark failed when enqueue throws', async () => { const { service, registry, records, queue } = createService(); const run = createRun(); diff --git a/apps/web/package.json b/apps/web/package.json index 33f97b1..7dbd239 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,7 +16,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ant-design/icons": "^6.1.0", + "@ant-design/icons": "^6.3.2", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", @@ -27,10 +27,11 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "^5.90.10", - "antd": "^5.29.1", + "antd": "^6.6.0", "axios": "^1.15.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "fzkit": "^0.2.1", "lucide-react": "^0.526.0", "next-themes": "^0.4.6", "react": "^19.2.0", diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 67fd66e..175217f 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,5 +1,6 @@ import queryClient from "@/lib/query-client"; import { QueryClientProvider } from "@tanstack/react-query"; +import { App as AntdApp } from "antd"; import { lazy, Suspense } from "react"; import { Loading } from "./components/loading"; import { Toaster } from "./components/ui/sonner"; @@ -10,8 +11,10 @@ const App = () => { return ( }> - - + + + + ); diff --git a/apps/web/src/components/jobs/job-detail-panel/index.tsx b/apps/web/src/components/jobs/job-detail-panel/index.tsx index 924fd3b..0130510 100644 --- a/apps/web/src/components/jobs/job-detail-panel/index.tsx +++ b/apps/web/src/components/jobs/job-detail-panel/index.tsx @@ -1,7 +1,13 @@ import { JobProgressSection } from "@/components/jobs/job-progress-section"; +import { JobRefreshModeToggle } from "@/components/jobs/job-refresh-mode-toggle"; import { JobStatusTag } from "@/components/jobs/job-status-tag"; -import type { IJobRun } from "@/services/types/job"; -import { Alert, Button, Card, Descriptions, Empty, Space, Typography } from "antd"; +import { + JOB_REFRESH_MODE, + type IJobRun, + type JobRefreshMode, +} from "@/services/types/job"; +import { Alert, Button, Card, Descriptions, Empty, Space, Tag, Typography } from "antd"; +import type { SseState } from "fzkit/http-client"; const { Paragraph, Text } = Typography; @@ -15,11 +21,25 @@ const formatJson = (value: unknown) => { return JSON.stringify(value, null, 2); }; +const SSE_CONNECTION_STATUS: Record< + SseState, + { color: string; label: string } +> = { + connecting: { color: "processing", label: "连接中" }, + open: { color: "success", label: "已连接" }, + retrying: { color: "warning", label: "重连中" }, + closed: { color: "default", label: "已关闭" }, +}; + interface IJobDetailPanelProps { job?: IJobRun; loading?: boolean; isFetching?: boolean; error?: Error | null; + refreshMode: JobRefreshMode; + connectionState: SseState; + lastEventAt: number | null; + onRefreshModeChange: (mode: JobRefreshMode) => void; onRetry?: () => void; } @@ -28,102 +48,121 @@ export const JobDetailPanel = ({ loading, isFetching, error, + refreshMode, + connectionState, + lastEventAt, + onRefreshModeChange, onRetry, }: IJobDetailPanelProps) => { - if (error) { - return ( - - - 重试 - - ) : undefined - } - /> - - ); - } - - if (!job) { - return ( - - - - ); - } + const isSseMode = refreshMode === JOB_REFRESH_MODE.SSE; + const sseConnection = SSE_CONNECTION_STATUS[connectionState]; return ( - - - + } + > + + {error && ( + + 重试 + + ) : undefined + } + /> + )} + + {!job && } -
-
- {job.name} -
{job.id}
-
- - {isFetching && 刷新中} - - -
+ {job && ( + <> +
+
+ {job.name} +
{job.id}
+
+ + {isSseMode ? ( + <> + + SSE {sseConnection.label} + + {lastEventAt && ( + + 最近事件 {new Date(lastEventAt).toLocaleTimeString()} + + )} + + ) : ( + isFetching && 刷新中 + )} + + +
- + - - {job.queueName} - {job.triggerType} - - {job.attemptsMade} / {job.maxAttempts} - - - {formatDateTime(job.createdAt)} - - - {formatDateTime(job.startedAt)} - - - {formatDateTime(job.finishedAt)} - - + + {job.queueName} + + {job.triggerType} + + + {job.attemptsMade} / {job.maxAttempts} + + + {formatDateTime(job.createdAt)} + + + {formatDateTime(job.startedAt)} + + + {formatDateTime(job.finishedAt)} + + -
- payload - -
-              {formatJson(job.payload)}
-            
-
-
+
+ payload + +
+                  {formatJson(job.payload)}
+                
+
+
-
- result - -
-              {formatJson(job.result)}
-            
-
-
+
+ result + +
+                  {formatJson(job.result)}
+                
+
+
-
- errorMessage - -
-              {job.errorMessage || "-"}
-            
-
-
+
+ errorMessage + +
+                  {job.errorMessage || "-"}
+                
+
+
+ + )}
); diff --git a/apps/web/src/components/jobs/job-refresh-mode-toggle/index.tsx b/apps/web/src/components/jobs/job-refresh-mode-toggle/index.tsx new file mode 100644 index 0000000..471c0b0 --- /dev/null +++ b/apps/web/src/components/jobs/job-refresh-mode-toggle/index.tsx @@ -0,0 +1,26 @@ +import { + JOB_REFRESH_MODE, + type JobRefreshMode, +} from "@/services/types/job"; +import { Segmented } from "antd"; + +interface IJobRefreshModeToggleProps { + mode: JobRefreshMode; + onChange: (mode: JobRefreshMode) => void; +} + +export const JobRefreshModeToggle = ({ + mode, + onChange, +}: IJobRefreshModeToggleProps) => { + return ( + + value={mode} + options={[ + { label: "SSE", value: JOB_REFRESH_MODE.SSE }, + { label: "Polling", value: JOB_REFRESH_MODE.POLLING }, + ]} + onChange={onChange} + /> + ); +}; diff --git a/apps/web/src/pages/jobs/index.tsx b/apps/web/src/pages/jobs/index.tsx index c4fc7f2..68650f2 100644 --- a/apps/web/src/pages/jobs/index.tsx +++ b/apps/web/src/pages/jobs/index.tsx @@ -14,8 +14,15 @@ import type { ISubmitFlakyRetryDto, } from "@/services/dtos/job"; import { useJobPolling } from "@/services/hooks/use-job-polling"; +import { useJobSse } from "@/services/hooks/use-job-sse"; import { useJobsList } from "@/services/hooks/use-jobs-list"; -import type { IJobRun, JobStatus } from "@/services/types/job"; +import { + JOB_REFRESH_MODE, + JOB_TERMINAL_STATUSES, + type IJobRun, + type JobRefreshMode, + type JobStatus, +} from "@/services/types/job"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Alert, Button, Card, message } from "antd"; import { useState } from "react"; @@ -27,6 +34,10 @@ export const Jobs = () => { const [pageSize, setPageSize] = useState(10); const [selectedJobId, setSelectedJobId] = useState(null); const [cancellingJobId, setCancellingJobId] = useState(null); + const [sseJobIds, setSseJobIds] = useState([]); + const [refreshMode, setRefreshMode] = useState( + JOB_REFRESH_MODE.SSE, + ); const queryClient = useQueryClient(); @@ -36,10 +47,30 @@ export const Jobs = () => { name: name || undefined, status, }); - const jobDetailQuery = useJobPolling(selectedJobId); + const pollingDetailQuery = useJobPolling( + selectedJobId, + refreshMode === JOB_REFRESH_MODE.POLLING, + ); + const sseDetailQuery = useJobSse( + selectedJobId, + sseJobIds, + refreshMode === JOB_REFRESH_MODE.SSE, + (jobId) => { + setSseJobIds((jobIds) => jobIds.filter((id) => id !== jobId)); + }, + ); + + const trackJobSse = (job: IJobRun) => { + if (JOB_TERMINAL_STATUSES.includes(job.status)) return; + + setSseJobIds((jobIds) => + jobIds.includes(job.id) ? jobIds : [...jobIds, job.id], + ); + }; const handleJobSubmitted = (job: IJobRun) => { message.success("任务已提交"); + trackJobSse(job); setSelectedJobId(job.id); queryClient.invalidateQueries({ queryKey: ["jobs"] }); }; @@ -109,6 +140,18 @@ export const Jobs = () => { flakyRetryMutation.mutate(data); }; + const isSseMode = refreshMode === JOB_REFRESH_MODE.SSE; + const jobDetailQuery = isSseMode ? sseDetailQuery : pollingDetailQuery; + + const handleDetailRetry = () => { + if (isSseMode) { + sseDetailQuery.retry(); + return; + } + + void pollingDetailQuery.refetch(); + }; + return (
{ onOpenBoard={handleOpenBoard} /> - - {jobsQuery.isError && ( { loading={jobsQuery.isLoading} selectedJobId={selectedJobId} cancellingJobId={cancellingJobId} - onSelectJob={(job) => setSelectedJobId(job.id)} + onSelectJob={(job) => { + trackJobSse(job); + setSelectedJobId(job.id); + }} onCancelJob={(job) => cancelMutation.mutate(job.id)} onPaginationChange={(nextPage, nextPageSize) => { setPage(nextPage); @@ -184,7 +222,11 @@ export const Jobs = () => { loading={jobDetailQuery.isLoading} isFetching={jobDetailQuery.isFetching} error={jobDetailQuery.error} - onRetry={() => jobDetailQuery.refetch()} + refreshMode={refreshMode} + connectionState={sseDetailQuery.connectionState} + lastEventAt={sseDetailQuery.lastEventAt} + onRefreshModeChange={setRefreshMode} + onRetry={handleDetailRetry} />
diff --git a/apps/web/src/services/api/__tests__/new-http.integration.test.ts b/apps/web/src/services/api/__tests__/new-http.integration.test.ts new file mode 100644 index 0000000..635f892 --- /dev/null +++ b/apps/web/src/services/api/__tests__/new-http.integration.test.ts @@ -0,0 +1,481 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { + AxiosError, + AxiosHeaders, + type InternalAxiosRequestConfig, +} from "axios"; +import type { HttpClientInstance } from "fzkit/http-client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const refreshTokenMock = vi.hoisted(() => vi.fn()); +const messageErrorMock = vi.hoisted(() => vi.fn()); + +vi.mock("../refresh-token", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + RefreshToken: refreshTokenMock, + }; +}); + +vi.mock("antd", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + message: { + ...actual.message, + error: messageErrorMock, + }, + }; +}); + +interface IRequestRecord { + authorization: string | undefined; + lastEventId: string | undefined; + pathname: string; +} + +interface IAppAssembly { + http: HttpClientInstance; + useUserStore: typeof import("@/stores/user").useUserStore; + handleRefreshAndReconnect: typeof import("../socket-client").handleRefreshAndReconnect; + socket: typeof import("../socket-client").socket; +} + +const wait = (ms: number) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const waitFor = async (predicate: () => boolean) => { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await wait(10); + } + + throw new Error("等待测试条件超时"); +}; + +const createUnauthorizedError = () => { + const error = new AxiosError("refresh token 已失效"); + error.response = { + config: { + headers: new AxiosHeaders(), + } as InternalAxiosRequestConfig, + data: null, + headers: {}, + status: 401, + statusText: "Unauthorized", + }; + + return error; +}; + +const loadAppAssembly = async (): Promise => { + vi.resetModules(); + + const [ + { default: http }, + { useUserStore }, + { handleRefreshAndReconnect, socket }, + ] = await Promise.all([ + import("../new-http"), + import("@/stores/user"), + import("../socket-client"), + ]); + + return { + http: http as HttpClientInstance, + useUserStore, + handleRefreshAndReconnect, + socket, + }; +}; + +describe("new-http fzkit 业务装配", () => { + let dedupeGate: Promise; + let releaseDedupeGate: () => void; + let dedupeRequestCount: number; + let server: Server; + let serverUrl: string; + let sseRequestCount: number; + let sseRequests: IRequestRecord[]; + + const writeJson = ( + response: ServerResponse, + status: number, + data: unknown, + ) => { + response.writeHead(status, { "Content-Type": "application/json" }); + response.end(JSON.stringify(data)); + }; + + const handleRequest = async ( + request: IncomingMessage, + response: ServerResponse, + ) => { + const url = new URL(request.url ?? "/", serverUrl); + const record: IRequestRecord = { + authorization: request.headers.authorization, + lastEventId: Array.isArray(request.headers["last-event-id"]) + ? request.headers["last-event-id"][0] + : request.headers["last-event-id"], + pathname: url.pathname, + }; + + if (url.pathname === "/token") { + writeJson(response, 200, { authorization: record.authorization }); + return; + } + + if (url.pathname.startsWith("/auth/")) { + writeJson(response, 401, { message: "unauthorized" }); + return; + } + + if (url.pathname.startsWith("/protected")) { + if (record.authorization === "Bearer old-access-token") { + writeJson(response, 401, { message: "unauthorized" }); + return; + } + + writeJson(response, 200, { authorization: record.authorization }); + return; + } + + if (url.pathname === "/error") { + writeJson(response, 500, { message: "internal server error" }); + return; + } + + if (url.pathname === "/dedupe") { + dedupeRequestCount += 1; + await dedupeGate; + writeJson(response, 200, { requestCount: dedupeRequestCount }); + return; + } + + if (url.pathname === "/events") { + sseRequestCount += 1; + sseRequests.push(record); + + if (record.authorization === "Bearer old-access-token") { + response.writeHead(401); + response.end(); + return; + } + + response.writeHead(200, { + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Content-Type": "text/event-stream", + }); + + if (sseRequestCount === 2) { + response.end("id: event-1\nevent: update\ndata: refreshed\n\n"); + } + } + }; + + beforeEach(async () => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + clear: () => storage.clear(), + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + get length() { + return storage.size; + }, + removeItem: (key: string) => storage.delete(key), + setItem: (key: string, value: string) => storage.set(key, value), + }); + dedupeRequestCount = 0; + dedupeGate = new Promise((resolve) => { + releaseDedupeGate = resolve; + }); + sseRequestCount = 0; + sseRequests = []; + refreshTokenMock.mockReset(); + messageErrorMock.mockReset(); + + server = createServer((request, response) => { + void handleRequest(request, response); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address() as AddressInfo; + serverUrl = `http://127.0.0.1:${address.port}`; + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + }); + + it("从用户 store 注入 access token", async () => { + const { http, useUserStore } = await loadAppAssembly(); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + + const response = await http.get<{ authorization: string }>("/token"); + + expect(response.data.authorization).toBe("Bearer old-access-token"); + }); + + it("并发 401 只刷新一次,并以新 token 重放请求", async () => { + const { http, useUserStore } = await loadAppAssembly(); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + refreshTokenMock.mockImplementation(async () => { + const refreshedToken = { + accessToken: "new-access-token", + expiresAt: Date.now() + 60_000, + }; + useUserStore.getState().setJwtToken(refreshedToken); + return refreshedToken; + }); + + const [first, second] = await Promise.all([ + http.get<{ authorization: string }>("/protected/first"), + http.get<{ authorization: string }>("/protected/second"), + ]); + + expect(refreshTokenMock).toHaveBeenCalledTimes(1); + expect(first.data.authorization).toBe("Bearer new-access-token"); + expect(second.data.authorization).toBe("Bearer new-access-token"); + }); + + it("刷新鉴权失败时只登出一次,并保留业务错误文案", async () => { + const { http, useUserStore } = await loadAppAssembly(); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + const logout = vi.spyOn(useUserStore.getState(), "logout"); + let rejectRefresh: (error: AxiosError) => void; + const refreshFailure = new Promise((_, reject) => { + rejectRefresh = reject; + }); + refreshTokenMock.mockReturnValue(refreshFailure); + + const requests = Promise.allSettled([ + http.get("/protected/first"), + http.get("/protected/second"), + ]); + await waitFor(() => refreshTokenMock.mock.calls.length === 1); + rejectRefresh!(createUnauthorizedError()); + const results = await requests; + + expect(logout).toHaveBeenCalledTimes(1); + expect(useUserStore.getState().jwtToken).toBeNull(); + expect(results.every((result) => result.status === "rejected")).toBe(true); + + const firstFailure = results[0]; + if (firstFailure?.status !== "rejected") { + throw new Error("预期请求会因 refresh token 失效而失败"); + } + + expect(firstFailure.reason).toMatchObject({ + message: "登录已过期,请重新登录", + }); + }); + + it("登录与刷新端点的 401 不触发 refresh 或 logout", async () => { + const { http, useUserStore } = await loadAppAssembly(); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + const logout = vi.spyOn(useUserStore.getState(), "logout"); + + await expect(http.post("/auth/login")).rejects.toMatchObject({ + response: { status: 401 }, + }); + + expect(refreshTokenMock).not.toHaveBeenCalled(); + expect(logout).not.toHaveBeenCalled(); + }); + + it("HTTP 错误日志仅记录脱敏字段", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + try { + const { http } = await loadAppAssembly(); + http.defaults.baseURL = serverUrl; + + await expect(http.get("/error")).rejects.toMatchObject({ + response: { status: 500 }, + }); + + expect(consoleError).toHaveBeenCalledWith( + "HTTP Error:", + expect.objectContaining({ message: expect.any(String) }), + ); + expect(consoleError.mock.calls.at(-1)?.[1]).not.toHaveProperty("config"); + } finally { + consoleError.mockRestore(); + } + }); + + it("合并相同的并发 GET 请求", async () => { + const { http, useUserStore } = await loadAppAssembly(); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + + const requests = Promise.all([http.get("/dedupe"), http.get("/dedupe")]); + + await waitFor(() => dedupeRequestCount > 0); + await wait(20); + releaseDedupeGate(); + await requests; + + expect(dedupeRequestCount).toBe(1); + }); + + it("SSE 在 401 后刷新重连、续传事件游标,手动关闭不再重连", async () => { + const { http, useUserStore } = await loadAppAssembly(); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + refreshTokenMock.mockImplementation(async () => { + const refreshedToken = { + accessToken: "new-access-token", + expiresAt: Date.now() + 60_000, + }; + useUserStore.getState().setJwtToken(refreshedToken); + return refreshedToken; + }); + const closeReasons: string[] = []; + + const subscription = http.sse("/events", { + lastEventId: "seed-event", + retryDelay: () => 0, + onClose: (reason) => { + closeReasons.push(reason); + }, + }); + + await waitFor(() => sseRequestCount === 3); + + expect(refreshTokenMock).toHaveBeenCalledTimes(1); + expect(sseRequests[0]).toMatchObject({ + authorization: "Bearer old-access-token", + lastEventId: "seed-event", + }); + expect(sseRequests[2]).toMatchObject({ + authorization: "Bearer new-access-token", + lastEventId: "event-1", + }); + + subscription.close(); + await wait(20); + + expect(sseRequestCount).toBe(3); + expect(closeReasons).toEqual(["manual"]); + }); + + it("HTTP 401 与 socket 并发刷新只走一次 RefreshToken", async () => { + const { http, useUserStore, handleRefreshAndReconnect, socket } = + await loadAppAssembly(); + const disconnect = vi.spyOn(socket, "disconnect").mockReturnValue(socket); + const connect = vi.spyOn(socket, "connect").mockReturnValue(socket); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + let releaseRefresh: () => void = () => undefined; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + refreshTokenMock.mockImplementation(async () => { + await refreshGate; + const refreshedToken = { + accessToken: "new-access-token", + expiresAt: Date.now() + 60_000, + }; + useUserStore.getState().setJwtToken(refreshedToken); + return refreshedToken; + }); + + const httpRequest = http.get<{ authorization: string }>("/protected/first"); + const socketRefresh = handleRefreshAndReconnect(); + await waitFor(() => refreshTokenMock.mock.calls.length === 1); + releaseRefresh(); + const [httpResponse] = await Promise.all([httpRequest, socketRefresh]); + + expect(refreshTokenMock).toHaveBeenCalledTimes(1); + expect(httpResponse.data.authorization).toBe("Bearer new-access-token"); + expect(useUserStore.getState().jwtToken?.accessToken).toBe( + "new-access-token", + ); + expect(socket.auth).toEqual({ token: "new-access-token" }); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledTimes(1); + }); + + it("HTTP 刷新完成后,冷却期内 socket 不再打 RefreshToken", async () => { + const { http, useUserStore, handleRefreshAndReconnect, socket } = + await loadAppAssembly(); + const disconnect = vi.spyOn(socket, "disconnect").mockReturnValue(socket); + const connect = vi.spyOn(socket, "connect").mockReturnValue(socket); + http.defaults.baseURL = serverUrl; + useUserStore.getState().setJwtToken({ + accessToken: "old-access-token", + expiresAt: Date.now() + 60_000, + }); + refreshTokenMock.mockImplementation(async () => { + const refreshedToken = { + accessToken: "new-access-token", + expiresAt: Date.now() + 60_000, + }; + useUserStore.getState().setJwtToken(refreshedToken); + return refreshedToken; + }); + + const httpResponse = await http.get<{ authorization: string }>( + "/protected/first", + ); + await handleRefreshAndReconnect(); + + expect(refreshTokenMock).toHaveBeenCalledTimes(1); + expect(httpResponse.data.authorization).toBe("Bearer new-access-token"); + expect(useUserStore.getState().jwtToken?.accessToken).toBe( + "new-access-token", + ); + expect(socket.auth).toEqual({ token: "new-access-token" }); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.cooldown.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.cooldown.test.ts deleted file mode 100644 index 46fc990..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.cooldown.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import "./test-utils/mock-axios"; -import axios from "axios"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { createHttpClient } from "../"; -import { - createRefreshAccessToken, - createTokenStore, - queueAxiosError, - queueCustomHandler, - queueResponse, -} from "./test-utils/mock-axios"; - -describe("Token 刷新冷却期机制", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - it("场景 3:刷新完成后,旧请求陆续返回 401 → 跳过刷新,用新 Token 重试", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - refreshCooldownMs: 15000, - }); - - // 第一个请求 401,触发刷新 - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { accessToken: "new-access", refreshToken: "new-refresh" }, - }); - queueCustomHandler(async (config) => ({ - status: 200, - data: { result: "first-retry" }, - config, - })); - - const firstRequest = http.get("/profile"); - - // 等待第一个请求完成刷新 - await vi.runAllTimersAsync(); - await firstRequest; - - // 刷新完成后 5 秒,旧请求返回 401 - await vi.advanceTimersByTimeAsync(5000); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueCustomHandler(async (config) => { - // 应该使用新 token 重试,不会再次刷新 - expect(config.headers?.Authorization).toBe("Bearer new-access"); - return { status: 200, data: { result: "second-retry" }, config }; - }); - - const secondRequest = await http.get("/me"); - - expect(secondRequest.data).toEqual({ result: "second-retry" }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); // 只刷新了一次 - }); - - it("场景 4:慢请求卡了 10 秒后返回 401 → 跳过刷新(在 15 秒冷却期内)", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - refreshCooldownMs: 15000, - }); - - // 第一个请求触发刷新 - queueAxiosError({ status: 401 }); - queueResponse({ - status: 200, - data: { accessToken: "new-access", refreshToken: "new-refresh" }, - }); - queueCustomHandler(async () => ({ - status: 200, - data: { result: "first" }, - })); - - await http.get("/fast"); - - // 10 秒后,慢请求返回 401 - await vi.advanceTimersByTimeAsync(10000); - - queueAxiosError({ status: 401 }); - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer new-access"); - return { status: 200, data: { result: "slow-retry" } }; - }); - - const slowResponse = await http.get("/slow"); - - expect(slowResponse.data).toEqual({ result: "slow-retry" }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - }); - - it("场景 5:冷却期过后(16 秒)又收到 401 → 正常执行刷新", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - refreshCooldownMs: 15000, - }); - - // 第一次刷新 - queueAxiosError({ status: 401 }); - queueResponse({ - status: 200, - data: { accessToken: "token-1", refreshToken: "refresh-1" }, - }); - queueCustomHandler(async () => ({ status: 200, data: { ok: true } })); - - await http.get("/first"); - - // 16 秒后,冷却期已过 - await vi.advanceTimersByTimeAsync(16000); - - // 再次 401,应该触发第二次刷新 - queueAxiosError({ status: 401 }); - queueResponse({ - status: 200, - data: { accessToken: "token-2", refreshToken: "refresh-2" }, - }); - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer token-2"); - return { status: 200, data: { result: "second" } }; - }); - - const secondResponse = await http.get("/second"); - - expect(secondResponse.data).toEqual({ result: "second" }); - expect(refreshAccessToken).toHaveBeenCalledTimes(2); - }); - - it("场景 6:刷新失败后重试 → 下次可以重新刷新", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - let refreshAttempts = 0; - - const refreshAccessToken = vi.fn(async () => { - refreshAttempts++; - - // 第一次刷新失败(模拟网络错误:AxiosError 无 response) - if (refreshAttempts === 1) { - const error = new axios.AxiosError("Network error"); - error.isAxiosError = true; - throw error; - } - - // 第二次刷新成功 - const response = await axios.post("/auth/refresh", { - refreshToken: await tokenStore.getRefreshToken(), - }); - const data = response.data as { - accessToken: string; - refreshToken: string; - }; - - tokenStore.setAccessToken(data.accessToken); - tokenStore.setRefreshToken(data.refreshToken); - - return data.accessToken; - }); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - refreshCooldownMs: 15000, - }); - - // 第一次刷新失败 - queueAxiosError({ status: 401 }); - - await expect(http.get("/first")).rejects.toThrow("Network error"); - - // 第二次刷新成功 - queueAxiosError({ status: 401 }); - queueResponse({ - status: 200, - data: { accessToken: "new-token", refreshToken: "new-refresh" }, - }); - queueCustomHandler(async () => ({ status: 200, data: { ok: true } })); - - const secondResponse = await http.get("/second"); - - expect(secondResponse.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(2); - }); - - it("场景 7:skipRefreshUrls 不触发刷新逻辑", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - const onAuthFailure = vi.fn(); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - skipRefreshUrls: ["/public"], - onAuthFailure, - refreshCooldownMs: 15000, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/public/data")).rejects.toThrow(); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("场景 8:冷却期边界测试(14.9 秒 vs 15.1 秒)", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - refreshCooldownMs: 15000, - }); - - // 第一次刷新 - queueAxiosError({ status: 401 }); - queueResponse({ - status: 200, - data: { accessToken: "token-1", refreshToken: "refresh-1" }, - }); - queueCustomHandler(async () => ({ status: 200, data: { ok: true } })); - - await http.get("/first"); - - // 14.9 秒后,仍在冷却期内 - await vi.advanceTimersByTimeAsync(14900); - - queueAxiosError({ status: 401 }); - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer token-1"); - return { status: 200, data: { result: "within-cooldown" } }; - }); - - await http.get("/within"); - - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - - // 再过 300ms,总共 15.2 秒,冷却期已过 - await vi.advanceTimersByTimeAsync(300); - - queueAxiosError({ status: 401 }); - queueResponse({ - status: 200, - data: { accessToken: "token-2", refreshToken: "refresh-2" }, - }); - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer token-2"); - return { status: 200, data: { result: "after-cooldown" } }; - }); - - await http.get("/after"); - - expect(refreshAccessToken).toHaveBeenCalledTimes(2); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts deleted file mode 100644 index 27d2ebb..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts +++ /dev/null @@ -1,601 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import "./test-utils/mock-axios"; - -import { createHttpClient } from "../"; -import { - createRefreshAccessToken, - createTokenStore, - queueAxiosError, - queueCustomHandler, - queueMatchedHandler, - queueResponse, -} from "./test-utils/mock-axios"; - -describe("createHttpClient edge cases", () => { - it("命中 skipRefreshUrls 时,401 不会触发 refresh", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => {}); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - refreshAccessToken, - skipRefreshUrls: ["/auth/login"], - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect( - http.post("/auth/login", { account: "u", password: "p" }), - ).rejects.toMatchObject({ - name: "Error", - message: "Request failed", - }); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - expect(tokenStore.getRefreshToken).not.toHaveBeenCalled(); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("命中 skipRefreshUrls 时,业务响应触发刷新也不会执行 refresh", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - skipRefreshUrls: ["/auth/login"], - shouldRefreshByResponseData: (response) => { - const data = response.data as { code?: number }; - return data.code === 40101; - }, - }); - - queueResponse({ - status: 200, - data: { code: 40101, message: "token expired" }, - }); - - const response = await http.post("/auth/login", { - account: "u", - password: "p", - }); - - expect(response.data).toEqual({ code: 40101, message: "token expired" }); - expect(refreshAccessToken).not.toHaveBeenCalled(); - expect(tokenStore.getRefreshToken).not.toHaveBeenCalled(); - }); - - it("自定义 unauthorizedStatusCode 命中时会刷新并重试", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - unauthorizedStatusCode: 498, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - }); - - queueAxiosError({ status: 498, data: { message: "token expired" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - }, - }); - - let retriedAuthorization = ""; - - queueCustomHandler(async (config) => { - retriedAuthorization = config.headers?.Authorization; - return { status: 200, data: { ok: true }, config }; - }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).toHaveBeenCalledWith("new-access"); - expect(retriedAuthorization).toBe("Bearer new-access"); - }); - - it("业务响应要求刷新但未启用刷新时会直接返回原响应", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => {}); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - shouldRefreshByResponseData: () => true, - }); - - queueResponse({ - status: 200, - data: { code: 40101, message: "token expired" }, - }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ code: 40101, message: "token expired" }); - expect(onAuthFailure).not.toHaveBeenCalled(); - }); - - it("refresh 成功响应缺少 accessToken 时默认不鉴权失败(业务 Error 需自定义 isRefreshFailure)", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => {}); - const onError = vi.fn(); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - onError, - refreshAccessToken, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - refreshToken: "new-refresh", - }, - }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "missing accessToken", - }); - - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.getRefreshToken).toHaveBeenCalledTimes(1); - expect(onAuthFailure).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).not.toHaveBeenCalled(); - expect(tokenStore.setRefreshToken).not.toHaveBeenCalled(); - }); - - it("并发 401 请求在 refresh 返回 500 时不会触发 onAuthFailure(服务端错误不代表 token 失效)", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => { - tokenStore.clearAuth(); - }); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - refreshAccessToken, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueAxiosError({ - message: "refresh server error", - status: 500, - data: { code: 50001, message: "server error" }, - }); - - const results = await Promise.allSettled([ - http.get("/profile"), - http.get("/me"), - ]); - - expect(results).toEqual([ - expect.objectContaining({ - status: "rejected", - reason: expect.objectContaining({ - name: "Error", - message: "refresh server error", - }), - }), - expect.objectContaining({ - status: "rejected", - reason: expect.objectContaining({ - name: "Error", - message: "refresh server error", - }), - }), - ]); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.getRefreshToken).toHaveBeenCalledTimes(1); - expect(onAuthFailure).not.toHaveBeenCalled(); - expect(tokenStore.setAccessToken).not.toHaveBeenCalled(); - }); - - it("重试后的业务响应再次命中刷新条件时会停止重试并退出登录", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => {}); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - refreshAccessToken, - shouldRefreshByResponseData: (response) => { - const data = response.data as { code?: number }; - return data.code === 40101; - }, - }); - - queueResponse({ - status: 200, - data: { code: 40101, message: "token expired" }, - }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - }); - queueResponse({ - status: 200, - data: { code: 40101, message: "still expired" }, - }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - name: "Error", - message: "Login session has expired", - }); - - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setRefreshToken).toHaveBeenCalledWith("new-refresh"); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("refresh 进行中时新来的 401 请求会复用同一次 refresh", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - let releaseRefresh: (value?: void | PromiseLike) => void = () => { - throw new Error("refresh resolver not initialized"); - }; - - const refreshStarted = new Promise((resolve) => { - releaseRefresh = resolve; - }); - - let notifyRefreshStarted: (() => void) | null = null; - - const waitRefreshStarted = new Promise((resolve) => { - notifyRefreshStarted = resolve; - }); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - }); - - queueMatchedHandler( - (config) => config.url === "/profile" && !config._retry, - async (config) => { - const error = new Error("unauthorized") as Error & { - config: any; - isAxiosError: boolean; - response?: { status?: number; data?: unknown; config: any }; - }; - - error.config = config; - error.isAxiosError = true; - error.response = { - status: 401, - data: { message: "unauthorized" }, - config, - }; - - throw error; - }, - ); - queueMatchedHandler( - (config) => config.url === "/me" && !config._retry, - async (config) => { - const error = new Error("unauthorized") as Error & { - config: any; - isAxiosError: boolean; - response?: { status?: number; data?: unknown; config: any }; - }; - - error.config = config; - error.isAxiosError = true; - error.response = { - status: 401, - data: { message: "unauthorized" }, - config, - }; - - throw error; - }, - ); - queueMatchedHandler( - (config) => config.url === "/auth/refresh", - async (config) => { - notifyRefreshStarted?.(); - await refreshStarted; - - return { - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - config, - }; - }, - ); - - const retriedAuthorizations: string[] = []; - - queueMatchedHandler( - (config) => config.url === "/profile" && config._retry, - async (config) => { - retriedAuthorizations.push(config.headers?.Authorization); - return { status: 200, data: { ok: "first" }, config }; - }, - ); - queueMatchedHandler( - (config) => config.url === "/me" && config._retry, - async (config) => { - retriedAuthorizations.push(config.headers?.Authorization); - return { status: 200, data: { ok: "second" }, config }; - }, - ); - - const firstRequest = http.get("/profile"); - await waitRefreshStarted; - const secondRequest = http.get("/me"); - releaseRefresh(); - - const [first, second] = await Promise.all([firstRequest, secondRequest]); - - expect(first.data).toEqual({ ok: "first" }); - expect(second.data).toEqual({ ok: "second" }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.getRefreshToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setRefreshToken).toHaveBeenCalledWith("new-refresh"); - expect(retriedAuthorizations).toEqual([ - "Bearer new-access", - "Bearer new-access", - ]); - }); - - it("refresh 错误命中 refreshFailureCodes 时会视为登录过期", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => {}); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - refreshFailureCodes: [1001002], - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - refreshAccessToken: createRefreshAccessToken(tokenStore), - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueAxiosError({ - message: "refresh forbidden", - status: 403, - data: { code: 1001002, message: "refresh expired" }, - }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - name: "Error", - message: "Refresh token is invalid or expired", - }); - - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("refresh 抛出非 AxiosError 时默认不触发 onAuthFailure", async () => { - const onAuthFailure = vi.fn(async () => {}); - const onError = vi.fn(); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: async () => "old-access", - onAuthFailure, - onError, - refreshAccessToken: async () => { - throw new TypeError("refresh broken"); - }, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "refresh broken", - }); - - expect(onAuthFailure).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "refresh broken" }), - { type: "refresh" }, - ); - }); - - it("自定义 isRefreshFailure 仍可把非 AxiosError 判为鉴权失败", async () => { - const onAuthFailure = vi.fn(async () => {}); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: async () => "old-access", - onAuthFailure, - isRefreshFailure: () => true, - refreshAccessToken: async () => { - throw new Error("business refresh failed"); - }, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Refresh token is invalid or expired", - }); - - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("refresh 返回空 token 时触发鉴权失败且不进入冷却", async () => { - const onAuthFailure = vi.fn(async () => {}); - const refreshAccessToken = vi - .fn() - .mockResolvedValueOnce("") - .mockResolvedValueOnce("new-access"); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: async () => "old-access", - onAuthFailure, - refreshAccessToken, - refreshCooldownMs: 15000, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Refresh token is invalid or expired", - }); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - - // 空 token 刷新失败不应进入冷却,下一次 401 仍可再次 refresh - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueCustomHandler(async (config) => ({ - status: 200, - data: { ok: true, auth: config.headers?.Authorization }, - config, - })); - - const response = await http.get("/profile"); - expect(response.data).toEqual({ ok: true, auth: "Bearer new-access" }); - expect(refreshAccessToken).toHaveBeenCalledTimes(2); - }); - - it("冷却期内 getAccessToken 为空时不重试原请求,直接鉴权失败", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => {}); - const refreshAccessToken = vi.fn(async () => { - tokenStore.setAccessToken("new-access"); - tokenStore.setRefreshToken("new-refresh"); - return "new-access"; - }); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - refreshAccessToken, - refreshCooldownMs: 15000, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueCustomHandler(async (config) => ({ - status: 200, - data: { ok: true }, - config, - })); - - await http.get("/profile"); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - - // 模拟 logout:清空 token,但仍处于冷却期 - tokenStore.clearAuth(); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/me")).rejects.toMatchObject({ - message: "Login session has expired", - }); - - expect(refreshAccessToken).toHaveBeenCalledTimes(1); // 冷却内未再次 refresh - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("onAuthFailure 抛错时会透出回调错误而不是原始鉴权错误", async () => { - const onAuthFailure = vi.fn(async () => { - throw new Error("cleanup failed"); - }); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: async () => "old-access", - onAuthFailure, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "cleanup failed", - }); - - expect(onAuthFailure).toHaveBeenCalledTimes(1); - expect(onAuthFailure).toHaveBeenCalledWith( - expect.objectContaining({ - name: "Error", - message: "Request failed", - }), - ); - }); - - it("onBusinessResponse 抛出的特定消息能在 onError 中正常接收", async () => { - const onError = vi.fn(); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: async () => "old-access", - onBusinessResponse: () => new Error("业务错误:余额不足"), - onError, - }); - - queueResponse({ status: 200, data: { ok: true } }); - - await expect(http.get("/wallet/balance")).rejects.toMatchObject({ - message: "业务错误:余额不足", - }); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "业务错误:余额不足" }), - { type: "request" }, - ); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.error.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.error.test.ts deleted file mode 100644 index 730d341..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.error.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { normalizeError, invokeOnError } from "../utils/error"; - -describe("normalizeError", () => { - it("Error 实例直接返回", () => { - const error = new Error("test"); - expect(normalizeError(error)).toBe(error); - }); - - it("具有 message 属性的对象 → 构造 Error", () => { - const result = normalizeError({ message: "from object" }); - expect(result).toBeInstanceOf(Error); - expect(result.message).toBe("from object"); - }); - - it("可序列化值 → JSON.stringify 结果作为 message", () => { - expect(normalizeError({ code: 1 }).message).toBe('{"code":1}'); - expect(normalizeError("string error").message).toBe('"string error"'); - expect(normalizeError(42).message).toBe("42"); - expect(normalizeError(null).message).toBe("null"); - expect(normalizeError(undefined).message).toBe(""); - }); - - it("循环引用对象 → String(error) 作为降级", () => { - const circular: Record = {}; - circular.self = circular; - - const result = normalizeError(circular); - expect(result).toBeInstanceOf(Error); - expect(result.message).toBe("[object Object]"); - }); -}); - -describe("invokeOnError", () => { - const baseError = new Error("original"); - - it("未传 onError → 返回原 error", async () => { - const result = await invokeOnError(baseError, { type: "request" }); - expect(result).toBe(baseError); - }); - - it("onError 返回 void → 返回原 error", async () => { - const onError = vi.fn(() => {}); - const result = await invokeOnError(baseError, { type: "request" }, onError); - expect(result).toBe(baseError); - expect(onError).toHaveBeenCalledWith(baseError, { type: "request" }); - }); - - it("onError 返回新 Error → 替换原 error", async () => { - const replacement = new Error("replaced"); - const onError = vi.fn(() => replacement); - const result = await invokeOnError(baseError, { type: "refresh" }, onError); - expect(result).toBe(replacement); - }); - - it("onError 抛错 → 返回原 error(降级)", async () => { - const onError = vi.fn(() => { - throw new Error("callback failed"); - }); - const result = await invokeOnError(baseError, { type: "request" }, onError); - expect(result).toBe(baseError); - }); - - it("onError 异步返回新 Error → 替换原 error", async () => { - const replacement = new Error("async replaced"); - const onError = vi.fn(async () => replacement); - const result = await invokeOnError(baseError, { type: "request" }, onError); - expect(result).toBe(replacement); - }); - - it("onError 异步抛错 → 返回原 error(降级)", async () => { - const onError = vi.fn(async () => { - throw new Error("async callback failed"); - }); - const result = await invokeOnError(baseError, { type: "request" }, onError); - expect(result).toBe(baseError); - }); - - it("onError 返回 undefined → 等同于 void,返回原 error", async () => { - const onError = vi.fn(() => undefined); - const result = await invokeOnError(baseError, { type: "request" }, onError); - expect(result).toBe(baseError); - }); - - it("context 透传 request 类型", async () => { - const onError = vi.fn(() => {}); - await invokeOnError(baseError, { type: "request" }, onError); - expect(onError).toHaveBeenCalledWith(baseError, { type: "request" }); - }); - - it("context 透传 refresh 类型", async () => { - const onError = vi.fn(() => {}); - await invokeOnError(baseError, { type: "refresh" }, onError); - expect(onError).toHaveBeenCalledWith(baseError, { type: "refresh" }); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts deleted file mode 100644 index 8ce54fe..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { InternalAxiosRequestConfig } from "axios"; -import { AxiosError, AxiosHeaders } from "axios"; -import { shouldSkipRefresh, defaultIsRefreshFailure } from "../utils/refresh"; -import type { RequestRetryState } from "../types/common"; - -vi.mock("axios", async () => { - const actual = await vi.importActual("axios"); - return { - ...actual, - default: { - ...actual.default, - isAxiosError: actual.default.isAxiosError, - }, - isAxiosError: actual.isAxiosError, - }; -}); - -const createRequestConfig = ( - config: Partial = {}, -): InternalAxiosRequestConfig & RequestRetryState => { - return { - headers: new AxiosHeaders(), - ...config, - } as InternalAxiosRequestConfig & RequestRetryState; -}; - -const makeAxiosError = ( - options: { status?: number; code?: number; response?: boolean } = {}, -): AxiosError => { - const error = new AxiosError("test error"); - if (options.response !== false) { - error.response = { - status: options.status ?? 401, - data: options.code !== undefined ? { code: options.code } : {}, - headers: {}, - statusText: "Error", - config: createRequestConfig(), - }; - } - return error; -}; - -describe("shouldSkipRefresh", () => { - it("config 为 undefined → false", () => { - expect(shouldSkipRefresh(["/auth"], undefined)).toBe(false); - }); - - it("config.url 为 undefined → false", () => { - expect(shouldSkipRefresh(["/auth"], createRequestConfig())).toBe(false); - }); - - it("URL exact 命中 skipRefreshUrl → true", () => { - expect( - shouldSkipRefresh(["/auth/login"], createRequestConfig({ url: "/auth/login" })), - ).toBe(true); - }); - - it("URL 以 prefix path 命中 skipRefreshUrl → true", () => { - expect( - shouldSkipRefresh(["/public"], createRequestConfig({ url: "/public/data" })), - ).toBe(true); - expect( - shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/auth/login" })), - ).toBe(true); - }); - - it("中间段/子串路径不会跳过刷新", () => { - expect( - shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/user/auth-history" })), - ).toBe(false); - expect( - shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/authorization" })), - ).toBe(false); - expect( - shouldSkipRefresh( - ["/auth"], - createRequestConfig({ url: "/gateway/user/auth/session" }), - ), - ).toBe(false); - expect( - shouldSkipRefresh( - ["/auth/login"], - createRequestConfig({ url: "/api/auth/login" }), - ), - ).toBe(false); - }); - - it("URL 不匹配任何 skipRefreshUrl → false", () => { - expect( - shouldSkipRefresh(["/auth/login"], createRequestConfig({ url: "/api/profile" })), - ).toBe(false); - }); - - it("skipRefreshUrls 为空数组 → 始终 false", () => { - expect( - shouldSkipRefresh([], createRequestConfig({ url: "/auth/login" })), - ).toBe(false); - }); -}); - -describe("defaultIsRefreshFailure", () => { - const baseOptions = { unauthorizedStatusCode: 401, refreshFailureCodes: [1001002] }; - - it("非 AxiosError → false(编程/业务 Error 默认不视为鉴权失败)", () => { - expect(defaultIsRefreshFailure(new Error("something"), baseOptions)).toBe(false); - expect(defaultIsRefreshFailure(new TypeError("boom"), baseOptions)).toBe(false); - expect(defaultIsRefreshFailure({ reason: "boom" }, baseOptions)).toBe(false); - }); - - it("AxiosError 无 response → false", () => { - expect( - defaultIsRefreshFailure(makeAxiosError({ response: false }), baseOptions), - ).toBe(false); - }); - - it("status >= 500 → false(服务端错误不视为刷新失败)", () => { - expect(defaultIsRefreshFailure(makeAxiosError({ status: 500 }), baseOptions)).toBe(false); - expect(defaultIsRefreshFailure(makeAxiosError({ status: 502 }), baseOptions)).toBe(false); - expect(defaultIsRefreshFailure(makeAxiosError({ status: 503 }), baseOptions)).toBe(false); - }); - - it("status 等于 unauthorizedStatusCode → true", () => { - expect(defaultIsRefreshFailure(makeAxiosError({ status: 401 }), baseOptions)).toBe(true); - }); - - it("data.code 在 refreshFailureCodes 中 → true", () => { - expect( - defaultIsRefreshFailure(makeAxiosError({ status: 403, code: 1001002 }), baseOptions), - ).toBe(true); - }); - - it("data.code 不在 refreshFailureCodes 中 → false", () => { - expect( - defaultIsRefreshFailure(makeAxiosError({ status: 403, code: 999999 }), baseOptions), - ).toBe(false); - }); - - it("data.code 为 undefined 且 status 不匹配 → false", () => { - expect( - defaultIsRefreshFailure(makeAxiosError({ status: 403 }), baseOptions), - ).toBe(false); - }); - - it("自定义 unauthorizedStatusCode 生效", () => { - const options = { unauthorizedStatusCode: 498, refreshFailureCodes: [] }; - expect(defaultIsRefreshFailure(makeAxiosError({ status: 498 }), options)).toBe(true); - expect(defaultIsRefreshFailure(makeAxiosError({ status: 401 }), options)).toBe(false); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.retry-policy.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.retry-policy.test.ts deleted file mode 100644 index 7963b41..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.retry-policy.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { AxiosError } from "axios"; -import { - defaultShouldRetry, - defaultRetryDelay, - resolveRetryPolicy, -} from "../utils/refresh"; - -vi.mock("axios", async () => { - const actual = await vi.importActual("axios"); - return { - ...actual, - default: { - ...actual.default, - isAxiosError: actual.default.isAxiosError, - }, - isAxiosError: actual.isAxiosError, - }; -}); - -const makeAxiosError = ( - options: { status?: number; response?: boolean } = {}, -): AxiosError => { - const error = new AxiosError("test error"); - if (options.response !== false) { - error.response = { - status: options.status ?? 500, - data: {}, - headers: {}, - statusText: "Error", - config: {} as any, - }; - } - return error; -}; - -describe("defaultShouldRetry", () => { - it("非 AxiosError(普通 Error)不重试", () => { - expect(defaultShouldRetry(new Error("something"))).toBe(false); - }); - - it("AxiosError 无 response(网络错误)重试", () => { - const error = makeAxiosError({ response: false }); - expect(defaultShouldRetry(error)).toBe(true); - }); - - it("AxiosError 5xx 状态码重试", () => { - expect(defaultShouldRetry(makeAxiosError({ status: 500 }))).toBe(true); - expect(defaultShouldRetry(makeAxiosError({ status: 502 }))).toBe(true); - expect(defaultShouldRetry(makeAxiosError({ status: 503 }))).toBe(true); - }); - - it("AxiosError 4xx 状态码不重试", () => { - expect(defaultShouldRetry(makeAxiosError({ status: 400 }))).toBe(false); - expect(defaultShouldRetry(makeAxiosError({ status: 401 }))).toBe(false); - expect(defaultShouldRetry(makeAxiosError({ status: 404 }))).toBe(false); - }); - - it("AxiosError 3xx 状态码不重试", () => { - expect(defaultShouldRetry(makeAxiosError({ status: 301 }))).toBe(false); - }); -}); - -describe("defaultRetryDelay", () => { - it("retryCount 0 → 1000ms", () => { - expect(defaultRetryDelay(0)).toBe(1000); - }); - - it("retryCount 1 → 2000ms", () => { - expect(defaultRetryDelay(1)).toBe(2000); - }); - - it("retryCount 2 → 4000ms(指数退避)", () => { - expect(defaultRetryDelay(2)).toBe(4000); - }); - - it("retryCount 足够大时上限为 30000ms", () => { - expect(defaultRetryDelay(10)).toBe(30000); - expect(defaultRetryDelay(100)).toBe(30000); - }); -}); - -describe("resolveRetryPolicy", () => { - it("未传入 retryPolicy → 返回 null", () => { - expect(resolveRetryPolicy(undefined)).toBeNull(); - }); - - it("maxRetries 未设置(默认 0)→ 返回 null", () => { - expect(resolveRetryPolicy({})).toBeNull(); - }); - - it("maxRetries 为 0 → 返回 null", () => { - expect(resolveRetryPolicy({ maxRetries: 0 })).toBeNull(); - }); - - it("maxRetries 为负数 → 返回 null", () => { - expect(resolveRetryPolicy({ maxRetries: -1 })).toBeNull(); - }); - - it("maxRetries > 0 → 返回完整策略对象", () => { - const result = resolveRetryPolicy({ maxRetries: 2 }); - expect(result).toEqual({ - maxRetries: 2, - shouldRetry: expect.any(Function), - retryDelay: expect.any(Function), - }); - }); - - it("自定义 shouldRetry 和 retryDelay 会保留", () => { - const customShouldRetry = () => true; - const customRetryDelay = () => 500; - - const result = resolveRetryPolicy({ - maxRetries: 1, - shouldRetry: customShouldRetry, - retryDelay: customRetryDelay, - }); - - expect(result?.shouldRetry).toBe(customShouldRetry); - expect(result?.retryDelay).toBe(customRetryDelay); - }); - - it("返回对象中的默认 shouldRetry 行为正确", () => { - const policy = resolveRetryPolicy({ maxRetries: 2 }); - expect(policy?.shouldRetry(makeAxiosError({ status: 500 }), 0)).toBe(true); - expect(policy?.shouldRetry(makeAxiosError({ status: 400 }), 0)).toBe(false); - }); - - it("返回对象中的默认 retryDelay 行为正确", () => { - const policy = resolveRetryPolicy({ maxRetries: 2 }); - expect(policy?.retryDelay(0)).toBe(1000); - expect(policy?.retryDelay(1)).toBe(2000); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts deleted file mode 100644 index ddb2d42..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts +++ /dev/null @@ -1,1443 +0,0 @@ -import "./test-utils/mock-axios"; -import axios from "axios"; -import { describe, expect, it, vi } from "vitest"; - -import { createHttpClient } from "../"; -import { - createRefreshAccessToken, - createTokenStore, - queueAxiosError, - queueCustomHandler, - queueMatchedHandler, - queueResponse, -} from "./test-utils/mock-axios"; - -describe("createHttpClient", () => { - it("会透传 axiosConfig 给 axios.create", () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - - createHttpClient({ - axiosConfig: { - baseURL: "/api", - timeout: 20 * 1000, - withCredentials: true, - headers: { - "X-App": "web", - }, - }, - getAccessToken: tokenStore.getAccessToken, - }); - - expect(axios.create).toHaveBeenCalledWith({ - baseURL: "/api", - timeout: 20 * 1000, - withCredentials: true, - headers: { - "X-App": "web", - }, - }); - }); - - it("并发 401 请求只刷新一次,并在重试时使用新 token", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - }); - - const retriedAuthHeaders: string[] = []; - - queueCustomHandler(async (config) => { - retriedAuthHeaders.push(config.headers?.Authorization); - return { status: 200, data: { ok: true }, config }; - }); - - queueCustomHandler(async (config) => { - retriedAuthHeaders.push(config.headers?.Authorization); - return { status: 200, data: { ok: true }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile"), - http.get("/me"), - ]); - - expect(first.data).toEqual({ ok: true }); - expect(second.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.getRefreshToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setRefreshToken).toHaveBeenCalledWith("new-refresh"); - expect(retriedAuthHeaders).toEqual([ - "Bearer new-access", - "Bearer new-access", - ]); - }); - - it("显式传入的鉴权 header 不会被 token 注入覆盖", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken: createRefreshAccessToken(tokenStore), - }); - - let receivedAuthorization = ""; - - queueCustomHandler(async (config) => { - receivedAuthorization = config.headers?.Authorization; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile", { - headers: { - Authorization: "Bearer explicit-token", - }, - }); - - expect(receivedAuthorization).toBe("Bearer explicit-token"); - expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); - }); - - it("显式传入空 Authorization 不会被 token 注入覆盖", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - }); - - let receivedAuthorization: unknown = "unset"; - - queueCustomHandler(async (config) => { - receivedAuthorization = config.headers?.Authorization; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile", { - headers: { - Authorization: "", - }, - }); - - expect(receivedAuthorization).toBe(""); - expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); - }); - - it("内部 refresh 重试不会污染 config 上的 __skipDedupe", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - dedupePolicy: { enabled: true }, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - }); - - let retriedConfig: { __skipDedupe?: unknown } | undefined; - queueCustomHandler(async (config) => { - retriedConfig = config as { __skipDedupe?: unknown }; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile"); - - expect(retriedConfig?.__skipDedupe).toBeUndefined(); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - }); - - it("getAccessToken 为空时不会注入鉴权 header", async () => { - const getAccessToken = vi.fn(async () => ""); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken, - }); - - let receivedAuthorization: unknown; - - queueCustomHandler(async (config) => { - receivedAuthorization = config.headers?.Authorization; - return { status: 200, data: { ok: true }, config }; - }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(getAccessToken).toHaveBeenCalledTimes(1); - expect(receivedAuthorization).toBeUndefined(); - }); - - it("refresh 失败时会触发一次 onAuthFailure 并返回登录过期错误", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onAuthFailure = vi.fn(async () => { - tokenStore.clearAuth(); - }); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - refreshAccessToken, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueAxiosError({ status: 401, data: { message: "refresh expired" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - name: "Error", - message: "Refresh token is invalid or expired", - }); - - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.clearAuth).toHaveBeenCalledTimes(1); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).not.toHaveBeenCalled(); - }); - - it("业务响应命中 accessToken 失效 code 时会刷新并重试", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken: createRefreshAccessToken(tokenStore), - shouldRefreshByResponseData: (response) => { - const data = response.data as { code?: number }; - return data.code === 40101; - }, - }); - - queueResponse({ - status: 200, - data: { code: 40101, message: "token expired" }, - }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - }); - - let retriedAuthorization = ""; - - queueCustomHandler(async (config) => { - retriedAuthorization = config.headers?.Authorization; - return { status: 200, data: { ok: true }, config }; - }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(tokenStore.setAccessToken).toHaveBeenCalledTimes(1); - expect(retriedAuthorization).toBe("Bearer new-access"); - }); - - it("refresh 返回空字符串 refreshToken 时会交给使用者自行处理", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "", - }, - }); - queueResponse({ - status: 200, - data: { ok: true }, - }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).toHaveBeenCalledWith("new-access"); - expect(tokenStore.setRefreshToken).toHaveBeenCalledWith(""); - }); - - it("未启用刷新时,401 会触发 onAuthFailure 且不会读取 refreshToken", async () => { - const onAuthFailure = vi.fn(async () => {}); - const tokenStore = createTokenStore("old-access", "old-refresh"); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - name: "Error", - message: "Request failed", - }); - - expect(onAuthFailure).toHaveBeenCalledTimes(1); - expect(tokenStore.getRefreshToken).not.toHaveBeenCalled(); - }); - - it("非 401 的 axios 错误会透传原始错误", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken: createRefreshAccessToken(tokenStore), - }); - - queueAxiosError({ - message: "Request failed", - status: 500, - data: { code: 50001, message: "server error" }, - }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - name: "Error", - message: "Request failed", - }); - - expect(tokenStore.clearAuth).not.toHaveBeenCalled(); - expect(tokenStore.setAccessToken).not.toHaveBeenCalled(); - }); - - it("重试后的请求再次返回 401 时会停止重试并退出登录", async () => { - const onAuthFailure = vi.fn(async () => {}); - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - }, - getAccessToken: tokenStore.getAccessToken, - onAuthFailure, - refreshAccessToken, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - }, - }); - queueAxiosError({ status: 401, data: { message: "still unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - name: "Error", - message: "Login session has expired", - }); - - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("多个客户端共享 TokenRefreshManager 时只刷新一次", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - const { TokenRefreshManager } = await import("../token-refresh-manager"); - const sharedManager = new TokenRefreshManager(15000); - - const http1 = createHttpClient({ - axiosConfig: { baseURL: "/api1" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - refreshManager: sharedManager, - }); - - const http2 = createHttpClient({ - axiosConfig: { baseURL: "/api2" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - refreshManager: sharedManager, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { accessToken: "new-access", refreshToken: "new-refresh" }, - }); - queueCustomHandler(async (config) => ({ - status: 200, - data: { ok: true }, - config, - })); - queueCustomHandler(async (config) => ({ - status: 200, - data: { ok: true }, - config, - })); - - const [result1, result2] = await Promise.all([ - http1.get("/profile"), - http2.get("/users"), - ]); - - expect(result1.data).toEqual({ ok: true }); - expect(result2.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(tokenStore.setAccessToken).toHaveBeenCalledTimes(1); - }); - - describe("headersProvider", () => { - it("同步 headersProvider 注入的 headers 会合并到请求中", async () => { - const tokenStore = createTokenStore("test-token", "test-refresh"); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - headersProvider: () => ({ - "x-trace-id": "trace-123", - "x-request-id": "req-456", - }), - }); - - let receivedHeaders: Record = {}; - - queueCustomHandler(async (config) => { - receivedHeaders = { - "x-trace-id": config.headers?.["x-trace-id"], - "x-request-id": config.headers?.["x-request-id"], - Authorization: config.headers?.Authorization, - }; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile"); - - expect(receivedHeaders).toEqual({ - "x-trace-id": "trace-123", - "x-request-id": "req-456", - Authorization: "Bearer test-token", - }); - }); - - it("异步 headersProvider 注入的 headers 会合并到请求中", async () => { - const tokenStore = createTokenStore("test-token", "test-refresh"); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - headersProvider: async () => { - // 模拟异步获取(如从 store 读取) - await new Promise((resolve) => setTimeout(resolve, 10)); - return { - "x-tenant-id": "tenant-789", - "x-region": "cn-east", - }; - }, - }); - - let receivedHeaders: Record = {}; - - queueCustomHandler(async (config) => { - receivedHeaders = { - "x-tenant-id": config.headers?.["x-tenant-id"], - "x-region": config.headers?.["x-region"], - Authorization: config.headers?.Authorization, - }; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile"); - - expect(receivedHeaders).toEqual({ - "x-tenant-id": "tenant-789", - "x-region": "cn-east", - Authorization: "Bearer test-token", - }); - }); - - it("headersProvider 返回的 Authorization 会覆盖默认注入的 token", async () => { - const tokenStore = createTokenStore("default-token", "test-refresh"); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - headersProvider: () => ({ - Authorization: "Bearer custom-token", - }), - }); - - let receivedAuthorization = ""; - - queueCustomHandler(async (config) => { - receivedAuthorization = config.headers?.Authorization; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile"); - - expect(receivedAuthorization).toBe("Bearer custom-token"); - expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); - }); - - it("headersProvider 返回小写 authorization 时不会再读取本地 token", async () => { - const tokenStore = createTokenStore("default-token", "test-refresh"); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - headersProvider: () => ({ - authorization: "Bearer custom-token", - }), - }); - - let receivedAuthorization: unknown; - - queueCustomHandler(async (config) => { - receivedAuthorization = - config.headers?.Authorization ?? config.headers?.authorization; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile"); - - expect(receivedAuthorization).toBe("Bearer custom-token"); - expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); - }); - - it("未配置 headersProvider 时行为不变", async () => { - const tokenStore = createTokenStore("test-token", "test-refresh"); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - }); - - let receivedHeaders: Record = {}; - - queueCustomHandler(async (config) => { - receivedHeaders = { - Authorization: config.headers?.Authorization, - "x-trace-id": config.headers?.["x-trace-id"], - }; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile"); - - expect(receivedHeaders).toEqual({ - Authorization: "Bearer test-token", - "x-trace-id": undefined, - }); - }); - - it("请求已预置 Authorization 时仍会执行 headersProvider", async () => { - const tokenStore = createTokenStore("default-token", "test-refresh"); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - headersProvider: () => ({ - "x-trace-id": "trace-from-provider", - }), - }); - - let receivedHeaders: Record = {}; - - queueCustomHandler(async (config) => { - receivedHeaders = { - Authorization: config.headers?.Authorization, - "x-trace-id": config.headers?.["x-trace-id"], - }; - return { status: 200, data: { ok: true }, config }; - }); - - await http.get("/profile", { - headers: { - Authorization: "Bearer explicit-token", - }, - }); - - expect(receivedHeaders).toEqual({ - Authorization: "Bearer explicit-token", - "x-trace-id": "trace-from-provider", - }); - expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); - }); - - it("刷新后重试仍会执行 headersProvider", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - let providerCallCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - headersProvider: () => { - providerCallCount += 1; - return { - "x-trace-id": `trace-${providerCallCount}`, - }; - }, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - }); - - let retriedHeaders: Record = {}; - queueCustomHandler(async (config) => { - retriedHeaders = { - Authorization: config.headers?.Authorization, - "x-trace-id": config.headers?.["x-trace-id"], - }; - return { status: 200, data: { ok: true }, config }; - }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(providerCallCount).toBe(2); - expect(retriedHeaders).toEqual({ - Authorization: "Bearer new-access", - "x-trace-id": "trace-2", - }); - }); - }); - - describe("retryPolicy", () => { - it("5xx 错误时会重试指定次数", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 2, - retryDelay: () => 0, // 测试时不延迟 - }, - }); - - // 前 2 次失败,第 3 次成功(初始 + 2 次重试) - queueAxiosError({ status: 500, data: { message: "server error" } }); - queueAxiosError({ status: 500, data: { message: "server error" } }); - queueResponse({ status: 200, data: { ok: true } }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - }); - - it("503 错误时会重试", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 1, - retryDelay: () => 0, - }, - }); - - queueAxiosError({ status: 503, data: { message: "service unavailable" } }); - queueResponse({ status: 200, data: { ok: true } }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - }); - - it("超过最大重试次数后会抛出错误", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 1, - retryDelay: () => 0, - }, - }); - - // 2 次失败(初始 + 1 次重试) - queueAxiosError({ status: 500, data: { message: "server error" } }); - queueAxiosError({ status: 500, data: { message: "server error" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Request failed", - }); - }); - - it("4xx 错误(非 401)不会重试", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 2, - retryDelay: () => 0, - }, - }); - - queueAxiosError({ status: 400, data: { message: "bad request" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Request failed", - }); - }); - - it("未配置 retryPolicy 时不会重试", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - }); - - queueAxiosError({ status: 500, data: { message: "server error" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Request failed", - }); - }); - - it("自定义 shouldRetry 可以控制重试条件", async () => { - const shouldRetry = vi.fn((_error, retryCount) => retryCount < 1); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 3, - shouldRetry, - retryDelay: () => 0, - }, - }); - - queueAxiosError({ status: 500, data: { message: "server error" } }); - queueResponse({ status: 200, data: { ok: true } }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(shouldRetry).toHaveBeenCalled(); - }); - - it("网络错误(无 response)时默认会重试", async () => { - vi.useFakeTimers(); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 1, - }, - }); - - queueMatchedHandler( - (config) => config.url === "/profile" && !config.__retryCount, - async (config) => { - const error = Object.assign(new Error("Network Error"), { - config, - isAxiosError: true, - }); - throw error; - }, - ); - - queueMatchedHandler( - (config) => config.url === "/profile" && config.__retryCount === 1, - async (config) => ({ - status: 200, - data: { ok: true }, - config, - }), - ); - - const responsePromise = http.get("/profile"); - await vi.advanceTimersByTimeAsync(2000); - const response = await responsePromise; - - expect(response.data).toEqual({ ok: true }); - - vi.useRealTimers(); - }); - }); - - describe("onError", () => { - it("刷新鉴权失败时 onError 只触发一次", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onError = vi.fn(); - const onAuthFailure = vi.fn(); - const refreshAccessToken = vi.fn(async () => { - // 空 token 由工厂按鉴权失败处理 - return ""; - }); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - onError, - onAuthFailure, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Refresh token is invalid or expired", - }); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "Refresh token is invalid or expired" }), - { type: "refresh" }, - ); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("刷新网络失败时 onError 只触发一次", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const onError = vi.fn(); - const onAuthFailure = vi.fn(); - const refreshAccessToken = vi.fn(async () => { - const error = new axios.AxiosError("Network error"); - error.isAxiosError = true; - throw error; - }); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - onError, - onAuthFailure, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - - await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Network error", - }); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "Network error" }), - { type: "refresh" }, - ); - expect(onAuthFailure).not.toHaveBeenCalled(); - }); - }); - - describe("401 与通用重试顺序", () => { - it("自定义 shouldRetry 返回 true 时,401 仍优先走 refresh 而不是通用重试", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - const shouldRetry = vi.fn(() => true); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - retryPolicy: { - maxRetries: 2, - shouldRetry, - retryDelay: () => 0, - }, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - }); - queueCustomHandler(async (config) => ({ - status: 200, - data: { ok: true }, - config, - })); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(shouldRetry).not.toHaveBeenCalled(); - }); - - it("5xx 仍按 retryPolicy 重试", async () => { - const shouldRetry = vi.fn(() => true); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 1, - shouldRetry, - retryDelay: () => 0, - }, - }); - - queueAxiosError({ status: 500, data: { message: "server error" } }); - queueResponse({ status: 200, data: { ok: true } }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ ok: true }); - expect(shouldRetry).toHaveBeenCalledTimes(1); - }); - }); - - describe("dedupePolicy", () => { - it("相同 in-flight GET 请求会复用同一个 Promise", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - // 只队列一个响应,第二个请求应该复用第一个 - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile"), - http.get("/profile"), - ]); - - // 两个请求复用同一个响应 - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 1 }); - expect(requestCount).toBe(1); - }); - - it("请求仍在进行中时,后续相同 GET 会继续合并", async () => { - let requestCount = 0; - let release: (() => void) | undefined; - const gate = new Promise((resolve) => { - release = resolve; - }); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - await gate; - return { status: 200, data: { count: requestCount }, config }; - }); - - const firstPromise = http.get("/profile"); - - await new Promise((resolve) => setTimeout(resolve, 40)); - - const secondPromise = http.get("/profile"); - release?.(); - - const [first, second] = await Promise.all([firstPromise, secondPromise]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 1 }); - expect(requestCount).toBe(1); - }); - - it("请求结束后,相同 GET 会重新发起", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const first = await http.get("/profile"); - const second = await http.get("/profile"); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 2 }); - expect(requestCount).toBe(2); - }); - - it("不同 URL 的请求不会合并", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { url: config.url }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { url: config.url }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile"), - http.get("/settings"), - ]); - - expect(first.data).toEqual({ url: "/profile" }); - expect(second.data).toEqual({ url: "/settings" }); - expect(requestCount).toBe(2); - }); - - it("请求级 dedupePolicy.enabled=false 可以禁用合并", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile"), - http.get("/profile", { dedupePolicy: { enabled: false } }), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 2 }); - expect(requestCount).toBe(2); - }); - - it("客户端未启用时,请求级 dedupePolicy.enabled=true 可临时启用合并", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile", { dedupePolicy: { enabled: true } }), - http.get("/profile", { dedupePolicy: { enabled: true } }), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 1 }); - expect(requestCount).toBe(1); - }); - - - - - - - it("未配置 dedupePolicy 时不会合并请求", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile"), - http.get("/profile"), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 2 }); - expect(requestCount).toBe(2); - }); - - it("合并后的请求会传递相同的响应数据", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - return { - status: 200, - data: { id: 1, name: "test" }, - config, - }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile"), - http.get("/profile"), - ]); - - expect(first.data).toEqual({ id: 1, name: "test" }); - expect(second.data).toEqual({ id: 1, name: "test" }); - expect(first.data).toBe(second.data); - }); - - it("POST 请求不会被合并", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.post("/users", { name: "test1" }), - http.post("/users", { name: "test2" }), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 2 }); - expect(requestCount).toBe(2); - }); - - it("自定义 generateKey 可以控制合并 key 的生成逻辑", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - generateKey: () => "fixed-key", - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile"), - http.get("/settings"), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 1 }); - expect(requestCount).toBe(1); - }); - - it("HEAD 请求不会被合并", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.request({ method: "head", url: "/profile" }), - http.request({ method: "HEAD", url: "/profile" }), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 2 }); - expect(requestCount).toBe(2); - }); - - it("启用 dedupe 后,401 刷新重试不会因 pending 复用而挂起", async () => { - const tokenStore = createTokenStore("old-access", "old-refresh"); - const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: tokenStore.getAccessToken, - refreshAccessToken, - dedupePolicy: { - enabled: true, - }, - }); - - queueAxiosError({ status: 401, data: { message: "unauthorized" } }); - queueResponse({ - status: 200, - data: { - accessToken: "new-access", - refreshToken: "new-refresh", - }, - }); - queueCustomHandler(async (config) => ({ - status: 200, - data: { ok: true, auth: config.headers?.Authorization }, - config, - })); - - await expect( - Promise.race([ - http.get("/profile"), - new Promise((_, reject) => { - setTimeout(() => reject(new Error("dedupe refresh retry hung")), 200); - }), - ]), - ).resolves.toMatchObject({ - data: { ok: true, auth: "Bearer new-access" }, - }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - }); - - it("启用 dedupe 后,5xx 通用重试不会因 pending 复用而挂起", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - retryPolicy: { - maxRetries: 1, - retryDelay: () => 0, - }, - }); - - queueAxiosError({ status: 500, data: { message: "server error" } }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { ok: true, attempt: requestCount }, config }; - }); - - await expect( - Promise.race([ - http.get("/profile"), - new Promise((_, reject) => { - setTimeout(() => reject(new Error("dedupe retry hung")), 200); - }), - ]), - ).resolves.toMatchObject({ - data: { ok: true, attempt: 1 }, - }); - expect(requestCount).toBe(1); - }); - - it("默认 key 会归一化 method 大小写并纳入 baseURL", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.request({ method: "GET", url: "/profile", baseURL: "/api" }), - http.request({ method: "get", url: "/profile", baseURL: "/api" }), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 1 }); - expect(requestCount).toBe(1); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [third, fourth] = await Promise.all([ - http.request({ method: "get", url: "/profile", baseURL: "/api" }), - http.request({ method: "get", url: "/profile", baseURL: "/api-v2" }), - ]); - - expect(third.data).toEqual({ count: 2 }); - expect(fourth.data).toEqual({ count: 3 }); - expect(requestCount).toBe(3); - }); - - it("params 顺序不同但内容相同时会合并", async () => { - let requestCount = 0; - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, - }); - - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - - const [first, second] = await Promise.all([ - http.get("/profile", { params: { b: 2, a: 1 } }), - http.get("/profile", { params: { a: 1, b: 2 } }), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 1 }); - expect(requestCount).toBe(1); - }); - }); - - describe("onBusinessResponse", () => { - it("返回完整 AxiosResponse 形态对象时会替换原响应", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - onBusinessResponse: () => ({ - status: 200, - data: { replaced: true }, - statusText: "OK", - headers: {}, - config: {} as any, - }), - }); - - queueResponse({ status: 200, data: { original: true } }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ replaced: true }); - }); - - it("仅含 status/data 的业务对象不会被当成响应替换", async () => { - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - onBusinessResponse: () => - ({ - status: 0, - data: { business: true }, - }) as unknown as import("axios").AxiosResponse, - }); - - queueResponse({ status: 200, data: { original: true } }); - - const response = await http.get("/profile"); - - expect(response.data).toEqual({ original: true }); - }); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts deleted file mode 100644 index 1417ee1..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -import "./test-utils/mock-axios"; -import axios from "axios"; -import { describe, expect, it, vi } from "vitest"; - -import type { AccessTokenResult } from "../types/token"; -import { createHttpClient } from "../"; -import { - queueCustomHandler, - queueResponse, -} from "./test-utils/mock-axios"; - -describe("Token 规范化边界情况", () => { - it("expiresAt 为 0 时应该返回 null,不触发主动刷新", async () => { - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: 0, - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - // 0 视为无效过期时间,不触发主动刷新 - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为空字符串时应该返回 null,不触发主动刷新", async () => { - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: "", - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为 null 时应该返回 null,不触发主动刷新", async () => { - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: null as unknown as number, - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为 undefined 时应该返回 null,不触发主动刷新", async () => { - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: undefined as unknown as number, - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为无效字符串时应该返回 null,不触发主动刷新", async () => { - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: "invalid-date-string", - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为有效时间且即将过期时应该触发主动刷新", async () => { - const futureTime = Date.now() + 30000; // 30 秒后过期 - let resolveRefresh!: () => void; - const refreshCalled = new Promise((resolve) => { - resolveRefresh = resolve; - }); - - const getAccessToken = vi.fn(async () => ({ - token: "expiring-token", - expiresAt: futureTime, - })); - - const refreshAccessToken = vi.fn(async () => { - queueResponse({ - status: 200, - data: { - accessToken: "refreshed-token", - refreshToken: "new-refresh", - }, - }); - resolveRefresh(); - return "refreshed-token" as AccessTokenResult; - }); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, // 提前 60 秒刷新 - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer expiring-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - // 应该异步触发主动刷新(不阻塞当前请求) - await refreshCalled; - - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - }); - - it("主动刷新网络失败时仍会触发 onError,且不阻塞当前请求", async () => { - const futureTime = Date.now() + 30000; - let resolveOnError!: () => void; - const onErrorCalled = new Promise((resolve) => { - resolveOnError = resolve; - }); - const onError = vi.fn(() => { - resolveOnError(); - }); - const onAuthFailure = vi.fn(); - - const getAccessToken = vi.fn(async () => ({ - token: "expiring-token", - expiresAt: futureTime, - })); - - const refreshAccessToken = vi.fn(async () => { - const error = new axios.AxiosError("Network error"); - error.isAxiosError = true; - throw error; - }); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - onError, - onAuthFailure, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer expiring-token"); - return { status: 200, data: { ok: true } }; - }); - - const response = await http.get("/test"); - - await onErrorCalled; - - expect(response.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "Network error" }), - { type: "refresh" }, - ); - expect(onAuthFailure).not.toHaveBeenCalled(); - }); - - it("主动刷新鉴权失败时会触发 onError 与 onAuthFailure,且不阻塞当前请求", async () => { - const futureTime = Date.now() + 30000; - let resolveOnError!: () => void; - const onErrorCalled = new Promise((resolve) => { - resolveOnError = resolve; - }); - const onError = vi.fn(() => { - resolveOnError(); - }); - const onAuthFailure = vi.fn(); - - const getAccessToken = vi.fn(async () => ({ - token: "expiring-token", - expiresAt: futureTime, - })); - - const refreshAccessToken = vi.fn(async () => { - // 空 token 由工厂按鉴权失败处理;返回形态需与 getAccessToken 的 T 一致 - return { - token: "", - expiresAt: futureTime, - }; - }); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - onError, - onAuthFailure, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer expiring-token"); - return { status: 200, data: { ok: true } }; - }); - - const response = await http.get("/test"); - - await onErrorCalled; - - expect(response.data).toEqual({ ok: true }); - expect(refreshAccessToken).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Refresh token is invalid or expired", - }), - { type: "refresh" }, - ); - expect(onAuthFailure).toHaveBeenCalledTimes(1); - }); - - it("expiresAt 为有效时间但未过期时不应该触发主动刷新", async () => { - const futureTime = Date.now() + 120000; // 120 秒后过期 - - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: futureTime, - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, // 提前 60 秒刷新 - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为数字类型的有效时间戳应该正常工作", async () => { - const futureTime = Date.now() + 120000; - - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: futureTime, // 数字时间戳 - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为 Date 对象应该正常工作", async () => { - const futureTime = new Date(Date.now() + 120000); - - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: futureTime, // Date 对象 - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); - - it("expiresAt 为 ISO 字符串应该正常工作", async () => { - const futureTime = new Date(Date.now() + 120000).toISOString(); - - const getAccessToken = vi.fn(async () => ({ - token: "valid-token", - expiresAt: futureTime, // ISO 字符串 - })); - - const refreshAccessToken = vi.fn(async () => "new-token" as AccessTokenResult); - - const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken, - refreshAccessToken, - refreshBufferMs: 60000, - }); - - queueCustomHandler(async (config) => { - expect(config.headers?.Authorization).toBe("Bearer valid-token"); - return { status: 200, data: { ok: true } }; - }); - - await http.get("/test"); - - expect(refreshAccessToken).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.token.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.token.test.ts deleted file mode 100644 index 0feed39..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.token.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatAccessToken, normalizeTokenResult } from "../utils/token"; - -describe("formatAccessToken", () => { - it("标准前缀 → 前缀 + 空格 + token", () => { - expect(formatAccessToken("Bearer", "abc123")).toBe("Bearer abc123"); - }); - - it("空前缀(空字符串)→ 仅返回 token", () => { - expect(formatAccessToken("", "abc123")).toBe("abc123"); - }); - - it("纯空格前缀(trim 后为空)→ 仅返回 token", () => { - expect(formatAccessToken(" ", "abc123")).toBe("abc123"); - }); - - it("前缀含前后空格 → trim 后拼接", () => { - expect(formatAccessToken(" Token ", "abc123")).toBe("Token abc123"); - }); - - it("token 为空字符串 → 前缀 + 空格 + 空字符串", () => { - expect(formatAccessToken("Bearer", "")).toBe("Bearer "); - }); -}); - -describe("normalizeTokenResult", () => { - it("字符串 token → { token, expiresAt: null }", () => { - expect(normalizeTokenResult("my-token")).toEqual({ - token: "my-token", - expiresAt: null, - }); - }); - - it("空字符串 → { token: '', expiresAt: null }", () => { - expect(normalizeTokenResult("")).toEqual({ - token: "", - expiresAt: null, - }); - }); - - it("null → { token: '', expiresAt: null }", () => { - expect(normalizeTokenResult(null as any)).toEqual({ - token: "", - expiresAt: null, - }); - }); - - it("undefined → { token: '', expiresAt: null }", () => { - expect(normalizeTokenResult(undefined as any)).toEqual({ - token: "", - expiresAt: null, - }); - }); - - it("对象无 expiresAt → { token, expiresAt: null }", () => { - expect(normalizeTokenResult({ token: "abc" } as any)).toEqual({ - token: "abc", - expiresAt: null, - }); - }); - - it("expiresAt 为 0 → expiresAt: null", () => { - expect(normalizeTokenResult({ token: "abc", expiresAt: 0 })).toEqual({ - token: "abc", - expiresAt: null, - }); - }); - - it("expiresAt 为 null → expiresAt: null", () => { - expect( - normalizeTokenResult({ token: "abc", expiresAt: null } as any), - ).toEqual({ token: "abc", expiresAt: null }); - }); - - it("expiresAt 为 undefined → expiresAt: null", () => { - expect( - normalizeTokenResult({ token: "abc", expiresAt: undefined } as any), - ).toEqual({ token: "abc", expiresAt: null }); - }); - - it("expiresAt 为空字符串 → expiresAt: null", () => { - expect(normalizeTokenResult({ token: "abc", expiresAt: "" } as any)).toEqual({ - token: "abc", - expiresAt: null, - }); - }); - - it("expiresAt 为无效日期字符串 → expiresAt: null", () => { - expect( - normalizeTokenResult({ token: "abc", expiresAt: "not-a-date" }), - ).toEqual({ token: "abc", expiresAt: null }); - }); - - it("expiresAt 为有效数字时间戳 → 返回 Date 对象", () => { - const ts = 1700000000000; - const result = normalizeTokenResult({ token: "abc", expiresAt: ts }); - expect(result.token).toBe("abc"); - expect(result.expiresAt).toBeInstanceOf(Date); - expect(result.expiresAt!.getTime()).toBe(ts); - }); - - it("expiresAt 为有效 ISO 字符串 → 返回 Date 对象", () => { - const iso = "2024-11-15T00:00:00.000Z"; - const result = normalizeTokenResult({ token: "abc", expiresAt: iso }); - expect(result.token).toBe("abc"); - expect(result.expiresAt).toBeInstanceOf(Date); - expect(result.expiresAt!.toISOString()).toBe(iso); - }); - - it("expiresAt 为 Date 对象 → 返回 Date 对象", () => { - const date = new Date("2024-12-25T00:00:00.000Z"); - const result = normalizeTokenResult({ token: "abc", expiresAt: date as any }); - expect(result.token).toBe("abc"); - expect(result.expiresAt).toBeInstanceOf(Date); - expect(result.expiresAt!.getTime()).toBe(date.getTime()); - }); -}); diff --git a/apps/web/src/services/api/http-factory/__tests__/test-utils/mock-axios.ts b/apps/web/src/services/api/http-factory/__tests__/test-utils/mock-axios.ts deleted file mode 100644 index b899e77..0000000 --- a/apps/web/src/services/api/http-factory/__tests__/test-utils/mock-axios.ts +++ /dev/null @@ -1,265 +0,0 @@ -import axios from "axios"; -import { afterEach, vi } from "vitest"; - -type MockHandler = (config: any) => Promise; - -type MatchedMockHandler = { - handler: MockHandler; - matcher: (config: any) => boolean; -}; - -const mockHandlers: MockHandler[] = []; -const matchedMockHandlers: MatchedMockHandler[] = []; - -vi.mock("axios", async () => { - const actual = await vi.importActual("axios"); - - class MockAxiosInstance { - public interceptors = { - request: { - handlers: [] as Array<{ - fulfilled?: (value: any) => any; - rejected?: (error: unknown) => unknown; - }>, - use: ( - fulfilled?: (value: any) => any, - rejected?: (error: unknown) => unknown, - ) => { - this.interceptors.request.handlers.push({ fulfilled, rejected }); - return this.interceptors.request.handlers.length - 1; - }, - }, - response: { - handlers: [] as Array<{ - fulfilled?: (value: any) => any; - rejected?: (error: unknown) => unknown; - }>, - use: ( - fulfilled?: (value: any) => any, - rejected?: (error: unknown) => unknown, - ) => { - this.interceptors.response.handlers.push({ fulfilled, rejected }); - return this.interceptors.response.handlers.length - 1; - }, - }, - }; - - async request(config: any) { - let nextConfig = config; - - for (const handler of this.interceptors.request.handlers) { - if (handler.fulfilled) { - nextConfig = await handler.fulfilled(nextConfig); - } - } - - const matchedHandlerIndex = matchedMockHandlers.findIndex(({ matcher }) => { - return matcher(nextConfig); - }); - - const handler = - matchedHandlerIndex >= 0 - ? matchedMockHandlers.splice(matchedHandlerIndex, 1)[0]?.handler - : mockHandlers.shift(); - - if (!handler) { - throw new Error( - `No mock handler for ${nextConfig.method || "get"} ${nextConfig.url}`, - ); - } - - try { - let response = await handler(nextConfig); - - if (!response.config) { - response = { ...response, config: nextConfig }; - } - - for (const responseHandler of this.interceptors.response.handlers) { - if (responseHandler.fulfilled) { - response = await responseHandler.fulfilled(response); - } - } - - return response; - } catch (error) { - let nextError = error; - - if ( - nextError && - typeof nextError === "object" && - !("config" in nextError) - ) { - if (nextError instanceof Error) { - Object.assign(nextError, { config: nextConfig }); - } else { - nextError = { ...nextError, config: nextConfig }; - } - } - - for (const responseHandler of this.interceptors.response.handlers) { - if (!responseHandler.rejected) continue; - - try { - return await responseHandler.rejected(nextError); - } catch (handledError) { - nextError = handledError; - } - } - - throw nextError; - } - } - - get(url: string, config?: any) { - return this.request({ ...config, method: "get", url }); - } - - post(url: string, data?: any, config?: any) { - return this.request({ ...config, data, method: "post", url }); - } - } - - return { - ...actual, - post: vi.fn((url: string, data?: any, config?: any) => { - const instance = new MockAxiosInstance(); - return instance.post(url, data, config); - }), - default: { - ...actual.default, - create: vi.fn(() => new MockAxiosInstance()), - post: vi.fn((url: string, data?: any, config?: any) => { - const instance = new MockAxiosInstance(); - return instance.post(url, data, config); - }), - isAxiosError: actual.default.isAxiosError, - }, - create: vi.fn(() => new MockAxiosInstance()), - isAxiosError: actual.isAxiosError, - }; -}); - -export const queueResponse = (response: { - status: number; - data: unknown; - config?: any; -}) => { - mockHandlers.push(async (config) => ({ - ...response, - config: response.config ?? config, - })); -}; - -export const queueCustomHandler = (handler: MockHandler) => { - mockHandlers.push(handler); -}; - -export const queueMatchedHandler = ( - matcher: (config: any) => boolean, - handler: MockHandler, -) => { - matchedMockHandlers.push({ matcher, handler }); -}; - -export const queueAxiosError = ({ - message = "Request failed", - status, - data, -}: { - message?: string; - status?: number; - data?: unknown; -}) => { - mockHandlers.push(async (config) => { - const error = new Error(message) as Error & { - config: any; - isAxiosError: boolean; - response?: { status?: number; data?: unknown; config: any }; - }; - - error.config = config; - error.isAxiosError = true; - error.response = { - status, - data, - config, - }; - - throw error; - }); -}; - -export const createTokenStore = ( - initialAccessToken: string, - initialRefreshToken: string, -) => { - let accessToken = initialAccessToken; - let refreshToken = initialRefreshToken; - let staleAccessTokenReadCount = 0; - - return { - getAccessToken: vi.fn(async () => { - if (staleAccessTokenReadCount > 0) { - staleAccessTokenReadCount -= 1; - return initialAccessToken; - } - - return accessToken; - }), - getRefreshToken: vi.fn(async () => refreshToken), - setAccessToken: vi.fn((nextAccessToken: string) => { - accessToken = nextAccessToken; - staleAccessTokenReadCount = 1; - }), - setRefreshToken: vi.fn((nextRefreshToken: string) => { - refreshToken = nextRefreshToken; - }), - clearAuth: vi.fn(() => { - accessToken = ""; - refreshToken = ""; - }), - }; -}; - -export const createRefreshAccessToken = ( - tokenStore: ReturnType, -) => { - return async () => { - const refreshToken = await tokenStore.getRefreshToken(); - - if (!refreshToken) { - throw new Error("missing refreshToken"); - } - - const response = await axios.post("/auth/refresh", { refreshToken }); - const data = response.data as { - accessToken?: string; - refreshToken?: string; - }; - - if (!data.accessToken) { - throw new Error("missing accessToken"); - } - - tokenStore.setAccessToken(data.accessToken); - - if (data.refreshToken !== undefined) { - tokenStore.setRefreshToken(data.refreshToken); - } - - return data.accessToken; - }; -}; - -export const wait = (ms: number) => { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -}; - -afterEach(() => { - mockHandlers.length = 0; - matchedMockHandlers.length = 0; - vi.clearAllMocks(); -}); diff --git a/apps/web/src/services/api/http-factory/constants.ts b/apps/web/src/services/api/http-factory/constants.ts deleted file mode 100644 index 3bff997..0000000 --- a/apps/web/src/services/api/http-factory/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** Default error messages */ -export const DEFAULT_MESSAGES = { - refreshTokenExpired: "Refresh token is invalid or expired", - loginExpired: "Login session has expired", - refreshDisabled: "Refresh token flow is not enabled", -} as const; - -/** Default proactive refresh buffer (ms) */ -export const DEFAULT_REFRESH_BUFFER_MS = 0; - -/** Default retry delay cap (ms) */ -export const DEFAULT_RETRY_DELAY_CAP = 30000; diff --git a/apps/web/src/services/api/http-factory/dedupe-manager.ts b/apps/web/src/services/api/http-factory/dedupe-manager.ts deleted file mode 100644 index a6826b0..0000000 --- a/apps/web/src/services/api/http-factory/dedupe-manager.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { AxiosRequestConfig } from "axios"; - -/** - * 请求合并管理器。 - * 仅合并仍在进行中的相同请求(in-flight coalesce)。 - */ -export class DedupeManager { - private pending = new Map>(); - private generateKey: (config: AxiosRequestConfig) => string; - - constructor(generateKey?: (config: AxiosRequestConfig) => string) { - this.generateKey = generateKey ?? this.defaultGenerateKey; - } - - /** - * 默认的 key 生成器。 - * 由 lower-case method + baseURL + url + 稳定序列化 params 组成。 - */ - private defaultGenerateKey(config: AxiosRequestConfig): string { - const method = (config.method ?? "get").toLowerCase(); - const baseURL = config.baseURL ?? ""; - const url = config.url ?? ""; - const sortedParams = this.stableSerialize(config.params); - return `${method}:${baseURL}:${url}:${sortedParams}`; - } - - private stableSerialize(value: unknown): string { - if (value == null) { - return ""; - } - - if (value instanceof URLSearchParams) { - return JSON.stringify( - Array.from(value.entries()).sort(([a], [b]) => a.localeCompare(b)), - ); - } - - if (Array.isArray(value)) { - return JSON.stringify(value.map((item) => this.normalizeForSerialize(item))); - } - - if (typeof value === "object") { - return JSON.stringify(this.normalizeForSerialize(value)); - } - - return JSON.stringify(value); - } - - private normalizeForSerialize(value: unknown): unknown { - if (value == null || typeof value !== "object") { - return value; - } - - if (Array.isArray(value)) { - return value.map((item) => this.normalizeForSerialize(item)); - } - - return Object.fromEntries( - Object.entries(value as Record) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, nested]) => [key, this.normalizeForSerialize(nested)]), - ); - } - - /** - * 生成请求的唯一 key。 - */ - getKey(config: AxiosRequestConfig): string { - return this.generateKey(config); - } - - /** - * 检查是否有正在进行的相同请求。 - * 有则返回该 Promise,无则返回 null。 - */ - getPending(key: string): Promise | null { - const promise = this.pending.get(key); - return (promise as Promise | undefined) ?? null; - } - - /** - * 注册新的请求。 - */ - setPending(key: string, promise: Promise): void { - this.pending.set(key, promise); - - // 请求完成后自动清理(使用 then 替代 finally,避免派生 Promise 未消费导致 unhandledrejection) - const cleanup = () => { - if (this.pending.get(key) === promise) { - this.pending.delete(key); - } - }; - void promise.then(cleanup, cleanup); - } -} diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts deleted file mode 100644 index 5625329..0000000 --- a/apps/web/src/services/api/http-factory/index.ts +++ /dev/null @@ -1,379 +0,0 @@ -import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"; -import axios, { AxiosError, AxiosHeaders } from "axios"; -import { DEFAULT_MESSAGES, DEFAULT_REFRESH_BUFFER_MS } from "./constants"; -import { DedupeManager } from "./dedupe-manager"; -import { REFRESH_SKIPPED, TokenRefreshManager } from "./token-refresh-manager"; -import type { ErrorContext, RequestRetryState } from "./types/common"; -import type { - HttpClientOptions, - ResolvedHttpClientOptions, -} from "./types/http-client-options"; -import type { AccessTokenResult } from "./types/token"; -import { invokeOnError, normalizeError } from "./utils/error"; -import { - defaultIsRefreshFailure, - resolveRetryPolicy, - shouldSkipRefresh, -} from "./utils/refresh"; -import { - formatAccessToken, - isTokenExpiringSoon, - normalizeTokenResult, -} from "./utils/token"; - -export const createHttpClient = < - T extends AccessTokenResult = AccessTokenResult, ->( - options: HttpClientOptions, -): AxiosInstance => { - const resolvedOptions: ResolvedHttpClientOptions = { - accessTokenHeaderName: "Authorization", - accessTokenPrefix: "Bearer", - refreshFailureCodes: [], - unauthorizedStatusCode: 401, - errorMessages: {}, - isRefreshFailure: (error: unknown) => - defaultIsRefreshFailure(error, { - unauthorizedStatusCode: resolvedOptions.unauthorizedStatusCode, - refreshFailureCodes: resolvedOptions.refreshFailureCodes, - }), - skipRefreshUrls: [], - refreshBufferMs: DEFAULT_REFRESH_BUFFER_MS, - shouldRefreshByResponseData: () => false, - ...options, - }; - - const refreshManager = - options.refreshManager ?? - new TokenRefreshManager(options.refreshCooldownMs); - const refreshEnabled = options.refreshAccessToken !== undefined; - const resolvedRetryPolicy = resolveRetryPolicy(options.retryPolicy); - - // 请求合并(in-flight coalesce) - // 始终准备 manager:客户端默认关闭时,仍允许请求级 { enabled: true } 临时启用 - // 请求级仅可覆盖 enabled,generateKey 只读取客户端级配置 - const clientDedupePolicy = options.dedupePolicy; - const clientDedupeEnabled = clientDedupePolicy?.enabled === true; - const dedupeManager = new DedupeManager(clientDedupePolicy?.generateKey); - - const instance = axios.create({ - timeout: 15 * 1000, - ...resolvedOptions.axiosConfig, - }); - - // 包装 request 方法,实现请求合并(仅 GET) - // 内部 refresh / retry 通过 replayRequest(=originalRequest) 旁路 dedupe, - // 避免 pending 自引用死锁,也不污染业务 config - const originalRequest = instance.request.bind(instance); - const replayRequest = ( - config: InternalAxiosRequestConfig & RequestRetryState, - ) => originalRequest(config); - - const handleAuthFailure = async (error?: unknown) => { - await resolvedOptions.onAuthFailure?.(error); - }; - - const rejectWithError = async ( - error: unknown, - type: ErrorContext["type"], - ) => { - const normalized = normalizeError(error); - const finalError = await invokeOnError( - normalized, - { type }, - resolvedOptions.onError, - ); - return Promise.reject(finalError); - }; - - const refreshAccessToken = async (): Promise< - AccessTokenResult | typeof REFRESH_SKIPPED - > => { - const requestRefreshAccessToken = resolvedOptions.refreshAccessToken; - - if (!refreshEnabled || !requestRefreshAccessToken) { - throw new Error(DEFAULT_MESSAGES.refreshDisabled); - } - - const rejectRefreshAuthFailure = async (): Promise => { - const authError = new Error( - resolvedOptions.errorMessages?.refreshTokenExpired ?? - DEFAULT_MESSAGES.refreshTokenExpired, - ); - await handleAuthFailure(authError); - throw await invokeOnError( - authError, - { type: "refresh" }, - resolvedOptions.onError, - ); - }; - - return refreshManager.runRefresh(async () => { - let result: AccessTokenResult; - - try { - result = await requestRefreshAccessToken(); - } catch (error) { - const normalizedError = normalizeError(error); - const isAuthFailure = resolvedOptions.isRefreshFailure(error); - - if (isAuthFailure) { - await rejectRefreshAuthFailure(); - } - - // 非鉴权失败(如网络错误 / 编程错误),不调用 handleAuthFailure - throw await invokeOnError( - normalizedError, - { type: "refresh" }, - resolvedOptions.onError, - ); - } - - const { token } = normalizeTokenResult(result); - - // 空 token 视为刷新鉴权失败:不写冷却,不重试业务请求 - if (!token) { - await rejectRefreshAuthFailure(); - } - - return result; - }); - }; - - const retryAfterRefresh = async ( - config: InternalAxiosRequestConfig & RequestRetryState, - ) => { - if (config._retry) { - const authError = new Error( - resolvedOptions.errorMessages?.loginExpired ?? - DEFAULT_MESSAGES.loginExpired, - ); - await handleAuthFailure(authError); - throw await invokeOnError( - authError, - { type: "refresh" }, - resolvedOptions.onError, - ); - } - - config._retry = true; - - const refreshResult = await refreshAccessToken(); - - // 冷却期内跳过刷新时重新获取 token,否则使用刷新结果 - const tokenSource = - refreshResult === REFRESH_SKIPPED - ? await resolvedOptions.getAccessToken() - : refreshResult; - const { token } = normalizeTokenResult(tokenSource); - - // 冷却跳过或刷新结果为空 token 时,不再重试原请求 - if (!token) { - const authError = new Error( - resolvedOptions.errorMessages?.loginExpired ?? - DEFAULT_MESSAGES.loginExpired, - ); - await handleAuthFailure(authError); - throw await invokeOnError( - authError, - { type: "refresh" }, - resolvedOptions.onError, - ); - } - - const headers = AxiosHeaders.from(config.headers ?? {}); - headers.set( - resolvedOptions.accessTokenHeaderName, - formatAccessToken(resolvedOptions.accessTokenPrefix, token), - ); - config.headers = headers; - // 重试需要重新跑 headersProvider / 请求准备逻辑 - config.__headersPrepared = false; - - // 内部重试旁路 dedupe:不经过 instance.request 包装 - return replayRequest(config); - }; - - const prepareRequestHeaders = async ( - config: InternalAxiosRequestConfig & RequestRetryState, - ) => { - if (config.__headersPrepared) { - return config; - } - - const headersProvider = resolvedOptions.headersProvider; - const runtimeHeaders = headersProvider - ? await headersProvider() - : undefined; - - // 先合并调用方 / headersProvider 的 headers,再做大小写无关的 token 判定 - const headers = AxiosHeaders.from(config.headers ?? {}); - if (runtimeHeaders) { - headers.set(runtimeHeaders); - } - config.headers = headers; - - const currentAuthorization = headers.get( - resolvedOptions.accessTokenHeaderName, - ); - - // 仅在调用方未显式提供 header(undefined/null)时注入 token - // 空字符串也视为显式控制,不覆盖 - const needToken = currentAuthorization == null; - - if (needToken) { - const tokenResult = await resolvedOptions.getAccessToken(); - const { token, expiresAt } = normalizeTokenResult(tokenResult); - - if (token !== "") { - // 主动刷新:token 即将过期时异步触发刷新,不阻塞当前请求 - if ( - refreshEnabled && - expiresAt && - !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) - ) { - const bufferMs = resolvedOptions.refreshBufferMs; - - if (bufferMs > 0 && isTokenExpiringSoon(expiresAt, bufferMs)) { - // fire-and-forget:refreshAccessToken 内部已调用 onError/onAuthFailure - // 这里仅吞掉 rejection,避免 unhandledrejection,且不阻塞当前请求 - void refreshAccessToken().catch(() => {}); - } - } - - headers.set( - resolvedOptions.accessTokenHeaderName, - formatAccessToken(resolvedOptions.accessTokenPrefix, token), - ); - } - } - - config.__headersPrepared = true; - return config; - }; - - // 请求级仅可覆盖 enabled;generateKey 固定使用客户端级配置 - instance.request = (( - config: InternalAxiosRequestConfig & RequestRetryState, - ) => { - const method = (config.method ?? "get").toLowerCase(); - if (method !== "get") { - return originalRequest(config); - } - - const requestDedupePolicy = config.dedupePolicy; - const shouldDedupe = requestDedupePolicy?.enabled ?? clientDedupeEnabled; - - if (!shouldDedupe) { - return originalRequest(config); - } - - const key = dedupeManager.getKey(config); - const pendingPromise = dedupeManager.getPending(key); - - if (pendingPromise) { - return pendingPromise as ReturnType; - } - - const promise = originalRequest(config); - dedupeManager.setPending(key, promise); - return promise; - }) as typeof instance.request; - - instance.interceptors.request.use( - async (config: InternalAxiosRequestConfig & RequestRetryState) => { - return prepareRequestHeaders(config); - }, - (error) => Promise.reject(error), - ); - - instance.interceptors.response.use( - async (response) => { - const config = response.config as InternalAxiosRequestConfig & - RequestRetryState; - - if ( - resolvedOptions.shouldRefreshByResponseData(response) && - refreshEnabled && - !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) - ) { - return retryAfterRefresh(config); - } - - if (resolvedOptions.onBusinessResponse) { - const businessResult = - await resolvedOptions.onBusinessResponse(response); - - if (businessResult instanceof Error) { - throw businessResult; - } - - // 仅接受完整 AxiosResponse 形态,避免 {status,data} 业务对象被误替换 - if ( - businessResult && - typeof businessResult === "object" && - "status" in businessResult && - "data" in businessResult && - "config" in businessResult && - "headers" in businessResult && - "statusText" in businessResult - ) { - return businessResult; - } - } - - return response; - }, - async (error: AxiosError) => { - const config = error.config as - | (InternalAxiosRequestConfig & RequestRetryState) - | undefined; - - const normalizedError = normalizeError(error); - - if (!config) { - return rejectWithError(normalizedError, "request"); - } - - const status = error.response?.status; - - // 鉴权失败优先于通用重试,避免 401 被 shouldRetry 空耗重试次数 - if (status === resolvedOptions.unauthorizedStatusCode) { - if ( - !refreshEnabled || - shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) - ) { - await handleAuthFailure(normalizedError); - return rejectWithError(normalizedError, "request"); - } - - try { - return await retryAfterRefresh(config); - } catch (refreshError) { - return Promise.reject(refreshError); - } - } - - // 通用重试逻辑(仅处理非鉴权失败) - if (resolvedRetryPolicy) { - const retryCount = config.__retryCount ?? 0; - - if ( - retryCount < resolvedRetryPolicy.maxRetries && - resolvedRetryPolicy.shouldRetry(error, retryCount) - ) { - const delay = resolvedRetryPolicy.retryDelay(retryCount); - config.__retryCount = retryCount + 1; - - await new Promise((resolve) => setTimeout(resolve, delay)); - // 内部重试旁路 dedupe:不经过 instance.request 包装 - return replayRequest(config); - } - } - - return rejectWithError(normalizedError, "request"); - }, - ); - - return instance; -}; diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md deleted file mode 100644 index 37c04ae..0000000 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ /dev/null @@ -1,342 +0,0 @@ -# `http-factory` - -`createHttpClient` 是一个基于 `axios` 的 HTTP 客户端工厂函数,用来创建"带统一鉴权、自动刷新 access token"的请求实例。 - -## 实现了什么 - -- 创建一个业务侧可直接使用的 `AxiosInstance` -- 在请求阶段按需自动注入 `accessToken` -- 遇到 `401` 或业务侧指定场景时自动尝试刷新 access token -- 刷新请求由业务侧在 `refreshAccessToken` 中自行发起 -- 通过 `TokenRefreshManager` 合并并发刷新,避免同一时刻重复刷新 -- 成功时返回原始 `AxiosResponse` -- 支持自定义业务响应拦截(`onBusinessResponse`)、全局错误钩子(`onError`)、刷新失败判定 -- 刷新鉴权失败、空 token 刷新结果、或重试后仍登录过期时执行 `onAuthFailure`;网络错误 / 5xx / 非 AxiosError 默认不触发 -- 请求失败默认透传 axios 原始 `AxiosError`;refresh 鉴权失败 / 登录过期时会构造普通 `Error`,均可经 `onError` 替换 -- 支持通用重试策略(`retryPolicy`):5xx 或网络错误时自动重试,指数退避 -- 支持请求合并(`dedupePolicy`):相同 in-flight GET 请求复用同一个 Promise -- 支持运行时动态 headers(`headersProvider`):每次请求时注入自定义 headers - -## 目录说明 - -``` -http-factory/ -├── index.ts # 工厂函数 createHttpClient,对外导出入口 -├── constants.ts # 默认错误消息、默认缓冲时间 -├── token-refresh-manager.ts # 并发刷新控制 -├── dedupe-manager.ts # 请求合并管理 -├── types/ -│ ├── common.ts # RequestRetryState、ErrorContext、BusinessResponseResult -│ ├── http-client-options.ts # HttpClientOptions、RetryPolicy、DedupePolicy -│ └── token.ts # AccessTokenResult、AccessTokenDetail -├── utils/ -│ ├── error.ts # normalizeError、invokeOnError -│ ├── refresh.ts # shouldSkipRefresh、defaultIsRefreshFailure、resolveRetryPolicy -│ └── token.ts # formatAccessToken、normalizeTokenResult、isTokenExpiringSoon -├── reference/ -│ ├── http-demo.ts # browserTokenStore、示例实例、示例调用 -│ └── README.md # 本文件 -└── __tests__/ # 测试文件 -``` - -## 基本用法 - -```ts -import { createHttpClient } from "@/services/api/http-factory"; - -const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - timeout: 15 * 1000, - }, - getAccessToken: () => localStorage.getItem("access_token"), - refreshAccessToken: async () => { - const refreshToken = localStorage.getItem("refresh_token"); - - if (!refreshToken) { - throw new Error("refreshToken 不存在"); - } - - const response = await axios.post("/auth/refresh", { refreshToken }); - - const data = response.data as { - accessToken?: string; - refreshToken?: string; - }; - - if (!data.accessToken) { - throw new Error("refresh 响应缺少 accessToken"); - } - - localStorage.setItem("access_token", data.accessToken); - - if (data.refreshToken !== undefined) { - localStorage.setItem("refresh_token", data.refreshToken); - } - - return data.accessToken; - }, - onAuthFailure: async () => { - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - }, -}); - -const profile = await http.get("/account/profile"); -const profileData = profile.data; -``` - -## 配置项 - -### 最小可用 - -- `axiosConfig` -- `getAccessToken` -- 可选:`refreshAccessToken` + `onAuthFailure` + `skipRefreshUrls` - -### 高级能力 - -- Token:`accessTokenHeaderName` / `accessTokenPrefix` / `headersProvider` / `refreshBufferMs` -- 刷新:`refreshCooldownMs` / `refreshManager` / `shouldRefreshByResponseData` / `isRefreshFailure` / `refreshFailureCodes` -- 稳定性:`retryPolicy` / `dedupePolicy` -- 业务钩子:`onBusinessResponse` / `onError` / `errorMessages` - -### Token 注入 - -- **`getAccessToken`** — 获取当前 access token。返回 `string` 时仅注入 token;返回 `AccessTokenDetail` 时会在 token 即将过期前主动触发刷新 - - 返回空值 / 空字符串时不注入鉴权 header -- **`accessTokenHeaderName`** — 注入到请求头的字段名,默认 `Authorization` - - 调用方未传该 header(`undefined/null`)时工厂注入 - - 调用方显式传入任意值(含空字符串)时不覆盖 -- **`accessTokenPrefix`** — token 前缀,默认 `Bearer` -- **`headersProvider`** — 运行时动态 headers 提供者,每次请求时调用,返回的 headers 会合并到请求中(支持同步/异步) - -```ts -const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: () => ({ - token: localStorage.getItem("access_token") ?? "", - expiresAt: localStorage.getItem("expires_at"), - }), - headersProvider: () => ({ - "x-request-id": crypto.randomUUID(), - "x-tenant-id": useAppStore.getState().tenantId, - }), -}); -``` - -### 错误处理 - -- **`onBusinessResponse`** — 业务响应拦截器,返回 `void` 表示继续,返回 `Error` 表示业务失败,返回 `AxiosResponse` 会替换原响应 -- **`onError`** — 全局错误钩子,在请求失败、刷新失败时触发,可修改错误或执行副作用 -- **`errorMessages`** — 覆盖内部默认错误消息(`refreshTokenExpired`、`loginExpired`) - - 默认文案为中性英文;业务侧如需中文提示,请通过该配置覆盖 - -```ts -const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: () => localStorage.getItem("access_token"), - onBusinessResponse: (response) => { - const data = response.data as { code?: number; message?: string }; - if (data.code !== 0) { - return new Error(data.message ?? "业务请求失败"); - } - }, - onError: (error, context) => { - console.error(`[${context.type}]`, error.message); - }, - errorMessages: { - refreshTokenExpired: "登录已过期,请重新登录", - loginExpired: "登录已失效,请重新登录", - }, -}); -``` - -### 刷新控制 - -- **`refreshAccessToken`** — 自定义刷新逻辑,返回类型与 `getAccessToken` 一致 -- **`shouldRefreshByResponseData`** — 通过业务响应内容判断是否需要刷新 token -- **`refreshBufferMs`** — 提前刷新的毫秒数,默认 `0`(不提前刷新),设置后会在 token 即将过期前异步触发刷新 - - 主动刷新失败不会阻塞当前请求;鉴权失败走 `onAuthFailure`,网络等错误仍会触发 `onError({ type: "refresh" })` -- **`refreshCooldownMs`** — 刷新后的冷却期,默认 `15000ms`,在冷却期内收到的 401 请求会跳过刷新,直接用新 token 重试 -- **`refreshManager`** — 外部传入 `TokenRefreshManager` 实例,用于多个客户端共享同一套 token 刷新逻辑 -- **`skipRefreshUrls`** — 不触发 refresh token 流程的请求路径列表 - - 仅 exact / prefix 匹配,不是任意子串 includes,也不做中间段滑动匹配 - - 例如 `/auth` 匹配 `/auth`、`/auth/login` - - 不会匹配 `/user/auth-history`、`/authorization`、`/api/auth/login` - -```ts -import { TokenRefreshManager } from "@/services/api/http-factory/token-refresh-manager"; - -const sharedManager = new TokenRefreshManager(15000); - -const http1 = createHttpClient({ - axiosConfig: { baseURL: "/api1" }, - refreshManager: sharedManager, - // ... -}); - -const http2 = createHttpClient({ - axiosConfig: { baseURL: "/api2" }, - refreshManager: sharedManager, - // ... -}); -``` - -### 鉴权失败判定 - -- **`refreshFailureCodes`** — 仅服务 refresh 失败判定的业务 code 列表(不是通用业务鉴权能力) - - 只在 `defaultIsRefreshFailure` 中读取响应体 `data.code` - - 普通请求鉴权失败请用 `shouldRefreshByResponseData` / `onBusinessResponse` / 自定义 `isRefreshFailure` -- **`unauthorizedStatusCode`** — 触发刷新流程的 HTTP 状态码,默认 `401` -- **`isRefreshFailure`** — 判断刷新请求是否已经失败到需要退出登录,默认行为: - - 非 AxiosError(编程错误 / 业务自定义 Error)→ 不视为鉴权失败 - - AxiosError 无 response(网络错误)或 status >= 500 → 不视为鉴权失败(token 可能仍有效) - - 命中 `unauthorizedStatusCode` 或 `refreshFailureCodes` → 视为鉴权失败 - - 若业务需要“refresh 抛 Error 即登出”,请自定义该函数 - - `refreshAccessToken` 返回空 token 时,工厂会按鉴权失败处理(不进入冷却、不重试原请求) - -### 重试策略 - -- **`retryPolicy`** — 通用重试策略,默认不重试 - - `maxRetries` — 最大重试次数(不含首次请求) - - `shouldRetry` — 自定义重试判断,默认:网络错误(无 response)或 5xx 时重试 - - `retryDelay` — 自定义重试延迟,默认:指数退避 `1000 * 2^retryCount`,上限 30 秒 - -```ts -const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - retryPolicy: { - maxRetries: 2, - // 可选:自定义重试条件和延迟 - // shouldRetry: (error, retryCount) => ..., - // retryDelay: (retryCount) => ..., - }, -}); -``` - -### 请求合并 - -- **`dedupePolicy`** — 请求合并策略,默认关闭 - - `enabled` — 是否启用 - - `generateKey` — 自定义合并 key 生成器,默认 `method:baseURL:url:stableParams` - -仅对 GET 请求生效,不支持配置其他 method。请求级 `dedupePolicy.enabled = false` 可覆盖客户端级配置,禁止单个请求的合并。 -内部 refresh / retry 会自动旁路 dedupe,避免 pending 自引用导致请求挂起。 -dedupe 合并的是用户可见 Promise(含 interceptor 后的 refresh/retry 结果),不是“仅首个网络 attempt”。 - -鉴权失败(命中 `unauthorizedStatusCode`)会优先于通用 `retryPolicy` 处理,避免 401 被自定义 `shouldRetry` 空耗重试次数。 - -```ts -const http = createHttpClient({ - axiosConfig: { baseURL: "/api" }, - getAccessToken: async () => "", - dedupePolicy: { - enabled: true, - }, -}); - -// 两个并发请求只会发出一次 HTTP 请求 -const [a, b] = await Promise.all([http.get("/config"), http.get("/config")]); -``` - -## 请求约定 - -- 会自动注入 `accessToken`(内部刷新请求除外) -- 配置了刷新能力时,遇到 `401` 或指定业务场景会自动尝试刷新 -- 未配置刷新能力时,`401` 会直接触发 `onAuthFailure` -- `_retry` 仅作为内部状态使用,不对业务侧开放 - -如果某类请求不希望带鉴权、不希望走自动刷新,建议直接使用独立的 `axios` 实例。 - -## 默认流程 - -1. 创建 `instance`(透传 `axiosConfig`) -2. 给 `instance` 挂请求拦截器和响应拦截器 -3. 请求时读取 `accessToken`,并按配置注入请求头,合并 `headersProvider` 返回的动态 headers -4. 响应成功时优先处理: - - 业务 token 失效判断(`shouldRefreshByResponseData`) - - 业务响应拦截(`onBusinessResponse`) - - 成功时返回原始 `AxiosResponse` -5. 响应失败时: - - 若为 `401` 且启用了刷新,优先进入刷新流程 - - 若为 `401` 且未启用刷新,直接触发 `onAuthFailure` - - 非鉴权失败时再执行通用重试(`retryPolicy`,5xx / 网络错误) -6. 刷新流程: - - 通过 `TokenRefreshManager` 合并并发刷新 - - 调用业务侧 `refreshAccessToken` - - 若在冷却期内,跳过刷新,直接用新 token 重试 - - 重放原请求 -7. 若刷新失败: - - 判断是否为鉴权失败(`isRefreshFailure`) - - 鉴权失败时执行 `onAuthFailure`,使用 `refreshTokenExpired` 消息 - - 非鉴权失败时透传原始错误 - - 抛出错误 - -## 适用场景 - -适合这类前端项目: - -- 需要统一接入 JWT / access token -- 有 refresh token 续期逻辑 -- 希望把 token 存储完全交给业务侧处理 -- 希望保留 axios 原生错误结构,自行处理错误格式 -- 希望保留 axios 原生 `AxiosResponse` 返回语义 -- 需要请求重试(5xx / 网络错误自动重试) -- 需要请求合并(减少重复 GET 请求) -- 需要运行时动态 headers(请求追踪、租户标识等) - -## 测试覆盖 - -### createHttpClient(`http-client.test.ts`) - -1. 会透传 axiosConfig 给 axios.create -2. 并发 401 请求只刷新一次,并在重试时使用新 token -3. 显式传入的鉴权 header 不会被 token 注入覆盖 -4. getAccessToken 为空时不会注入鉴权 header -5. refresh 鉴权失败(如 401 / refreshFailureCodes / 空 token)时会触发 onAuthFailure 并返回登录过期错误 -6. 业务响应命中 accessToken 失效 code 时会刷新并重试 -7. refresh 返回空字符串 refreshToken 时会交给使用者自行处理 -8. 未启用刷新时,401 会触发 onAuthFailure 且不会读取 refreshToken -9. 非 401 的 axios 错误会透传原始错误 -10. 重试后的请求再次返回 401 时会停止重试并退出登录 -11. 多个客户端共享 TokenRefreshManager 时只刷新一次 -12. headersProvider:同步/异步注入、覆盖 Authorization、未配置时不变 -13. retryPolicy:5xx 重试、503 重试、超过上限、4xx 不重试、未配置不重试、自定义 shouldRetry、网络错误重试 -14. dedupePolicy:相同 GET 合并、不同 URL 不合并、请求级禁用、未配置不合并、POST/HEAD 不合并、自定义 generateKey、与 401/5xx 重试交互 -15. onBusinessResponse 返回 AxiosResponse 对象时会替换原响应 - -### edge cases(`http-client.edge-cases.test.ts`) - -1. 命中 skipRefreshUrls 时,401 / 业务响应触发刷新都不会执行 refresh -2. 自定义 unauthorizedStatusCode 命中时会刷新并重试 -3. 业务响应要求刷新但未启用刷新时会原样返回响应 -4. refresh 成功响应缺少 accessToken / 返回空 token 时会触发鉴权失败(refreshToken 已失效) -5. 并发 401 请求在 refresh 返回 500 时不会触发 onAuthFailure(服务端错误不代表 token 失效) -6. 重试后的业务响应再次命中刷新条件时会停止重试并退出登录 -7. refresh 进行中时新来的 401 请求会复用同一次 refresh -8. refresh 错误命中 refreshFailureCodes 时会视为登录过期 -9. refresh 抛出非 AxiosError / 非 Error 异常时默认不触发鉴权失败,仅透传错误 -10. onAuthFailure 抛错时会透出回调错误而不是原始鉴权错误 -11. onBusinessResponse 抛出的特定消息能在 onError 中正常接收 - -### cooldown(`http-client.cooldown.test.ts`) - -1. 刷新完成后旧请求陆续返回 401 → 跳过刷新,用新 token 重试 -2. 慢请求卡了 10 秒后返回 401 → 跳过刷新(在冷却期内) -3. 冷却期过后又收到 401 → 正常执行刷新 -4. 刷新失败后重试 → 下次可以重新刷新 -5. skipRefreshUrls 不触发刷新逻辑 -6. 冷却期边界测试(14.9 秒 vs 15.1 秒) - -### token normalization(`http-client.token-normalization.test.ts`) - -expiresAt 边界:0、空字符串、null、undefined、无效字符串、有效时间戳、Date 对象、ISO 字符串 - -### 工具函数单元测试 - -- `http-client.error.test.ts` — normalizeError(Error/对象/可序列化/循环引用)、invokeOnError(void/替换/抛错/异步) -- `http-client.refresh.test.ts` — shouldSkipRefresh(undefined config/url)、defaultIsRefreshFailure(非 AxiosError/无 response/5xx/refreshFailureCodes) -- `http-client.retry-policy.test.ts` — defaultShouldRetry(非 AxiosError/网络错误/5xx/4xx)、defaultRetryDelay(指数退避/上限)、resolveRetryPolicy(undefined/0/负数/自定义) -- `http-client.token.test.ts` — formatAccessToken(空/空格前缀)、normalizeTokenResult(null/undefined/0/空串/无效日期/Date/ISO) diff --git a/apps/web/src/services/api/http-factory/reference/http-demo.ts b/apps/web/src/services/api/http-factory/reference/http-demo.ts deleted file mode 100644 index 454032b..0000000 --- a/apps/web/src/services/api/http-factory/reference/http-demo.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * 示例文件:展示如何使用 createHttpClient 创建带 token 刷新的 HTTP 实例。 - * 实际项目中请根据需求调整配置,或创建独立的 client 文件。 - */ -import axios from "axios"; -import { createHttpClient } from "../"; - -const ACCESS_TOKEN_STORAGE_KEY = "APP_ACCESS_TOKEN"; -const REFRESH_TOKEN_STORAGE_KEY = "APP_REFRESH_TOKEN"; - -const EXPIRES_AT_STORAGE_KEY = "APP_EXPIRES_AT"; - -export const browserTokenStore = { - getAccessToken(): string | null { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY); - }, - - getAccessTokenDetail() { - const token = browserTokenStore.getAccessToken(); - if (!token) return null; - - const expiresAtRaw = window.localStorage.getItem(EXPIRES_AT_STORAGE_KEY); - const expiresAt = expiresAtRaw ? new Date(expiresAtRaw) : null; - - return { - token, - expiresAt: expiresAt ?? new Date(Date.now() + 15 * 60_000), - }; - }, - - getRefreshToken(): string | null { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY); - }, - - setAccessToken(accessToken: string, expiresAt?: Date) { - if (typeof window === "undefined") return; - window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, accessToken); - - if (expiresAt) { - window.localStorage.setItem(EXPIRES_AT_STORAGE_KEY, expiresAt.toISOString()); - } - }, - - setRefreshToken(refreshToken: string) { - if (typeof window === "undefined") return; - window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken); - }, - - clearAuth() { - if (typeof window === "undefined") return; - window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); - window.localStorage.removeItem(REFRESH_TOKEN_STORAGE_KEY); - window.localStorage.removeItem(EXPIRES_AT_STORAGE_KEY); - }, -}; - -export const http = createHttpClient({ - axiosConfig: { - baseURL: "/api", - timeout: 15 * 1000, - }, - - // ---- Token ---- - - // 返回 AccessTokenDetail 时,会在 token 即将过期前主动触发刷新 - getAccessToken: () => browserTokenStore.getAccessTokenDetail(), - - // ---- Auth failure ---- - - // refresh 请求失败时,用于识别 refresh token 失效的业务 code 列表 - refreshFailureCodes: [40103, 1001002], - - // ---- Refresh ---- - - refreshAccessToken: async () => { - const refreshToken = browserTokenStore.getRefreshToken(); - - if (!refreshToken) { - throw new Error("refreshToken 不存在"); - } - - const response = await axios.post("/auth/refresh", { - refreshToken, - }); - - const data = response.data as { - accessToken?: string; - refreshToken?: string; - expiresAt?: string; - }; - - if (!data.accessToken) { - throw new Error("refresh 响应缺少 accessToken"); - } - - const expiresAt = data.expiresAt - ? new Date(data.expiresAt) - : new Date(Date.now() + 15 * 60_000); - - browserTokenStore.setAccessToken(data.accessToken, expiresAt); - - if (data.refreshToken !== undefined) { - browserTokenStore.setRefreshToken(data.refreshToken); - } - - return { - token: data.accessToken, - expiresAt, - }; - }, - - // 通过业务响应内容判断是否需要刷新 token(如 code=40101 表示 token 过期) - shouldRefreshByResponseData: (response) => { - const data = response.data as { code?: number }; - return data.code === 40101 || data.code === 1001001; - }, - - // 刷新 token 后的冷却期(毫秒)。 - // 在冷却期内收到的 401 请求会跳过刷新,直接用新 token 重试。 - // 处理刷新完成后旧请求陆续返回 401 的并发场景。 - refreshCooldownMs: 15_000, - - // ---- Retry ---- - - // 通用重试策略:5xx 或网络错误时重试,指数退避 - retryPolicy: { - maxRetries: 2, - // shouldRetry / retryDelay 可自定义,默认行为见 resolveRetryPolicy - }, - - // ---- Dedupe ---- - - // 请求合并:相同 in-flight GET 请求复用同一个 Promise - dedupePolicy: { - enabled: true, - }, - - // ---- Runtime headers ---- - - // 运行时动态 headers,每次请求时调用 - headersProvider: () => ({ - "x-request-id": crypto.randomUUID(), - }), - - // ---- Callbacks ---- - - // 业务响应拦截器:code !== 0 时视为业务失败 - onBusinessResponse: (response) => { - const data = response.data as { code?: number; message?: string }; - - if (data.code !== undefined && data.code !== 0) { - return new Error(data.message ?? "业务请求失败"); - } - }, - - // 全局错误钩子 - onError: (error, context) => { - console.error(`[${context.type}]`, error.message); - }, - - // 登录失效后的收尾回调 - onAuthFailure: () => { - browserTokenStore.clearAuth(); - - if (typeof window === "undefined") return; - - const loginPath = "/login"; - if (window.location.pathname !== loginPath) { - window.location.href = loginPath; - } - }, -}); - -export const getDemoProfile = async () => { - return http.get("/account/profile"); -}; diff --git a/apps/web/src/services/api/http-factory/token-refresh-manager.ts b/apps/web/src/services/api/http-factory/token-refresh-manager.ts deleted file mode 100644 index 8721280..0000000 --- a/apps/web/src/services/api/http-factory/token-refresh-manager.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AccessTokenResult } from "./types/token"; - -export const REFRESH_SKIPPED = Symbol("REFRESH_SKIPPED"); - -export class TokenRefreshManager { - private refreshingPromise: Promise | null = null; - private lastRefreshTime: number | null = null; - private readonly cooldownMs: number; - - constructor(cooldownMs = 15_000) { - this.cooldownMs = cooldownMs; - } - - async runRefresh( - task: () => Promise, - ): Promise { - // 冷却期内跳过刷新 - if ( - this.lastRefreshTime !== null && - Date.now() - this.lastRefreshTime < this.cooldownMs - ) { - return REFRESH_SKIPPED; - } - - if (!this.refreshingPromise) { - this.refreshingPromise = (async () => { - try { - const result = await task(); - this.lastRefreshTime = Date.now(); - return result; - } finally { - this.refreshingPromise = null; - } - })(); - } - - return this.refreshingPromise; - } -} diff --git a/apps/web/src/services/api/http-factory/types/common.ts b/apps/web/src/services/api/http-factory/types/common.ts deleted file mode 100644 index ff70fd2..0000000 --- a/apps/web/src/services/api/http-factory/types/common.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AxiosResponse } from "axios"; - -/** - * 请求内部状态(仅工厂内部使用,业务侧请勿写入)。 - */ -export interface RequestRetryState { - /** token 刷新重试标记 */ - _retry?: boolean; - /** 通用重试计数 */ - __retryCount?: number; - /** 内部标记:请求 headers 已完成注入,避免 dedupe / interceptor 重复处理 */ - __headersPrepared?: boolean; -} - -/** - * onBusinessResponse 的返回值类型。 - * - void:继续正常流程(表示成功) - * - Error:抛出错误(表示业务失败) - * - AxiosResponse:用完整响应形态(status/data/headers/config/statusText)替换原响应,不会二次触发 onBusinessResponse - */ -export type BusinessResponseResult = void | Error | AxiosResponse; - -/** - * 错误上下文,传递给 onError 钩子。 - */ -export interface ErrorContext { - /** - * 错误来源类型。 - */ - type: "request" | "refresh"; -} - -/** - * 自定义错误消息。 - */ -export interface ErrorMessages { - refreshTokenExpired?: string; - loginExpired?: string; -} diff --git a/apps/web/src/services/api/http-factory/types/http-client-options.ts b/apps/web/src/services/api/http-factory/types/http-client-options.ts deleted file mode 100644 index e610d05..0000000 --- a/apps/web/src/services/api/http-factory/types/http-client-options.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; -import type { TokenRefreshManager } from "../token-refresh-manager"; -import type { - BusinessResponseResult, - ErrorContext, - ErrorMessages, -} from "./common"; -import type { AccessTokenResult } from "./token"; - -/** - * 请求合并配置。 - */ -export interface DedupePolicy { - /** 是否启用请求合并。默认 false。仅合并 GET 请求。 */ - enabled?: boolean; - - /** - * 自定义合并 key 生成器。 - * 默认:`method:baseURL:url:stableParams` - */ - generateKey?: (config: AxiosRequestConfig) => string; -} - -/** - * 请求级合并配置。 - * 仅允许覆盖 enabled;generateKey 只能在客户端级配置。 - */ -export interface RequestDedupePolicy { - /** 是否启用请求合并。覆盖客户端级 enabled。 */ - enabled?: boolean; -} - -// 应用内 axios 类型扩展:仅 createHttpClient 实例消费 dedupePolicy。 -// 其他 axios 实例即使出现该字段也无运行时效果。 -declare module "axios" { - interface AxiosRequestConfig { - /** - * 请求级合并策略。 - * 仅可覆盖 enabled,不能修改 generateKey。 - * 仅 http-factory 创建的实例生效。 - */ - dedupePolicy?: RequestDedupePolicy; - } -} - -/** - * 重试策略配置。 - */ -export interface RetryPolicy { - /** 最大重试次数(不含首次请求)。默认 0(不重试)。 */ - maxRetries?: number; - - /** - * 判断是否需要重试。 - * 默认:网络错误(无 response)或 5xx 状态码时重试。 - */ - shouldRetry?: (error: unknown, retryCount: number) => boolean; - - /** - * 计算重试延迟(毫秒)。 - * 默认:指数退避,公式 `1000 * 2^retryCount`,上限 30 秒。 - */ - retryDelay?: (retryCount: number) => number; -} - -/** - * 创建 HTTP 客户端时可传入的配置。 - * - * @typeParam T - getAccessToken / refreshAccessToken 的统一返回类型, - * 默认为 `AccessTokenResult`。当 T 为 `AccessTokenDetail` 时启用主动刷新。 - */ -export interface HttpClientOptions< - T extends AccessTokenResult = AccessTokenResult, -> { - /** - * 透传给 axios.create 的初始化配置。 - */ - axiosConfig: AxiosRequestConfig; - - // ---- Token ---- - - /** - * 获取当前 access token。 - * - * 返回 `string` 时仅注入 token,不做主动刷新判断。 - * 返回 `AccessTokenDetail` 时,会在 token 即将过期前主动触发刷新。 - */ - getAccessToken: () => T | Promise; - - /** - * access token 注入到请求头时使用的字段名。 - * 默认 `Authorization`。 - */ - accessTokenHeaderName?: string; - - /** - * access token 的前缀。 - * 默认 `Bearer`。 - */ - accessTokenPrefix?: string; - - // ---- Auth failure ---- - - /** - * 刷新失败判定的业务 code 列表。 - * - * 仅用于 `defaultIsRefreshFailure`:当 refresh 请求响应体存在 `data.code` - * 且命中该列表时,视为刷新鉴权失败。 - * - * 不会影响普通业务请求的鉴权识别;更通用的入口是 `isRefreshFailure`。 - * 默认假设响应体形状为 `{ code?: number }`。 - */ - refreshFailureCodes?: number[]; - - /** - * 通用重试策略。 - * 默认不重试(maxRetries: 0)。 - */ - retryPolicy?: RetryPolicy; - - /** - * 请求合并策略。 - * 默认关闭(enabled: false)。 - */ - dedupePolicy?: DedupePolicy; - - /** - * 运行时动态 headers 提供者。 - * - * 每次请求时调用,返回的 headers 会合并到请求中。 - * 支持同步或异步返回。 - * - * 典型场景: - * - 请求追踪:`{ 'x-trace-id': crypto.randomUUID() }` - * - 业务标识:从 store 读取 `{ 'x-tenant-id': tenantId }` - * - * 注意:返回的 headers 会覆盖默认注入的 headers(如 Authorization)。 - */ - headersProvider?: () => - | Record - | Promise>; - - /** - * 触发刷新流程的 HTTP 状态码。 - * 默认 `401`。 - */ - unauthorizedStatusCode?: number; - - /** - * 自定义错误消息,覆盖内部默认值。 - */ - errorMessages?: ErrorMessages; - - /** - * 判断刷新 token 请求本身是否已经失败到需要退出登录。 - * - * 默认行为: - * - 非 AxiosError(编程错误 / 业务自定义 Error)不视为刷新鉴权失败 - * - AxiosError 无 response(网络错误)或状态码 >= 500 时不视为刷新失败 - * - 状态码 === unauthorizedStatusCode 时视为刷新失败 - * - 响应 data.code 在 refreshFailureCodes 列表中时视为刷新鉴权失败 - * - * 若业务需要“refresh 抛 Error 即登出”,请自定义该函数。 - */ - isRefreshFailure?: (error: unknown) => boolean; - - // ---- Refresh ---- - - /** - * 自定义刷新逻辑,返回类型必须与 getAccessToken 一致。 - * 返回 AccessTokenDetail 时可携带新的过期时间,避免后续请求因旧过期时间重复触发刷新。 - */ - refreshAccessToken?: () => T | Promise; - - /** - * 不触发 refresh token 流程的请求路径列表。 - * - * 仅 exact / prefix 匹配,不是任意子串 includes,也不做中间段滑动匹配。 - * 例如配置 `/auth` 会匹配 `/auth`、`/auth/login`, - * 但不会匹配 `/user/auth-history`、`/authorization`、`/api/auth/login`。 - */ - skipRefreshUrls?: string[]; - - /** - * 提前刷新的毫秒数,默认 0(不提前刷新)。 - * 设置为大于 0 的值时,会在 token 即将过期前主动触发刷新。 - */ - refreshBufferMs?: number; - - /** - * 刷新 token 后的冷却期(毫秒)。 - * 在冷却期内收到的 401 请求会跳过刷新,直接使用新 token 重试。 - * 用于处理刷新完成后,旧请求陆续返回 401 的并发场景。 - * 默认 15000(15 秒)。 - */ - refreshCooldownMs?: number; - - /** - * 外部传入的 TokenRefreshManager 实例。 - * 用于多个客户端共享同一套 token 刷新逻辑。 - * 不传则自动创建独立实例。 - */ - refreshManager?: TokenRefreshManager; - - /** - * 通过业务响应内容判断是否需要刷新 token。 - */ - shouldRefreshByResponseData?: (response: AxiosResponse) => boolean; - - // ---- Callbacks ---- - - /** - * 登录失效后的统一收尾回调。 - */ - onAuthFailure?: (error?: unknown) => void | Promise; - - /** - * 业务响应拦截器。 - * - 返回 void:继续正常流程(表示成功) - * - 返回 Error:抛出错误(表示业务失败) - * - 返回完整 AxiosResponse 形态:用新响应替换原响应,不会二次触发 onBusinessResponse - * - 可以是 async - */ - onBusinessResponse?: ( - response: AxiosResponse, - ) => BusinessResponseResult | Promise; - - /** - * 全局错误钩子。 - * - 返回 Error:用新错误替换原错误 - * - 返回 void:继续抛出原错误 - * - 可以是 async - * - * error 类型为 AxiosError | Error: - * - 请求失败时为 AxiosError,可通过 axios.isAxiosError(error) 收敛访问 response/config - * - 刷新失败时可能为 AxiosError 或业务侧抛出的任意 Error - * - 登录过期等内部构造的错误为普通 Error - */ - onError?: ( - error: AxiosError | Error, - context: ErrorContext, - ) => AxiosError | Error | void | Promise; -} - -/** - * 补齐默认值后的完整配置类型,仅供内部使用。 - */ -type ResolvedHttpClientOptionKeys = - | "accessTokenHeaderName" - | "accessTokenPrefix" - | "refreshFailureCodes" - | "unauthorizedStatusCode" - | "errorMessages" - | "isRefreshFailure" - | "skipRefreshUrls" - | "refreshBufferMs" - | "shouldRefreshByResponseData"; - -type ResolvedHttpClientOptionOverrides = { - [K in ResolvedHttpClientOptionKeys]-?: NonNullable[K]>; -}; - -export type ResolvedHttpClientOptions< - T extends AccessTokenResult = AccessTokenResult, -> = Omit, ResolvedHttpClientOptionKeys> & - ResolvedHttpClientOptionOverrides; diff --git a/apps/web/src/services/api/http-factory/types/token.ts b/apps/web/src/services/api/http-factory/types/token.ts deleted file mode 100644 index 3fa1f2b..0000000 --- a/apps/web/src/services/api/http-factory/types/token.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * token 详细信息,用于主动刷新判断。 - */ -export interface AccessTokenDetail { - token: string; - expiresAt: Date | string | number; -} - -/** - * getAccessToken 的返回值类型,兼容旧版纯字符串返回。 - */ -export type AccessTokenResult = string | AccessTokenDetail | null; diff --git a/apps/web/src/services/api/http-factory/utils/error.ts b/apps/web/src/services/api/http-factory/utils/error.ts deleted file mode 100644 index a241379..0000000 --- a/apps/web/src/services/api/http-factory/utils/error.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ErrorContext } from "../types/common"; - -/** - * 将任意值标准化为 Error 实例。 - */ -export const normalizeError = (error: unknown): Error => { - if (error instanceof Error) { - return error; - } - - if ( - typeof error === "object" && - error !== null && - "message" in error && - typeof error.message === "string" - ) { - return new Error(error.message); - } - - try { - return new Error(JSON.stringify(error)); - } catch { - return new Error(String(error)); - } -}; - -/** - * 调用错误回调,返回最终错误。 - */ -export const invokeOnError = async ( - error: Error, - context: ErrorContext, - onError?: ( - error: Error, - context: ErrorContext, - ) => Error | void | Promise, -): Promise => { - if (!onError) { - return error; - } - - try { - const result = await onError(error, context); - return result ?? error; - } catch { - return error; - } -}; diff --git a/apps/web/src/services/api/http-factory/utils/refresh.ts b/apps/web/src/services/api/http-factory/utils/refresh.ts deleted file mode 100644 index 7935d87..0000000 --- a/apps/web/src/services/api/http-factory/utils/refresh.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { InternalAxiosRequestConfig } from "axios"; -import axios from "axios"; -import { DEFAULT_RETRY_DELAY_CAP } from "../constants"; -import type { RequestRetryState } from "../types/common"; -import type { RetryPolicy } from "../types/http-client-options"; - -/** - * 默认的重试判断逻辑。 - * 网络错误(AxiosError 无 response)或 5xx 状态码时重试。 - * 非 AxiosError(如编程错误)不重试。 - */ -export const defaultShouldRetry = (error: unknown): boolean => { - // 非 AxiosError 不重试(如 onBusinessResponse 抛出的普通 Error) - if (!axios.isAxiosError(error)) { - return false; - } - - // 网络错误(无 response)重试 - if (!error.response) { - return true; - } - - // 5xx 状态码重试 - return error.response.status >= 500; -}; - -/** - * 默认的重试延迟计算。 - * 指数退避:1000 * 2^retryCount,上限 30 秒。 - */ -export const defaultRetryDelay = (retryCount: number): number => { - return Math.min(1000 * Math.pow(2, retryCount), DEFAULT_RETRY_DELAY_CAP); -}; - -/** - * 解析重试策略,补齐默认值。 - */ -export const resolveRetryPolicy = ( - retryPolicy?: RetryPolicy, -): Required | null => { - if (!retryPolicy) { - return null; - } - - const maxRetries = retryPolicy.maxRetries ?? 0; - - if (maxRetries <= 0) { - return null; - } - - return { - maxRetries, - shouldRetry: retryPolicy.shouldRetry ?? defaultShouldRetry, - retryDelay: retryPolicy.retryDelay ?? defaultRetryDelay, - }; -}; - -/** - * 判断是否跳过刷新流程。 - * - * 匹配规则: - * - 仅 exact / prefix 路径匹配 - * - 不是任意子串 includes,也不做中间段滑动匹配 - * - 例如 `/auth` 匹配 `/auth`、`/auth/login` - * - 不匹配 `/user/auth-history`、`/authorization`、`/api/auth/login` - */ -export const shouldSkipRefresh = ( - skipRefreshUrls: string[], - config?: InternalAxiosRequestConfig & RequestRetryState, -) => { - const requestUrl = config?.url; - - if (!requestUrl) { - return false; - } - - const normalizedRequestPath = normalizeRequestPath(requestUrl); - - return skipRefreshUrls.some((skipUrl) => - matchesSkipPath(normalizedRequestPath, skipUrl), - ); -}; - -const normalizeRequestPath = (requestUrl: string): string => { - // 兼容相对路径、绝对 URL、带 query/hash 的地址 - try { - const parsed = new URL(requestUrl, "http://localhost"); - return parsed.pathname || "/"; - } catch { - const withoutQuery = requestUrl.split("?")[0]?.split("#")[0] ?? requestUrl; - return withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`; - } -}; - -const matchesSkipPath = (requestPath: string, skipUrl: string): boolean => { - if (!skipUrl) { - return false; - } - - const normalizedSkip = skipUrl.startsWith("/") ? skipUrl : `/${skipUrl}`; - const skipPath = normalizedSkip.replace(/\/+$/, "") || "/"; - const path = requestPath.replace(/\/+$/, "") || "/"; - - return path === skipPath || path.startsWith(`${skipPath}/`); -}; - -/** - * 默认的刷新失败判断逻辑。 - */ -export const defaultIsRefreshFailure = ( - error: unknown, - options: { unauthorizedStatusCode: number; refreshFailureCodes: number[] }, -): boolean => { - // 非 AxiosError(编程错误 / 业务自定义 Error)默认不视为鉴权失败。 - // 若业务需要“抛 Error 即登出”,请自定义 isRefreshFailure。 - if (!axios.isAxiosError(error)) { - return false; - } - - // 无 response(网络错误)或 5xx 服务端错误:不代表 token 失效 - if (!error.response || error.response.status >= 500) { - return false; - } - - const status = error.response.status; - const data = error.response.data as { code?: number } | undefined; - - return ( - status === options.unauthorizedStatusCode || - (data?.code !== undefined && options.refreshFailureCodes.includes(data.code)) - ); -}; diff --git a/apps/web/src/services/api/http-factory/utils/token.ts b/apps/web/src/services/api/http-factory/utils/token.ts deleted file mode 100644 index cac9f2d..0000000 --- a/apps/web/src/services/api/http-factory/utils/token.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { AccessTokenResult } from "../types/token"; - -/** - * 格式化 token(添加前缀)。 - */ -export const formatAccessToken = (prefix: string, token: string) => { - const normalizedPrefix = prefix.trim(); - return normalizedPrefix ? `${normalizedPrefix} ${token}` : token; -}; - -/** - * 标准化 token 结果,提取 token 和 expiresAt。 - */ -export const normalizeTokenResult = (result: AccessTokenResult) => { - if (!result || typeof result === "string") { - return { token: result ?? "", expiresAt: null }; - } - - // 验证 expiresAt 是否为有效的时间值 - const expiresAtValue = result.expiresAt; - - // 处理无效值:null、undefined、空字符串、0 - if (expiresAtValue == null || expiresAtValue === "" || expiresAtValue === 0) { - return { token: result.token, expiresAt: null }; - } - - const expiresAtDate = new Date(expiresAtValue); - - // 检查是否为 Invalid Date - if (Number.isNaN(expiresAtDate.getTime())) { - return { token: result.token, expiresAt: null }; - } - - return { - token: result.token, - expiresAt: expiresAtDate, - }; -}; - -/** - * 判断 token 是否即将过期。 - */ -export const isTokenExpiringSoon = ( - expiresAt: Date, - refreshBufferMs: number, -): boolean => { - return Date.now() >= expiresAt.getTime() - refreshBufferMs; -}; diff --git a/apps/web/src/services/api/new-http.ts b/apps/web/src/services/api/new-http.ts index e67bac2..714c449 100644 --- a/apps/web/src/services/api/new-http.ts +++ b/apps/web/src/services/api/new-http.ts @@ -1,6 +1,11 @@ import { RefreshToken } from "@/services/api/refresh-token"; +import { + AUTH_REFRESH_SCOPE_KEY, + tokenRefreshManager, +} from "@/services/api/token-refresh-manager"; import { useUserStore } from "@/stores/user"; -import { createHttpClient } from "./http-factory"; +import { message } from "antd"; +import { createHttpClient } from "fzkit"; const NO_AUTO_REFRESH_API_LIST = ["/auth/login", "/auth/refresh-token"]; @@ -12,7 +17,12 @@ const newHttp = createHttpClient({ }, timeout: 1000 * 10, }, + dedupePolicy: { + enabled: true, + }, refreshBufferMs: import.meta.env.DEV ? 1000 * 10 : 60_000, + refreshManager: tokenRefreshManager, + refreshScopeKey: AUTH_REFRESH_SCOPE_KEY, getAccessToken: () => { const jwtToken = useUserStore.getState().jwtToken; if (!jwtToken?.accessToken) return null; @@ -33,6 +43,12 @@ const newHttp = createHttpClient({ onAuthFailure: () => { useUserStore.getState().logout(); }, + onError: (error) => { + message.error(`HTTP Error: ${error.message || "请求失败"}`); + console.error("HTTP Error:", { + message: error.message, + }); + }, skipRefreshUrls: NO_AUTO_REFRESH_API_LIST, errorMessages: { refreshTokenExpired: "登录已过期,请重新登录", diff --git a/apps/web/src/services/api/socket-client.ts b/apps/web/src/services/api/socket-client.ts index e46aeb1..437b119 100644 --- a/apps/web/src/services/api/socket-client.ts +++ b/apps/web/src/services/api/socket-client.ts @@ -5,8 +5,9 @@ import type { ClientToServerEvents, ServerToClientEvents, } from "./socket-event.types"; +import { tokenRefreshManager } from "./token-refresh-manager"; -let isRefreshing = false; +const SOCKET_REFRESH_OWNER = Symbol("socket-refresh-owner"); export const socket: Socket = io( "/socket", @@ -32,19 +33,43 @@ export const leaveRoom = (room: string): void => { socket.emit("leave-room", { room }); }; -const handleRefreshAndReconnect = async (): Promise => { - if (isRefreshing) return; +const resolveAccessToken = (result: unknown): string | undefined => { + if (typeof result === "string" && result) { + return result; + } + + if (result && typeof result === "object" && "token" in result) { + const token = (result as { token?: unknown }).token; + if (typeof token === "string" && token) { + return token; + } + } + + return useUserStore.getState().jwtToken?.accessToken; +}; - isRefreshing = true; +export const handleRefreshAndReconnect = async (): Promise => { try { - const { accessToken } = await RefreshToken(); + const result = await tokenRefreshManager.runRefresh(async () => { + const { accessToken, expiresAt } = await RefreshToken(); + + return { + token: accessToken, + expiresAt, + }; + }, SOCKET_REFRESH_OWNER); + + const accessToken = resolveAccessToken(result); + if (!accessToken) { + useUserStore.getState().logout(); + return; + } + socket.auth = { token: accessToken }; socket.disconnect(); socket.connect(); } catch { useUserStore.getState().logout(); - } finally { - isRefreshing = false; } }; diff --git a/apps/web/src/services/api/token-refresh-manager.ts b/apps/web/src/services/api/token-refresh-manager.ts new file mode 100644 index 0000000..ebc1d90 --- /dev/null +++ b/apps/web/src/services/api/token-refresh-manager.ts @@ -0,0 +1,5 @@ +import { TokenRefreshManager } from "fzkit/http-client"; + +export const AUTH_REFRESH_SCOPE_KEY = Symbol("primary-auth"); + +export const tokenRefreshManager = new TokenRefreshManager(); diff --git a/apps/web/src/services/hooks/__tests__/job-sse.test.ts b/apps/web/src/services/hooks/__tests__/job-sse.test.ts new file mode 100644 index 0000000..0549146 --- /dev/null +++ b/apps/web/src/services/hooks/__tests__/job-sse.test.ts @@ -0,0 +1,469 @@ +import { + JOB_SSE_EVENT, + JOB_STATUS, + JOB_TRIGGER_TYPE, + type IJobRun, + type IJobsPage, +} from "@/services/types/job"; +import { QueryClient } from "@tanstack/react-query"; +import type { SseOptions, SseSubscription } from "fzkit/http-client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sse = vi.hoisted(() => vi.fn()); + +vi.mock("@/services/api/new-http", () => ({ + default: { sse }, +})); + +import { createJobSseSubscriptionManager, subscribeToJobSse } from "../job-sse"; +import { getJobDetailQueryKey } from "../use-job-polling"; +import { JOBS_LIST_QUERY_KEY, type IJobsListQueryKey } from "../use-jobs-list"; + +const createJob = (overrides: Partial = {}): IJobRun => ({ + id: "job-1", + name: "export-report", + queueName: "default", + status: JOB_STATUS.QUEUED, + progress: 0, + attemptsMade: 0, + maxAttempts: 3, + triggerType: JOB_TRIGGER_TYPE.MANUAL, + createdAt: "2026-08-17T00:00:00.000Z", + ...overrides, +}); + +describe("subscribeToJobSse", () => { + let queryClient: QueryClient; + let subscription: SseSubscription; + let sseOptions: SseOptions | undefined; + let listQueryKey: IJobsListQueryKey; + + beforeEach(() => { + queryClient = new QueryClient(); + subscription = { + close: vi.fn(), + lastEventId: undefined, + state: "connecting", + }; + sseOptions = undefined; + listQueryKey = [...JOBS_LIST_QUERY_KEY, { page: 1, pageSize: 10 }]; + sse.mockReset(); + sse.mockImplementation((_url: string, options: SseOptions) => { + sseOptions = options; + return subscription; + }); + }); + + it("将完整 SSE 快照替换详情与任务列表缓存", async () => { + const queuedJob = createJob(); + const activeJob = createJob({ + status: JOB_STATUS.ACTIVE, + progress: 50, + attemptsMade: 1, + }); + const onEventReceived = vi.fn(); + + queryClient.setQueryData(listQueryKey, { + list: [queuedJob], + total: 1, + page: 1, + pageSize: 10, + }); + + const cleanup = subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError: vi.fn(), + onEventReceived, + }); + + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.SNAPSHOT, + data: JSON.stringify(activeJob), + }); + + expect(sse).toHaveBeenCalledWith( + "/jobs/job-1/events", + expect.objectContaining({ + maxRetries: 5, + sequentialMessages: true, + }), + ); + expect(queryClient.getQueryData(getJobDetailQueryKey("job-1"))).toEqual( + activeJob, + ); + expect(queryClient.getQueryData(listQueryKey)?.list[0]).toEqual( + activeJob, + ); + expect(onEventReceived).toHaveBeenCalledTimes(1); + + cleanup?.(); + }); + + it("非 snapshot 事件先到时不让后续 snapshot 回退缓存", async () => { + const updatedJob = createJob({ + status: JOB_STATUS.ACTIVE, + progress: 60, + attemptsMade: 1, + }); + const staleSnapshotJob = createJob({ progress: 30 }); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError: vi.fn(), + onEventReceived: vi.fn(), + }); + + await sseOptions?.onOpen?.(new Response()); + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: JSON.stringify(updatedJob), + }); + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.SNAPSHOT, + data: JSON.stringify(staleSnapshotJob), + }); + + expect(queryClient.getQueryData(getJobDetailQueryKey("job-1"))).toEqual( + updatedJob, + ); + }); + + it("新增任务订阅时保留前一条任务流并分别同步缓存", async () => { + const subscriptionsByUrl = new Map(); + const optionsByUrl = new Map(); + const jobOne = createJob({ + id: "job-1", + status: JOB_STATUS.ACTIVE, + progress: 50, + }); + const jobTwo = createJob({ + id: "job-2", + status: JOB_STATUS.ACTIVE, + progress: 20, + }); + + sse.mockImplementation((url: string, options: SseOptions) => { + const nextSubscription: SseSubscription = { + close: vi.fn(), + lastEventId: undefined, + state: "connecting", + }; + subscriptionsByUrl.set(url, nextSubscription); + optionsByUrl.set(url, options); + return nextSubscription; + }); + + const manager = createJobSseSubscriptionManager({ + queryClient, + createCallbacks: () => ({ + onConnectionState: vi.fn(), + onError: vi.fn(), + onEventReceived: vi.fn(), + }), + }); + + manager.sync(["job-1"]); + manager.sync(["job-1", "job-2"]); + + expect( + subscriptionsByUrl.get("/jobs/job-1/events")?.close, + ).not.toHaveBeenCalled(); + expect(sse).toHaveBeenCalledTimes(2); + + await optionsByUrl.get("/jobs/job-1/events")?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: JSON.stringify(jobOne), + }); + await optionsByUrl.get("/jobs/job-2/events")?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: JSON.stringify(jobTwo), + }); + + expect(queryClient.getQueryData(getJobDetailQueryKey("job-1"))).toEqual( + jobOne, + ); + expect(queryClient.getQueryData(getJobDetailQueryKey("job-2"))).toEqual( + jobTwo, + ); + + manager.close(); + + expect( + subscriptionsByUrl.get("/jobs/job-1/events")?.close, + ).toHaveBeenCalledTimes(1); + expect( + subscriptionsByUrl.get("/jobs/job-2/events")?.close, + ).toHaveBeenCalledTimes(1); + }); + + it("移除跟踪任务时只关闭对应流并保留其他任务流", async () => { + const subscriptionsByUrl = new Map(); + const optionsByUrl = new Map(); + const activeJob = createJob({ + id: "job-2", + status: JOB_STATUS.ACTIVE, + progress: 50, + }); + + sse.mockImplementation((url: string, options: SseOptions) => { + const nextSubscription: SseSubscription = { + close: vi.fn(), + lastEventId: undefined, + state: "connecting", + }; + subscriptionsByUrl.set(url, nextSubscription); + optionsByUrl.set(url, options); + return nextSubscription; + }); + + const manager = createJobSseSubscriptionManager({ + queryClient, + createCallbacks: () => ({ + onConnectionState: vi.fn(), + onError: vi.fn(), + onEventReceived: vi.fn(), + }), + }); + + manager.sync(["job-1", "job-2"]); + manager.sync(["job-2"]); + + expect( + subscriptionsByUrl.get("/jobs/job-1/events")?.close, + ).toHaveBeenCalledTimes(1); + expect( + subscriptionsByUrl.get("/jobs/job-2/events")?.close, + ).not.toHaveBeenCalled(); + + await optionsByUrl.get("/jobs/job-2/events")?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: JSON.stringify(activeJob), + }); + + expect(queryClient.getQueryData(getJobDetailQueryKey("job-2"))).toEqual( + activeJob, + ); + }); + + it("重连后允许新的 snapshot 恢复缓存", async () => { + const activeJob = createJob({ + status: JOB_STATUS.ACTIVE, + progress: 80, + attemptsMade: 1, + }); + const snapshotJob = createJob({ progress: 30 }); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError: vi.fn(), + onEventReceived: vi.fn(), + }); + + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: JSON.stringify(activeJob), + }); + await sseOptions?.onOpen?.(new Response()); + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.SNAPSHOT, + data: JSON.stringify(snapshotJob), + }); + + expect(queryClient.getQueryData(getJobDetailQueryKey("job-1"))).toEqual( + snapshotJob, + ); + }); + + it("终态事件更新缓存后关闭订阅", async () => { + const completedJob = createJob({ + status: JOB_STATUS.COMPLETED, + progress: 100, + finishedAt: "2026-08-17T00:00:10.000Z", + }); + const onError = vi.fn(); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError, + onEventReceived: vi.fn(), + }); + + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.COMPLETED, + data: JSON.stringify(completedJob), + }); + + expect(queryClient.getQueryData(getJobDetailQueryKey("job-1"))).toEqual( + completedJob, + ); + expect(subscription.close).toHaveBeenCalledTimes(1); + + await sseOptions?.onClose?.("manual"); + + expect(onError).not.toHaveBeenCalled(); + }); + + it("禁用订阅不发请求,清理函数关闭活动订阅", () => { + const options = { + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError: vi.fn(), + onEventReceived: vi.fn(), + }; + + expect(subscribeToJobSse({ ...options, enabled: false })).toBeUndefined(); + expect(sse).not.toHaveBeenCalled(); + + const cleanup = subscribeToJobSse(options); + cleanup?.(); + + expect(subscription.close).toHaveBeenCalledTimes(1); + }); + + it("无法解析的 SSE 数据写入错误并关闭订阅", async () => { + const onError = vi.fn(); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError, + onEventReceived: vi.fn(), + }); + + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: "not-json", + }); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(subscription.close).toHaveBeenCalledTimes(1); + }); + + it("字段不完整的 SSE 数据写入错误并关闭订阅", async () => { + const onError = vi.fn(); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError, + onEventReceived: vi.fn(), + }); + + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: JSON.stringify({ id: "job-1", status: JOB_STATUS.ACTIVE }), + }); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(subscription.close).toHaveBeenCalledTimes(1); + }); + + it("事件任务 ID 与订阅不匹配时拒绝写入并关闭订阅", async () => { + const onError = vi.fn(); + const mismatchedJob = createJob({ + id: "job-2", + status: JOB_STATUS.ACTIVE, + progress: 50, + }); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError, + onEventReceived: vi.fn(), + }); + + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: JSON.stringify(mismatchedJob), + }); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(subscription.close).toHaveBeenCalledTimes(1); + expect( + queryClient.getQueryData(getJobDetailQueryKey("job-1")), + ).toBeUndefined(); + }); + + it("重试耗尽时写入可展示的订阅错误", async () => { + const onError = vi.fn(); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError, + onEventReceived: vi.fn(), + }); + + await sseOptions?.onClose?.("max-retries"); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "任务 SSE 订阅已关闭:max-retries" }), + ); + }); + + it("连接成功和重连时更新连接状态", async () => { + const onConnectionState = vi.fn(); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState, + onError: vi.fn(), + onEventReceived: vi.fn(), + }); + + await sseOptions?.onOpen?.(new Response()); + await sseOptions?.onRetry?.({ + attempt: 1, + delayMs: 1000, + reason: "error", + }); + + expect(onConnectionState).toHaveBeenNthCalledWith(1, "open"); + expect(onConnectionState).toHaveBeenNthCalledWith(2, "retrying"); + }); + + it("连接重新打开后可以继续上报错误", async () => { + const onError = vi.fn(); + + subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError, + onEventReceived: vi.fn(), + }); + + await sseOptions?.onError?.(new Error("first")); + await sseOptions?.onOpen?.(new Response()); + await sseOptions?.onError?.(new Error("second")); + + expect(onError).toHaveBeenNthCalledWith(1, new Error("first")); + expect(onError).toHaveBeenNthCalledWith(2, new Error("second")); + }); +}); diff --git a/apps/web/src/services/hooks/__tests__/use-job-polling.test.ts b/apps/web/src/services/hooks/__tests__/use-job-polling.test.ts index 248e255..751b7c2 100644 --- a/apps/web/src/services/hooks/__tests__/use-job-polling.test.ts +++ b/apps/web/src/services/hooks/__tests__/use-job-polling.test.ts @@ -10,7 +10,7 @@ import { type IJobsPage, } from "@/services/types/job"; import { QueryClient } from "@tanstack/react-query"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; const createJob = (overrides: Partial = {}): IJobRun => ({ id: "job-1", @@ -82,6 +82,35 @@ describe("syncJobToJobsListCache", () => { expect(queryClient.getQueryData(listQueryKey)).toEqual(page); }); + it("任务进入当前筛选条件时失效对应列表查询", () => { + const queryClient = new QueryClient(); + const listQueryKey: IJobsListQueryKey = [ + ...JOBS_LIST_QUERY_KEY, + { page: 1, pageSize: 10, status: JOB_STATUS.ACTIVE }, + ]; + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + + queryClient.setQueryData(listQueryKey, { + list: [createJob({ id: "job-2" })], + total: 1, + page: 1, + pageSize: 10, + }); + + syncJobToJobsListCache( + queryClient, + createJob({ + status: JOB_STATUS.ACTIVE, + progress: 45, + }), + ); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: listQueryKey, + exact: true, + }); + }); + it("任务离开当前筛选条件时从该列表缓存移除", () => { const queryClient = new QueryClient(); const queuedListQueryKey: IJobsListQueryKey = [ @@ -114,9 +143,7 @@ describe("syncJobToJobsListCache", () => { syncJobToJobsListCache(queryClient, activeJob); - expect( - queryClient.getQueryData(queuedListQueryKey), - ).toEqual({ + expect(queryClient.getQueryData(queuedListQueryKey)).toEqual({ list: [], total: 0, page: 1, diff --git a/apps/web/src/services/hooks/job-sse.ts b/apps/web/src/services/hooks/job-sse.ts new file mode 100644 index 0000000..2fc9d57 --- /dev/null +++ b/apps/web/src/services/hooks/job-sse.ts @@ -0,0 +1,242 @@ +import http from "@/services/api/new-http"; +import { + JOB_SSE_EVENT, + JOB_STATUS, + JOB_TERMINAL_STATUSES, + JOB_TRIGGER_TYPE, + type IJobRun, + type JobSseEventName, +} from "@/services/types/job"; +import type { QueryClient } from "@tanstack/react-query"; +import type { SseState, SseSubscription } from "fzkit/http-client"; +import { + getJobDetailQueryKey, + syncJobToJobsListCache, +} from "./use-job-polling"; + +interface ISubscribeToJobSseOptions { + jobId: string | null; + enabled: boolean; + queryClient: QueryClient; + onConnectionState: (state: SseState) => void; + onError: (error: Error) => void; + onEventReceived: () => void; + onTerminal?: () => void; +} + +export interface IJobSseCallbacks { + onConnectionState: (state: SseState) => void; + onError: (error: Error) => void; + onEventReceived: () => void; + onTerminal?: () => void; +} + +interface IJobSseSubscriptionManagerOptions { + queryClient: QueryClient; + createCallbacks: (jobId: string) => IJobSseCallbacks; +} + +export interface IJobSseSubscriptionManager { + sync: (jobIds: string[]) => void; + retry: (jobId: string) => void; + close: () => void; +} + +const JOB_SSE_EVENT_NAMES = new Set(Object.values(JOB_SSE_EVENT)); +const JOB_STATUSES = new Set(Object.values(JOB_STATUS)); +const JOB_TRIGGER_TYPES = new Set(Object.values(JOB_TRIGGER_TYPE)); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isNullableString = (value: unknown) => + value === null || typeof value === "string"; + +const isJobSseEventName = (value: string): value is JobSseEventName => + JOB_SSE_EVENT_NAMES.has(value); + +const isJobRun = (value: unknown): value is IJobRun => { + if (!isRecord(value)) return false; + + if ( + typeof value.id !== "string" || + typeof value.name !== "string" || + typeof value.queueName !== "string" || + typeof value.progress !== "number" || + typeof value.attemptsMade !== "number" || + typeof value.maxAttempts !== "number" || + typeof value.createdAt !== "string" || + typeof value.status !== "string" || + typeof value.triggerType !== "string" + ) { + return false; + } + + if ( + !JOB_STATUSES.has(value.status) || + !JOB_TRIGGER_TYPES.has(value.triggerType) + ) { + return false; + } + + if ( + ("errorMessage" in value && !isNullableString(value.errorMessage)) || + ("startedAt" in value && !isNullableString(value.startedAt)) || + ("finishedAt" in value && !isNullableString(value.finishedAt)) + ) { + return false; + } + + return true; +}; + +const isTerminalJob = (job: IJobRun) => + JOB_TERMINAL_STATUSES.includes(job.status); + +export const subscribeToJobSse = ({ + jobId, + enabled, + queryClient, + onConnectionState, + onError, + onEventReceived, + onTerminal, +}: ISubscribeToJobSseOptions) => { + if (!enabled || !jobId) return undefined; + + let disposed = false; + let terminal = false; + let receivedError = false; + let receivedNonSnapshotEvent = false; + + const reportError = (error: Error) => { + if (disposed || terminal || receivedError) return; + + receivedError = true; + onError(error); + }; + + const subscription: SseSubscription = http.sse(`/jobs/${jobId}/events`, { + maxRetries: 5, + sequentialMessages: true, + onOpen: () => { + if (disposed) return; + + receivedNonSnapshotEvent = false; + receivedError = false; + onConnectionState("open"); + }, + onRetry: () => { + if (!disposed) onConnectionState("retrying"); + }, + onError: (error) => reportError(error), + onMessage: (event) => { + if (disposed || !isJobSseEventName(event.event)) return; + if (event.event === JOB_SSE_EVENT.SNAPSHOT && receivedNonSnapshotEvent) { + return; + } + + let data: unknown; + try { + data = JSON.parse(event.data); + } catch { + reportError(new Error("任务 SSE 事件数据不是有效 JSON")); + subscription.close(); + return; + } + + if (!isJobRun(data) || data.id !== jobId) { + reportError(new Error("任务 SSE 事件数据格式无效")); + subscription.close(); + return; + } + + if (event.event !== JOB_SSE_EVENT.SNAPSHOT) { + receivedNonSnapshotEvent = true; + } + + queryClient.setQueryData(getJobDetailQueryKey(jobId), data); + syncJobToJobsListCache(queryClient, data); + onEventReceived(); + + if (isTerminalJob(data)) { + terminal = true; + subscription.close(); + onTerminal?.(); + } + }, + onClose: (reason) => { + if (disposed) return; + + onConnectionState("closed"); + if (!terminal && reason !== "manual" && reason !== "signal") { + reportError(new Error(`任务 SSE 订阅已关闭:${reason}`)); + } + }, + }); + + return () => { + disposed = true; + subscription.close(); + }; +}; + +export const createJobSseSubscriptionManager = ({ + queryClient, + createCallbacks, +}: IJobSseSubscriptionManagerOptions): IJobSseSubscriptionManager => { + const cleanups = new Map void>(); + const terminalJobIds = new Set(); + + const start = (jobId: string) => { + const callbacks = createCallbacks(jobId); + const cleanup = subscribeToJobSse({ + jobId, + enabled: true, + queryClient, + ...callbacks, + onTerminal: () => { + terminalJobIds.add(jobId); + callbacks.onTerminal?.(); + }, + }); + + if (cleanup) { + cleanups.set(jobId, cleanup); + } + }; + + const close = () => { + for (const cleanup of cleanups.values()) { + cleanup(); + } + cleanups.clear(); + terminalJobIds.clear(); + }; + + return { + sync: (jobIds) => { + const nextJobIds = new Set(jobIds); + + for (const [jobId, cleanup] of cleanups) { + if (!nextJobIds.has(jobId)) { + cleanup(); + cleanups.delete(jobId); + terminalJobIds.delete(jobId); + } + } + + for (const jobId of nextJobIds) { + if (cleanups.has(jobId) || terminalJobIds.has(jobId)) continue; + start(jobId); + } + }, + retry: (jobId) => { + cleanups.get(jobId)?.(); + cleanups.delete(jobId); + terminalJobIds.delete(jobId); + start(jobId); + }, + close, + }; +}; diff --git a/apps/web/src/services/hooks/use-job-polling.ts b/apps/web/src/services/hooks/use-job-polling.ts index 5cf99eb..60495ba 100644 --- a/apps/web/src/services/hooks/use-job-polling.ts +++ b/apps/web/src/services/hooks/use-job-polling.ts @@ -6,7 +6,14 @@ import { type IJobRun, type IJobsPage, } from "@/services/types/job"; -import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; + +export const getJobDetailQueryKey = (jobId: string) => + ["jobs", "detail", jobId] as const; export const syncJobToJobsListCache = ( queryClient: QueryClient, @@ -17,14 +24,17 @@ export const syncJobToJobsListCache = ( }); for (const [queryKey, current] of queries) { - if (!current?.list.some((item) => item.id === job.id)) { - continue; - } - const filters = queryKey[2] as IFindJobsQuery | undefined; const matchesName = !filters?.name || job.name === filters.name; const matchesStatus = !filters?.status || job.status === filters.status; + if (!current?.list.some((item) => item.id === job.id)) { + if (current && matchesName && matchesStatus) { + void queryClient.invalidateQueries({ queryKey, exact: true }); + } + continue; + } + if (!matchesName || !matchesStatus) { queryClient.setQueryData(queryKey, { ...current, @@ -41,17 +51,17 @@ export const syncJobToJobsListCache = ( } }; -export const useJobPolling = (jobId: string | null) => { +export const useJobPolling = (jobId: string | null, enabled = true) => { const queryClient = useQueryClient(); return useQuery({ - queryKey: ["jobs", "detail", jobId], + queryKey: getJobDetailQueryKey(jobId ?? ""), queryFn: async () => { const job = await GetJob(jobId ?? ""); syncJobToJobsListCache(queryClient, job); return job; }, - enabled: Boolean(jobId), + enabled: enabled && Boolean(jobId), refetchInterval: (query) => { const status = query.state.data?.status; if (status && JOB_TERMINAL_STATUSES.includes(status)) return false; diff --git a/apps/web/src/services/hooks/use-job-sse.ts b/apps/web/src/services/hooks/use-job-sse.ts new file mode 100644 index 0000000..00e41ac --- /dev/null +++ b/apps/web/src/services/hooks/use-job-sse.ts @@ -0,0 +1,116 @@ +import { GetJob } from "@/services/api/jobs"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { SseState } from "fzkit/http-client"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { + createJobSseSubscriptionManager, + type IJobSseSubscriptionManager, +} from "./job-sse"; +import { getJobDetailQueryKey } from "./use-job-polling"; + +export const useJobSse = ( + jobId: string | null, + jobIds: string[], + enabled: boolean, + onTerminal?: (jobId: string) => void, +) => { + const [connectionState, setConnectionState] = useState("closed"); + const [error, setError] = useState(null); + const [lastEventAt, setLastEventAt] = useState(null); + + const queryClient = useQueryClient(); + const managerRef = useRef(null); + const detailQuery = useQuery({ + queryKey: getJobDetailQueryKey(jobId ?? ""), + queryFn: () => GetJob(jobId ?? ""), + enabled: false, + }); + + const handleConnectionState = useEffectEvent( + (streamJobId: string, nextState: SseState) => { + if (streamJobId !== jobId) return; + setConnectionState(nextState); + if (nextState === "open") { + setError(null); + } + }, + ); + + const handleError = useEffectEvent( + (streamJobId: string, nextError: Error) => { + if (streamJobId !== jobId) return; + setError(nextError); + }, + ); + + const handleEventReceived = useEffectEvent((streamJobId: string) => { + if (streamJobId !== jobId) return; + setLastEventAt(Date.now()); + }); + + useEffect(() => { + if (!enabled || !jobId) { + setConnectionState("closed"); + setError(null); + setLastEventAt(null); + return; + } + + setConnectionState("connecting"); + setError(null); + setLastEventAt(null); + }, [enabled, jobId]); + + useEffect(() => { + if (!enabled) { + managerRef.current?.close(); + managerRef.current = null; + return; + } + + const manager = createJobSseSubscriptionManager({ + queryClient, + createCallbacks: (streamJobId) => ({ + onConnectionState: (state) => handleConnectionState(streamJobId, state), + onError: (nextError) => handleError(streamJobId, nextError), + onEventReceived: () => handleEventReceived(streamJobId), + onTerminal: () => onTerminal?.(streamJobId), + }), + }); + managerRef.current = manager; + + return () => { + manager.close(); + managerRef.current = null; + }; + }, [enabled, queryClient]); + + useEffect(() => { + if (!enabled) return; + + const trackedJobIds = new Set(jobIds); + if (jobId) { + trackedJobIds.add(jobId); + } + managerRef.current?.sync([...trackedJobIds]); + }, [enabled, jobId, jobIds]); + + const retry = () => { + if (!jobId) return; + + setConnectionState("connecting"); + setError(null); + managerRef.current?.retry(jobId); + }; + + return { + data: detailQuery.data, + error, + isLoading: Boolean(jobId && enabled && !detailQuery.data && !error), + isFetching: + connectionState === "connecting" || connectionState === "retrying", + connectionState, + lastEventAt, + retry, + }; +}; diff --git a/apps/web/src/services/types/job.ts b/apps/web/src/services/types/job.ts index a9ee510..bfa7b04 100644 --- a/apps/web/src/services/types/job.ts +++ b/apps/web/src/services/types/job.ts @@ -26,6 +26,25 @@ export const JOB_NAMES = { export type JobName = (typeof JOB_NAMES)[keyof typeof JOB_NAMES]; +export const JOB_REFRESH_MODE = { + POLLING: "polling", + SSE: "sse", +} as const; + +export type JobRefreshMode = + (typeof JOB_REFRESH_MODE)[keyof typeof JOB_REFRESH_MODE]; + +export const JOB_SSE_EVENT = { + SNAPSHOT: "job.snapshot", + UPDATED: "job.updated", + COMPLETED: "job.completed", + FAILED: "job.failed", + CANCELLED: "job.cancelled", +} as const; + +export type JobSseEventName = + (typeof JOB_SSE_EVENT)[keyof typeof JOB_SSE_EVENT]; + export const JOB_TERMINAL_STATUSES: readonly JobStatus[] = [ JOB_STATUS.COMPLETED, JOB_STATUS.FAILED, diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 26e8e5b..fb686f9 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -28,5 +28,6 @@ "@/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c36d52a..263e279 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,7 +2,8 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.test.json" } ], "compilerOptions": { "paths": { diff --git a/apps/web/tsconfig.test.json b/apps/web/tsconfig.test.json new file mode 100644 index 0000000..60b0205 --- /dev/null +++ b/apps/web/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "types": ["node"] + }, + "include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/vite-env.d.ts"], + "exclude": [] +} diff --git a/docs/superpowers/plans/2026-08-17-jobs-sse-frontend-integration.md b/docs/superpowers/plans/2026-08-17-jobs-sse-frontend-integration.md new file mode 100644 index 0000000..462358a --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-jobs-sse-frontend-integration.md @@ -0,0 +1,453 @@ +# Jobs Frontend SSE Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 Jobs 详情默认启用 SSE,并提供与既有轮询路径互斥的可见切换控件。 + +**Architecture:** 复用 `new-http.sse()` 建立携带 JWT 的 SSE 订阅,依赖 fzkit 处理 Token 注入、401 刷新、断线重连和 `Last-Event-ID`。SSE 完整快照直接写入既有 React Query 详情缓存,并使用现有 `syncJobToJobsListCache` 更新列表缓存;轮询继续由 `useQuery` 驱动,只有当前选择 Polling 时启用。 + +**Tech Stack:** React 19、TypeScript、TanStack Query、Ant Design 6、Vitest、fzkit 0.2.1。 + +## Global Constraints + +- 使用简体中文响应;不新增依赖。 +- 只修改 `apps/web` 中 Jobs 详情刷新关联的类型、hooks、页面、组件和测试,以及已有设计/计划文档。 +- 复用 `new-http.sse()`,不使用原生 `EventSource`,不把 Token 放入 query string。 +- `Polling` 与 `SSE` 必须互斥运行;页面默认 `SSE`,轮询实现保留且可切换。 +- SSE `data` 是完整 `IJobRun` 快照,必须整对象替换缓存,禁止字段级合并。 +- 服务端未实现事件历史回放;`Last-Event-ID` 由 fzkit 自动维护,重连后的 `job.snapshot` 是当前状态恢复依据。 +- 服务端可能在 `job.snapshot` 前发送 `job.updated`;同一连接已处理非 snapshot 事件时,后到 snapshot 不得覆盖缓存。 +- 终态、模式切换和组件卸载都必须关闭 SSE;任务切换仅切换详情展示,其他已跟踪的未终态任务保持订阅;终态关闭不显示错误。 +- SSE 可恢复重连最多 5 次;终态错误、消息解析错误和重试耗尽保留最后快照并提供手动重试,不自动降级到轮询。 +- TypeScript 不使用 `any`;测试直接验证原始生产实现,不重写业务逻辑。 +- 未经用户明确要求,不创建 Git 提交、push 或 PR。 + +--- + +## File Structure + +```text +apps/web/src/ +├── components/jobs/ +│ ├── job-detail-panel/index.tsx # 模式控件、状态、错误与详情展示 +│ └── job-refresh-mode-toggle/index.tsx # Polling / SSE 分段控件 +├── pages/jobs/index.tsx # 默认模式,两个 hooks 的互斥启用 +└── services/ + ├── hooks/ + │ ├── job-sse.ts # SSE 事件解析、缓存写入和订阅生命周期 + │ ├── use-job-polling.ts # 增加 enabled,导出详情 query key + │ ├── use-job-sse.ts # React 生命周期、状态和手动重试 + │ └── __tests__/ + │ ├── job-sse.test.ts # SSE 订阅与缓存行为 + │ └── use-job-polling.test.ts # 保持列表缓存同步覆盖 + └── types/job.ts # 刷新模式与 SSE 事件名类型 +``` + +### Task 1: SSE 协议与缓存订阅 + +**Files:** +- Modify: `apps/web/src/services/types/job.ts` +- Modify: `apps/web/src/services/hooks/use-job-polling.ts` +- Create: `apps/web/src/services/hooks/job-sse.ts` +- Create: `apps/web/src/services/hooks/__tests__/job-sse.test.ts` +- Modify: `apps/web/src/services/hooks/__tests__/use-job-polling.test.ts` + +**Interfaces:** +- Produces `JOB_REFRESH_MODE`、`JobRefreshMode`、`JOB_SSE_EVENT`、`JobSseEventName`。 +- Produces `getJobDetailQueryKey(jobId: string)`,供轮询与 SSE 共用 `['jobs', 'detail', jobId]` 缓存键。 +- Produces `subscribeToJobSse(options): (() => void) | undefined`;它写入 React Query 缓存,并由返回函数关闭 `SseSubscription`。 + +- [x] **Step 1: 写 SSE 订阅的失败测试** + +在 `job-sse.test.ts` mock `@/services/api/new-http` 的 `sse` 方法。使用真实 `QueryClient` 和现有 `syncJobToJobsListCache`,准备 `job-1` 的详情/列表缓存与可控 `SseSubscription`。 + +```ts +it("将完整 SSE 快照替换详情与任务列表缓存", async () => { + const cleanup = subscribeToJobSse({ + jobId: "job-1", + enabled: true, + queryClient, + onConnectionState: vi.fn(), + onError: vi.fn(), + onEventReceived: vi.fn(), + }); + + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.SNAPSHOT, + data: JSON.stringify(activeJob), + }); + + expect(queryClient.getQueryData(getJobDetailQueryKey("job-1"))).toEqual( + activeJob, + ); + expect(queryClient.getQueryData(listKey)?.list[0]).toEqual( + activeJob, + ); + cleanup?.(); +}); +``` + +再添加以下独立断言: + +```ts +it("非 snapshot 事件先到时不让后续 snapshot 回退缓存", async () => { + await sseOptions?.onOpen?.(new Response()); + await sseOptions?.onMessage?.(updatedEvent); + await sseOptions?.onMessage?.(staleSnapshotEvent); + + expect(queryClient.getQueryData(getJobDetailQueryKey("job-1"))).toEqual( + updatedJob, + ); +}); + +it("终态事件更新缓存后关闭订阅", async () => { + await sseOptions?.onMessage?.(completedEvent); + + expect(subscription.close).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); +}); + +it("禁用订阅不发请求,清理函数关闭活动订阅", () => { + expect(subscribeToJobSse({ ...options, enabled: false })).toBeUndefined(); + expect(sse).not.toHaveBeenCalled(); + + const cleanup = subscribeToJobSse(options); + cleanup?.(); + expect(subscription.close).toHaveBeenCalledTimes(1); +}); + +it("无法解析的 SSE 数据写入错误并关闭订阅", async () => { + await sseOptions?.onMessage?.({ + event: JOB_SSE_EVENT.UPDATED, + data: "not-json", + }); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(subscription.close).toHaveBeenCalledTimes(1); +}); +``` + +- [x] **Step 2: 运行测试,确认 RED** + +Run: `cd apps/web && yarn test src/services/hooks/__tests__/job-sse.test.ts` + +Expected: FAIL,因为 `job-sse.ts`、SSE 常量和 `getJobDetailQueryKey` 尚不存在。 + +- [x] **Step 3: 增加共享类型和详情缓存键** + +在 `types/job.ts` 增加与服务端同名的 `JOB_SSE_EVENT`,并定义刷新模式: + +```ts +export const JOB_REFRESH_MODE = { + POLLING: "polling", + SSE: "sse", +} as const; + +export type JobRefreshMode = + (typeof JOB_REFRESH_MODE)[keyof typeof JOB_REFRESH_MODE]; + +export const JOB_SSE_EVENT = { + SNAPSHOT: "job.snapshot", + UPDATED: "job.updated", + COMPLETED: "job.completed", + FAILED: "job.failed", + CANCELLED: "job.cancelled", +} as const; + +export type JobSseEventName = + (typeof JOB_SSE_EVENT)[keyof typeof JOB_SSE_EVENT]; +``` + +在 `use-job-polling.ts` 增加共享 key,原有轮询使用它: + +```ts +export const getJobDetailQueryKey = (jobId: string) => + ["jobs", "detail", jobId] as const; +``` + +- [x] **Step 4: 实现可测试的 SSE 订阅函数** + +创建 `job-sse.ts`。函数接收 `jobId`、`enabled`、`QueryClient` 和状态回调;未启用或缺少任务 ID 时返回 `undefined`。启用时调用: + +```ts +export interface ISubscribeToJobSseOptions { + jobId: string | null; + enabled: boolean; + queryClient: QueryClient; + onConnectionState: (state: SseState) => void; + onError: (error: Error) => void; + onEventReceived: () => void; +} + +export const subscribeToJobSse = ({ + jobId, + enabled, + queryClient, + onConnectionState, + onError, + onEventReceived, +}: ISubscribeToJobSseOptions) => { + if (!enabled || !jobId) return undefined; + + let disposed = false; + let terminal = false; + let receivedNonSnapshotEvent = false; + let receivedError = false; + let subscription: SseSubscription; + + const reportError = (error: Error) => { + if (disposed || terminal || receivedError) return; + receivedError = true; + onError(error); + }; + + subscription = http.sse(`/jobs/${jobId}/events`, { + maxRetries: 5, + sequentialMessages: true, + onOpen: () => { + if (disposed) return; + receivedNonSnapshotEvent = false; + onConnectionState("open"); + }, + onRetry: () => { + if (!disposed) onConnectionState("retrying"); + }, + onError: (error) => reportError(error), + onMessage: async (event) => { + if (disposed || !isJobSseEventName(event.event)) return; + if (event.event === JOB_SSE_EVENT.SNAPSHOT && receivedNonSnapshotEvent) { + return; + } + + let data: unknown; + try { + data = JSON.parse(event.data); + } catch { + reportError(new Error("任务 SSE 事件数据不是有效 JSON")); + subscription.close(); + return; + } + + if (!isJobRun(data)) { + reportError(new Error("任务 SSE 事件数据格式无效")); + subscription.close(); + return; + } + + if (event.event !== JOB_SSE_EVENT.SNAPSHOT) { + receivedNonSnapshotEvent = true; + } + + queryClient.setQueryData(getJobDetailQueryKey(jobId), data); + syncJobToJobsListCache(queryClient, data); + onEventReceived(); + + if (isTerminalJob(data)) { + terminal = true; + subscription.close(); + } + }, + onClose: (reason) => { + if (disposed) return; + onConnectionState("closed"); + if (!terminal && reason !== "manual" && reason !== "signal") { + reportError(new Error(`任务 SSE 订阅已关闭:${reason}`)); + } + }, +}); + + return () => { + disposed = true; + subscription.close(); + }; +}; +``` + +在同一文件中实现 `isJobSseEventName`、`isJobRun` 和 `isTerminalJob`:先以 `Record` 检查对象,再校验 `id`、`name`、`queueName`、`progress`、`attemptsMade`、`maxAttempts`、`createdAt` 为预期基础类型,以及 `status`、`triggerType` 分别属于既有 `JOB_STATUS`、`JOB_TRIGGER_TYPE` 的 value union;可选字段存在时再校验 `errorMessage`、时间字段的类型。解析错误调用 `reportError` 后关闭订阅;`onOpen` 重置“已收到非 snapshot”标记,确保内部重连后的 snapshot 能恢复状态。cleanup 先标记 `disposed`,再关闭订阅,防止卸载后回调写 React 状态。 + +- [x] **Step 5: 运行聚焦测试,确认 GREEN** + +Run: `cd apps/web && yarn test src/services/hooks/__tests__/job-sse.test.ts src/services/hooks/__tests__/use-job-polling.test.ts` + +Expected: PASS,且列表缓存同步的既有三项断言继续通过。 + +### Task 2: React SSE Hook 与轮询互斥启用 + +**Files:** +- Create: `apps/web/src/services/hooks/use-job-sse.ts` +- Modify: `apps/web/src/services/hooks/use-job-polling.ts` +- Test: `apps/web/src/services/hooks/__tests__/job-sse.test.ts` + +**Interfaces:** +- Produces `useJobSse(jobId: string | null, enabled: boolean)`。 +- 返回 `{ data, error, isLoading, isFetching, connectionState, lastEventAt, retry }`。 +- `useJobPolling(jobId, enabled)` 只在 `enabled && Boolean(jobId)` 时请求与轮询。 + +- [x] **Step 1: 扩展失败测试,锁定订阅配置** + +在 `job-sse.test.ts` 为 `subscribeToJobSse` 增加配置断言,使 hook 可以依赖这个已验证的适配层: + +```ts +expect(sse).toHaveBeenCalledWith( + "/jobs/job-1/events", + expect.objectContaining({ + maxRetries: 5, + sequentialMessages: true, + }), +); +``` + +并断言 `onRetry` 将状态回调更新为 `"retrying"`,`onOpen` 更新为 `"open"`。这两个断言在订阅实现缺少选项或生命周期回调时应失败。 + +- [x] **Step 2: 运行测试,确认 RED** + +Run: `cd apps/web && yarn test src/services/hooks/__tests__/job-sse.test.ts` + +Expected: FAIL,直到订阅实现按固定选项和连接状态回调完成。 + +- [x] **Step 3: 实现 useJobSse** + +通过禁用的 React Query observer 订阅同一详情缓存键,但不由它自动发出 REST 请求: + +```ts +const detailQuery = useQuery({ + queryKey: getJobDetailQueryKey(jobId ?? ""), + queryFn: () => GetJob(jobId ?? ""), + enabled: false, +}); +``` + +使用 `useEffectEvent` 封装状态写入回调,并以任务 ID 管理 `subscribeToJobSse` 返回的多个 cleanup。提交或查看新任务时加入对应订阅,不能关闭其他已跟踪的未终态任务;切换 Polling 或卸载时关闭全部订阅。`retry()` 仅重建当前详情任务的 SSE 订阅。 + +派生返回值: + +```ts +return { + data: detailQuery.data, + error, + isLoading: Boolean(jobId && enabled && !detailQuery.data && !error), + isFetching: + connectionState === "connecting" || connectionState === "retrying", + connectionState, + lastEventAt, + retry, +}; +``` + +- [x] **Step 4: 让轮询显式接收 enabled** + +将函数签名调整为: + +```ts +export const useJobPolling = (jobId: string | null, enabled: boolean) => { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: getJobDetailQueryKey(jobId ?? ""), + queryFn: async () => { + const job = await GetJob(jobId ?? ""); + syncJobToJobsListCache(queryClient, job); + return job; + }, + enabled: enabled && Boolean(jobId), + refetchInterval: (query) => { + const status = query.state.data?.status; + return status && JOB_TERMINAL_STATUSES.includes(status) ? false : 2000; + }, + }); +}; +``` + +轮询成功后继续调用 `syncJobToJobsListCache`;终态时仍返回 `false` 停止 `refetchInterval`。 + +- [x] **Step 5: 运行 hooks 测试与类型检查** + +Run: `cd apps/web && yarn test src/services/hooks/__tests__/job-sse.test.ts src/services/hooks/__tests__/use-job-polling.test.ts` + +Run: `cd apps/web && yarn type-check` + +Expected: 两个测试文件通过;TypeScript 不出现未使用变量、错误的 SSE 类型导入或 hook 签名调用错误。 + +### Task 3: Jobs 页面模式切换与详情状态 + +**Files:** +- Create: `apps/web/src/components/jobs/job-refresh-mode-toggle/index.tsx` +- Modify: `apps/web/src/components/jobs/job-detail-panel/index.tsx` +- Modify: `apps/web/src/pages/jobs/index.tsx` +- Modify: `docs/superpowers/specs/2026-07-28-jobs-phase2-learning-console-design.md` only if implementation differs from the approved design + +**Interfaces:** +- `JobRefreshModeToggle` 接收 `mode: JobRefreshMode` 与 `onChange(mode: JobRefreshMode)`。 +- `JobDetailPanel` 接收当前模式、SSE 连接状态、最近事件时间、错误和重试函数。 +- `Jobs` 同时调用两个详情 hook,但以 `enabled` 参数保证仅当前模式工作。 + +- [x] **Step 1: 创建刷新模式控件** + +使用 Ant Design `Segmented`,不新增自绘按钮: + +```tsx + + value={mode} + options={[ + { label: "SSE", value: JOB_REFRESH_MODE.SSE }, + { label: "Polling", value: JOB_REFRESH_MODE.POLLING }, + ]} + onChange={onChange} +/> +``` + +该组件不维护本地模式状态,确保任务切换后当前选择保持不变。 + +- [x] **Step 2: 页面默认 SSE 并启用互斥 hooks** + +在 `Jobs` 增加: + +```ts +const [refreshMode, setRefreshMode] = useState( + JOB_REFRESH_MODE.SSE, +); +const pollingDetailQuery = useJobPolling( + selectedJobId, + refreshMode === JOB_REFRESH_MODE.POLLING, +); +const sseDetailQuery = useJobSse( + selectedJobId, + refreshMode === JOB_REFRESH_MODE.SSE, +); +``` + +根据 `refreshMode` 选择传给详情面板的 `data`、loading、fetching、error 和 retry 函数。删除页面顶部已过期的“后续再接入 SSE”说明;任务列表的加载失败 Alert 保持不变。 + +- [x] **Step 3: 更新详情面板** + +将模式控件放在 `Card` 的 `extra`,使详情为空或 SSE 出错时仍可切换到 Polling。把错误 Alert 放在详情内容前而不是提前 `return`,这样 SSE 出错时最后一份完整任务快照仍可见。 + +SSE 模式显示连接状态(`连接中`、`已连接`、`重连中`、`已关闭`)与最近事件时间;Polling 模式沿用既有 `isFetching` 的“刷新中”。复用 `onRetry`:Polling 调用 query 的 `refetch`,SSE 调用 hook 的 `retry`。终态手动关闭不产生错误提示。 + +- [x] **Step 4: 运行完整前端验证** + +Run: `cd apps/web && yarn test` + +Run: `cd apps/web && yarn type-check` + +Run: `cd apps/web && yarn lint` + +Run: `cd apps/web && yarn build` + +Expected: 全部命令以退出码 0 完成;现有 `new-http` SSE 集成测试继续覆盖 401 刷新、重连和事件游标。 + +- [x] **Step 5: 手动验证与文档核对** + +启动服务端和 Web 开发服务器,使用真实登录会话: + +1. 在 `/jobs` 触发 export-report,确认默认 SSE 将进度更新到 completed。 +2. 连续触发多条长 export-report,确认新任务订阅建立后,前一条任务仍通过 SSE 更新列表状态与进度。 +3. 切换 Polling,确认详情继续更新;再切回 SSE,确认轮询停止且新 SSE 订阅建立。 +4. 选择已完成任务,确认 snapshot 后 SSE 立即关闭且没有重连错误。 +5. 对照已确认的设计文档核对默认模式、错误重试、缓存替换和“不自动降级”均与实现一致;仅在有实现差异时修改设计文档。 + +### Plan Self-Review + +- **Spec coverage:** 覆盖默认 SSE、可见模式切换、轮询保留、JWT/401 恢复复用、完整快照缓存替换、事件先后竞态、终态关闭、错误重试、测试与手动验收;不修改服务端 SSE 协议。 +- **Placeholder scan:** 没有占位标记或未定义的实现步骤;每个行为均指向具体文件、接口与验证命令。 +- **Type consistency:** `JobRefreshMode` 和 `JobSseEventName` 定义在 `types/job.ts`;`SseSubscription`、`SseState`、`SseEvent` 从 `fzkit/http-client` 以类型导入;详情缓存统一使用 `getJobDetailQueryKey(jobId)`。 diff --git a/docs/superpowers/specs/2026-07-28-jobs-phase2-learning-console-design.md b/docs/superpowers/specs/2026-07-28-jobs-phase2-learning-console-design.md index 5e5e937..b3bec6e 100644 --- a/docs/superpowers/specs/2026-07-28-jobs-phase2-learning-console-design.md +++ b/docs/superpowers/specs/2026-07-28-jobs-phase2-learning-console-design.md @@ -1,8 +1,8 @@ # 任务系统二期设计:任务中心 + Bull Board + SSE -> 状态:已确认并实现 -> 日期:2026-07-28 -> 范围:在一期服务端任务底座之上,实现学习向完整二期 +> 状态:二期与前端 SSE 接入均已实现
+> 日期:2026-07-28(更新:2026-08-17)
+> 范围:在一期服务端任务底座之上,实现学习向完整二期,并补齐 Jobs 前端 SSE 详情刷新
> 前置:`docs/superpowers/specs/2026-07-14-jobs-system-design.md` ## 1. 目标 @@ -11,21 +11,21 @@ 1. 前端任务中心:列表、筛选、详情、手动触发、取消 2. Bull Board:观察 BullMQ 队列内部状态 -3. SSE 任务进度推送:本期先实现服务端能力,前端暂不接入 +3. SSE 任务进度推送:前端详情默认使用 SSE,并可切换到轮询进行学习对比 核心学习点: - 业务层 `job_runs` 与队列层 BullMQ 的定位差异 -- 保留轮询与 SSE 两种实时刷新路径的设计;本期前端先使用轮询,SSE 通过后端接口单独验证 +- 保留轮询与 SSE 两种可运行、可观察的实时刷新路径 ## 2. 共识与约束 | 项 | 结论 | | ---------- | --------------------------------------------------------------- | | 复用一期 | 必须复用现有 JobService / JobRecordService / handlers / JWT API | -| 轮询 | 保留完整代码路径,不删除、不降级为隐藏实现 | -| SSE | 后端新增第二种实时方式,不替换轮询;前端暂不接入 | -| 切换 | 方案保留,前端本期不开发 Polling / SSE 切换 | +| 轮询 | 保留完整代码路径,并可在详情面板中主动选择 | +| SSE | 复用 `fzkit` 的 `http.sse()` 接入,不替换或删除轮询 | +| 切换 | 详情面板提供 Polling / SSE 切换,默认 SSE;两种模式必须互斥 | | Bull Board | 挂载管理入口,必须鉴权,不替代任务中心 | | 鉴权 | 沿用全局 JWT;不做用户级任务隔离 / RBAC 细化 | | 不做 | 动态 cron 管理台、WebSocket 进度、新业务 handler、完整 RBAC | @@ -38,7 +38,7 @@ - 触发:POST /api/background-tasks/* - 取消:POST /api/jobs/:id/cancel - 详情刷新模式 A:轮询 GET /api/jobs/:id - - 后端预留刷新模式 B:SSE GET /api/jobs/:id/events(前端暂不接) + - 详情刷新模式 B:SSE GET /api/jobs/:id/events(默认) - 队列监控入口:打开 /admin/queues 服务端 @@ -87,8 +87,8 @@ │ - cleanup-expired-... │ 操作:查看详情 / 取消 │ ├──────────────────────────┴─────────────────────────────────┤ │ 任务详情(选中后展开) │ -│ 刷新方式:Polling;SSE 后端能力保留,前端暂不接入 │ -│ 当前轮询状态 │ +│ 刷新方式:[SSE] [Polling];默认 SSE │ +│ 当前连接 / 轮询状态 │ │ progress / status / attempts / timestamps │ │ payload / result / errorMessage │ └────────────────────────────────────────────────────────────┘ @@ -114,7 +114,8 @@ apps/web/src/ │ ├── dtos/job.ts │ └── hooks/ │ ├── use-jobs-list.ts -│ └── use-job-polling.ts +│ ├── use-job-polling.ts +│ └── use-job-sse.ts └── constants/ # 如补充 PATHS / JOB 常量 ``` @@ -123,7 +124,7 @@ apps/web/src/ - 组件目录 kebab-case,命名导出 - 不做桶导出 - HTTP 复用 `new-http` -- React Query 负责列表与轮询;前端 SSE hook 暂缓实现 +- React Query 负责列表、详情缓存和轮询;SSE hook 通过完整快照替换这些缓存 ## 5. API 增补 @@ -162,7 +163,8 @@ apps/web/src/ - 连接后立即推送一次当前快照(`job.snapshot`) - 后续仅推送该 `jobId` 的变更 - 终态 `completed` / `failed` / `cancelled` 推送后关闭流 -- 前端可断线重连;若已终态,重连后收到 snapshot 后立即结束 +- 前端由 `fzkit` 在可恢复网络异常或 401 刷新 Token 后重连;若已终态,重连后收到 snapshot 后立即结束 +- `fzkit` 会自动维护并在重连时发送 `Last-Event-ID`。当前单实例服务端不持久化事件历史,也不按该游标重放;重连后的完整 snapshot 是恢复当前任务状态的依据 ### 5.3 Bull Board @@ -176,7 +178,7 @@ apps/web/src/ - 必须鉴权 - 至少暴露 default 队列 waiting/active/completed/failed -## 6. 轮询与 SSE 并存设计(SSE 前端暂缓) +## 6. 轮询与 SSE 并存设计 ### 6.1 模式定义 @@ -192,17 +194,17 @@ export type JobRefreshMode = ### 6.2 行为规则 -| 模式 | 行为 | 停止条件 | -| ------- | -------------------------------------------------------------- | ---------------------------------- | -| Polling | `useQuery(GetJobById)` + `refetchInterval`(建议 1500–2000ms) | 终态、关闭详情 | -| SSE | 本期只实现后端 `/api/jobs/:id/events`,前端暂不建立连接 | 后端终态后结束流 | +| 模式 | 行为 | 停止条件 | +| ------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| Polling | `useQuery(GetJobById)` + `refetchInterval`(建议 1500–2000ms) | 终态、切换 SSE、关闭详情 | +| SSE | 已提交或查看过的未终态任务各自通过 `new-http.sse('/jobs/:id/events')` 接收完整快照并更新详情与列表缓存;默认启用 | 任务终态、切换 Polling、页面卸载 | -后续前端接入时的切换规则: +前端切换规则: 1. 切换前先 teardown 旧模式(取消 interval / 关闭流) 2. 切换后立即拉一次当前快照或建立新连接 3. 两种模式互斥运行,避免双通道同时写状态 -4. 列表页本身可用手动刷新或低频 invalidate;详情实时刷新只由当前模式负责 +4. 列表页本身可用手动刷新或低频 invalidate;SSE 模式持续跟踪已提交或查看过的未终态任务,切换详情只改变当前展示任务,不关闭其他仍在跟踪的流 ### 6.3 前端 hooks @@ -214,22 +216,25 @@ export type JobRefreshMode = `use-job-sse.ts` -- 本期暂不实现 -- 输入:`jobId`、`enabled` -- 输出:job 数据、connectionStatus、lastEventAt、error -- 终态后关闭连接 -- 断线后有限次重连(学习示例即可,如指数退避到上限) +- 输入:当前详情 `jobId`、已跟踪的未终态 `jobIds`、`enabled` +- 输出:job 数据、connectionStatus、lastEventAt、error、retry +- 使用现有 `new-http.sse()`;Token 注入、401 刷新重连、`Last-Event-ID` 续传由 `fzkit` 处理 +- 以任务 ID 管理多条独立订阅;新任务加入时不得关闭既有未终态任务的流,终态后释放对应订阅 +- 通过 `sequentialMessages: true` 按接收顺序处理事件;解析成功后将完整 `IJobRun` 替换到详情缓存,并复用 `syncJobToJobsListCache` 同步当前列表缓存 +- 服务端为避免读快照期间漏事件而先订阅,因此 `job.updated` 可能先于本连接的 `job.snapshot` 到达;已处理任何非 snapshot 事件时,随后到达的 snapshot 不得覆盖缓存 +- 终态事件主动关闭订阅,避免服务端关闭后的 EOF 触发无意义重连 +- 可恢复错误按 `fzkit` 默认指数退避重连,最多 5 次;达到上限或收到不可恢复错误后展示错误并允许用户重试 -`job-detail-panel` 本期只启用 `use-job-polling`;`use-job-sse` 作为后续接入点保留在方案中。 +`job-detail-panel` 增加紧凑的分段控件,由页面维护当前 `JobRefreshMode`。默认 `SSE`;详情轮询与 SSE 管理器必须以互斥的 `enabled` 参数运行,切换到 Polling 时关闭全部已跟踪 SSE 流,切回 SSE 时为仍未终态的已跟踪任务重新建立流。 -### 6.4 学习对比文案(页面内固定展示) +### 6.4 学习对比文案(详情面板展示) | 方式 | 优点 | 缺点 | 适用 | | ---- | ------------------ | ---------------- | -------- | | 轮询 | 实现简单、兼容性好 | 有延迟、多余请求 | 通用默认 | | SSE | 实时、服务端推送 | 连接管理更复杂 | 进度场景 | -页面可先保留说明:SSE 是后端已预留的第二种实现示例,前端暂不接入;轮询不被替代。 +页面展示当前模式与 SSE 连接状态。SSE 遇到不可恢复错误时展示重试操作,不自动降级到轮询,以便两条路径的行为保持可观察。 ## 7. SSE 事件协议 @@ -265,7 +270,9 @@ export const JOB_SSE_EVENT = { ### 7.3 data payload -所有 SSE 事件的 `data` 都是完整 `IJobRunView` 快照,不是部分字段补丁。前端应按整份任务对象替换缓存,不要做字段合并。 +服务端所有 SSE 事件的 `data` 都是完整 `IJobRunView` 快照,不是部分字段补丁。`formatSseEvent` 通过 `JSON.stringify` 序列化后,`Date` 字段转换为 ISO 8601 字符串,`undefined` 字段省略,`null` 字段保留。 + +前端将 `data` 解析为完整 `IJobRun`。除 `startedAt`、`finishedAt`、`createdAt` 由服务端的 `Date | null` / `Date` 变为 JSON 字符串外,其余字段一一对应。前端应按整份对象替换详情缓存,不要做字段合并。 ```ts export interface IJobRunView { @@ -291,15 +298,15 @@ export interface IJobRunView { ```text event: job.snapshot id: 1 -data: {"id":"123","name":"export-report","status":"active","progress":40,"errorMessage":null,"result":null,"attemptsMade":1,"maxAttempts":1,"triggerType":"manual","startedAt":"...","finishedAt":null,"createdAt":"..."} +data: {"id":"123","name":"export-report","queueName":"default","status":"queued","progress":0,"payload":{"title":"report"},"result":null,"errorMessage":null,"attemptsMade":0,"maxAttempts":3,"triggerType":"manual","startedAt":null,"finishedAt":null,"createdAt":"..."} event: job.updated id: 2 -data: {"id":"123","name":"export-report","queueName":"default","status":"active","progress":80,"payload":{"title":"report"},"result":null,"errorMessage":null,"attemptsMade":1,"maxAttempts":1,"triggerType":"manual","startedAt":"...","finishedAt":null,"createdAt":"..."} +data: {"id":"123","name":"export-report","queueName":"default","status":"active","progress":80,"payload":{"title":"report"},"result":null,"errorMessage":null,"attemptsMade":1,"maxAttempts":3,"triggerType":"manual","startedAt":"...","finishedAt":null,"createdAt":"..."} event: job.completed id: 3 -data: {"id":"123","status":"completed","progress":100,"result":{"file":"mock.pdf"},...} +data: {"id":"123","name":"export-report","queueName":"default","status":"completed","progress":100,"payload":{"title":"report"},"result":{"file":"mock.pdf"},"errorMessage":null,"attemptsMade":1,"maxAttempts":3,"triggerType":"manual","startedAt":"...","finishedAt":"...","createdAt":"..."} ``` ### 7.4 服务端事件源 @@ -333,13 +340,27 @@ SSE controller 流程: 浏览器原生 `EventSource` 不能自定义 `Authorization` header。 -后续前端接入推荐实现: +前端实现: -- 前端用 `fetch` + `ReadableStream` 读 SSE,并带上 JWT -- 复用现有 token 获取方式 +- 通过 `fzkit` 的 `http.sse()` 使用 `fetch` + `ReadableStream` 读取 SSE,并自动带上 JWT +- 复用 `new-http` 中既有 token 获取、刷新和登出策略 不推荐把 accessToken 塞进 query string 作为主方案。 +### 7.6 前端缓存与关闭规则 + +1. 收到 `job.snapshot`、`job.updated` 或任一终态事件时,将服务端完整 `IJobRunView` 的 JSON `data` 解析为前端完整 `IJobRun`,按整份对象替换详情缓存;不做字段级合并。 +2. 详情缓存更新后,调用既有 `syncJobToJobsListCache` 更新已缓存且包含该任务的列表页;任务尚未在列表中但已符合筛选条件时,仅失效对应精确查询,以保留分页和排序契约;任务不再符合当前筛选条件时从缓存移除。 +3. `job.completed`、`job.failed`、`job.cancelled` 三种事件更新缓存后立即调用订阅的 `close()`;这属于正常完成,不展示连接错误。 +4. 切换刷新模式或卸载详情面板时必须关闭全部 SSE 订阅。切换任务仅更新详情展示目标,其他已跟踪且未终态任务的订阅继续运行;手动关闭后不得再次发起重连。 +5. 不可恢复连接错误、JSON 解析失败或最大重试次数耗尽时保留最后一份任务快照,并由详情面板展示错误与“重试”操作;重试仅重建 SSE 订阅。 + +### 7.7 前端测试范围 + +1. 将 SSE 事件解析、缓存替换、终态判断抽为无 React 副作用的小函数;Vitest 覆盖正常 snapshot、updated、终态事件,以及 `updated` 先于 snapshot 时快照不回退状态。 +2. mock `new-http.sse()` 验证订阅参数、终态主动关闭、禁用或卸载后的 cleanup,以及解析错误写入可展示的错误状态。 +3. Vitest 覆盖新增任务时保留既有流、移除一个任务时不影响其他流,以及重连后的 snapshot 恢复;刷新模式通过手动验收覆盖默认 SSE、切换到 Polling 后 SSE 被禁用、切回 SSE 后轮询被禁用。 + ## 8. Bull Board 挂载与鉴权 ### 8.1 挂载路径 @@ -409,7 +430,7 @@ README / 页面固定说明: - `@bull-board/express` - 以及兼容的 Nest 适配包(若选用) -前端本期不实现 SSE;后续接入时用原生 fetch stream 即可。 +前端通过现有 `fzkit` 的 `http.sse()` 接入 SSE;不新增独立 SSE 依赖。 ## 10. 前端实现要点 @@ -418,7 +439,7 @@ README / 页面固定说明: - 页面挂在 `AuthGuard` 下 - 未登录跳转登录 - REST 请求继续走 `new-http` 自动带 JWT -- SSE 前端接入暂缓 +- SSE 请求继续走 `new-http.sse()` 自动带 JWT,并复用 401 刷新与登出策略 ### 10.2 列表 @@ -448,7 +469,8 @@ README / 页面固定说明: - payload / result / errorMessage - attemptsMade / maxAttempts - startedAt / finishedAt / createdAt -- 轮询刷新状态 + SSE 暂缓接入说明 +- 默认 SSE 的连接状态;可切换的 Polling / SSE 刷新方式 +- SSE 终态错误的重试操作 ## 11. 文档改动 @@ -490,42 +512,43 @@ README / 页面固定说明: 1. 登录后可进入任务中心完整流程 2. 可触发 3 类后台任务并看到结果 3. 同一任务详情支持: - - 仅轮询刷新 - - 后端 SSE 可通过接口单独验证 + - 默认 SSE 刷新,并正确更新进度和终态 + - 通过可见控件切换到轮询,且两种模式不会同时运行 + - 连续提交或查看多条未终态任务时,前一条 SSE 流不会因详情切换而被丢弃 4. 轮询终态停止;SSE 后端终态后结束流 -5. Bull Board 可访问且受保护 -6. 文档能讲清: +5. SSE 在 401 后刷新 Token 重连,并在可恢复断线后使用完整 snapshot 恢复当前状态 +6. Bull Board 可访问且受保护 +7. 文档能讲清: - 任务中心 vs Bull Board - 轮询 vs SSE -7. 代码风格匹配现有 monorepo -8. 本地可运行并给出测试步骤 +8. 代码风格匹配现有 monorepo +9. 本地可运行并给出测试步骤 -## 14. 建议实现顺序 +## 14. 前端 SSE 后续实现顺序 -1. 服务端:JobEventsService + 记录变更发事件 -2. 服务端:SSE `GET /jobs/:id/events` -3. 服务端:Bull Board 模块 + 鉴权 + vite proxy -4. 前端:jobs API/types + 列表/触发/取消 -5. 前端:详情 + polling hook -6. 文档与手动验收 -7. 提交独立分支 `feat/jobs-phase2-learning-console` +1. 前端:补充刷新模式类型与可测试的 SSE 事件缓存函数 +2. 前端:新增 `use-job-sse.ts`,复用 `new-http.sse()` +3. 前端:调整 `use-job-polling.ts` 接受 `enabled`,保证模式互斥 +4. 前端:增加详情面板的刷新模式切换、连接状态、错误和重试操作 +5. 前端:更新页面默认模式与事件缓存同步 +6. 测试、类型检查、构建与手动验收 ## 15. 本地验证提纲(实现后执行) 1. 登录 web -2. 打开任务中心,触发 export-report,用 Polling 看到 progress → completed -3. 用 curl 或浏览器开发工具验证 `/api/jobs/:id/events` 可收到 snapshot / updated / terminal 事件并结束流 -4. 触发 flaky-retry,观察重试与最终状态 -5. 触发 cleanup-expired-refresh-tokens,观察完成 -6. 对 queued/delayed 任务执行取消 -7. 打开队列监控,确认需鉴权且能看到 default 队列 -8. 未登录访问 `/jobs` 应跳转登录;未带 token 访问 Board/SSE 应 401 - -## 16. 待确认后执行 - -用户确认本设计后: - -1. 产出实现计划(writing-plans) -2. 在独立分支编码 -3. 手动验证 + 必要测试 -4. 按需开 PR +2. 打开任务中心,触发 export-report,默认 SSE 看到 progress → completed +3. 切换到 Polling 后,确认详情继续刷新且 SSE 订阅已关闭 +4. 触发 SSE 的 401 或临时断线,确认刷新 Token / 重连后由 snapshot 恢复当前状态 +5. 触发 flaky-retry,观察重试与最终状态 +6. 触发 cleanup-expired-refresh-tokens,观察完成 +7. 对 queued/delayed 任务执行取消 +8. 打开队列监控,确认需鉴权且能看到 default 队列 +9. 未登录访问 `/jobs` 应跳转登录;未带 token 访问 Board/SSE 应 401 + +## 16. 确认后的执行边界 + +本设计已于 2026-08-17 确认。后续实现: + +1. 仅修改 `apps/web` 中与 Jobs 详情刷新相关的类型、hooks、页面组件和测试,以及本设计文档和对应实现计划。 +2. 不修改既有服务端 SSE 协议、轮询 API、Bull Board 或 refresh token 流程。 +3. 完成后按需提交;不自动推送或创建 PR。 diff --git a/package.json b/package.json index 77566a4..8982ec0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "yarn workspaces foreach -A run build", "dev": "yarn workspaces foreach -Ap run dev", "start": "yarn workspaces foreach -A run start", + "type-check": "yarn workspace @my-first-nest/server type-check && yarn workspace @my-first-nest/web type-check", "lint": "yarn workspaces foreach -A run lint", "test": "yarn workspaces foreach -A run test", "clean": "yarn workspaces foreach -A run clean && rm -rf node_modules", diff --git a/yarn.lock b/yarn.lock index 8e27d41..e3613c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -85,16 +85,7 @@ __metadata: languageName: node linkType: hard -"@ant-design/colors@npm:^7.0.0, @ant-design/colors@npm:^7.2.1": - version: 7.2.1 - resolution: "@ant-design/colors@npm:7.2.1" - dependencies: - "@ant-design/fast-color": "npm:^2.0.6" - checksum: 10c0/4748a0bfb1ea98e08e29dcd4f7afd2781ae2119f783e6e9f80e889fd15fc19f7137e2a3d91f26bae2ab1ee76c04d520cc35f2bb0a708cd71e463f4d9deb4192d - languageName: node - linkType: hard - -"@ant-design/colors@npm:^8.0.0": +"@ant-design/colors@npm:^8.0.1": version: 8.0.1 resolution: "@ant-design/colors@npm:8.0.1" dependencies: @@ -103,44 +94,35 @@ __metadata: languageName: node linkType: hard -"@ant-design/cssinjs-utils@npm:^1.1.3": - version: 1.1.3 - resolution: "@ant-design/cssinjs-utils@npm:1.1.3" +"@ant-design/cssinjs-utils@npm:^2.1.2": + version: 2.1.2 + resolution: "@ant-design/cssinjs-utils@npm:2.1.2" dependencies: - "@ant-design/cssinjs": "npm:^1.21.0" + "@ant-design/cssinjs": "npm:^2.1.2" "@babel/runtime": "npm:^7.23.2" - rc-util: "npm:^5.38.0" + "@rc-component/util": "npm:^1.4.0" peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/e8a443a613689c4e984f5cf44b799f6288a6debf9a35cafb27a0411ef77ae335ba5ace7a38efd8e04f04ac897ddf24c81f0ad6615ac586f2616cb7f2b72f6176 + react: ">=18" + react-dom: ">=18" + checksum: 10c0/46663c8c92e23f2fbc61682099b5ec03aa26e9bffab96be6889b310d2a58adf46758c5f9f977aa13d46ba3606cf4c64ea0bb76985bc1f4b5619b81be5849b172 languageName: node linkType: hard -"@ant-design/cssinjs@npm:^1.21.0, @ant-design/cssinjs@npm:^1.23.0": - version: 1.24.0 - resolution: "@ant-design/cssinjs@npm:1.24.0" +"@ant-design/cssinjs@npm:^2.1.2": + version: 2.1.2 + resolution: "@ant-design/cssinjs@npm:2.1.2" dependencies: "@babel/runtime": "npm:^7.11.1" "@emotion/hash": "npm:^0.8.0" "@emotion/unitless": "npm:^0.7.5" - classnames: "npm:^2.3.1" + "@rc-component/util": "npm:^1.4.0" + clsx: "npm:^2.1.1" csstype: "npm:^3.1.3" - rc-util: "npm:^5.35.0" stylis: "npm:^4.3.4" peerDependencies: react: ">=16.0.0" react-dom: ">=16.0.0" - checksum: 10c0/e84bc33bd74d386f87813641287ad3ba7494adcde944277bbec3745ea14cc19bb430c0723d8e86058d23c450a4c22e7fe63281e0d5e50d3a0e94ea55185305b4 - languageName: node - linkType: hard - -"@ant-design/fast-color@npm:^2.0.6": - version: 2.0.6 - resolution: "@ant-design/fast-color@npm:2.0.6" - dependencies: - "@babel/runtime": "npm:^7.24.7" - checksum: 10c0/8d30649bd8d4e56d5c48393fcf0ad5c24d1099ec4cbf88f55bd9f4489e61efc30087d301da384c4ed21f2d5597087c8ba27dfbcc7693915310c26d307f5a8276 + checksum: 10c0/70d8169f1e2044bb44d6a51265a3123e8ac2018b1725ccda018b083a610afdf8e9a27e16df27ad0eaa67e85c827d5abcc8f47dd5ad448c0c8560dfcd0353aa68 languageName: node linkType: hard @@ -151,56 +133,47 @@ __metadata: languageName: node linkType: hard -"@ant-design/icons-svg@npm:^4.4.0": - version: 4.4.2 - resolution: "@ant-design/icons-svg@npm:4.4.2" - checksum: 10c0/d08f051824599850efcd691a67b0ee602ee886f23fe04e77890b083a0343cde72560317e3909fd029f999df00aef7b57142c863326fff7293251d9162828079b +"@ant-design/fast-color@npm:^3.0.1": + version: 3.0.1 + resolution: "@ant-design/fast-color@npm:3.0.1" + checksum: 10c0/bc6535351f855b1af777b9d18c5b5ca7eeb8769c1dc9cd8579408ed5284e4d6dd2d9cd192ff8110dc7a82e9dc9bc2e5c51ceeff3237c88c5464aca0e4d70eb9a languageName: node linkType: hard -"@ant-design/icons@npm:^5.6.1": - version: 5.6.1 - resolution: "@ant-design/icons@npm:5.6.1" - dependencies: - "@ant-design/colors": "npm:^7.0.0" - "@ant-design/icons-svg": "npm:^4.4.0" - "@babel/runtime": "npm:^7.24.8" - classnames: "npm:^2.2.6" - rc-util: "npm:^5.31.1" - peerDependencies: - react: ">=16.0.0" - react-dom: ">=16.0.0" - checksum: 10c0/7a9d9fd388c5c66d92818fd0eb794a54ef0b0dc3d75f15ac24c7cfde21c5c836c220cf35423768fd1faa28d55443a6917bf4260469c1485be83f799daa351976 +"@ant-design/icons-svg@npm:^4.5.0": + version: 4.5.0 + resolution: "@ant-design/icons-svg@npm:4.5.0" + checksum: 10c0/6d8c15ffc43c7c39560e075682da5b177462d42ca92ac227702c9ce267fea688025449bc6528d7b64f1bc88408384af5d6c52464afa47a01b5394894816d7f9e languageName: node linkType: hard -"@ant-design/icons@npm:^6.1.0": - version: 6.1.0 - resolution: "@ant-design/icons@npm:6.1.0" +"@ant-design/icons@npm:^6.3.2": + version: 6.3.2 + resolution: "@ant-design/icons@npm:6.3.2" dependencies: - "@ant-design/colors": "npm:^8.0.0" - "@ant-design/icons-svg": "npm:^4.4.0" - "@rc-component/util": "npm:^1.3.0" + "@ant-design/colors": "npm:^8.0.1" + "@ant-design/icons-svg": "npm:^4.5.0" + "@rc-component/util": "npm:^1.11.0" clsx: "npm:^2.1.1" peerDependencies: react: ">=16.0.0" react-dom: ">=16.0.0" - checksum: 10c0/d2fc94a348e18fcc37507dd7febbd179770dea9e8dbedecb362a4d9d0c1a503ed628f2973067b14dc8882d3c25b7c9c979753dea18faef5d20b7d2021d468315 + checksum: 10c0/3f7dcfda65d22f43f78173de60ac47fd5ffcb54a9db4dcaa2fd8380aa424facb8c51edc9c7a9afeaab8ca012c07827edfe73bb51e7a33891fed90ce1da692620 languageName: node linkType: hard -"@ant-design/react-slick@npm:~1.1.2": - version: 1.1.2 - resolution: "@ant-design/react-slick@npm:1.1.2" +"@ant-design/react-slick@npm:~2.0.0": + version: 2.0.0 + resolution: "@ant-design/react-slick@npm:2.0.0" dependencies: - "@babel/runtime": "npm:^7.10.4" - classnames: "npm:^2.2.5" + "@babel/runtime": "npm:^7.28.4" + clsx: "npm:^2.1.1" json2mq: "npm:^0.2.0" - resize-observer-polyfill: "npm:^1.5.1" throttle-debounce: "npm:^5.0.0" peerDependencies: - react: ">=16.9.0" - checksum: 10c0/4f758e28cf8418e9f1a9b03da3814b2342fcee8a4039cae2fe6f77c01e9c8b7ea78a7e10961128a5ccba4992f520e88cb72c4f7fa1bd22314ce628b0d9fb3f5c + react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/1aaa3aae2a9e7d06840bf620b0ac1d0de4088f969fec45ae1b1c7a16ab1e0d6d89809ec9c915fad07c8a719b447ebf5031bb53024d1a92f1ab75e3c6788ed42e languageName: node linkType: hard @@ -397,13 +370,27 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0": +"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7": version: 7.28.6 resolution: "@babel/runtime@npm:7.28.6" checksum: 10c0/358cf2429992ac1c466df1a21c1601d595c46930a13c1d4662fde908d44ee78ec3c183aaff513ecb01ef8c55c3624afe0309eeeb34715672dbfadb7feedb2c0d languageName: node linkType: hard +"@babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.29.2": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + languageName: node + linkType: hard + +"@babel/runtime@npm:^8.0.0": + version: 8.0.0 + resolution: "@babel/runtime@npm:8.0.0" + checksum: 10c0/450857cf52b3a3935d4aad505277201361c86185dad3bdd2ae083d78b76c6265c7fc7610e170c80de1b825d5bfdcfb50697f0853fff922b2661c91cff1fbbd81 + languageName: node + linkType: hard + "@babel/template@npm:^7.28.6": version: 7.28.6 resolution: "@babel/template@npm:7.28.6" @@ -1541,7 +1528,7 @@ __metadata: version: 0.0.0-use.local resolution: "@my-first-nest/web@workspace:apps/web" dependencies: - "@ant-design/icons": "npm:^6.1.0" + "@ant-design/icons": "npm:^6.3.2" "@hookform/resolvers": "npm:^5.2.2" "@radix-ui/react-avatar": "npm:^1.1.11" "@radix-ui/react-dialog": "npm:^1.1.15" @@ -1556,7 +1543,7 @@ __metadata: "@types/react": "npm:^19.2.6" "@types/react-dom": "npm:^19.2.3" "@vitejs/plugin-react": "npm:^5.1.1" - antd: "npm:^5.29.1" + antd: "npm:^6.6.0" axios: "npm:^1.15.2" babel-plugin-react-compiler: "npm:^1.0.0" class-variance-authority: "npm:^0.7.1" @@ -1566,6 +1553,7 @@ __metadata: eslint-plugin-prettier: "npm:^5.5.4" eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-react-refresh: "npm:^0.4.24" + fzkit: "npm:^0.2.1" globals: "npm:^16.5.0" lucide-react: "npm:^0.526.0" next-themes: "npm:^0.4.6" @@ -2880,410 +2868,906 @@ __metadata: languageName: node linkType: hard -"@rc-component/async-validator@npm:^5.0.3": - version: 5.1.0 - resolution: "@rc-component/async-validator@npm:5.1.0" +"@rc-component/async-validator@npm:^6.0.0": + version: 6.0.0 + resolution: "@rc-component/async-validator@npm:6.0.0" dependencies: "@babel/runtime": "npm:^7.24.4" - checksum: 10c0/6acbac53777a4e189ec3d825cd689af9a1ba6e370289794d573a7b8fa4678ae9cb9583d9a4f67035e6165d19e414a5fb406b73549d44b973d981f627f996480e + checksum: 10c0/9918b080cfd2c59b48da548f58050514627b71f83823bfa0fcfdd6d7bb43f63c8c99abe0200791880139c9877bd335d5d26d91f74b21c9fadfa653673687752f languageName: node linkType: hard -"@rc-component/color-picker@npm:~2.0.1": - version: 2.0.1 - resolution: "@rc-component/color-picker@npm:2.0.1" +"@rc-component/cascader@npm:~1.22.0": + version: 1.22.0 + resolution: "@rc-component/cascader@npm:1.22.0" + dependencies: + "@rc-component/select": "npm:~1.10.0" + "@rc-component/tree": "npm:~1.4.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/2e768478843313005ad6b66b6ddc72572d83174c55486ad15fab86b47deb9b7d986948ca74ca660ebe3a894f08940f74bc4e8ee645942afd0acfe88f02cdfcb8 + languageName: node + linkType: hard + +"@rc-component/checkbox@npm:~2.0.0": + version: 2.0.0 + resolution: "@rc-component/checkbox@npm:2.0.0" dependencies: - "@ant-design/fast-color": "npm:^2.0.6" - "@babel/runtime": "npm:^7.23.6" - classnames: "npm:^2.2.6" - rc-util: "npm:^5.38.1" + "@rc-component/util": "npm:^1.3.0" + clsx: "npm:^2.1.1" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10c0/be0b851a609fdb360367f38765b568c52f935dde4e70f7b832146a921eb221ddd51735144a35ce36c55810f47f830078e271508920585e3ab789e47c4f824775 + checksum: 10c0/b31d072843d1961401fd4652108cca7d97597d26c521038c5cc1a9aed311b5a1e6c723cbfb7084cd0f1fb62f7fc2c6006d6638feb80f4291c9fc2e26ba1a26f9 languageName: node linkType: hard -"@rc-component/context@npm:^1.4.0": - version: 1.4.0 - resolution: "@rc-component/context@npm:1.4.0" +"@rc-component/collapse@npm:~1.2.0": + version: 1.2.0 + resolution: "@rc-component/collapse@npm:1.2.0" dependencies: "@babel/runtime": "npm:^7.10.1" - rc-util: "npm:^5.27.0" + "@rc-component/motion": "npm:^1.1.4" + "@rc-component/util": "npm:^1.3.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/b8f954b21d0dada200a3dcec3ea7455b6c827ce358827dd50aa721953f4bac6224fcbb0f07546e6b9a5286c781819ebbe57ecaeb8fd898054ac276b8beb1945c + languageName: node + linkType: hard + +"@rc-component/color-picker@npm:~3.1.1": + version: 3.1.1 + resolution: "@rc-component/color-picker@npm:3.1.1" + dependencies: + "@ant-design/fast-color": "npm:^3.0.1" + "@rc-component/util": "npm:^1.3.0" + clsx: "npm:^2.1.1" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10c0/4da3773deca15107adccf6635c703663f5a9202c09d8877ee003ccf144de9991c5eefbca4458f31d95d234e57babf0f8f1926e0b887f8a503a43119a39a653aa + checksum: 10c0/7eb5308e20ecc4b164294642d5115b923aab7d282afbf555ed858eed2711ee65c3b204e6776e6accda1da352bd4e51e6302952dc7206f9f2d9796b068e550c32 languageName: node linkType: hard -"@rc-component/mini-decimal@npm:^1.0.1": - version: 1.1.0 - resolution: "@rc-component/mini-decimal@npm:1.1.0" +"@rc-component/context@npm:^2.0.1": + version: 2.0.2 + resolution: "@rc-component/context@npm:2.0.2" dependencies: - "@babel/runtime": "npm:^7.18.0" - checksum: 10c0/53a7ca71591bc03eba71ab844016df788e83c96c3c7c542710c3eeeae7f55340c88c4930d7a0b11ebe7f1cd9fc65cb5bc284f466fbe95589992dd9833edf6ddf + "@rc-component/util": "npm:^1.11.0" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/64e8f332098502cef5dbb23a47ab566587b5a6d8d91d528868744449ce07dc4cf4c20fefa403bd153e9605921dcb48c057dcd5267a99c28e17ee467b69067ac8 languageName: node linkType: hard -"@rc-component/mutate-observer@npm:^1.1.0": - version: 1.1.0 - resolution: "@rc-component/mutate-observer@npm:1.1.0" +"@rc-component/dialog@npm:~1.10.0": + version: 1.10.0 + resolution: "@rc-component/dialog@npm:1.10.0" dependencies: - "@babel/runtime": "npm:^7.18.0" - classnames: "npm:^2.3.2" - rc-util: "npm:^5.24.4" + "@rc-component/motion": "npm:^1.3.3" + "@rc-component/portal": "npm:^2.1.0" + "@rc-component/util": "npm:^1.9.0" + clsx: "npm:^2.1.1" peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/90159acd831ed04b7d2d412354892fd042ad51e63bf17a9fcfa84f3b799e23479617fcc7404566a5b97e5b512b6097b87b8eb36e0c419d803692796a39186a2e + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/0defdb60e7d147c22111442f7edbe61d5eaa732cd31bafb05d8f798119e418234270f0e48b515aaffd00d9158988f14f80d6fb95ec07cfc0dbbe29f103d367c9 languageName: node linkType: hard -"@rc-component/portal@npm:^1.0.0-8, @rc-component/portal@npm:^1.0.0-9, @rc-component/portal@npm:^1.0.2, @rc-component/portal@npm:^1.1.0, @rc-component/portal@npm:^1.1.1": - version: 1.1.2 - resolution: "@rc-component/portal@npm:1.1.2" +"@rc-component/drawer@npm:~1.4.2": + version: 1.4.2 + resolution: "@rc-component/drawer@npm:1.4.2" dependencies: - "@babel/runtime": "npm:^7.18.0" - classnames: "npm:^2.3.2" - rc-util: "npm:^5.24.4" + "@rc-component/motion": "npm:^1.1.4" + "@rc-component/portal": "npm:^2.1.3" + "@rc-component/util": "npm:^1.9.0" + clsx: "npm:^2.1.1" peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/3c0297356635d47f364be79de02bb16009f06b1ce82124c3e63da9a71b8e7d3ea2c147e4703ead9cae0f662435a21e9feb30d2edf7197108bc3dbf969f3ca51f + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/4d69581372ec7ce00f36a0a61010e0129f20b78ebece324df75b8107cd76d2590e94540cff95b48102c62d8e1042ffeb215e2a35a2175cdd3a40eb350470cefd languageName: node linkType: hard -"@rc-component/qrcode@npm:~1.1.0": - version: 1.1.1 - resolution: "@rc-component/qrcode@npm:1.1.1" +"@rc-component/dropdown@npm:~1.0.0, @rc-component/dropdown@npm:~1.0.3": + version: 1.0.3 + resolution: "@rc-component/dropdown@npm:1.0.3" dependencies: - "@babel/runtime": "npm:^7.24.7" + "@rc-component/trigger": "npm:^3.0.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.11.0" + react-dom: ">=16.11.0" + checksum: 10c0/b94641655106e4e356fec9a3850c411f3a7f88b163a1298ceec351f2c7788d5883ef149d9e0749adb0e1545da3debe7c1dde3c22fcc8c6960066d12ccb075b86 + languageName: node + linkType: hard + +"@rc-component/form@npm:~1.8.6": + version: 1.8.6 + resolution: "@rc-component/form@npm:1.8.6" + dependencies: + "@rc-component/async-validator": "npm:^6.0.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10c0/8b7f38b2f1b319322a7b1b75ce2766789035cdf61d31ee45d0ca9e3570ee7345760c9fc1d51bb9ff9c65d8d7a823d90abd31b1ff4f207480c54ed04479fb8b25 + checksum: 10c0/dbedb4527642ae31cb478c69958af0ac20eefc3185e5809d36d5e681928876d5270f67da1d2ca1064e64954e08f676039f990311829c15d9d37da2e35f58d6c2 languageName: node linkType: hard -"@rc-component/tour@npm:~1.15.1": - version: 1.15.1 - resolution: "@rc-component/tour@npm:1.15.1" +"@rc-component/image@npm:~1.10.0": + version: 1.10.0 + resolution: "@rc-component/image@npm:1.10.0" dependencies: - "@babel/runtime": "npm:^7.18.0" - "@rc-component/portal": "npm:^1.0.0-9" - "@rc-component/trigger": "npm:^2.0.0" - classnames: "npm:^2.3.2" - rc-util: "npm:^5.24.4" + "@rc-component/motion": "npm:^1.0.0" + "@rc-component/portal": "npm:^2.1.2" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10c0/8324edb96bbca2838e9bdcca5ac02e615596593d0e79ee74ad2e7d3a4801975f205907170bb16f92303b22701bc730c34337cb10270281cea36659bac10193b5 + checksum: 10c0/4db2fdeb3d2176d18d18fdbe5da700540282c9c5c9dfd12f6405d687a233061fef2adde50c396363f1ac320d078d0eecaf6eefc2c4478a12efd83caec4b9b2af languageName: node linkType: hard -"@rc-component/trigger@npm:^2.0.0, @rc-component/trigger@npm:^2.1.1, @rc-component/trigger@npm:^2.3.0": - version: 2.3.1 - resolution: "@rc-component/trigger@npm:2.3.1" +"@rc-component/input-number@npm:~1.6.2": + version: 1.6.2 + resolution: "@rc-component/input-number@npm:1.6.2" dependencies: - "@babel/runtime": "npm:^7.23.2" - "@rc-component/portal": "npm:^1.1.0" - classnames: "npm:^2.3.2" - rc-motion: "npm:^2.0.0" - rc-resize-observer: "npm:^1.3.1" - rc-util: "npm:^5.44.0" + "@rc-component/mini-decimal": "npm:^1.0.1" + "@rc-component/util": "npm:^1.4.0" + clsx: "npm:^2.1.1" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10c0/6baf0b0c22cea1a3869f931948145d32c6a3dfb711d022d290453685e79a4f06fedaf9e250e60082506ce3dee5a861ef5b78b337b05a2fa5a9a77cf0b4d97ec4 + checksum: 10c0/322a585d9e1a86b4e775de27cd9cbad94f38c4d21d1848148eb12e6114e74e4de148414d3ac89e24d312a471d482d4d6259a528b22b3e2ca3c44ab230284ba8f languageName: node linkType: hard -"@rc-component/util@npm:^1.3.0": - version: 1.7.0 - resolution: "@rc-component/util@npm:1.7.0" +"@rc-component/input@npm:~1.3.1": + version: 1.3.1 + resolution: "@rc-component/input@npm:1.3.1" dependencies: - is-mobile: "npm:^5.0.0" - react-is: "npm:^18.2.0" + "@rc-component/resize-observer": "npm:^1.1.1" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" peerDependencies: - react: ">=18.0.0" - react-dom: ">=18.0.0" - checksum: 10c0/71ab8c9f321be160cd55aa2f02bacf4a62b2d41b63d297b87dc73af3d7cc7c7716bcaa304cb03f443794411990e3a17aa455aef21d557f52aa5609d985474de5 + react: ">=16.0.0" + react-dom: ">=16.0.0" + checksum: 10c0/8a61ab9dfef2dc5044f7cb51e5da81700a76f1dd586e63256563ad9f820ab8f226beb295b9ae12b676c079b016fe3551ab4d09768b44e2eee317885cfeda8499 languageName: node linkType: hard -"@redis/client@npm:^5.10.0, @redis/client@npm:^5.12.1": - version: 5.12.1 - resolution: "@redis/client@npm:5.12.1" +"@rc-component/listy@npm:~1.2.3": + version: 1.2.3 + resolution: "@rc-component/listy@npm:1.2.3" dependencies: - cluster-key-slot: "npm:1.1.2" + "@rc-component/motion": "npm:^1.1.4" + "@rc-component/portal": "npm:^2.0.0" + "@rc-component/resize-observer": "npm:^1.0.0" + "@rc-component/util": "npm:^1.3.1" + "@rc-component/virtual-list": "npm:^1.4.0" + clsx: "npm:^2.1.1" peerDependencies: - "@node-rs/xxhash": ^1.1.0 - "@opentelemetry/api": ">=1 <2" - peerDependenciesMeta: - "@node-rs/xxhash": - optional: true - "@opentelemetry/api": - optional: true - checksum: 10c0/f475aa936b7cf73c3d2166862377d3502f16deaf5423d31f6899f6387440195aa2524c74d1df26da591a61b4ac0122a1e1f8d783b770e7630c5f7444445c41b0 + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/91856323c7c1084048f74683e46a42790778b143f59d1a3879d61163dc6f11262d38d6bd9df381889e47a40bbabda062dc777127fb5875498c27621e9e0911b1 languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.17" - conditions: os=android & cpu=arm64 +"@rc-component/mentions@npm:~1.11.0": + version: 1.11.0 + resolution: "@rc-component/mentions@npm:1.11.0" + dependencies: + "@rc-component/input": "npm:~1.3.1" + "@rc-component/menu": "npm:~1.4.0" + "@rc-component/trigger": "npm:^3.0.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/face4f3fb42407a89def1fe2e694e8ca2a289fb6c032e7cbdc3f0970d6e3b4481028b0fcf950939417f34e2daa478e88fc98145cdb9905c17a1d28773b0d1762 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17" - conditions: os=darwin & cpu=arm64 +"@rc-component/menu@npm:~1.4.0, @rc-component/menu@npm:~1.4.1": + version: 1.4.1 + resolution: "@rc-component/menu@npm:1.4.1" + dependencies: + "@rc-component/motion": "npm:^1.1.4" + "@rc-component/overflow": "npm:^1.0.0" + "@rc-component/trigger": "npm:^3.0.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/6fd214cf3ee2bc48ddd40f958c8ad20a95804f19850193934b4429b0d305773c46da5df3fb57deade2febb12c60c3b8c63337f546928d5ab83f68123c8d65ea4 languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.17" - conditions: os=darwin & cpu=x64 +"@rc-component/mini-decimal@npm:^1.0.1": + version: 1.1.0 + resolution: "@rc-component/mini-decimal@npm:1.1.0" + dependencies: + "@babel/runtime": "npm:^7.18.0" + checksum: 10c0/53a7ca71591bc03eba71ab844016df788e83c96c3c7c542710c3eeeae7f55340c88c4930d7a0b11ebe7f1cd9fc65cb5bc284f466fbe95589992dd9833edf6ddf languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17" - conditions: os=freebsd & cpu=x64 +"@rc-component/motion@npm:^1.0.0, @rc-component/motion@npm:^1.1.3, @rc-component/motion@npm:^1.1.4, @rc-component/motion@npm:^1.3.3": + version: 1.3.3 + resolution: "@rc-component/motion@npm:1.3.3" + dependencies: + "@rc-component/util": "npm:^1.11.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/1731f51d3856f514ac1d36fe7d1747c722e13e1404a1df0b8000d9cded5321771d7079c1d1016705531c148c38cd9054c7de2e4fe08beb05e8e4d96956ed6c0c languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17" - conditions: os=linux & cpu=arm +"@rc-component/mutate-observer@npm:^2.0.1": + version: 2.0.1 + resolution: "@rc-component/mutate-observer@npm:2.0.1" + dependencies: + "@rc-component/util": "npm:^1.2.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/45b63795a818eba95ca67dd28aacd9f535ef652919a13bed692d077a818e0365dbae6acf1bac83952905064767e6b1b176da998e52bc00d0a35f89fe866b6884 languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=arm64 & libc=glibc +"@rc-component/notification@npm:~2.0.7": + version: 2.0.7 + resolution: "@rc-component/notification@npm:2.0.7" + dependencies: + "@rc-component/motion": "npm:^1.1.4" + "@rc-component/util": "npm:^1.11.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/3a21cf070c43769fc98abe0c2818012204ec4f96a99be7463322b8f1d3644e7572a394c0db43851ccf7bafd3676660308cc082970360bb9a55a0670f11f385b0 languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17" - conditions: os=linux & cpu=arm64 & libc=musl +"@rc-component/overflow@npm:^1.0.0": + version: 1.0.1 + resolution: "@rc-component/overflow@npm:1.0.1" + dependencies: + "@babel/runtime": "npm:^7.11.1" + "@rc-component/resize-observer": "npm:^1.0.1" + "@rc-component/util": "npm:^1.4.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/710df9afcf06ea80a79a89bd7f92bf92d524ac6fa3c82cbd5e48e18e74bf60a4b56705dfcb2d9738c64091db87bd0f8336959a952e175d67eb63aad36395ba9a languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=ppc64 & libc=glibc +"@rc-component/pagination@npm:~1.4.0": + version: 1.4.0 + resolution: "@rc-component/pagination@npm:1.4.0" + dependencies: + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/36a425c3fe7266c71969c27278bb01171fed92394f21f1a6da62213f925069415cfb2281f7c2a0d8346623b9e49750e92fa85191c63aa9f7241ead024c696f7c languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=s390x & libc=glibc +"@rc-component/picker@npm:~1.12.0": + version: 1.12.0 + resolution: "@rc-component/picker@npm:1.12.0" + dependencies: + "@rc-component/overflow": "npm:^1.0.0" + "@rc-component/resize-observer": "npm:^1.0.0" + "@rc-component/trigger": "npm:^3.6.15" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + date-fns: ">= 2.x" + dayjs: ">= 1.x" + luxon: ">= 3.x" + moment: ">= 2.x" + react: ">=16.9.0" + react-dom: ">=16.9.0" + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + checksum: 10c0/6b2bd3f63be2b917d6a0425eae27f312685fefeab098f6e0a442d9b3b5d3ff090c335248f5fe47ce198095c2a19d2baf07273cbb5eb1b9a105d4696a03a8b32b languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17" - conditions: os=linux & cpu=x64 & libc=glibc +"@rc-component/portal@npm:^2.0.0, @rc-component/portal@npm:^2.1.0, @rc-component/portal@npm:^2.1.2, @rc-component/portal@npm:^2.1.3, @rc-component/portal@npm:^2.2.0, @rc-component/portal@npm:^2.2.1": + version: 2.2.1 + resolution: "@rc-component/portal@npm:2.2.1" + dependencies: + "@rc-component/util": "npm:^1.11.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/6576386bae88d139bc8b7e1699689bed38a14b7a555e6c185aa6e408a2c6eac421eb363fc9a7428b29ede0c1a526f227aeec239f4bbe8c8e05d569dd27faf8eb languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17" - conditions: os=linux & cpu=x64 & libc=musl +"@rc-component/progress@npm:~1.0.2": + version: 1.0.2 + resolution: "@rc-component/progress@npm:1.0.2" + dependencies: + "@rc-component/util": "npm:^1.2.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/2148f75bd0f4ad8431669a91acd7e4d69566387e315860e4b83a6ff1b85271703540a4050c03815c857825c25a4fcd6ab0f8ed78071c96513cb784efbb385483 languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17" - conditions: os=openharmony & cpu=arm64 +"@rc-component/qrcode@npm:~2.0.0": + version: 2.0.0 + resolution: "@rc-component/qrcode@npm:2.0.0" + dependencies: + "@babel/runtime": "npm:^7.24.7" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/b0b21e574719214bd30195df0dda2b29fa87394b40d6108f3d6f42b029d4d82de230a74338d72e1e04c0a61b90ae25e532cf366289c2b6deb6d92e4ef314562e languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17" +"@rc-component/rate@npm:~1.0.1": + version: 1.0.1 + resolution: "@rc-component/rate@npm:1.0.1" dependencies: - "@emnapi/core": "npm:1.10.0" - "@emnapi/runtime": "npm:1.10.0" - "@napi-rs/wasm-runtime": "npm:^1.1.4" - conditions: cpu=wasm32 + "@rc-component/util": "npm:^1.3.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/34eba661e6d54286f8ef1934ccbab2d7a6269b26249f84393b77206151706c6cc56001a497a841abcc1ed1221f86134e4b7fd34f216287ef46e50869fdd9d99d languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17" - conditions: os=win32 & cpu=arm64 +"@rc-component/resize-observer@npm:^1.0.0, @rc-component/resize-observer@npm:^1.0.1, @rc-component/resize-observer@npm:^1.1.1, @rc-component/resize-observer@npm:^1.1.2": + version: 1.1.2 + resolution: "@rc-component/resize-observer@npm:1.1.2" + dependencies: + "@rc-component/util": "npm:^1.2.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/059b6015adac38c1001940360308d44e47a0bd62992be4c0b6e566df6d5429a8b60428cd6d21289da48a393c8cd6ead4c61032b36315ca394e660d3261956c33 languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17" - conditions: os=win32 & cpu=x64 +"@rc-component/segmented@npm:~1.3.0": + version: 1.3.0 + resolution: "@rc-component/segmented@npm:1.3.0" + dependencies: + "@babel/runtime": "npm:^7.11.1" + "@rc-component/motion": "npm:^1.1.4" + "@rc-component/util": "npm:^1.3.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.0.0" + react-dom: ">=16.0.0" + checksum: 10c0/ef76db98618700439673670c4b6ea00a822e02c4e90ea6781b9f626ba5acbae861b2a7c8acc4985d8b5171fbe8bf4d81b31387bc26f3453f999e2d8348d7e792 languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-beta.53": - version: 1.0.0-beta.53 - resolution: "@rolldown/pluginutils@npm:1.0.0-beta.53" - checksum: 10c0/e8b0a7eb76be22f6f103171f28072de821525a4e400454850516da91a7381957932ff0ce495f227bcb168e86815788b0c1d249ca9e34dca366a82c8825b714ce +"@rc-component/select@npm:~1.10.0": + version: 1.10.1 + resolution: "@rc-component/select@npm:1.10.1" + dependencies: + "@rc-component/overflow": "npm:^1.0.0" + "@rc-component/trigger": "npm:^3.0.0" + "@rc-component/util": "npm:^1.11.1" + "@rc-component/virtual-list": "npm:^1.2.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: "*" + react-dom: "*" + checksum: 10c0/72f50aba0ed29e4c7acefe0690206649ebabc35268184f3c510ab83df929da3b04d0495750b9d23bb7e2512ea3efb3883ef8fac6e965f6a3c4c7558f7b65562e languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/pluginutils@npm:1.0.0-rc.17" - checksum: 10c0/5e840b20cc531910c093c1ca36e550952cf4936465a50d89f0a98fc9d0dfd7d319d06a10a5f4376209d89e9bf4d60af6cc8363ebf0dcc5e60842f7fef438b2f0 +"@rc-component/slider@npm:~1.1.1": + version: 1.1.1 + resolution: "@rc-component/slider@npm:1.1.1" + dependencies: + "@rc-component/util": "npm:^1.3.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/e68a1c8d36881291c3ba70482a4d28d3e8a532af0f094d358c63a16632aa79e8a16a652f302606b7ab0cbf128a132a904068740a660f9cc1ca62868bb0b68b16 languageName: node linkType: hard -"@rollup/pluginutils@npm:^5.1.0": - version: 5.3.0 - resolution: "@rollup/pluginutils@npm:5.3.0" +"@rc-component/steps@npm:~1.2.2": + version: 1.2.2 + resolution: "@rc-component/steps@npm:1.2.2" dependencies: - "@types/estree": "npm:^1.0.0" - estree-walker: "npm:^2.0.2" - picomatch: "npm:^4.0.2" + "@rc-component/util": "npm:^1.2.1" + clsx: "npm:^2.1.1" peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - checksum: 10c0/001834bf62d7cf5bac424d2617c113f7f7d3b2bf3c1778cbcccb72cdc957b68989f8e7747c782c2b911f1dde8257f56f8ac1e779e29e74e638e3f1e2cac2bcd0 + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/6910ab1a7bccd91a12d5a839a012599389b6e4be71637280200913e160a2f12be1bf8b7c211f3ce9f02d7791677225178b5012e309c00f9f45471c33b052d302 languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.56.0" - conditions: os=android & cpu=arm +"@rc-component/switch@npm:~1.0.3": + version: 1.0.3 + resolution: "@rc-component/switch@npm:1.0.3" + dependencies: + "@rc-component/util": "npm:^1.3.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/6ef1746f056d63bae06a00fa09a4c437539f7e60821c479722dc9800a327564b5e749d7f5d3874236d9424e4eb56ddee4561b56f0ce8d1417d431e710b9789f9 languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-android-arm64@npm:4.56.0" - conditions: os=android & cpu=arm64 +"@rc-component/table@npm:~1.11.0": + version: 1.11.1 + resolution: "@rc-component/table@npm:1.11.1" + dependencies: + "@rc-component/context": "npm:^2.0.1" + "@rc-component/resize-observer": "npm:^1.0.0" + "@rc-component/util": "npm:^1.11.1" + "@rc-component/virtual-list": "npm:^1.0.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/df50b7f7a09ac06677763870e89e0f1529d088f3771ffc2e940fb154b25f65d2a7028139de05464a62b81cd18bbab53800245ad0aedf227959648f65c9ff36cc languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-darwin-arm64@npm:4.56.0" - conditions: os=darwin & cpu=arm64 +"@rc-component/tabs@npm:~1.12.0": + version: 1.12.0 + resolution: "@rc-component/tabs@npm:1.12.0" + dependencies: + "@rc-component/dropdown": "npm:~1.0.0" + "@rc-component/menu": "npm:~1.4.0" + "@rc-component/motion": "npm:^1.1.3" + "@rc-component/resize-observer": "npm:^1.0.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/b2fc073329ece31d3a91ce6de6e8ab7b8dfde58f3ba9e80a646716425617cf1a0f72282f705590c9535b779723cf0db29cce5309b7cee925bb4d6cdcda3bc39e languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-darwin-x64@npm:4.56.0" - conditions: os=darwin & cpu=x64 +"@rc-component/tooltip@npm:~1.5.0": + version: 1.5.0 + resolution: "@rc-component/tooltip@npm:1.5.0" + dependencies: + "@rc-component/trigger": "npm:^3.10.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/024cde089e6c9c01876d1b71864c068f84646bcce782d2bc6da1e1f940ee8c33d3bea771820cd681d9ea5ebd3c0a8d59ffdfcab3173727eb71f9566c5a7c6012 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.56.0" - conditions: os=freebsd & cpu=arm64 +"@rc-component/tour@npm:~2.4.0": + version: 2.4.0 + resolution: "@rc-component/tour@npm:2.4.0" + dependencies: + "@rc-component/portal": "npm:^2.2.0" + "@rc-component/trigger": "npm:^3.0.0" + "@rc-component/util": "npm:^1.7.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/a65a88d81d04ea588f778ef19427db25f95f7827c2fb2b038dfeeccbbc025a885b96c63fbabf57f46d6a3c31d8be1fe029980bd444a7c02157cb9c40130d3c9d languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-freebsd-x64@npm:4.56.0" - conditions: os=freebsd & cpu=x64 +"@rc-component/tree-select@npm:~1.16.0": + version: 1.16.1 + resolution: "@rc-component/tree-select@npm:1.16.1" + dependencies: + "@rc-component/select": "npm:~1.10.0" + "@rc-component/tree": "npm:~1.4.0" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: "*" + react-dom: "*" + checksum: 10c0/87d332868ece7ea91383c78b219b4b0c1cf9f4ab31748554fd897b063300464139a655d0888247d383059e551dd5f222853d526d6a8effb92526a847f866e7ae languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.56.0" - conditions: os=linux & cpu=arm & libc=glibc +"@rc-component/tree@npm:~1.4.0": + version: 1.4.0 + resolution: "@rc-component/tree@npm:1.4.0" + dependencies: + "@rc-component/motion": "npm:^1.0.0" + "@rc-component/util": "npm:^1.11.1" + "@rc-component/virtual-list": "npm:^1.2.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: "*" + react-dom: "*" + checksum: 10c0/64bf80e74fe5e0f842bd18af89f099af65331b4e5453608d55505301a15549f7ce104b31195ac66914c98f71cb72b7276d77fc5e120cc797712f81c9147ae48d languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.56.0" - conditions: os=linux & cpu=arm & libc=musl +"@rc-component/trigger@npm:^3.0.0, @rc-component/trigger@npm:^3.10.0, @rc-component/trigger@npm:^3.10.1, @rc-component/trigger@npm:^3.6.15": + version: 3.10.1 + resolution: "@rc-component/trigger@npm:3.10.1" + dependencies: + "@rc-component/motion": "npm:^1.3.3" + "@rc-component/portal": "npm:^2.2.1" + "@rc-component/resize-observer": "npm:^1.1.2" + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/20f70f4de12d4077ba6dac9352e3cf7386e6c7e9092752a6951938fbe591acc7a511f7ecacfa534d1e7ee1a858077b91795ceec381ef34bff866699d3a57d549 languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.56.0" - conditions: os=linux & cpu=arm64 & libc=glibc +"@rc-component/upload@npm:~1.1.1": + version: 1.1.1 + resolution: "@rc-component/upload@npm:1.1.1" + dependencies: + "@rc-component/util": "npm:^1.11.1" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/0bb37a2050df385aee1767231afcb6432201765c679141c6d0198eea83496e4d47975d0e7a403d40531c5916dd78003805a15feb4f6c8a128cf9c8d0a886ca84 languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.56.0" - conditions: os=linux & cpu=arm64 & libc=musl +"@rc-component/util@npm:^1.11.0, @rc-component/util@npm:^1.11.1, @rc-component/util@npm:^1.12.0, @rc-component/util@npm:^1.2.0, @rc-component/util@npm:^1.2.1, @rc-component/util@npm:^1.3.1, @rc-component/util@npm:^1.4.0, @rc-component/util@npm:^1.7.0, @rc-component/util@npm:^1.9.0": + version: 1.12.0 + resolution: "@rc-component/util@npm:1.12.0" + dependencies: + is-mobile: "npm:^5.0.0" + react-is: "npm:^19.2.7" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/357aa5d88dc2662d81aa9d84c94ba0349589b53c89ad46989bdaf08840902186d4970f35b1f62cdf1fb0be2632323f3c56f2eb5e5ac961e0107333e34e7a34ab languageName: node linkType: hard -"@rollup/rollup-linux-loong64-gnu@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.56.0" - conditions: os=linux & cpu=loong64 & libc=glibc +"@rc-component/util@npm:^1.3.0": + version: 1.7.0 + resolution: "@rc-component/util@npm:1.7.0" + dependencies: + is-mobile: "npm:^5.0.0" + react-is: "npm:^18.2.0" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/71ab8c9f321be160cd55aa2f02bacf4a62b2d41b63d297b87dc73af3d7cc7c7716bcaa304cb03f443794411990e3a17aa455aef21d557f52aa5609d985474de5 languageName: node linkType: hard -"@rollup/rollup-linux-loong64-musl@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-loong64-musl@npm:4.56.0" - conditions: os=linux & cpu=loong64 & libc=musl +"@rc-component/virtual-list@npm:^1.0.1, @rc-component/virtual-list@npm:^1.2.0, @rc-component/virtual-list@npm:^1.4.0": + version: 1.5.1 + resolution: "@rc-component/virtual-list@npm:1.5.1" + dependencies: + "@babel/runtime": "npm:^8.0.0" + "@rc-component/resize-observer": "npm:^1.0.1" + "@rc-component/util": "npm:^1.4.0" + clsx: "npm:^2.1.1" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/cda45e0ac82adf8c1b9f622dc79d05bd94f7808544884a4469e557671c4a16aed523b836d91faacf63ad74e37323726247fb2dc849a4a9601da53edd015dd3a1 languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-gnu@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.56.0" - conditions: os=linux & cpu=ppc64 & libc=glibc +"@redis/client@npm:^5.10.0, @redis/client@npm:^5.12.1": + version: 5.12.1 + resolution: "@redis/client@npm:5.12.1" + dependencies: + cluster-key-slot: "npm:1.1.2" + peerDependencies: + "@node-rs/xxhash": ^1.1.0 + "@opentelemetry/api": ">=1 <2" + peerDependenciesMeta: + "@node-rs/xxhash": + optional: true + "@opentelemetry/api": + optional: true + checksum: 10c0/f475aa936b7cf73c3d2166862377d3502f16deaf5423d31f6899f6387440195aa2524c74d1df26da591a61b4ac0122a1e1f8d783b770e7630c5f7444445c41b0 languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-musl@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.56.0" - conditions: os=linux & cpu=ppc64 & libc=musl +"@rolldown/binding-android-arm64@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.17" + conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.56.0" - conditions: os=linux & cpu=riscv64 & libc=glibc +"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17" + conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.56.0" - conditions: os=linux & cpu=riscv64 & libc=musl +"@rolldown/binding-darwin-x64@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.17" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.56.0": - version: 4.56.0 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.56.0" - conditions: os=linux & cpu=s390x & libc=glibc +"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17" + dependencies: + "@emnapi/core": "npm:1.10.0" + "@emnapi/runtime": "npm:1.10.0" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.53" + checksum: 10c0/e8b0a7eb76be22f6f103171f28072de821525a4e400454850516da91a7381957932ff0ce495f227bcb168e86815788b0c1d249ca9e34dca366a82c8825b714ce + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:1.0.0-rc.17": + version: 1.0.0-rc.17 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.17" + checksum: 10c0/5e840b20cc531910c093c1ca36e550952cf4936465a50d89f0a98fc9d0dfd7d319d06a10a5f4376209d89e9bf4d60af6cc8363ebf0dcc5e60842f7fef438b2f0 + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^5.1.0": + version: 5.3.0 + resolution: "@rollup/pluginutils@npm:5.3.0" + dependencies: + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^4.0.2" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/001834bf62d7cf5bac424d2617c113f7f7d3b2bf3c1778cbcccb72cdc957b68989f8e7747c782c2b911f1dde8257f56f8ac1e779e29e74e638e3f1e2cac2bcd0 + languageName: node + linkType: hard + +"@rollup/rollup-android-arm-eabi@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.56.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-android-arm64@npm:4.56.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.56.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.56.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.56.0" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-freebsd-x64@npm:4.56.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.56.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.56.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.56.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.56.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.56.0" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.56.0" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.56.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.56.0" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.56.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-musl@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.56.0" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.56.0": + version: 4.56.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.56.0" + conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard @@ -5344,63 +5828,62 @@ __metadata: languageName: node linkType: hard -"antd@npm:^5.29.1": - version: 5.29.3 - resolution: "antd@npm:5.29.3" - dependencies: - "@ant-design/colors": "npm:^7.2.1" - "@ant-design/cssinjs": "npm:^1.23.0" - "@ant-design/cssinjs-utils": "npm:^1.1.3" - "@ant-design/fast-color": "npm:^2.0.6" - "@ant-design/icons": "npm:^5.6.1" - "@ant-design/react-slick": "npm:~1.1.2" - "@babel/runtime": "npm:^7.26.0" - "@rc-component/color-picker": "npm:~2.0.1" - "@rc-component/mutate-observer": "npm:^1.1.0" - "@rc-component/qrcode": "npm:~1.1.0" - "@rc-component/tour": "npm:~1.15.1" - "@rc-component/trigger": "npm:^2.3.0" - classnames: "npm:^2.5.1" - copy-to-clipboard: "npm:^3.3.3" +"antd@npm:^6.6.0": + version: 6.6.0 + resolution: "antd@npm:6.6.0" + dependencies: + "@ant-design/colors": "npm:^8.0.1" + "@ant-design/cssinjs": "npm:^2.1.2" + "@ant-design/cssinjs-utils": "npm:^2.1.2" + "@ant-design/fast-color": "npm:^3.0.1" + "@ant-design/icons": "npm:^6.3.2" + "@ant-design/react-slick": "npm:~2.0.0" + "@babel/runtime": "npm:^7.29.2" + "@rc-component/cascader": "npm:~1.22.0" + "@rc-component/checkbox": "npm:~2.0.0" + "@rc-component/collapse": "npm:~1.2.0" + "@rc-component/color-picker": "npm:~3.1.1" + "@rc-component/dialog": "npm:~1.10.0" + "@rc-component/drawer": "npm:~1.4.2" + "@rc-component/dropdown": "npm:~1.0.3" + "@rc-component/form": "npm:~1.8.6" + "@rc-component/image": "npm:~1.10.0" + "@rc-component/input": "npm:~1.3.1" + "@rc-component/input-number": "npm:~1.6.2" + "@rc-component/listy": "npm:~1.2.3" + "@rc-component/mentions": "npm:~1.11.0" + "@rc-component/menu": "npm:~1.4.1" + "@rc-component/motion": "npm:^1.3.3" + "@rc-component/mutate-observer": "npm:^2.0.1" + "@rc-component/notification": "npm:~2.0.7" + "@rc-component/pagination": "npm:~1.4.0" + "@rc-component/picker": "npm:~1.12.0" + "@rc-component/progress": "npm:~1.0.2" + "@rc-component/qrcode": "npm:~2.0.0" + "@rc-component/rate": "npm:~1.0.1" + "@rc-component/resize-observer": "npm:^1.1.2" + "@rc-component/segmented": "npm:~1.3.0" + "@rc-component/select": "npm:~1.10.0" + "@rc-component/slider": "npm:~1.1.1" + "@rc-component/steps": "npm:~1.2.2" + "@rc-component/switch": "npm:~1.0.3" + "@rc-component/table": "npm:~1.11.0" + "@rc-component/tabs": "npm:~1.12.0" + "@rc-component/tooltip": "npm:~1.5.0" + "@rc-component/tour": "npm:~2.4.0" + "@rc-component/tree": "npm:~1.4.0" + "@rc-component/tree-select": "npm:~1.16.0" + "@rc-component/trigger": "npm:^3.10.1" + "@rc-component/upload": "npm:~1.1.1" + "@rc-component/util": "npm:^1.12.0" + clsx: "npm:^2.1.1" dayjs: "npm:^1.11.11" - rc-cascader: "npm:~3.34.0" - rc-checkbox: "npm:~3.5.0" - rc-collapse: "npm:~3.9.0" - rc-dialog: "npm:~9.6.0" - rc-drawer: "npm:~7.3.0" - rc-dropdown: "npm:~4.2.1" - rc-field-form: "npm:~2.7.1" - rc-image: "npm:~7.12.0" - rc-input: "npm:~1.8.0" - rc-input-number: "npm:~9.5.0" - rc-mentions: "npm:~2.20.0" - rc-menu: "npm:~9.16.1" - rc-motion: "npm:^2.9.5" - rc-notification: "npm:~5.6.4" - rc-pagination: "npm:~5.1.0" - rc-picker: "npm:~4.11.3" - rc-progress: "npm:~4.0.0" - rc-rate: "npm:~2.13.1" - rc-resize-observer: "npm:^1.4.3" - rc-segmented: "npm:~2.7.0" - rc-select: "npm:~14.16.8" - rc-slider: "npm:~11.1.9" - rc-steps: "npm:~6.0.1" - rc-switch: "npm:~4.1.0" - rc-table: "npm:~7.54.0" - rc-tabs: "npm:~15.7.0" - rc-textarea: "npm:~1.10.2" - rc-tooltip: "npm:~6.4.0" - rc-tree: "npm:~5.13.1" - rc-tree-select: "npm:~5.27.0" - rc-upload: "npm:~4.11.0" - rc-util: "npm:^5.44.4" scroll-into-view-if-needed: "npm:^3.1.0" throttle-debounce: "npm:^5.0.2" peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/e2e488dfd51f0a7c1e5ef2aa51ab6809b0015151d338dcdd27a7bef6f7ac9f7644e73693b37d0213089f6aa02718090ceba957966c76c387b258c789b3ca28fd + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/ebb4233bfbb309708f44685c56fcbd191dc0a967fee8d7c7bcaa7cc73902c4fd129b3d7c36b1bd4a46a300cf56fe2c9cf91398afbb5d105b26e9646e81c6fa94 languageName: node linkType: hard @@ -5980,13 +6463,6 @@ __metadata: languageName: node linkType: hard -"classnames@npm:2.x, classnames@npm:^2.2.1, classnames@npm:^2.2.3, classnames@npm:^2.2.5, classnames@npm:^2.2.6, classnames@npm:^2.3.1, classnames@npm:^2.3.2, classnames@npm:^2.5.1": - version: 2.5.1 - resolution: "classnames@npm:2.5.1" - checksum: 10c0/afff4f77e62cea2d79c39962980bf316bacb0d7c49e13a21adaadb9221e1c6b9d3cdb829d8bb1b23c406f4e740507f37e1dcf506f7e3b7113d17c5bab787aa69 - languageName: node - linkType: hard - "cli-cursor@npm:^3.1.0": version: 3.1.0 resolution: "cli-cursor@npm:3.1.0" @@ -6257,15 +6733,6 @@ __metadata: languageName: node linkType: hard -"copy-to-clipboard@npm:^3.3.3": - version: 3.3.3 - resolution: "copy-to-clipboard@npm:3.3.3" - dependencies: - toggle-selection: "npm:^1.0.6" - checksum: 10c0/3ebf5e8ee00601f8c440b83ec08d838e8eabb068c1fae94a9cda6b42f288f7e1b552f3463635f419af44bf7675afc8d0390d30876cf5c2d5d35f86d9c56a3e5f - languageName: node - linkType: hard - "core-util-is@npm:^1.0.3, core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -7115,6 +7582,13 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0": + version: 3.1.1 + resolution: "eventsource-parser@npm:3.1.1" + checksum: 10c0/dd3236c61140587253dcaaf947de528ac0e7371caffdc53c77afaeee36a17661f00e251a6ea16af56c99be5ac138303e67b5d750630e7c41b02e3564e8ad8c44 + languageName: node + linkType: hard + "execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -7571,6 +8045,17 @@ __metadata: languageName: node linkType: hard +"fzkit@npm:^0.2.1": + version: 0.2.1 + resolution: "fzkit@npm:0.2.1" + dependencies: + eventsource-parser: "npm:^3.0.0" + peerDependencies: + axios: ^1.7.0 + checksum: 10c0/6d43284df99475ecc7dd7bbdfb725cf5670e6af4043424d2493231141bb9baa4f3dcf5c8eb80c0330310e13def2d5b96ad69eb7c484f9da05f98ade1f4759792 + languageName: node + linkType: hard + "generator-function@npm:^2.0.0": version: 2.0.1 resolution: "generator-function@npm:2.0.1" @@ -9708,970 +10193,436 @@ __metadata: linkType: hard "path-to-regexp@npm:8.3.0, path-to-regexp@npm:^8.0.0": - version: 8.3.0 - resolution: "path-to-regexp@npm:8.3.0" - checksum: 10c0/ee1544a73a3f294a97a4c663b0ce71bbf1621d732d80c9c9ed201b3e911a86cb628ebad691b9d40f40a3742fe22011e5a059d8eed2cf63ec2cb94f6fb4efe67c - languageName: node - linkType: hard - -"path-type@npm:^4.0.0": - version: 4.0.0 - resolution: "path-type@npm:4.0.0" - checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c - languageName: node - linkType: hard - -"pathe@npm:^2.0.3": - version: 2.0.3 - resolution: "pathe@npm:2.0.3" - checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 - languageName: node - linkType: hard - -"pause@npm:0.0.1": - version: 0.0.1 - resolution: "pause@npm:0.0.1" - checksum: 10c0/f362655dfa7f44b946302c5a033148852ed5d05f744bd848b1c7eae6a543f743e79c7751ee896ba519fd802affdf239a358bb2ea5ca1b1c1e4e916279f83ab75 - languageName: node - linkType: hard - -"pend@npm:~1.2.0": - version: 1.2.0 - resolution: "pend@npm:1.2.0" - checksum: 10c0/8a87e63f7a4afcfb0f9f77b39bb92374afc723418b9cb716ee4257689224171002e07768eeade4ecd0e86f1fa3d8f022994219fb45634f2dbd78c6803e452458 - languageName: node - linkType: hard - -"perfect-debounce@npm:^2.0.0": - version: 2.1.0 - resolution: "perfect-debounce@npm:2.1.0" - checksum: 10c0/c4f833816f249129cea996d60b1351b640cf06954ab4eecaca440234ef70f4aefe6483637049750f5b00e15a5036307ea77f831c9779d6ad878457680af49b6d - languageName: node - linkType: hard - -"pg-cloudflare@npm:^1.3.0": - version: 1.3.0 - resolution: "pg-cloudflare@npm:1.3.0" - checksum: 10c0/b0866c88af8e54c7b3ed510719d92df37714b3af5e3a3a10d9f761fcec99483e222f5b78a1f2de590368127648087c45c01aaf66fadbe46edb25673eedc4f8fc - languageName: node - linkType: hard - -"pg-connection-string@npm:^2.10.1": - version: 2.10.1 - resolution: "pg-connection-string@npm:2.10.1" - checksum: 10c0/f218a72b59c661022caca9a7f2116655632b1d7e7d6dc9a8ee9f238744e0927e0d6f44e12f50d9767c6d9cd47d9b3766aa054b77504b15c6bf503400530e053e - languageName: node - linkType: hard - -"pg-int8@npm:1.0.1": - version: 1.0.1 - resolution: "pg-int8@npm:1.0.1" - checksum: 10c0/be6a02d851fc2a4ae3e9de81710d861de3ba35ac927268973eb3cb618873a05b9424656df464dd43bd7dc3fc5295c3f5b3c8349494f87c7af50ec59ef14e0b98 - languageName: node - linkType: hard - -"pg-pool@npm:^3.11.0": - version: 3.11.0 - resolution: "pg-pool@npm:3.11.0" - peerDependencies: - pg: ">=8.0" - checksum: 10c0/4b104b48a47257a0edad0c62e5ea1908b72cb79386270264b452e69895e9e4c589d00cdbf6e46d4e9c05bc7e7d191656b66814b5282d65f33b12648a21df3c7f - languageName: node - linkType: hard - -"pg-protocol@npm:^1.11.0": - version: 1.11.0 - resolution: "pg-protocol@npm:1.11.0" - checksum: 10c0/93e83581781418c9173eba4e4545f73392cfe66b78dd1d3624d7339fbd37e7f4abebaf2615e68e0701a9bf0edf5b81a4ad533836f388f775fe25fa24a691c464 - languageName: node - linkType: hard - -"pg-types@npm:2.2.0": - version: 2.2.0 - resolution: "pg-types@npm:2.2.0" - dependencies: - pg-int8: "npm:1.0.1" - postgres-array: "npm:~2.0.0" - postgres-bytea: "npm:~1.0.0" - postgres-date: "npm:~1.0.4" - postgres-interval: "npm:^1.1.0" - checksum: 10c0/ab3f8069a323f601cd2d2279ca8c425447dab3f9b61d933b0601d7ffc00d6200df25e26a4290b2b0783b59278198f7dd2ed03e94c4875797919605116a577c65 - languageName: node - linkType: hard - -"pg@npm:^8.15.6": - version: 8.17.2 - resolution: "pg@npm:8.17.2" - dependencies: - pg-cloudflare: "npm:^1.3.0" - pg-connection-string: "npm:^2.10.1" - pg-pool: "npm:^3.11.0" - pg-protocol: "npm:^1.11.0" - pg-types: "npm:2.2.0" - pgpass: "npm:1.0.5" - peerDependencies: - pg-native: ">=3.0.1" - dependenciesMeta: - pg-cloudflare: - optional: true - peerDependenciesMeta: - pg-native: - optional: true - checksum: 10c0/74b022587f92953f498dba747ccf9c7c90767af70326595d30c7ab0e2f00b2b468226c8abae54caef63ab444a8ac6f1597d859174386c7ba7c318c225d711c5f - languageName: node - linkType: hard - -"pgpass@npm:1.0.5": - version: 1.0.5 - resolution: "pgpass@npm:1.0.5" - dependencies: - split2: "npm:^4.1.0" - checksum: 10c0/5ea6c9b2de04c33abb08d33a2dded303c4a3c7162a9264519cbe85c0a9857d712463140ba42fad0c7cd4b21f644dd870b45bb2e02fcbe505b4de0744fd802c1d - languageName: node - linkType: hard - -"picocolors@npm:^1.1.1": - version: 1.1.1 - resolution: "picocolors@npm:1.1.1" - checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 - languageName: node - linkType: hard - -"picomatch@npm:4.0.2": - version: 4.0.2 - resolution: "picomatch@npm:4.0.2" - checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc - languageName: node - linkType: hard - -"picomatch@npm:^2.3.1": - version: 2.3.1 - resolution: "picomatch@npm:2.3.1" - checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be - languageName: node - linkType: hard - -"picomatch@npm:^4.0.2, picomatch@npm:^4.0.4": - version: 4.0.4 - resolution: "picomatch@npm:4.0.4" - checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 - languageName: node - linkType: hard - -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 - languageName: node - linkType: hard - -"piscina@npm:^4.3.1": - version: 4.9.2 - resolution: "piscina@npm:4.9.2" - dependencies: - "@napi-rs/nice": "npm:^1.0.1" - dependenciesMeta: - "@napi-rs/nice": - optional: true - checksum: 10c0/ab67830065ff41523cd901db41b11045cb00a0be43bf79323ff7b4ef2fbce5e3a56ad440d99d6c3944ce94451a0a69fd175500e3220b21efe54142e601322189 - languageName: node - linkType: hard - -"pluralize@npm:8.0.0": - version: 8.0.0 - resolution: "pluralize@npm:8.0.0" - checksum: 10c0/2044cfc34b2e8c88b73379ea4a36fc577db04f651c2909041b054c981cd863dd5373ebd030123ab058d194ae615d3a97cfdac653991e499d10caf592e8b3dc33 - languageName: node - linkType: hard - -"possible-typed-array-names@npm:^1.0.0": - version: 1.1.0 - resolution: "possible-typed-array-names@npm:1.1.0" - checksum: 10c0/c810983414142071da1d644662ce4caebce890203eb2bc7bf119f37f3fe5796226e117e6cca146b521921fa6531072674174a3325066ac66fce089a53e1e5196 - languageName: node - linkType: hard - -"postcss@npm:^8.5.10": - version: 8.5.12 - resolution: "postcss@npm:8.5.12" - dependencies: - nanoid: "npm:^3.3.11" - picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10c0/5baebaf574c567bc1b3d61197f38af4ce5920b8f611c887fb6bc3dcc14af00253c169dbf19897bc889cce0b0d9818ab5eb4ea0caedf02b0bab10da8a43ce8c12 - languageName: node - linkType: hard - -"postcss@npm:^8.5.6": - version: 8.5.6 - resolution: "postcss@npm:8.5.6" - dependencies: - nanoid: "npm:^3.3.11" - picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10c0/5127cc7c91ed7a133a1b7318012d8bfa112da9ef092dddf369ae699a1f10ebbd89b1b9f25f3228795b84585c72aabd5ced5fc11f2ba467eedf7b081a66fad024 - languageName: node - linkType: hard - -"postgres-array@npm:~2.0.0": - version: 2.0.0 - resolution: "postgres-array@npm:2.0.0" - checksum: 10c0/cbd56207e4141d7fbf08c86f2aebf21fa7064943d3f808ec85f442ff94b48d891e7a144cc02665fb2de5dbcb9b8e3183a2ac749959e794b4a4cfd379d7a21d08 - languageName: node - linkType: hard - -"postgres-bytea@npm:~1.0.0": - version: 1.0.1 - resolution: "postgres-bytea@npm:1.0.1" - checksum: 10c0/10b28a27c9d703d5befd97c443e62b551096d1014bc59ab574c65bf0688de7f3f068003b2aea8dcff83cf0f6f9a35f9f74457c38856cf8eb81b00cf3fb44f164 - languageName: node - linkType: hard - -"postgres-date@npm:~1.0.4": - version: 1.0.7 - resolution: "postgres-date@npm:1.0.7" - checksum: 10c0/0ff91fccc64003e10b767fcfeefb5eaffbc522c93aa65d5051c49b3c4ce6cb93ab091a7d22877a90ad60b8874202c6f1d0f935f38a7235ed3b258efd54b97ca9 - languageName: node - linkType: hard - -"postgres-interval@npm:^1.1.0": - version: 1.2.0 - resolution: "postgres-interval@npm:1.2.0" - dependencies: - xtend: "npm:^4.0.0" - checksum: 10c0/c1734c3cb79e7f22579af0b268a463b1fa1d084e742a02a7a290c4f041e349456f3bee3b4ee0bb3f226828597f7b76deb615c1b857db9a742c45520100456272 - languageName: node - linkType: hard - -"prelude-ls@npm:^1.2.1": - version: 1.2.1 - resolution: "prelude-ls@npm:1.2.1" - checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd - languageName: node - linkType: hard - -"prettier-linter-helpers@npm:^1.0.1": - version: 1.0.1 - resolution: "prettier-linter-helpers@npm:1.0.1" - dependencies: - fast-diff: "npm:^1.1.2" - checksum: 10c0/91cea965681bc5f62c9d26bd3ca6358b81557261d4802e96ec1cf0acbd99d4b61632d53320cd2c3ec7d7f7805a81345644108a41ef46ddc9688e783a9ac792d1 - languageName: node - linkType: hard - -"prettier-plugin-tailwindcss@npm:^0.6.14": - version: 0.6.14 - resolution: "prettier-plugin-tailwindcss@npm:0.6.14" - peerDependencies: - "@ianvs/prettier-plugin-sort-imports": "*" - "@prettier/plugin-hermes": "*" - "@prettier/plugin-oxc": "*" - "@prettier/plugin-pug": "*" - "@shopify/prettier-plugin-liquid": "*" - "@trivago/prettier-plugin-sort-imports": "*" - "@zackad/prettier-plugin-twig": "*" - prettier: ^3.0 - prettier-plugin-astro: "*" - prettier-plugin-css-order: "*" - prettier-plugin-import-sort: "*" - prettier-plugin-jsdoc: "*" - prettier-plugin-marko: "*" - prettier-plugin-multiline-arrays: "*" - prettier-plugin-organize-attributes: "*" - prettier-plugin-organize-imports: "*" - prettier-plugin-sort-imports: "*" - prettier-plugin-style-order: "*" - prettier-plugin-svelte: "*" - peerDependenciesMeta: - "@ianvs/prettier-plugin-sort-imports": - optional: true - "@prettier/plugin-hermes": - optional: true - "@prettier/plugin-oxc": - optional: true - "@prettier/plugin-pug": - optional: true - "@shopify/prettier-plugin-liquid": - optional: true - "@trivago/prettier-plugin-sort-imports": - optional: true - "@zackad/prettier-plugin-twig": - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-import-sort: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-multiline-arrays: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-style-order: - optional: true - prettier-plugin-svelte: - optional: true - checksum: 10c0/1bf635be28b30b3f171a184497eecf512601d19328e402dd2eb1ede52aa57b4f5b605eae2929f4916de9ba22526f3d730d9afa90334db09d17c59f463f7b26d8 - languageName: node - linkType: hard - -"prettier@npm:^3.6.2": - version: 3.8.1 - resolution: "prettier@npm:3.8.1" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 - languageName: node - linkType: hard - -"proc-log@npm:^6.0.0": - version: 6.1.0 - resolution: "proc-log@npm:6.1.0" - checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 - languageName: node - linkType: hard - -"process-nextick-args@npm:~2.0.0": - version: 2.0.1 - resolution: "process-nextick-args@npm:2.0.1" - checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367 - languageName: node - linkType: hard - -"promise-retry@npm:^2.0.1": - version: 2.0.1 - resolution: "promise-retry@npm:2.0.1" - dependencies: - err-code: "npm:^2.0.2" - retry: "npm:^0.12.0" - checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96 + version: 8.3.0 + resolution: "path-to-regexp@npm:8.3.0" + checksum: 10c0/ee1544a73a3f294a97a4c663b0ce71bbf1621d732d80c9c9ed201b3e911a86cb628ebad691b9d40f40a3742fe22011e5a059d8eed2cf63ec2cb94f6fb4efe67c languageName: node linkType: hard -"property-information@npm:^7.0.0": - version: 7.1.0 - resolution: "property-information@npm:7.1.0" - checksum: 10c0/e0fe22cff26103260ad0e82959229106563fa115a54c4d6c183f49d88054e489cc9f23452d3ad584179dc13a8b7b37411a5df873746b5e4086c865874bfa968e +"path-type@npm:^4.0.0": + version: 4.0.0 + resolution: "path-type@npm:4.0.0" + checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c languageName: node linkType: hard -"proxy-addr@npm:^2.0.7": - version: 2.0.7 - resolution: "proxy-addr@npm:2.0.7" - dependencies: - forwarded: "npm:0.2.0" - ipaddr.js: "npm:1.9.1" - checksum: 10c0/c3eed999781a35f7fd935f398b6d8920b6fb00bbc14287bc6de78128ccc1a02c89b95b56742bf7cf0362cc333c61d138532049c7dedc7a328ef13343eff81210 +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 languageName: node linkType: hard -"proxy-from-env@npm:^2.1.0": - version: 2.1.0 - resolution: "proxy-from-env@npm:2.1.0" - checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 +"pause@npm:0.0.1": + version: 0.0.1 + resolution: "pause@npm:0.0.1" + checksum: 10c0/f362655dfa7f44b946302c5a033148852ed5d05f744bd848b1c7eae6a543f743e79c7751ee896ba519fd802affdf239a358bb2ea5ca1b1c1e4e916279f83ab75 languageName: node linkType: hard -"punycode@npm:^2.1.0": - version: 2.3.1 - resolution: "punycode@npm:2.3.1" - checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 10c0/8a87e63f7a4afcfb0f9f77b39bb92374afc723418b9cb716ee4257689224171002e07768eeade4ecd0e86f1fa3d8f022994219fb45634f2dbd78c6803e452458 languageName: node linkType: hard -"qs@npm:^6.14.0, qs@npm:^6.14.1": - version: 6.14.1 - resolution: "qs@npm:6.14.1" - dependencies: - side-channel: "npm:^1.1.0" - checksum: 10c0/0e3b22dc451f48ce5940cbbc7c7d9068d895074f8c969c0801ac15c1313d1859c4d738e46dc4da2f498f41a9ffd8c201bd9fb12df67799b827db94cc373d2613 +"perfect-debounce@npm:^2.0.0": + version: 2.1.0 + resolution: "perfect-debounce@npm:2.1.0" + checksum: 10c0/c4f833816f249129cea996d60b1351b640cf06954ab4eecaca440234ef70f4aefe6483637049750f5b00e15a5036307ea77f831c9779d6ad878457680af49b6d languageName: node linkType: hard -"qs@npm:^6.15.2": - version: 6.15.3 - resolution: "qs@npm:6.15.3" - dependencies: - es-define-property: "npm:^1.0.1" - side-channel: "npm:^1.1.1" - checksum: 10c0/8f3f6e45ece255347d57696628401cde29e9ec649fff698b53bd3150dea7cefdf33036e1bc1826b9f110bfa7cb0ec4ab9f5297eca628ce216c55af82c304e08e +"pg-cloudflare@npm:^1.3.0": + version: 1.3.0 + resolution: "pg-cloudflare@npm:1.3.0" + checksum: 10c0/b0866c88af8e54c7b3ed510719d92df37714b3af5e3a3a10d9f761fcec99483e222f5b78a1f2de590368127648087c45c01aaf66fadbe46edb25673eedc4f8fc languageName: node linkType: hard -"quick-lru@npm:^5.1.1": - version: 5.1.1 - resolution: "quick-lru@npm:5.1.1" - checksum: 10c0/a24cba5da8cec30d70d2484be37622580f64765fb6390a928b17f60cd69e8dbd32a954b3ff9176fa1b86d86ff2ba05252fae55dc4d40d0291c60412b0ad096da +"pg-connection-string@npm:^2.10.1": + version: 2.10.1 + resolution: "pg-connection-string@npm:2.10.1" + checksum: 10c0/f218a72b59c661022caca9a7f2116655632b1d7e7d6dc9a8ee9f238744e0927e0d6f44e12f50d9767c6d9cd47d9b3766aa054b77504b15c6bf503400530e053e languageName: node linkType: hard -"randombytes@npm:^2.1.0": - version: 2.1.0 - resolution: "randombytes@npm:2.1.0" - dependencies: - safe-buffer: "npm:^5.1.0" - checksum: 10c0/50395efda7a8c94f5dffab564f9ff89736064d32addf0cc7e8bf5e4166f09f8ded7a0849ca6c2d2a59478f7d90f78f20d8048bca3cdf8be09d8e8a10790388f3 +"pg-int8@npm:1.0.1": + version: 1.0.1 + resolution: "pg-int8@npm:1.0.1" + checksum: 10c0/be6a02d851fc2a4ae3e9de81710d861de3ba35ac927268973eb3cb618873a05b9424656df464dd43bd7dc3fc5295c3f5b3c8349494f87c7af50ec59ef14e0b98 languageName: node linkType: hard -"range-parser@npm:^1.2.1": - version: 1.2.1 - resolution: "range-parser@npm:1.2.1" - checksum: 10c0/96c032ac2475c8027b7a4e9fe22dc0dfe0f6d90b85e496e0f016fbdb99d6d066de0112e680805075bd989905e2123b3b3d002765149294dce0c1f7f01fcc2ea0 +"pg-pool@npm:^3.11.0": + version: 3.11.0 + resolution: "pg-pool@npm:3.11.0" + peerDependencies: + pg: ">=8.0" + checksum: 10c0/4b104b48a47257a0edad0c62e5ea1908b72cb79386270264b452e69895e9e4c589d00cdbf6e46d4e9c05bc7e7d191656b66814b5282d65f33b12648a21df3c7f languageName: node linkType: hard -"raw-body@npm:^3.0.1, raw-body@npm:^3.0.2": - version: 3.0.2 - resolution: "raw-body@npm:3.0.2" - dependencies: - bytes: "npm:~3.1.2" - http-errors: "npm:~2.0.1" - iconv-lite: "npm:~0.7.0" - unpipe: "npm:~1.0.0" - checksum: 10c0/d266678d08e1e7abea62c0ce5864344e980fa81c64f6b481e9842c5beaed2cdcf975f658a3ccd67ad35fc919c1f6664ccc106067801850286a6cbe101de89f29 +"pg-protocol@npm:^1.11.0": + version: 1.11.0 + resolution: "pg-protocol@npm:1.11.0" + checksum: 10c0/93e83581781418c9173eba4e4545f73392cfe66b78dd1d3624d7339fbd37e7f4abebaf2615e68e0701a9bf0edf5b81a4ad533836f388f775fe25fa24a691c464 languageName: node linkType: hard -"rc-cascader@npm:~3.34.0": - version: 3.34.0 - resolution: "rc-cascader@npm:3.34.0" +"pg-types@npm:2.2.0": + version: 2.2.0 + resolution: "pg-types@npm:2.2.0" dependencies: - "@babel/runtime": "npm:^7.25.7" - classnames: "npm:^2.3.1" - rc-select: "npm:~14.16.2" - rc-tree: "npm:~5.13.0" - rc-util: "npm:^5.43.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/1fc8c55e0f78ff2be59e2bcd8faa53aafecebbb28f4bb9982ad39e8f9f9620e15d6119797c7890347e46b05c32b43177ece047e81ef04c22a9f041eb0dd53e0a + pg-int8: "npm:1.0.1" + postgres-array: "npm:~2.0.0" + postgres-bytea: "npm:~1.0.0" + postgres-date: "npm:~1.0.4" + postgres-interval: "npm:^1.1.0" + checksum: 10c0/ab3f8069a323f601cd2d2279ca8c425447dab3f9b61d933b0601d7ffc00d6200df25e26a4290b2b0783b59278198f7dd2ed03e94c4875797919605116a577c65 languageName: node linkType: hard -"rc-checkbox@npm:~3.5.0": - version: 3.5.0 - resolution: "rc-checkbox@npm:3.5.0" +"pg@npm:^8.15.6": + version: 8.17.2 + resolution: "pg@npm:8.17.2" dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:^2.3.2" - rc-util: "npm:^5.25.2" + pg-cloudflare: "npm:^1.3.0" + pg-connection-string: "npm:^2.10.1" + pg-pool: "npm:^3.11.0" + pg-protocol: "npm:^1.11.0" + pg-types: "npm:2.2.0" + pgpass: "npm:1.0.5" peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/53fd419030a8c9e3d08ebb7c51dee79be810ccd92ed177066c2afa8f61a8fe4417232bbc4741ecc0a627d0c4b939a5e945c6f0d6a941c748d65c2ddad71775e3 + pg-native: ">=3.0.1" + dependenciesMeta: + pg-cloudflare: + optional: true + peerDependenciesMeta: + pg-native: + optional: true + checksum: 10c0/74b022587f92953f498dba747ccf9c7c90767af70326595d30c7ab0e2f00b2b468226c8abae54caef63ab444a8ac6f1597d859174386c7ba7c318c225d711c5f languageName: node linkType: hard -"rc-collapse@npm:~3.9.0": - version: 3.9.0 - resolution: "rc-collapse@npm:3.9.0" +"pgpass@npm:1.0.5": + version: 1.0.5 + resolution: "pgpass@npm:1.0.5" dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:2.x" - rc-motion: "npm:^2.3.4" - rc-util: "npm:^5.27.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/68d2c7a6614fea2bf4a30a39e67d5b74b933fd25e31762cd810ff0f7bcf7e57676db6c3c1389461d5d18be4a68b9cfeda65321a8d1f5978ec2a5aa3d7b9010cc + split2: "npm:^4.1.0" + checksum: 10c0/5ea6c9b2de04c33abb08d33a2dded303c4a3c7162a9264519cbe85c0a9857d712463140ba42fad0c7cd4b21f644dd870b45bb2e02fcbe505b4de0744fd802c1d languageName: node linkType: hard -"rc-dialog@npm:~9.6.0": - version: 9.6.0 - resolution: "rc-dialog@npm:9.6.0" - dependencies: - "@babel/runtime": "npm:^7.10.1" - "@rc-component/portal": "npm:^1.0.0-8" - classnames: "npm:^2.2.6" - rc-motion: "npm:^2.3.0" - rc-util: "npm:^5.21.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/19e9acef746baa25c3167a961919123b0b457288188c18bc4d468ae31144bf750d6d6468dd3be43b376eba42ddda26fef1aac8ae9bd016f5d0428ffee0c615e7 +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 languageName: node linkType: hard -"rc-drawer@npm:~7.3.0": - version: 7.3.0 - resolution: "rc-drawer@npm:7.3.0" - dependencies: - "@babel/runtime": "npm:^7.23.9" - "@rc-component/portal": "npm:^1.1.1" - classnames: "npm:^2.2.6" - rc-motion: "npm:^2.6.1" - rc-util: "npm:^5.38.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/e2c3211d6a3790813bf2c1626cebf3fdb3a4c48ab56bee2d208ba07dd0e5058154981563e89e02571d573dd56c2ddc65db33a0cf37c58820ecc4b08785e8d169 +"picomatch@npm:4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc languageName: node linkType: hard -"rc-dropdown@npm:~4.2.0, rc-dropdown@npm:~4.2.1": - version: 4.2.1 - resolution: "rc-dropdown@npm:4.2.1" - dependencies: - "@babel/runtime": "npm:^7.18.3" - "@rc-component/trigger": "npm:^2.0.0" - classnames: "npm:^2.2.6" - rc-util: "npm:^5.44.1" - peerDependencies: - react: ">=16.11.0" - react-dom: ">=16.11.0" - checksum: 10c0/ec980e6c9f8bbba53e895002a0c3a28f294ae07f3ebc6c9a9cb80c7e1bb74ba9f0e0c4b9c23f487fdf8c5a4531000e05b5b43744ef506f0fd869165486768817 +"picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be languageName: node linkType: hard -"rc-field-form@npm:~2.7.1": - version: 2.7.1 - resolution: "rc-field-form@npm:2.7.1" - dependencies: - "@babel/runtime": "npm:^7.18.0" - "@rc-component/async-validator": "npm:^5.0.3" - rc-util: "npm:^5.32.2" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/2493cb9f26e69e17d55f32ad689da103a325e613d9222bfb332c2dcbdc96d44ce7dc4c8642a9b89a932ad2c2573508c997a4685e7fe6de2e951a027d2837403a +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 languageName: node linkType: hard -"rc-image@npm:~7.12.0": - version: 7.12.0 - resolution: "rc-image@npm:7.12.0" - dependencies: - "@babel/runtime": "npm:^7.11.2" - "@rc-component/portal": "npm:^1.0.2" - classnames: "npm:^2.2.6" - rc-dialog: "npm:~9.6.0" - rc-motion: "npm:^2.6.2" - rc-util: "npm:^5.34.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/229f848725f8cff5b6015eb0468a24a3d92c2aead48dc98abe19e6ff15840defc9b42f1a126b7f8180f678b5380ff99528bb89e972298ad456773e4070f33934 +"picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 languageName: node linkType: hard -"rc-input-number@npm:~9.5.0": - version: 9.5.0 - resolution: "rc-input-number@npm:9.5.0" +"piscina@npm:^4.3.1": + version: 4.9.2 + resolution: "piscina@npm:4.9.2" dependencies: - "@babel/runtime": "npm:^7.10.1" - "@rc-component/mini-decimal": "npm:^1.0.1" - classnames: "npm:^2.2.5" - rc-input: "npm:~1.8.0" - rc-util: "npm:^5.40.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/332aef42cd1f0e9eeee08c85db978c9615dfec5c8972e91c37a2ba4e06c3578d84dda05698e98893c6e62620d0d53aa910c0fbee2afac8f54c6f68759c296a58 + "@napi-rs/nice": "npm:^1.0.1" + dependenciesMeta: + "@napi-rs/nice": + optional: true + checksum: 10c0/ab67830065ff41523cd901db41b11045cb00a0be43bf79323ff7b4ef2fbce5e3a56ad440d99d6c3944ce94451a0a69fd175500e3220b21efe54142e601322189 languageName: node linkType: hard -"rc-input@npm:~1.8.0": - version: 1.8.0 - resolution: "rc-input@npm:1.8.0" - dependencies: - "@babel/runtime": "npm:^7.11.1" - classnames: "npm:^2.2.1" - rc-util: "npm:^5.18.1" - peerDependencies: - react: ">=16.0.0" - react-dom: ">=16.0.0" - checksum: 10c0/fe4e67b6980b22f77d62dcd87177a2381976baeaff265a27c4adb63bab48735f7c89b271c541eb0aab8c9d58af66979f45e79b44b72342838ac038ee5db0ba73 +"pluralize@npm:8.0.0": + version: 8.0.0 + resolution: "pluralize@npm:8.0.0" + checksum: 10c0/2044cfc34b2e8c88b73379ea4a36fc577db04f651c2909041b054c981cd863dd5373ebd030123ab058d194ae615d3a97cfdac653991e499d10caf592e8b3dc33 languageName: node linkType: hard -"rc-mentions@npm:~2.20.0": - version: 2.20.0 - resolution: "rc-mentions@npm:2.20.0" - dependencies: - "@babel/runtime": "npm:^7.22.5" - "@rc-component/trigger": "npm:^2.0.0" - classnames: "npm:^2.2.6" - rc-input: "npm:~1.8.0" - rc-menu: "npm:~9.16.0" - rc-textarea: "npm:~1.10.0" - rc-util: "npm:^5.34.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/2b242221772bad982c47916328e0245365134ba48519d171a93a8d79ddbdfb20a98421d7962215867dc9097dd58c307ea9bb6c9590125c0484c01d0b78e207e0 +"possible-typed-array-names@npm:^1.0.0": + version: 1.1.0 + resolution: "possible-typed-array-names@npm:1.1.0" + checksum: 10c0/c810983414142071da1d644662ce4caebce890203eb2bc7bf119f37f3fe5796226e117e6cca146b521921fa6531072674174a3325066ac66fce089a53e1e5196 languageName: node linkType: hard -"rc-menu@npm:~9.16.0, rc-menu@npm:~9.16.1": - version: 9.16.1 - resolution: "rc-menu@npm:9.16.1" +"postcss@npm:^8.5.10": + version: 8.5.12 + resolution: "postcss@npm:8.5.12" dependencies: - "@babel/runtime": "npm:^7.10.1" - "@rc-component/trigger": "npm:^2.0.0" - classnames: "npm:2.x" - rc-motion: "npm:^2.4.3" - rc-overflow: "npm:^1.3.1" - rc-util: "npm:^5.27.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/b61f21013cd679777b673d6e1a9f8429ffd9481a49665fc9a9c78198025d52aa76ae162186c46ca9f332117c8c7ff32f30d72435e1ae1b2a3daec0f86cb48810 + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/5baebaf574c567bc1b3d61197f38af4ce5920b8f611c887fb6bc3dcc14af00253c169dbf19897bc889cce0b0d9818ab5eb4ea0caedf02b0bab10da8a43ce8c12 languageName: node linkType: hard -"rc-motion@npm:^2.0.0, rc-motion@npm:^2.0.1, rc-motion@npm:^2.3.0, rc-motion@npm:^2.3.4, rc-motion@npm:^2.4.3, rc-motion@npm:^2.4.4, rc-motion@npm:^2.6.1, rc-motion@npm:^2.6.2, rc-motion@npm:^2.9.0, rc-motion@npm:^2.9.5": - version: 2.9.5 - resolution: "rc-motion@npm:2.9.5" +"postcss@npm:^8.5.6": + version: 8.5.6 + resolution: "postcss@npm:8.5.6" dependencies: - "@babel/runtime": "npm:^7.11.1" - classnames: "npm:^2.2.1" - rc-util: "npm:^5.44.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/84b12b2443dc1b929c8a688e8c9834a44cf88897402e9363fcea80b77f2803b2de83b24dac5873a8695304827e02fb3103c74349d0451ed247cb366553f9d88e + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/5127cc7c91ed7a133a1b7318012d8bfa112da9ef092dddf369ae699a1f10ebbd89b1b9f25f3228795b84585c72aabd5ced5fc11f2ba467eedf7b081a66fad024 languageName: node linkType: hard -"rc-notification@npm:~5.6.4": - version: 5.6.4 - resolution: "rc-notification@npm:5.6.4" - dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:2.x" - rc-motion: "npm:^2.9.0" - rc-util: "npm:^5.20.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/ea6a587b6a6057e8e6273d642cac5608b44948374ed636c9a83d104d21731c114b43036e33add05f755ceefb3f9258b881776672d5745c0e5d19f1d66449f37a +"postgres-array@npm:~2.0.0": + version: 2.0.0 + resolution: "postgres-array@npm:2.0.0" + checksum: 10c0/cbd56207e4141d7fbf08c86f2aebf21fa7064943d3f808ec85f442ff94b48d891e7a144cc02665fb2de5dbcb9b8e3183a2ac749959e794b4a4cfd379d7a21d08 languageName: node linkType: hard -"rc-overflow@npm:^1.3.1, rc-overflow@npm:^1.3.2": - version: 1.5.0 - resolution: "rc-overflow@npm:1.5.0" - dependencies: - "@babel/runtime": "npm:^7.11.1" - classnames: "npm:^2.2.1" - rc-resize-observer: "npm:^1.0.0" - rc-util: "npm:^5.37.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/7e9ce2fc3db7eb6e960b2848c0da9f67dcdc2a06eadd22baebe9933253424f6243c98fa7f093b765cb355d5fc3a880542748e199bee279dabc790fcf1bb2d34b +"postgres-bytea@npm:~1.0.0": + version: 1.0.1 + resolution: "postgres-bytea@npm:1.0.1" + checksum: 10c0/10b28a27c9d703d5befd97c443e62b551096d1014bc59ab574c65bf0688de7f3f068003b2aea8dcff83cf0f6f9a35f9f74457c38856cf8eb81b00cf3fb44f164 languageName: node linkType: hard -"rc-pagination@npm:~5.1.0": - version: 5.1.0 - resolution: "rc-pagination@npm:5.1.0" - dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:^2.3.2" - rc-util: "npm:^5.38.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/6cc6f0fa591c3d9f1cd0abcc1f918ddf18d6b5c71fefb97a6c3888b8492505e8e8951903de2bae7c64c0947cf1d53bc70f52577a3f6b38bdb3e9140a7bb5a32e +"postgres-date@npm:~1.0.4": + version: 1.0.7 + resolution: "postgres-date@npm:1.0.7" + checksum: 10c0/0ff91fccc64003e10b767fcfeefb5eaffbc522c93aa65d5051c49b3c4ce6cb93ab091a7d22877a90ad60b8874202c6f1d0f935f38a7235ed3b258efd54b97ca9 languageName: node linkType: hard -"rc-picker@npm:~4.11.3": - version: 4.11.3 - resolution: "rc-picker@npm:4.11.3" +"postgres-interval@npm:^1.1.0": + version: 1.2.0 + resolution: "postgres-interval@npm:1.2.0" dependencies: - "@babel/runtime": "npm:^7.24.7" - "@rc-component/trigger": "npm:^2.0.0" - classnames: "npm:^2.2.1" - rc-overflow: "npm:^1.3.2" - rc-resize-observer: "npm:^1.4.0" - rc-util: "npm:^5.43.0" - peerDependencies: - date-fns: ">= 2.x" - dayjs: ">= 1.x" - luxon: ">= 3.x" - moment: ">= 2.x" - react: ">=16.9.0" - react-dom: ">=16.9.0" - peerDependenciesMeta: - date-fns: - optional: true - dayjs: - optional: true - luxon: - optional: true - moment: - optional: true - checksum: 10c0/5136966ae7f9c0fa9acb9620f3d5e12341a4f2d1369d1c0e12697fbd519be47025a427acd63c8c39ec6b0a88acc5408b8318d197d8bf37f8d2b19d6726e6868d + xtend: "npm:^4.0.0" + checksum: 10c0/c1734c3cb79e7f22579af0b268a463b1fa1d084e742a02a7a290c4f041e349456f3bee3b4ee0bb3f226828597f7b76deb615c1b857db9a742c45520100456272 languageName: node linkType: hard -"rc-progress@npm:~4.0.0": - version: 4.0.0 - resolution: "rc-progress@npm:4.0.0" - dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:^2.2.6" - rc-util: "npm:^5.16.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/d3b47565470c5fec71a16f8d1939f1b1dd7d2dc9260893c6f70cafa84d9ee4231f3466be817db5fb9580932af46e34d52c74a710700ca9391b1901fa06c31f1e +"prelude-ls@npm:^1.2.1": + version: 1.2.1 + resolution: "prelude-ls@npm:1.2.1" + checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd languageName: node linkType: hard -"rc-rate@npm:~2.13.1": - version: 2.13.1 - resolution: "rc-rate@npm:2.13.1" +"prettier-linter-helpers@npm:^1.0.1": + version: 1.0.1 + resolution: "prettier-linter-helpers@npm:1.0.1" dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:^2.2.5" - rc-util: "npm:^5.0.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/b26d4741fffb06e1beebe1aba135ba6ab4ee898faf1f876ce802ed5ddcdc8dabe7a4662be63e60226713fad9b3dd8d4034ed9b8b3e27ba5ef9673d7e8f47d497 + fast-diff: "npm:^1.1.2" + checksum: 10c0/91cea965681bc5f62c9d26bd3ca6358b81557261d4802e96ec1cf0acbd99d4b61632d53320cd2c3ec7d7f7805a81345644108a41ef46ddc9688e783a9ac792d1 languageName: node linkType: hard -"rc-resize-observer@npm:^1.0.0, rc-resize-observer@npm:^1.1.0, rc-resize-observer@npm:^1.3.1, rc-resize-observer@npm:^1.4.0, rc-resize-observer@npm:^1.4.3": - version: 1.4.3 - resolution: "rc-resize-observer@npm:1.4.3" - dependencies: - "@babel/runtime": "npm:^7.20.7" - classnames: "npm:^2.2.1" - rc-util: "npm:^5.44.1" - resize-observer-polyfill: "npm:^1.5.1" +"prettier-plugin-tailwindcss@npm:^0.6.14": + version: 0.6.14 + resolution: "prettier-plugin-tailwindcss@npm:0.6.14" peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/93073c9ef5cc704f9d99307f58f8eeccabb953edf4e8a056b090104fc28ed19b77c2a32bd88ca2e0407fbedeb266d1985e655b35b8bc36b04d243e9d0471c911 + "@ianvs/prettier-plugin-sort-imports": "*" + "@prettier/plugin-hermes": "*" + "@prettier/plugin-oxc": "*" + "@prettier/plugin-pug": "*" + "@shopify/prettier-plugin-liquid": "*" + "@trivago/prettier-plugin-sort-imports": "*" + "@zackad/prettier-plugin-twig": "*" + prettier: ^3.0 + prettier-plugin-astro: "*" + prettier-plugin-css-order: "*" + prettier-plugin-import-sort: "*" + prettier-plugin-jsdoc: "*" + prettier-plugin-marko: "*" + prettier-plugin-multiline-arrays: "*" + prettier-plugin-organize-attributes: "*" + prettier-plugin-organize-imports: "*" + prettier-plugin-sort-imports: "*" + prettier-plugin-style-order: "*" + prettier-plugin-svelte: "*" + peerDependenciesMeta: + "@ianvs/prettier-plugin-sort-imports": + optional: true + "@prettier/plugin-hermes": + optional: true + "@prettier/plugin-oxc": + optional: true + "@prettier/plugin-pug": + optional: true + "@shopify/prettier-plugin-liquid": + optional: true + "@trivago/prettier-plugin-sort-imports": + optional: true + "@zackad/prettier-plugin-twig": + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + checksum: 10c0/1bf635be28b30b3f171a184497eecf512601d19328e402dd2eb1ede52aa57b4f5b605eae2929f4916de9ba22526f3d730d9afa90334db09d17c59f463f7b26d8 languageName: node linkType: hard -"rc-segmented@npm:~2.7.0": - version: 2.7.1 - resolution: "rc-segmented@npm:2.7.1" - dependencies: - "@babel/runtime": "npm:^7.11.1" - classnames: "npm:^2.2.1" - rc-motion: "npm:^2.4.4" - rc-util: "npm:^5.17.0" - peerDependencies: - react: ">=16.0.0" - react-dom: ">=16.0.0" - checksum: 10c0/051626c9124b641284d0b63a64c56bc21282d5e4bbd906a722f92b87a63e7b0d0ede1f541c623dab0355b16f7aaecb26792915e46d74fb1540aa9e0881715d3e +"prettier@npm:^3.6.2": + version: 3.8.1 + resolution: "prettier@npm:3.8.1" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 languageName: node linkType: hard -"rc-select@npm:~14.16.2, rc-select@npm:~14.16.8": - version: 14.16.8 - resolution: "rc-select@npm:14.16.8" - dependencies: - "@babel/runtime": "npm:^7.10.1" - "@rc-component/trigger": "npm:^2.1.1" - classnames: "npm:2.x" - rc-motion: "npm:^2.0.1" - rc-overflow: "npm:^1.3.1" - rc-util: "npm:^5.16.1" - rc-virtual-list: "npm:^3.5.2" - peerDependencies: - react: "*" - react-dom: "*" - checksum: 10c0/45f93e270c4b5e5ffc4b0ba0ce5e5ea72fff591a9a7a19b460b1ead0517d17327af9a4c32ce3c7f92b765724f4dabd1aa7146f5a06db73be91c884fe13c92774 +"proc-log@npm:^6.0.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 languageName: node linkType: hard -"rc-slider@npm:~11.1.9": - version: 11.1.9 - resolution: "rc-slider@npm:11.1.9" - dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:^2.2.5" - rc-util: "npm:^5.36.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/193c432e2859ba42b2235cc1949de2d929ba70fa4aa5672eaa5da692797f3fe927f8c0b2a75cc45c6b9f666f204f4ed038ccf904273d6cbc39e112f4a00ddd4a +"process-nextick-args@npm:~2.0.0": + version: 2.0.1 + resolution: "process-nextick-args@npm:2.0.1" + checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367 languageName: node linkType: hard -"rc-steps@npm:~6.0.1": - version: 6.0.1 - resolution: "rc-steps@npm:6.0.1" +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" dependencies: - "@babel/runtime": "npm:^7.16.7" - classnames: "npm:^2.2.3" - rc-util: "npm:^5.16.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/0ba1051a469ae95916cafbb6d7fe76f94e6666181129d3012174d8cc25913c6afd691f551ee0dac48a4a86b59cb91699d6a44a1398dcafd661a8a519f70c95e1 + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96 languageName: node linkType: hard -"rc-switch@npm:~4.1.0": - version: 4.1.0 - resolution: "rc-switch@npm:4.1.0" - dependencies: - "@babel/runtime": "npm:^7.21.0" - classnames: "npm:^2.2.1" - rc-util: "npm:^5.30.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/5ce5b1cadea6e7cd38c0725456ea15c39748fefc39576f7c9288192e69b7f426c4ac7627e266369ece164f281ae08e14ab8f54d4d7858c8bd20707b296980743 +"property-information@npm:^7.0.0": + version: 7.1.0 + resolution: "property-information@npm:7.1.0" + checksum: 10c0/e0fe22cff26103260ad0e82959229106563fa115a54c4d6c183f49d88054e489cc9f23452d3ad584179dc13a8b7b37411a5df873746b5e4086c865874bfa968e languageName: node linkType: hard -"rc-table@npm:~7.54.0": - version: 7.54.0 - resolution: "rc-table@npm:7.54.0" +"proxy-addr@npm:^2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" dependencies: - "@babel/runtime": "npm:^7.10.1" - "@rc-component/context": "npm:^1.4.0" - classnames: "npm:^2.2.5" - rc-resize-observer: "npm:^1.1.0" - rc-util: "npm:^5.44.3" - rc-virtual-list: "npm:^3.14.2" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/bf78af66c97aca8437bcf1f88df81b4171afbf647959c9b3600b246c15f9db8763fa50255749390d7f2c0ba0a6aea4f467f64aa4dd6265d56ebf35ffc0d6b94f + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: 10c0/c3eed999781a35f7fd935f398b6d8920b6fb00bbc14287bc6de78128ccc1a02c89b95b56742bf7cf0362cc333c61d138532049c7dedc7a328ef13343eff81210 languageName: node linkType: hard -"rc-tabs@npm:~15.7.0": - version: 15.7.0 - resolution: "rc-tabs@npm:15.7.0" - dependencies: - "@babel/runtime": "npm:^7.11.2" - classnames: "npm:2.x" - rc-dropdown: "npm:~4.2.0" - rc-menu: "npm:~9.16.0" - rc-motion: "npm:^2.6.2" - rc-resize-observer: "npm:^1.0.0" - rc-util: "npm:^5.34.1" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/472561f2ec611e9f2a396ba9fec6b83138621651aa2e9fb5a1c68b4da8cb6cab01c23ca71b3940876a595f30c2b8324b8899f93486862271be8eb16a95433764 +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 languageName: node linkType: hard -"rc-textarea@npm:~1.10.0, rc-textarea@npm:~1.10.2": - version: 1.10.2 - resolution: "rc-textarea@npm:1.10.2" - dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:^2.2.1" - rc-input: "npm:~1.8.0" - rc-resize-observer: "npm:^1.0.0" - rc-util: "npm:^5.27.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/ccfe7bae33187c382e12bc14e9f2617fe183a4d4e8c0d3b9f71455728172f31a6140d0855ff557b6c658daf31c7ff935a1a347a336f8106ddda84e042ab23448 +"punycode@npm:^2.1.0": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 languageName: node linkType: hard -"rc-tooltip@npm:~6.4.0": - version: 6.4.0 - resolution: "rc-tooltip@npm:6.4.0" +"qs@npm:^6.14.0, qs@npm:^6.14.1": + version: 6.14.1 + resolution: "qs@npm:6.14.1" dependencies: - "@babel/runtime": "npm:^7.11.2" - "@rc-component/trigger": "npm:^2.0.0" - classnames: "npm:^2.3.1" - rc-util: "npm:^5.44.3" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/49b9c56fc877b38084b4076edb1b61f0272bdd290c6ef161a0e1cf6426488e948c20439cf4ae31e076f3957b894feb326e4a1d7880400de2c29b1d54f736a342 + side-channel: "npm:^1.1.0" + checksum: 10c0/0e3b22dc451f48ce5940cbbc7c7d9068d895074f8c969c0801ac15c1313d1859c4d738e46dc4da2f498f41a9ffd8c201bd9fb12df67799b827db94cc373d2613 languageName: node linkType: hard -"rc-tree-select@npm:~5.27.0": - version: 5.27.0 - resolution: "rc-tree-select@npm:5.27.0" +"qs@npm:^6.15.2": + version: 6.15.3 + resolution: "qs@npm:6.15.3" dependencies: - "@babel/runtime": "npm:^7.25.7" - classnames: "npm:2.x" - rc-select: "npm:~14.16.2" - rc-tree: "npm:~5.13.0" - rc-util: "npm:^5.43.0" - peerDependencies: - react: "*" - react-dom: "*" - checksum: 10c0/26aad0e13e5f9fe501574ba50826edda9b67a5bf22adbe1dc8e3a793fb784318b235165d6054a047b5934cdfbbd88ea1a524726edbad9107cd0f1d28782f9cc5 + es-define-property: "npm:^1.0.1" + side-channel: "npm:^1.1.1" + checksum: 10c0/8f3f6e45ece255347d57696628401cde29e9ec649fff698b53bd3150dea7cefdf33036e1bc1826b9f110bfa7cb0ec4ab9f5297eca628ce216c55af82c304e08e languageName: node linkType: hard -"rc-tree@npm:~5.13.0, rc-tree@npm:~5.13.1": - version: 5.13.1 - resolution: "rc-tree@npm:5.13.1" - dependencies: - "@babel/runtime": "npm:^7.10.1" - classnames: "npm:2.x" - rc-motion: "npm:^2.0.1" - rc-util: "npm:^5.16.1" - rc-virtual-list: "npm:^3.5.1" - peerDependencies: - react: "*" - react-dom: "*" - checksum: 10c0/4a27783d319f9e5367e9d123a2f9a6daa0383e705e055abb47f3ff7fa93249c5c26bbb27b7c6602163faefbfe0f3e923eb3a55d1e1f1d09d04b7bdf37942c2d4 +"quick-lru@npm:^5.1.1": + version: 5.1.1 + resolution: "quick-lru@npm:5.1.1" + checksum: 10c0/a24cba5da8cec30d70d2484be37622580f64765fb6390a928b17f60cd69e8dbd32a954b3ff9176fa1b86d86ff2ba05252fae55dc4d40d0291c60412b0ad096da languageName: node linkType: hard -"rc-upload@npm:~4.11.0": - version: 4.11.0 - resolution: "rc-upload@npm:4.11.0" +"randombytes@npm:^2.1.0": + version: 2.1.0 + resolution: "randombytes@npm:2.1.0" dependencies: - "@babel/runtime": "npm:^7.18.3" - classnames: "npm:^2.2.5" - rc-util: "npm:^5.2.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/9ce59e22e0f9839e482fd37e63d8489e1a9d418113bfe39cd4d2b1e88f59236d4f7c60a3bb5928d2d85781d6f4cf8c30b6085863c802de605ef339fa415903d3 + safe-buffer: "npm:^5.1.0" + checksum: 10c0/50395efda7a8c94f5dffab564f9ff89736064d32addf0cc7e8bf5e4166f09f8ded7a0849ca6c2d2a59478f7d90f78f20d8048bca3cdf8be09d8e8a10790388f3 languageName: node linkType: hard -"rc-util@npm:^5.0.1, rc-util@npm:^5.16.1, rc-util@npm:^5.17.0, rc-util@npm:^5.18.1, rc-util@npm:^5.2.0, rc-util@npm:^5.20.1, rc-util@npm:^5.21.0, rc-util@npm:^5.24.4, rc-util@npm:^5.25.2, rc-util@npm:^5.27.0, rc-util@npm:^5.30.0, rc-util@npm:^5.31.1, rc-util@npm:^5.32.2, rc-util@npm:^5.34.1, rc-util@npm:^5.35.0, rc-util@npm:^5.36.0, rc-util@npm:^5.37.0, rc-util@npm:^5.38.0, rc-util@npm:^5.38.1, rc-util@npm:^5.40.1, rc-util@npm:^5.43.0, rc-util@npm:^5.44.0, rc-util@npm:^5.44.1, rc-util@npm:^5.44.3, rc-util@npm:^5.44.4": - version: 5.44.4 - resolution: "rc-util@npm:5.44.4" - dependencies: - "@babel/runtime": "npm:^7.18.3" - react-is: "npm:^18.2.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/748b71a6280ddaaac93d1fb2c92f03818775468e7ccb6c221484687cc0b7e879d083e98e338f75ac0fe2e942dbb9c2405bd32d25e5a804bf1fb7a11f3f897127 +"range-parser@npm:^1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 10c0/96c032ac2475c8027b7a4e9fe22dc0dfe0f6d90b85e496e0f016fbdb99d6d066de0112e680805075bd989905e2123b3b3d002765149294dce0c1f7f01fcc2ea0 languageName: node linkType: hard -"rc-virtual-list@npm:^3.14.2, rc-virtual-list@npm:^3.5.1, rc-virtual-list@npm:^3.5.2": - version: 3.19.2 - resolution: "rc-virtual-list@npm:3.19.2" +"raw-body@npm:^3.0.1, raw-body@npm:^3.0.2": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" dependencies: - "@babel/runtime": "npm:^7.20.0" - classnames: "npm:^2.2.6" - rc-resize-observer: "npm:^1.0.0" - rc-util: "npm:^5.36.0" - peerDependencies: - react: ">=16.9.0" - react-dom: ">=16.9.0" - checksum: 10c0/3778ade183a33d113555fb99465c2f59391c9b5178629cb7bb2947d5ee71a1b166bf9468b063394f63384965166ef368acf78cb5f4b3a23e9393af04543b6626 + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10c0/d266678d08e1e7abea62c0ce5864344e980fa81c64f6b481e9842c5beaed2cdcf975f658a3ccd67ad35fc919c1f6664ccc106067801850286a6cbe101de89f29 languageName: node linkType: hard @@ -10702,6 +10653,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^19.2.7": + version: 19.2.8 + resolution: "react-is@npm:19.2.8" + checksum: 10c0/ed5322c84efe035c8fc814b1614ff5ca7fb8c2872a7c045197daf99f9b1bd68f32a2c79ffcbcfcff2fc5d93924ff9f9447400898f5b1534e6302bb0736a257f0 + languageName: node + linkType: hard + "react-refresh@npm:^0.18.0": version: 0.18.0 resolution: "react-refresh@npm:0.18.0" @@ -10887,13 +10845,6 @@ __metadata: languageName: node linkType: hard -"resize-observer-polyfill@npm:^1.5.1": - version: 1.5.1 - resolution: "resize-observer-polyfill@npm:1.5.1" - checksum: 10c0/5e882475067f0b97dc07e0f37c3e335ac5bc3520d463f777cec7e894bb273eddbfecb857ae668e6fb6881fd6f6bb7148246967172139302da50fa12ea3a15d95 - languageName: node - linkType: hard - "resolve-alpn@npm:^1.2.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" @@ -12052,13 +12003,6 @@ __metadata: languageName: node linkType: hard -"toggle-selection@npm:^1.0.6": - version: 1.0.6 - resolution: "toggle-selection@npm:1.0.6" - checksum: 10c0/f2cf1f2c70f374fd87b0cdc8007453ba9e981c4305a8bf4eac10a30e62ecdfd28bca7d18f8f15b15a506bf8a7bfb20dbe3539f0fcf2a2c8396c1a78d53e1f179 - languageName: node - linkType: hard - "toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1"