feat(FEAT-MO-001): 增加TDX直连配置与客户端
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createTdxClient } from './tdx.client';
|
||||
|
||||
function response(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
describe('TDX 客户端', () => {
|
||||
it('发送固定 JSON 请求并返回业务结果', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
response({ id: 1, result: { ErrorId: '0', Value: {} } }),
|
||||
);
|
||||
const client = createTdxClient({
|
||||
baseUrl: 'http://127.0.0.1:17710',
|
||||
timeoutMs: 5_000,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await expect(client.request('get_market_data', { stock_list: ['600519.SH'] })).resolves.toEqual({
|
||||
ErrorId: '0',
|
||||
Value: {},
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:17710/',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const request = fetchImpl.mock.calls[0]?.[1];
|
||||
expect(JSON.parse(String(request?.body))).toMatchObject({
|
||||
id: 1,
|
||||
method: 'get_market_data',
|
||||
params: { stock_list: ['600519.SH'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('将 TDX 错误响应转换为脱敏协议错误', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
response({ id: 1, error: { code: 'bad_request', message: '内部连接信息' } }),
|
||||
);
|
||||
const client = createTdxClient({
|
||||
baseUrl: 'http://127.0.0.1:17710',
|
||||
timeoutMs: 5_000,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const firstError = client.request('get_market_data', {});
|
||||
await expect(firstError).rejects.toMatchObject({
|
||||
kind: 'protocol',
|
||||
message: 'TDX 返回业务错误',
|
||||
});
|
||||
await expect(firstError).rejects.not.toThrow('内部连接信息');
|
||||
});
|
||||
|
||||
it('将 AbortError 转换为超时错误', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
||||
const client = createTdxClient({
|
||||
baseUrl: 'http://127.0.0.1:17710',
|
||||
timeoutMs: 50,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await expect(client.request('get_market_data', {})).rejects.toMatchObject({ kind: 'timeout' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { TdxRuntimeConfig } from '../../../../../libs/config/src/runtime-config';
|
||||
|
||||
export type TdxClientErrorKind = 'timeout' | 'network' | 'protocol' | 'unavailable';
|
||||
|
||||
export class TdxClientError extends Error {
|
||||
constructor(
|
||||
readonly kind: TdxClientErrorKind,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TdxClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface TdxClient {
|
||||
request<TResponse>(method: string, params: Record<string, unknown>): Promise<TResponse>;
|
||||
}
|
||||
|
||||
export interface TdxClientOptions extends TdxRuntimeConfig {
|
||||
fetchImpl?: typeof fetch;
|
||||
maxResponseBytes?: number;
|
||||
}
|
||||
|
||||
interface TdxRpcEnvelope<TResponse> {
|
||||
id?: number;
|
||||
result?: TResponse;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
function hasBusinessError(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return 'ErrorId' in record && record.ErrorId !== '0' && record.ErrorId !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 TDX 的 JSON-RPC/业务错误限制在客户端边界内,避免 URL、请求头和原始载荷进入上层异常。
|
||||
*/
|
||||
export function createTdxClient({
|
||||
baseUrl,
|
||||
timeoutMs,
|
||||
fetchImpl = fetch,
|
||||
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
||||
}: TdxClientOptions): TdxClient {
|
||||
const endpoint = `${baseUrl.replace(/\/$/, '')}/`;
|
||||
let requestId = 0;
|
||||
|
||||
return {
|
||||
async request<TResponse>(method: string, params: Record<string, unknown>): Promise<TResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: ++requestId, method, params }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && Number(contentLength) > maxResponseBytes) {
|
||||
throw new TdxClientError('protocol', 'TDX 响应体超过安全上限');
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, 'utf8') > maxResponseBytes) {
|
||||
throw new TdxClientError('protocol', 'TDX 响应体超过安全上限');
|
||||
}
|
||||
if (!response.ok) throw new TdxClientError('unavailable', 'TDX 服务不可用');
|
||||
|
||||
let envelope: TdxRpcEnvelope<TResponse>;
|
||||
try {
|
||||
envelope = JSON.parse(text) as TdxRpcEnvelope<TResponse>;
|
||||
} catch {
|
||||
throw new TdxClientError('protocol', 'TDX 返回格式错误');
|
||||
}
|
||||
|
||||
if (envelope.error !== undefined || envelope.result === undefined) {
|
||||
throw new TdxClientError('protocol', 'TDX 返回业务错误');
|
||||
}
|
||||
if (hasBusinessError(envelope.result)) {
|
||||
throw new TdxClientError('protocol', 'TDX 返回业务错误');
|
||||
}
|
||||
return envelope.result;
|
||||
} catch (error) {
|
||||
if (error instanceof TdxClientError) throw error;
|
||||
if (isAbortError(error)) throw new TdxClientError('timeout', 'TDX 请求超时');
|
||||
throw new TdxClientError('network', 'TDX 网络请求失败');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user