From c2fa929936e688c90853cb6274ac151f30bbd7e9 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 18 Aug 2026 13:16:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-MO-001):=20=E5=A2=9E=E5=8A=A0TDX?= =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/env/development/api.env.example | 2 + deploy/env/production/api.env.example | 2 + .../market-overview/tdx/tdx.client.test.ts | 72 +++++++++++++ .../api/src/market-overview/tdx/tdx.client.ts | 101 ++++++++++++++++++ server/libs/config/src/runtime-config.test.ts | 30 ++++++ server/libs/config/src/runtime-config.ts | 32 ++++++ server/test/process-smoke.test.ts | 1 + 7 files changed, 240 insertions(+) create mode 100644 server/apps/api/src/market-overview/tdx/tdx.client.test.ts create mode 100644 server/apps/api/src/market-overview/tdx/tdx.client.ts diff --git a/deploy/env/development/api.env.example b/deploy/env/development/api.env.example index 9ac7ff0..a446e98 100644 --- a/deploy/env/development/api.env.example +++ b/deploy/env/development/api.env.example @@ -4,6 +4,8 @@ APP_ENV=development API_HOST=127.0.0.1 API_PORT=3000 LOG_LEVEL=debug +TDX_BASE_URL=http://127.0.0.1:17710 +TDX_TIMEOUT_MS=5000 # 第一阶段实际启用的 Redis。 REDIS_HOST=127.0.0.1 diff --git a/deploy/env/production/api.env.example b/deploy/env/production/api.env.example index d9154b5..19ee7c8 100644 --- a/deploy/env/production/api.env.example +++ b/deploy/env/production/api.env.example @@ -3,6 +3,8 @@ APP_ENV=production API_HOST=127.0.0.1 API_PORT=3000 LOG_LEVEL=info +TDX_BASE_URL=http://127.0.0.1:17710 +TDX_TIMEOUT_MS=5000 REDIS_HOST=127.0.0.1 REDIS_PORT=6379 diff --git a/server/apps/api/src/market-overview/tdx/tdx.client.test.ts b/server/apps/api/src/market-overview/tdx/tdx.client.test.ts new file mode 100644 index 0000000..2071ceb --- /dev/null +++ b/server/apps/api/src/market-overview/tdx/tdx.client.test.ts @@ -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().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().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().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' }); + }); +}); diff --git a/server/apps/api/src/market-overview/tdx/tdx.client.ts b/server/apps/api/src/market-overview/tdx/tdx.client.ts new file mode 100644 index 0000000..789ff87 --- /dev/null +++ b/server/apps/api/src/market-overview/tdx/tdx.client.ts @@ -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(method: string, params: Record): Promise; +} + +export interface TdxClientOptions extends TdxRuntimeConfig { + fetchImpl?: typeof fetch; + maxResponseBytes?: number; +} + +interface TdxRpcEnvelope { + 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; + 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(method: string, params: Record): Promise { + 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; + try { + envelope = JSON.parse(text) as TdxRpcEnvelope; + } 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); + } + }, + }; +} diff --git a/server/libs/config/src/runtime-config.test.ts b/server/libs/config/src/runtime-config.test.ts index 0099fb0..2fd28ac 100644 --- a/server/libs/config/src/runtime-config.test.ts +++ b/server/libs/config/src/runtime-config.test.ts @@ -50,6 +50,8 @@ describe('进程级运行配置', () => { 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, @@ -129,6 +131,8 @@ describe('进程级运行配置', () => { 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, ...enabledMysql, ...disabledAiEnv, @@ -138,12 +142,38 @@ describe('进程级运行配置', () => { 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('日志摘要不包含任何连接密码、密钥或未知字段', () => { const config = loadApiConfig({ 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, diff --git a/server/libs/config/src/runtime-config.ts b/server/libs/config/src/runtime-config.ts index d1363ee..a7da6de 100644 --- a/server/libs/config/src/runtime-config.ts +++ b/server/libs/config/src/runtime-config.ts @@ -35,6 +35,12 @@ export interface AiRuntimeConfig { timeoutMs: number; } +/** API 只允许通过本机 SSH/Tailscale 隧道访问 TDX,禁止把家庭地址暴露给业务请求。 */ +export interface TdxRuntimeConfig { + baseUrl: string; + timeoutMs: number; +} + /** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */ export interface RocketMqRuntimeConfig { enabled: boolean; @@ -54,6 +60,7 @@ interface CommonRuntimeConfig { export interface ApiRuntimeConfig extends CommonRuntimeConfig { processName: 'api'; api: Readonly<{ host: string; port: number }>; + tdx: Readonly; mysql: MysqlRuntimeConfig; 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 { const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED'); const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR'); @@ -225,6 +256,7 @@ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig { host: requireText(env, 'API_HOST'), port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }), }, + tdx: loadTdxConfig(env), redis: loadRedisConfig(env), mysql: loadMysqlConfig(env), ai: loadAiConfig(env), diff --git a/server/test/process-smoke.test.ts b/server/test/process-smoke.test.ts index b40a3b0..8b68497 100644 --- a/server/test/process-smoke.test.ts +++ b/server/test/process-smoke.test.ts @@ -59,6 +59,7 @@ describe('NestJS 三进程入口', () => { appEnv: 'development', logLevel: 'debug', api: Object.freeze({ host: '127.0.0.1', port: 3000 }), + tdx: Object.freeze({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 5000 }), redis: redisConfig, mysql: mysqlConfig, ai: aiConfig,