From 15a25002ce6557b37db7d0ed834165e7621bbe95 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 18 Aug 2026 13:31:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-MO-001):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E6=A6=82=E8=A7=88=E5=88=86=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/apps/api/src/api.module.ts | 2 + .../market-overview.controller.ts | 47 +++ .../market-overview/market-overview.dto.ts | 112 +++++++ .../market-overview/market-overview.module.ts | 21 ++ .../market-overview.service.test.ts | 44 +++ .../market-overview.service.ts | 108 +++++++ .../market-overview/tdx/tdx.mapper.test.ts | 58 ++++ .../api/src/market-overview/tdx/tdx.mapper.ts | 48 +++ .../api/src/market-overview/tdx/tdx.types.ts | 27 ++ .../apps/api/test/market-overview.e2e-spec.ts | 83 ++++++ server/openapi/openapi.json | 279 ++++++++++++++++++ server/tools/export-openapi.ts | 6 +- 12 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 server/apps/api/src/market-overview/market-overview.controller.ts create mode 100644 server/apps/api/src/market-overview/market-overview.dto.ts create mode 100644 server/apps/api/src/market-overview/market-overview.module.ts create mode 100644 server/apps/api/src/market-overview/market-overview.service.test.ts create mode 100644 server/apps/api/src/market-overview/market-overview.service.ts create mode 100644 server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts create mode 100644 server/apps/api/src/market-overview/tdx/tdx.mapper.ts create mode 100644 server/apps/api/src/market-overview/tdx/tdx.types.ts create mode 100644 server/apps/api/test/market-overview.e2e-spec.ts diff --git a/server/apps/api/src/api.module.ts b/server/apps/api/src/api.module.ts index 2787674..f2f4049 100644 --- a/server/apps/api/src/api.module.ts +++ b/server/apps/api/src/api.module.ts @@ -6,6 +6,7 @@ import { createMysqlHealthClient } from '../../../libs/infrastructure/src/mysql- import { createRedisHealthClient } from '../../../libs/infrastructure/src/redis-health.client'; import { HealthController } from './health.controller'; import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service'; +import { MarketOverviewModule } from './market-overview/market-overview.module'; /** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */ export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG'); @@ -19,6 +20,7 @@ export class ApiModule { static register(config: ApiRuntimeConfig): DynamicModule { return { module: ApiModule, + imports: [MarketOverviewModule.register(config)], controllers: [HealthController], providers: [ HealthService, diff --git a/server/apps/api/src/market-overview/market-overview.controller.ts b/server/apps/api/src/market-overview/market-overview.controller.ts new file mode 100644 index 0000000..78c1ec6 --- /dev/null +++ b/server/apps/api/src/market-overview/market-overview.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Inject } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; + +import { + BreadthResponseDto, + FundsResponseDto, + IndicesResponseDto, + SectorsResponseDto, + SummaryResponseDto, +} from './market-overview.dto'; +import { MarketOverviewService } from './market-overview.service'; + +@ApiTags('market-overview') +@Controller('market-overview') +export class MarketOverviewController { + constructor(@Inject(MarketOverviewService) private readonly service: MarketOverviewService) {} + + @Get('summary') + @ApiOkResponse({ type: SummaryResponseDto }) + getSummary(): Promise { + return this.service.getSummary(); + } + + @Get('indices') + @ApiOkResponse({ type: IndicesResponseDto }) + getIndices(): Promise { + return this.service.getIndices(); + } + + @Get('breadth') + @ApiOkResponse({ type: BreadthResponseDto }) + getBreadth(): Promise { + return this.service.getBreadth(); + } + + @Get('sectors') + @ApiOkResponse({ type: SectorsResponseDto }) + getSectors(): Promise { + return this.service.getSectors(); + } + + @Get('funds') + @ApiOkResponse({ type: FundsResponseDto }) + getFunds(): Promise { + return this.service.getFunds(); + } +} diff --git a/server/apps/api/src/market-overview/market-overview.dto.ts b/server/apps/api/src/market-overview/market-overview.dto.ts new file mode 100644 index 0000000..3f861b0 --- /dev/null +++ b/server/apps/api/src/market-overview/market-overview.dto.ts @@ -0,0 +1,112 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export type MarketOverviewStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable'; +export type QuoteDirection = 'up' | 'down' | 'flat'; + +export class MarketOverviewMetaDto { + @ApiProperty({ type: String }) + snapshotId!: string; + + @ApiProperty({ type: String, format: 'date-time' }) + asOf!: string; + + @ApiProperty({ type: String, enum: ['tdx'] }) + source!: 'tdx'; + + @ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] }) + status!: MarketOverviewStatus; + + @ApiProperty({ type: [String] }) + warnings!: string[]; +} + +export class IndexQuoteDto { + @ApiProperty({ type: String }) + code!: string; + + @ApiProperty({ type: String }) + name!: string; + + @ApiProperty({ type: Number }) + price!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + changePercent!: number | null; + + @ApiProperty({ enum: ['up', 'down', 'flat'] }) + direction!: QuoteDirection; + + @ApiProperty({ type: String }) + tradeDate!: string; +} + +export class IndicesDataDto { + @ApiProperty({ type: [IndexQuoteDto] }) + items!: IndexQuoteDto[]; +} + +export class SummaryDataDto { + @ApiProperty({ type: String }) + verdict!: string; + + @ApiProperty({ type: String }) + marketStatus!: string; + + @ApiProperty({ type: String }) + volumeStatus!: string; + + @ApiProperty({ type: String }) + sentimentStatus!: string; + + @ApiProperty({ type: [String] }) + drivers!: string[]; + + @ApiProperty({ type: [String] }) + risks!: string[]; +} + +export class BreadthDataDto { + @ApiProperty({ type: Number }) + advancing!: number; + + @ApiProperty({ type: Number }) + declining!: number; + + @ApiProperty({ type: Number }) + unchanged!: number; +} + +export class SectorDataDto { + @ApiProperty({ type: [String] }) + items!: string[]; +} + +export class FundDataDto { + @ApiProperty({ type: [String] }) + items!: string[]; +} + +export class SummaryResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => SummaryDataDto, nullable: true }) + data!: SummaryDataDto | null; +} + +export class IndicesResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => IndicesDataDto, nullable: true }) + data!: IndicesDataDto | null; +} + +export class BreadthResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => BreadthDataDto, nullable: true }) + data!: BreadthDataDto | null; +} + +export class SectorsResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => SectorDataDto, nullable: true }) + data!: SectorDataDto | null; +} + +export class FundsResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => FundDataDto, nullable: true }) + data!: FundDataDto | null; +} diff --git a/server/apps/api/src/market-overview/market-overview.module.ts b/server/apps/api/src/market-overview/market-overview.module.ts new file mode 100644 index 0000000..cbf81f6 --- /dev/null +++ b/server/apps/api/src/market-overview/market-overview.module.ts @@ -0,0 +1,21 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import type { ApiRuntimeConfig } from '../../../../libs/config/src/runtime-config'; +import { MarketOverviewController } from './market-overview.controller'; +import { MarketOverviewService, TDX_CLIENT_PORT } from './market-overview.service'; +import { createTdxClient } from './tdx/tdx.client'; + +@Module({}) +export class MarketOverviewModule { + /** 将 API 已校验的 TDX 配置注入业务模块,避免模块内部重新读取环境变量。 */ + static register(config: ApiRuntimeConfig): DynamicModule { + return { + module: MarketOverviewModule, + controllers: [MarketOverviewController], + providers: [ + MarketOverviewService, + { provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) }, + ], + }; + } +} diff --git a/server/apps/api/src/market-overview/market-overview.service.test.ts b/server/apps/api/src/market-overview/market-overview.service.test.ts new file mode 100644 index 0000000..60beec9 --- /dev/null +++ b/server/apps/api/src/market-overview/market-overview.service.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MarketOverviewService } from './market-overview.service'; + +const indexSeries = { + Date: ['20260817', '20260818'], + Open: ['3350', '3370'], + High: ['3385', '3400'], + Low: ['3340', '3360'], + Close: ['3360', '3380'], + Amount: ['100', '120'], + Volume: ['1000', '1100'], +}; + +const indexResult = { + ErrorId: '0', + Value: Object.fromEntries( + ['000001.SH', '399001.SZ', '399006.SZ'].map((code) => [code, indexSeries]), + ), +}; + +describe('市场概览服务', () => { + it('读取指数并返回统一快照元数据', async () => { + const client = { request: vi.fn().mockResolvedValue(indexResult) }; + const service = new MarketOverviewService(client); + + const response = await service.getIndices(); + + expect(response).toMatchObject({ source: 'tdx', status: 'fresh', data: { items: expect.any(Array) } }); + expect(response.snapshotId).toMatch(/^20260818-/); + expect(client.request).toHaveBeenCalledWith('get_market_data', expect.objectContaining({ count: 5 })); + }); + + it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => { + const client = { request: vi.fn() }; + const service = new MarketOverviewService(client); + + const response = await service.getBreadth(); + + expect(response).toMatchObject({ source: 'tdx', status: 'unavailable', data: null }); + expect(response.warnings[0]).toContain('市场宽度'); + expect(client.request).not.toHaveBeenCalled(); + }); +}); diff --git a/server/apps/api/src/market-overview/market-overview.service.ts b/server/apps/api/src/market-overview/market-overview.service.ts new file mode 100644 index 0000000..6f234d7 --- /dev/null +++ b/server/apps/api/src/market-overview/market-overview.service.ts @@ -0,0 +1,108 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { + BreadthDataDto, + FundDataDto, + IndicesDataDto, + SectorDataDto, + SummaryDataDto, +} from './market-overview.dto'; +import { CORE_INDEXES } from './tdx/tdx.types'; +import { mapIndexQuotes } from './tdx/tdx.mapper'; +import { TdxClientError, type TdxClient } from './tdx/tdx.client'; +import type { TdxMarketDataResult } from './tdx/tdx.types'; + +export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT'); + +export type MarketOverviewResponse = { + snapshotId: string; + asOf: string; + source: 'tdx'; + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + data: TData | null; + warnings: string[]; +}; + +function createSnapshotMeta(): Pick, 'snapshotId' | 'asOf' | 'source'> { + const asOf = new Date().toISOString(); + const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`; + return { snapshotId, asOf, source: 'tdx' }; +} + +function unavailable(warning: string): MarketOverviewResponse { + return { ...createSnapshotMeta(), status: 'unavailable', data: null, warnings: [warning] }; +} + +@Injectable() +export class MarketOverviewService { + constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {} + + async getIndices(): Promise> { + const meta = createSnapshotMeta(); + try { + const result = await this.tdxClient.request('get_market_data', { + stock_list: CORE_INDEXES.map((item) => item.code), + count: 5, + dividend_type: 'none', + period: '1d', + }); + const items = mapIndexQuotes(result, CORE_INDEXES); + const status = items.length === CORE_INDEXES.length ? 'fresh' : 'partial'; + return { + ...meta, + status, + data: { items }, + warnings: status === 'partial' ? ['部分核心指数未返回有效数据'] : [], + }; + } catch (error) { + return { + ...meta, + status: 'unavailable', + data: null, + warnings: [this.toWarning(error, '核心指数')], + }; + } + } + + async getSummary(): Promise> { + const indices = await this.getIndices(); + if (indices.status === 'unavailable' || !indices.data) { + return { ...indices, data: null }; + } + + const changes = indices.data.items + .map((item) => item.changePercent) + .filter((value): value is number => value !== null); + const averageChange = changes.length + ? Math.round((changes.reduce((sum, value) => sum + value, 0) / changes.length) * 100) / 100 + : null; + const marketStatus = averageChange === null ? '指数数据不完整' : averageChange >= 0 ? '指数偏强' : '指数偏弱'; + const summary: SummaryDataDto = { + verdict: averageChange === null ? '市场状态待确认' : averageChange >= 0 ? '市场维持偏强震荡' : '市场维持偏弱震荡', + marketStatus, + volumeStatus: '成交量口径待接入', + sentimentStatus: '市场情绪口径待接入', + drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'], + risks: ['市场宽度、板块和资金指标尚未由当前 TDX 桥接层提供'], + }; + return { ...indices, data: summary }; + } + + async getBreadth(): Promise> { + return unavailable('当前 TDX 桥接层未提供市场宽度方法'); + } + + async getSectors(): Promise> { + return unavailable('当前 TDX 桥接层未提供板块排行方法'); + } + + async getFunds(): Promise> { + return unavailable('当前 TDX 桥接层未提供资金趋势方法'); + } + + private toWarning(error: unknown, subject: string): string { + if (error instanceof TdxClientError && error.kind === 'timeout') return `${subject}请求超时`; + if (error instanceof TdxClientError && error.kind === 'network') return `${subject}网络不可用`; + return `${subject}数据不可用`; + } +} diff --git a/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts b/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts new file mode 100644 index 0000000..881b5c4 --- /dev/null +++ b/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { mapIndexQuotes } from './tdx.mapper'; + +describe('TDX 行情映射', () => { + it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => { + const result = mapIndexQuotes( + { + ErrorId: '0', + Value: { + '000001.SH': { + Date: ['20260817', '20260818'], + Open: ['3350.00', '3370.00'], + High: ['3385.00', '3400.00'], + Low: ['3340.00', '3360.00'], + Close: ['3360.00', '3380.16'], + Amount: ['100', '120'], + Volume: ['1000', '1100'], + }, + }, + }, + [{ code: '000001.SH', name: '上证指数' }], + ); + + expect(result).toEqual([ + expect.objectContaining({ + code: '000001.SH', + name: '上证指数', + price: 3380.16, + changePercent: 0.6, + direction: 'up', + tradeDate: '20260818', + }), + ]); + }); + + it('缺少前一日收盘价时标记为平盘并保留当前价', () => { + const result = mapIndexQuotes( + { + ErrorId: '0', + Value: { + '399006.SZ': { + Date: ['20260818'], + Open: ['2100.00'], + High: ['2110.00'], + Low: ['2090.00'], + Close: ['2105.00'], + Amount: ['100'], + Volume: ['1000'], + }, + }, + }, + [{ code: '399006.SZ', name: '创业板指' }], + ); + + expect(result[0]).toMatchObject({ price: 2105, changePercent: null, direction: 'flat' }); + }); +}); diff --git a/server/apps/api/src/market-overview/tdx/tdx.mapper.ts b/server/apps/api/src/market-overview/tdx/tdx.mapper.ts new file mode 100644 index 0000000..fb8d4c6 --- /dev/null +++ b/server/apps/api/src/market-overview/tdx/tdx.mapper.ts @@ -0,0 +1,48 @@ +import type { IndexQuoteDto } from '../market-overview.dto'; +import type { IndexDefinition, TdxMarketDataResult, TdxKlineSeries } from './tdx.types'; + +function toNumber(value: string | undefined): number | null { + if (value === undefined || value.trim() === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function mapDirection(changePercent: number | null): IndexQuoteDto['direction'] { + if (changePercent === null || changePercent === 0) return 'flat'; + return changePercent > 0 ? 'up' : 'down'; +} + +function mapIndexQuote(series: TdxKlineSeries, definition: IndexDefinition): IndexQuoteDto | null { + const lastIndex = series.Close.length - 1; + const price = toNumber(series.Close[lastIndex]); + const previousPrice = toNumber(series.Close[lastIndex - 1]); + const tradeDate = series.Date[lastIndex]; + if (price === null || !tradeDate) return null; + + const changePercent = + previousPrice === null || previousPrice === 0 + ? null + : Math.round(((price - previousPrice) / previousPrice) * 100 * 100) / 100; + + return { + code: definition.code, + name: definition.name, + price, + changePercent, + direction: mapDirection(changePercent), + tradeDate, + }; +} + +/** 只把已确认字段映射为领域 DTO,缺失的单个指数不阻断其他指数。 */ +export function mapIndexQuotes( + result: TdxMarketDataResult, + definitions: readonly IndexDefinition[], +): IndexQuoteDto[] { + return definitions.flatMap((definition) => { + const series = result.Value[definition.code]; + if (!series) return []; + const quote = mapIndexQuote(series, definition); + return quote ? [quote] : []; + }); +} diff --git a/server/apps/api/src/market-overview/tdx/tdx.types.ts b/server/apps/api/src/market-overview/tdx/tdx.types.ts new file mode 100644 index 0000000..4161137 --- /dev/null +++ b/server/apps/api/src/market-overview/tdx/tdx.types.ts @@ -0,0 +1,27 @@ +export interface TdxKlineSeries { + Date: string[]; + Open: string[]; + High: string[]; + Low: string[]; + Close: string[]; + Amount: string[]; + Volume: string[]; + ForwardFactor?: string[]; +} + +export interface TdxMarketDataResult { + ErrorId: string | number; + Value: Record; +} + +export interface IndexDefinition { + code: string; + name: string; +} + +/** 首屏只使用已通过 TDX K 线只读冒烟的三大指数,代码口径统一带市场后缀。 */ +export const CORE_INDEXES: readonly IndexDefinition[] = [ + { code: '000001.SH', name: '上证指数' }, + { code: '399001.SZ', name: '深证成指' }, + { code: '399006.SZ', name: '创业板指' }, +]; diff --git a/server/apps/api/test/market-overview.e2e-spec.ts b/server/apps/api/test/market-overview.e2e-spec.ts new file mode 100644 index 0000000..cfd37a9 --- /dev/null +++ b/server/apps/api/test/market-overview.e2e-spec.ts @@ -0,0 +1,83 @@ +import type { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MarketOverviewController } from '../src/market-overview/market-overview.controller'; +import { MarketOverviewService, TDX_CLIENT_PORT } from '../src/market-overview/market-overview.service'; + +describe('市场概览 HTTP API', () => { + let app: INestApplication | undefined; + + afterEach(async () => { + await app?.close(); + app = undefined; + }); + + it('按模块返回指数和 TDX 来源元数据', async () => { + const client = { + request: vi.fn().mockResolvedValue({ + ErrorId: '0', + Value: { + '000001.SH': { + Date: ['20260817', '20260818'], + Open: ['3350', '3370'], + High: ['3385', '3400'], + Low: ['3340', '3360'], + Close: ['3360', '3380'], + Amount: ['100', '120'], + Volume: ['1000', '1100'], + }, + '399001.SZ': { + Date: ['20260817', '20260818'], + Open: ['10000', '10100'], + High: ['10200', '10300'], + Low: ['9900', '10050'], + Close: ['10000', '10200'], + Amount: ['100', '120'], + Volume: ['1000', '1100'], + }, + '399006.SZ': { + Date: ['20260817', '20260818'], + Open: ['2100', '2110'], + High: ['2120', '2130'], + Low: ['2090', '2100'], + Close: ['2100', '2120'], + Amount: ['100', '120'], + Volume: ['1000', '1100'], + }, + }, + }), + }; + const moduleRef = await Test.createTestingModule({ + controllers: [MarketOverviewController], + providers: [MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: client }], + }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api/v1'); + await app.init(); + + const response = await request(app.getHttpServer()).get('/api/v1/market-overview/indices').expect(200); + + expect(response.body).toMatchObject({ source: 'tdx', status: 'fresh', data: { items: expect.any(Array) } }); + expect(JSON.stringify(response.body)).not.toContain('127.0.0.1'); + }); + + it('未提供的板块能力返回可读的不可用状态', async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [MarketOverviewController], + providers: [ + MarketOverviewService, + { provide: TDX_CLIENT_PORT, useValue: { request: vi.fn() } }, + ], + }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api/v1'); + await app.init(); + + const response = await request(app.getHttpServer()).get('/api/v1/market-overview/sectors').expect(200); + + expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null }); + expect(response.body.warnings[0]).toContain('板块排行'); + }); +}); diff --git a/server/openapi/openapi.json b/server/openapi/openapi.json index 222ff7c..2500513 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -1,6 +1,41 @@ { "components": { "schemas": { + "BreadthDataDto": { + "properties": { + "advancing": { "type": "number" }, + "declining": { "type": "number" }, + "unchanged": { "type": "number" } + }, + "required": ["advancing", "declining", "unchanged"], + "type": "object" + }, + "BreadthResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/BreadthDataDto" }], + "nullable": true, + "type": "object" + }, + "snapshotId": { "type": "string" }, + "source": { "enum": ["tdx"], "type": "string" }, + "status": { + "enum": ["fresh", "delayed", "partial", "unavailable"], + "type": "string" + }, + "warnings": { "items": { "type": "string" }, "type": "array" } + }, + "required": [ + "snapshotId", + "asOf", + "source", + "status", + "warnings", + "data" + ], + "type": "object" + }, "DependencyHealthDto": { "properties": { "detail": { "type": "string" }, @@ -12,6 +47,39 @@ "required": ["status", "detail"], "type": "object" }, + "FundDataDto": { + "properties": { + "items": { "items": { "type": "string" }, "type": "array" } + }, + "required": ["items"], + "type": "object" + }, + "FundsResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/FundDataDto" }], + "nullable": true, + "type": "object" + }, + "snapshotId": { "type": "string" }, + "source": { "enum": ["tdx"], "type": "string" }, + "status": { + "enum": ["fresh", "delayed", "partial", "unavailable"], + "type": "string" + }, + "warnings": { "items": { "type": "string" }, "type": "array" } + }, + "required": [ + "snapshotId", + "asOf", + "source", + "status", + "warnings", + "data" + ], + "type": "object" + }, "HealthDependenciesDto": { "properties": { "mysql": { "$ref": "#/components/schemas/DependencyHealthDto" }, @@ -32,6 +100,132 @@ }, "required": ["status", "process", "checkedAt", "dependencies"], "type": "object" + }, + "IndexQuoteDto": { + "properties": { + "changePercent": { "nullable": true, "type": "number" }, + "code": { "type": "string" }, + "direction": { "enum": ["up", "down", "flat"], "type": "string" }, + "name": { "type": "string" }, + "price": { "type": "number" }, + "tradeDate": { "type": "string" } + }, + "required": ["code", "name", "price", "direction", "tradeDate"], + "type": "object" + }, + "IndicesDataDto": { + "properties": { + "items": { + "items": { "$ref": "#/components/schemas/IndexQuoteDto" }, + "type": "array" + } + }, + "required": ["items"], + "type": "object" + }, + "IndicesResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/IndicesDataDto" }], + "nullable": true, + "type": "object" + }, + "snapshotId": { "type": "string" }, + "source": { "enum": ["tdx"], "type": "string" }, + "status": { + "enum": ["fresh", "delayed", "partial", "unavailable"], + "type": "string" + }, + "warnings": { "items": { "type": "string" }, "type": "array" } + }, + "required": [ + "snapshotId", + "asOf", + "source", + "status", + "warnings", + "data" + ], + "type": "object" + }, + "SectorDataDto": { + "properties": { + "items": { "items": { "type": "string" }, "type": "array" } + }, + "required": ["items"], + "type": "object" + }, + "SectorsResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/SectorDataDto" }], + "nullable": true, + "type": "object" + }, + "snapshotId": { "type": "string" }, + "source": { "enum": ["tdx"], "type": "string" }, + "status": { + "enum": ["fresh", "delayed", "partial", "unavailable"], + "type": "string" + }, + "warnings": { "items": { "type": "string" }, "type": "array" } + }, + "required": [ + "snapshotId", + "asOf", + "source", + "status", + "warnings", + "data" + ], + "type": "object" + }, + "SummaryDataDto": { + "properties": { + "drivers": { "items": { "type": "string" }, "type": "array" }, + "marketStatus": { "type": "string" }, + "risks": { "items": { "type": "string" }, "type": "array" }, + "sentimentStatus": { "type": "string" }, + "verdict": { "type": "string" }, + "volumeStatus": { "type": "string" } + }, + "required": [ + "verdict", + "marketStatus", + "volumeStatus", + "sentimentStatus", + "drivers", + "risks" + ], + "type": "object" + }, + "SummaryResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/SummaryDataDto" }], + "nullable": true, + "type": "object" + }, + "snapshotId": { "type": "string" }, + "source": { "enum": ["tdx"], "type": "string" }, + "status": { + "enum": ["fresh", "delayed", "partial", "unavailable"], + "type": "string" + }, + "warnings": { "items": { "type": "string" }, "type": "array" } + }, + "required": [ + "snapshotId", + "asOf", + "source", + "status", + "warnings", + "data" + ], + "type": "object" } } }, @@ -67,6 +261,91 @@ }, "tags": ["health"] } + }, + "/api/v1/market-overview/breadth": { + "get": { + "operationId": "MarketOverviewController_getBreadth", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/BreadthResponseDto" } + } + }, + "description": "" + } + }, + "tags": ["market-overview"] + } + }, + "/api/v1/market-overview/funds": { + "get": { + "operationId": "MarketOverviewController_getFunds", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FundsResponseDto" } + } + }, + "description": "" + } + }, + "tags": ["market-overview"] + } + }, + "/api/v1/market-overview/indices": { + "get": { + "operationId": "MarketOverviewController_getIndices", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/IndicesResponseDto" } + } + }, + "description": "" + } + }, + "tags": ["market-overview"] + } + }, + "/api/v1/market-overview/sectors": { + "get": { + "operationId": "MarketOverviewController_getSectors", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SectorsResponseDto" } + } + }, + "description": "" + } + }, + "tags": ["market-overview"] + } + }, + "/api/v1/market-overview/summary": { + "get": { + "operationId": "MarketOverviewController_getSummary", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SummaryResponseDto" } + } + }, + "description": "" + } + }, + "tags": ["market-overview"] + } } }, "servers": [], diff --git a/server/tools/export-openapi.ts b/server/tools/export-openapi.ts index d9a059b..51fe778 100644 --- a/server/tools/export-openapi.ts +++ b/server/tools/export-openapi.ts @@ -8,17 +8,21 @@ import path from 'node:path'; import { format } from 'prettier'; import { HealthController } from '../apps/api/src/health.controller'; +import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller'; import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT, } from '../apps/api/src/health.service'; +import { MarketOverviewService, TDX_CLIENT_PORT } from '../apps/api/src/market-overview/market-overview.service'; import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client'; @Module({ - controllers: [HealthController], + controllers: [HealthController, MarketOverviewController], providers: [ HealthService, + MarketOverviewService, + { provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } }, { provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') }, { provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') }, ],