feat(FEAT-MO-001): 增加TDX直连配置与客户端
This commit is contained in:
+2
@@ -4,6 +4,8 @@ APP_ENV=development
|
|||||||
API_HOST=127.0.0.1
|
API_HOST=127.0.0.1
|
||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
TDX_BASE_URL=http://127.0.0.1:17710
|
||||||
|
TDX_TIMEOUT_MS=5000
|
||||||
|
|
||||||
# 第一阶段实际启用的 Redis。
|
# 第一阶段实际启用的 Redis。
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
|
|||||||
+2
@@ -3,6 +3,8 @@ APP_ENV=production
|
|||||||
API_HOST=127.0.0.1
|
API_HOST=127.0.0.1
|
||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
TDX_BASE_URL=http://127.0.0.1:17710
|
||||||
|
TDX_TIMEOUT_MS=5000
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -50,6 +50,8 @@ describe('进程级运行配置', () => {
|
|||||||
LOG_LEVEL: 'debug',
|
LOG_LEVEL: 'debug',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
...baseRedisEnv,
|
...baseRedisEnv,
|
||||||
...disabledMysqlEnv,
|
...disabledMysqlEnv,
|
||||||
...disabledAiEnv,
|
...disabledAiEnv,
|
||||||
@@ -129,6 +131,8 @@ describe('进程级运行配置', () => {
|
|||||||
LOG_LEVEL: 'debug',
|
LOG_LEVEL: 'debug',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
...baseRedisEnv,
|
...baseRedisEnv,
|
||||||
...enabledMysql,
|
...enabledMysql,
|
||||||
...disabledAiEnv,
|
...disabledAiEnv,
|
||||||
@@ -138,12 +142,38 @@ describe('进程级运行配置', () => {
|
|||||||
expect(() => loadApiConfig({ ...apiEnv, MYSQL_TIMEZONE: 'SYSTEM' })).toThrow(/MYSQL_TIMEZONE/);
|
expect(() => loadApiConfig({ ...apiEnv, MYSQL_TIMEZONE: 'SYSTEM' })).toThrow(/MYSQL_TIMEZONE/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('TDX 只允许本机回环地址和固定端口', () => {
|
||||||
|
const apiEnv = {
|
||||||
|
APP_ENV: 'development',
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
API_HOST: '127.0.0.1',
|
||||||
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
|
...baseRedisEnv,
|
||||||
|
...disabledMysqlEnv,
|
||||||
|
...disabledAiEnv,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => loadApiConfig({ ...apiEnv, TDX_BASE_URL: 'http://192.168.28.106:17709' })).toThrow(
|
||||||
|
/TDX_BASE_URL 必须指向本机回环地址/,
|
||||||
|
);
|
||||||
|
expect(() => loadApiConfig({ ...apiEnv, TDX_BASE_URL: 'https://127.0.0.1:17710' })).toThrow(
|
||||||
|
/TDX_BASE_URL 必须使用 http 协议/,
|
||||||
|
);
|
||||||
|
expect(() => loadApiConfig({ ...apiEnv, TDX_BASE_URL: 'http://127.0.0.1:17711' })).toThrow(
|
||||||
|
/TDX_BASE_URL 必须使用 17710 端口/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('日志摘要不包含任何连接密码、密钥或未知字段', () => {
|
it('日志摘要不包含任何连接密码、密钥或未知字段', () => {
|
||||||
const config = loadApiConfig({
|
const config = loadApiConfig({
|
||||||
APP_ENV: 'development',
|
APP_ENV: 'development',
|
||||||
LOG_LEVEL: 'debug',
|
LOG_LEVEL: 'debug',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
...baseRedisEnv,
|
...baseRedisEnv,
|
||||||
...disabledMysqlEnv,
|
...disabledMysqlEnv,
|
||||||
...disabledAiEnv,
|
...disabledAiEnv,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export interface AiRuntimeConfig {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** API 只允许通过本机 SSH/Tailscale 隧道访问 TDX,禁止把家庭地址暴露给业务请求。 */
|
||||||
|
export interface TdxRuntimeConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
||||||
export interface RocketMqRuntimeConfig {
|
export interface RocketMqRuntimeConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -54,6 +60,7 @@ interface CommonRuntimeConfig {
|
|||||||
export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
||||||
processName: 'api';
|
processName: 'api';
|
||||||
api: Readonly<{ host: string; port: number }>;
|
api: Readonly<{ host: string; port: number }>;
|
||||||
|
tdx: Readonly<TdxRuntimeConfig>;
|
||||||
mysql: MysqlRuntimeConfig;
|
mysql: MysqlRuntimeConfig;
|
||||||
ai: AiRuntimeConfig;
|
ai: AiRuntimeConfig;
|
||||||
}
|
}
|
||||||
@@ -196,6 +203,30 @@ function loadAiConfig(env: EnvironmentSource): AiRuntimeConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadTdxConfig(env: EnvironmentSource): TdxRuntimeConfig {
|
||||||
|
const baseUrl = requireText(env, 'TDX_BASE_URL').replace(/\/$/, '');
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(baseUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error('TDX_BASE_URL 必须为有效 URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:') throw new Error('TDX_BASE_URL 必须使用 http 协议');
|
||||||
|
if (parsed.hostname !== '127.0.0.1') {
|
||||||
|
throw new Error('TDX_BASE_URL 必须指向本机回环地址');
|
||||||
|
}
|
||||||
|
if (parsed.port !== '17710') throw new Error('TDX_BASE_URL 必须使用 17710 端口');
|
||||||
|
if (parsed.pathname !== '' && parsed.pathname !== '/') {
|
||||||
|
throw new Error('TDX_BASE_URL 不得包含额外路径');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: parsed.toString().replace(/\/$/, ''),
|
||||||
|
timeoutMs: parseInteger(env, 'TDX_TIMEOUT_MS', { min: 100, max: 30_000 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
||||||
const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED');
|
const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED');
|
||||||
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
||||||
@@ -225,6 +256,7 @@ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig {
|
|||||||
host: requireText(env, 'API_HOST'),
|
host: requireText(env, 'API_HOST'),
|
||||||
port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }),
|
port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }),
|
||||||
},
|
},
|
||||||
|
tdx: loadTdxConfig(env),
|
||||||
redis: loadRedisConfig(env),
|
redis: loadRedisConfig(env),
|
||||||
mysql: loadMysqlConfig(env),
|
mysql: loadMysqlConfig(env),
|
||||||
ai: loadAiConfig(env),
|
ai: loadAiConfig(env),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe('NestJS 三进程入口', () => {
|
|||||||
appEnv: 'development',
|
appEnv: 'development',
|
||||||
logLevel: 'debug',
|
logLevel: 'debug',
|
||||||
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
||||||
|
tdx: Object.freeze({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 5000 }),
|
||||||
redis: redisConfig,
|
redis: redisConfig,
|
||||||
mysql: mysqlConfig,
|
mysql: mysqlConfig,
|
||||||
ai: aiConfig,
|
ai: aiConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user