feat(GOV-009): 建立前端 OpenAPI 客户端边界

This commit is contained in:
2026-08-13 17:36:55 +08:00
parent 420aa22342
commit 4665336a63
11 changed files with 691 additions and 27 deletions
+3
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"api:generate": "openapi-typescript ../server/openapi/openapi.json -o src/api/generated/schema.ts",
"lint": "eslint .",
"typecheck": "vue-tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
"test": "vitest run",
@@ -14,6 +15,7 @@
},
"dependencies": {
"naive-ui": "2.44.1",
"openapi-fetch": "0.17.0",
"pinia": "3.0.4",
"vue": "3.5.41",
"vue-router": "4.6.4"
@@ -26,6 +28,7 @@
"dotenv": "16.6.1",
"jsdom": "26.1.0",
"katex": "0.18.4",
"openapi-typescript": "7.13.0",
"sass": "1.102.0",
"typescript": "5.9.3",
"vite": "6.4.3",
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import type { components } from '../generated/schema';
import { toHealthViewModel } from './health.adapter';
type HealthResponseDto = components['schemas']['HealthResponseDto'];
const healthyDto: HealthResponseDto = {
status: 'healthy',
process: 'api',
checkedAt: '2026-08-13T09:30:00.000Z',
dependencies: {
mysql: { status: 'healthy', detail: 'MySQL 5.7 ready' },
redis: { status: 'healthy', detail: 'PONG' },
rocketMq: { status: 'healthy', detail: 'RocketMQ ready' },
},
};
describe('健康响应页面适配器', () => {
it('把全部可用的传输状态映射为健康页面模型', () => {
expect(toHealthViewModel(healthyDto)).toEqual({
state: 'healthy',
stateLabel: '运行正常',
checkedAt: new Date('2026-08-13T09:30:00.000Z'),
services: [
{ key: 'redis', name: 'Redis', available: true, note: 'PONG' },
{ key: 'mysql', name: 'MySQL', available: true, note: 'MySQL 5.7 ready' },
{ key: 'rocketMq', name: 'RocketMQ', available: true, note: 'RocketMQ ready' },
],
});
});
it('把可选能力停用映射为降级而不是后端枚举', () => {
const dto: HealthResponseDto = {
...healthyDto,
dependencies: {
...healthyDto.dependencies,
rocketMq: { status: 'disabled', detail: '当前版本未启用' },
},
};
const viewModel = toHealthViewModel(dto);
expect(viewModel.state).toBe('degraded');
expect(viewModel.stateLabel).toBe('部分能力停用');
expect(viewModel.services[2]).toEqual({
key: 'rocketMq',
name: 'RocketMQ',
available: false,
note: '当前版本未启用',
});
});
it('把任一依赖不可用映射为不可用', () => {
const dto: HealthResponseDto = {
...healthyDto,
status: 'unavailable',
dependencies: {
...healthyDto.dependencies,
redis: { status: 'unavailable', detail: '检查失败' },
},
};
expect(toHealthViewModel(dto).state).toBe('unavailable');
expect(toHealthViewModel(dto).stateLabel).toBe('服务异常');
});
it('拒绝运行时出现的未知依赖状态', () => {
const malformedDto = structuredClone(healthyDto) as unknown as {
dependencies: { redis: { status: string } };
};
malformedDto.dependencies.redis.status = 'unknown';
expect(() => toHealthViewModel(malformedDto as never)).toThrow(/未知依赖状态/);
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { components } from '../generated/schema';
type HealthResponseDto = components['schemas']['HealthResponseDto'];
type DependencyHealthDto = components['schemas']['DependencyHealthDto'];
export type HealthViewState = 'healthy' | 'degraded' | 'unavailable';
export interface HealthServiceViewModel {
key: 'redis' | 'mysql' | 'rocketMq';
name: 'Redis' | 'MySQL' | 'RocketMQ';
available: boolean;
note: string;
}
export interface HealthViewModel {
state: HealthViewState;
stateLabel: '运行正常' | '部分能力停用' | '服务异常';
checkedAt: Date;
services: HealthServiceViewModel[];
}
function assertDependencyStatus(status: string): asserts status is DependencyHealthDto['status'] {
if (status !== 'healthy' && status !== 'unavailable' && status !== 'disabled') {
throw new Error(`未知依赖状态:${status}`);
}
}
function toServiceViewModel(
key: HealthServiceViewModel['key'],
name: HealthServiceViewModel['name'],
dependency: DependencyHealthDto,
): HealthServiceViewModel {
assertDependencyStatus(dependency.status);
return {
key,
name,
available: dependency.status === 'healthy',
note: dependency.detail,
};
}
export function toHealthViewModel(dto: HealthResponseDto): HealthViewModel {
const dependencies = Object.values(dto.dependencies);
for (const dependency of dependencies) assertDependencyStatus(dependency.status);
const hasUnavailable = dependencies.some((dependency) => dependency.status === 'unavailable');
const hasDisabled = dependencies.some((dependency) => dependency.status === 'disabled');
const state: HealthViewState =
dto.status === 'unavailable' || hasUnavailable
? 'unavailable'
: hasDisabled
? 'degraded'
: 'healthy';
const stateLabel = {
healthy: '运行正常',
degraded: '部分能力停用',
unavailable: '服务异常',
} as const;
// 页面只消费稳定的展示模型,后端枚举和依赖对象结构不会直接扩散到 Vue 组件。
return {
state,
stateLabel: stateLabel[state],
checkedAt: new Date(dto.checkedAt),
services: [
toServiceViewModel('redis', 'Redis', dto.dependencies.redis),
toServiceViewModel('mysql', 'MySQL', dto.dependencies.mysql),
toServiceViewModel('rocketMq', 'RocketMQ', dto.dependencies.rocketMq),
],
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { ApiRequestError, createApiClient } from './client';
describe('OpenAPI 请求客户端', () => {
it('使用调用方提供的 API 根地址请求公开路径', async () => {
let requestedUrl = '';
const client = createApiClient('http://127.0.0.1:3000', async (request) => {
requestedUrl = request.url;
return Response.json({
status: 'healthy',
process: 'api',
checkedAt: '2026-08-13T09:30:00.000Z',
dependencies: {
mysql: { status: 'healthy', detail: 'ready' },
redis: { status: 'healthy', detail: 'ready' },
rocketMq: { status: 'disabled', detail: 'disabled' },
},
});
});
await client.GET('/api/v1/health');
expect(requestedUrl).toBe('http://127.0.0.1:3000/api/v1/health');
});
it('把网络错误归一化为不泄漏底层连接信息的错误', async () => {
const client = createApiClient('http://127.0.0.1:3000', async () => {
throw new Error('connect ECONNREFUSED mysql://root:secret@internal');
});
await expect(client.GET('/api/v1/health')).rejects.toEqual(
expect.objectContaining({
name: 'ApiRequestError',
message: 'API 请求失败,请稍后重试',
}),
);
await expect(client.GET('/api/v1/health')).rejects.toBeInstanceOf(ApiRequestError);
});
});
+28
View File
@@ -0,0 +1,28 @@
import createClient, { type Client } from 'openapi-fetch';
import type { paths } from './generated/schema';
export class ApiRequestError extends Error {
constructor() {
super('API 请求失败,请稍后重试');
this.name = 'ApiRequestError';
}
}
export function createApiClient(
baseUrl: string,
fetchImplementation: (request: Request) => Promise<Response> = globalThis.fetch,
): Client<paths> {
const client = createClient<paths>({
baseUrl: baseUrl.replace(/\/$/, ''),
fetch: fetchImplementation,
});
client.use({
onError() {
// 浏览器错误可能带出内网主机、代理或底层连接细节,统一转换为安全的用户提示。
return new ApiRequestError();
},
});
return client;
}
+82
View File
@@ -0,0 +1,82 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
'/api/v1/health': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['HealthController_getHealth'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
DependencyHealthDto: {
detail: string;
/** @enum {string} */
status: 'healthy' | 'unavailable' | 'disabled';
};
HealthDependenciesDto: {
mysql: components['schemas']['DependencyHealthDto'];
redis: components['schemas']['DependencyHealthDto'];
rocketMq: components['schemas']['DependencyHealthDto'];
};
HealthResponseDto: {
/** Format: date-time */
checkedAt: string;
dependencies: components['schemas']['HealthDependenciesDto'];
/** @enum {string} */
process: 'api';
/** @enum {string} */
status: 'healthy' | 'unavailable';
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
HealthController_getHealth: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HealthResponseDto'];
};
};
503: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HealthResponseDto'];
};
};
};
};
}