From 8174f3f6985dd7875c5ef7c6df564de83d3f2d49 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 25 Aug 2026 01:51:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-MO-005):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=A1=8C=E4=B8=9A=E9=A2=98=E6=9D=90=E6=9D=BF=E5=9D=97=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../market-overview/market-overview.module.ts | 18 ++- .../structure/market-structure.controller.ts | 28 ++++ .../structure/market-structure.dto.ts | 49 +++++++ .../market-structure.service.test.ts | 56 ++++++++ .../structure/market-structure.service.ts | 121 ++++++++++++++++++ server/openapi/openapi.json | 101 +++++++++++++++ server/tools/export-openapi.ts | 16 +++ web/src/api/generated/schema.ts | 71 ++++++++++ 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 server/apps/api/src/market-overview/structure/market-structure.controller.ts create mode 100644 server/apps/api/src/market-overview/structure/market-structure.dto.ts create mode 100644 server/apps/api/src/market-overview/structure/market-structure.service.test.ts create mode 100644 server/apps/api/src/market-overview/structure/market-structure.service.ts diff --git a/server/apps/api/src/market-overview/market-overview.module.ts b/server/apps/api/src/market-overview/market-overview.module.ts index cc981ca..ec453db 100644 --- a/server/apps/api/src/market-overview/market-overview.module.ts +++ b/server/apps/api/src/market-overview/market-overview.module.ts @@ -9,6 +9,12 @@ import { createAktoolsClient } from './aktools/aktools.client'; import { createTdxClient } from './tdx/tdx.client'; import { MarketDetailController } from './detail/market-detail.controller'; import { MarketDetailService } from './detail/market-detail.service'; +import { createAktoolsStructureClient } from './structure/aktools-structure.client'; +import { MarketStructureController } from './structure/market-structure.controller'; +import { + MARKET_STRUCTURE_SOURCE_PORT, + MarketStructureService, +} from './structure/market-structure.service'; @Module({}) export class MarketOverviewModule { @@ -16,13 +22,23 @@ export class MarketOverviewModule { static register(config: ApiRuntimeConfig): DynamicModule { return { module: MarketOverviewModule, - controllers: [MarketOverviewController, MarketDetailController, ShortTermMarketController], + controllers: [ + MarketOverviewController, + MarketDetailController, + ShortTermMarketController, + MarketStructureController, + ], providers: [ MarketOverviewService, MarketDetailService, + MarketStructureService, { provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) }, ShortTermMarketService, { provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) }, + { + provide: MARKET_STRUCTURE_SOURCE_PORT, + useValue: createAktoolsStructureClient(config.aktools), + }, ], }; } diff --git a/server/apps/api/src/market-overview/structure/market-structure.controller.ts b/server/apps/api/src/market-overview/structure/market-structure.controller.ts new file mode 100644 index 0000000..73376fb --- /dev/null +++ b/server/apps/api/src/market-overview/structure/market-structure.controller.ts @@ -0,0 +1,28 @@ +import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; + +import { SectorRankingsResponseDto } from './market-structure.dto'; +import { MarketStructureService, parseSectorRankingQuery } from './market-structure.service'; + +@ApiTags('market-overview-structure') +@Controller('market-overview') +export class MarketStructureController { + constructor(private readonly service: MarketStructureService) {} + + @Get('sector-rankings') + @ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] }) + @ApiQuery({ name: 'sort', required: false, enum: ['rise', 'fall', 'turnover'] }) + @ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 }) + @ApiOkResponse({ type: SectorRankingsResponseDto }) + getSectorRankings( + @Query('category') category?: string, + @Query('sort') sort?: string, + @Query('limit') limit?: string, + ): Promise { + try { + return this.service.getSectorRankings(parseSectorRankingQuery(category, sort, limit)); + } catch (error) { + throw new BadRequestException(error instanceof Error ? error.message : '排行参数无效'); + } + } +} diff --git a/server/apps/api/src/market-overview/structure/market-structure.dto.ts b/server/apps/api/src/market-overview/structure/market-structure.dto.ts new file mode 100644 index 0000000..1e21a71 --- /dev/null +++ b/server/apps/api/src/market-overview/structure/market-structure.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +import { MarketOverviewMetaDto } from '../market-overview.dto'; +import type { StructureCategory, StructureSort } from './market-structure.types'; + +export class SectorRankingItemDto { + @ApiProperty({ enum: ['industry', 'concept', 'board'] }) + category!: StructureCategory; + + @ApiProperty({ type: String }) + code!: string; + + @ApiProperty({ type: String }) + name!: string; + + @ApiPropertyOptional({ type: Number, nullable: true }) + changePercent!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' }) + turnoverAmount!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + advancingCount!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + decliningCount!: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + leader!: string | null; + + @ApiPropertyOptional({ type: String, nullable: true, example: '20260825' }) + tradeDate!: string | null; +} + +export class SectorRankingsDataDto { + @ApiProperty({ type: [SectorRankingItemDto] }) + items!: SectorRankingItemDto[]; + + @ApiProperty({ enum: ['rise', 'fall', 'turnover'] }) + sort!: Extract; + + @ApiProperty({ enum: ['industry', 'concept', 'board'] }) + category!: StructureCategory; +} + +export class SectorRankingsResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => SectorRankingsDataDto, nullable: true }) + data!: SectorRankingsDataDto | null; +} diff --git a/server/apps/api/src/market-overview/structure/market-structure.service.test.ts b/server/apps/api/src/market-overview/structure/market-structure.service.test.ts new file mode 100644 index 0000000..5f68f5a --- /dev/null +++ b/server/apps/api/src/market-overview/structure/market-structure.service.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { MarketStructureService, parseSectorRankingQuery } from './market-structure.service'; +import type { MarketStructureSourceClient } from './market-structure.types'; + +const source: MarketStructureSourceClient = { + getSectorRows: async () => [ + { 代码: 'BK2', 名称: '弱势', 涨跌幅: '-2', 成交额: '200000000', 日期: '20260825' }, + { 代码: 'BK1', 名称: '强势', 涨跌幅: '5', 成交额: '100000000', 日期: '20260825' }, + ], + getCapitalFlowRows: async () => [], + getMarketFundFlowRows: async () => [], +}; + +describe('行业题材排行服务', () => { + it('校验分类、排序和 1-100 数量范围', () => { + expect(parseSectorRankingQuery('concept', 'fall', '10')).toEqual({ + category: 'concept', + sort: 'fall', + limit: 10, + }); + expect(() => parseSectorRankingQuery('stock', 'rise', '10')).toThrow('分类参数无效'); + expect(() => parseSectorRankingQuery('industry', 'unknown', '10')).toThrow('排序参数无效'); + expect(() => parseSectorRankingQuery('industry', 'rise', '0')).toThrow('数量参数无效'); + expect(() => parseSectorRankingQuery('industry', 'rise', '101')).toThrow('数量参数无效'); + }); + + it('按涨幅排序并截取指定数量', async () => { + const service = new MarketStructureService(source); + const response = await service.getSectorRankings({ + category: 'industry', + sort: 'rise', + limit: 1, + }); + expect(response.status).toBe('fresh'); + expect(response.data?.items).toHaveLength(1); + expect(response.data?.items[0]).toMatchObject({ code: 'BK1', changePercent: 5 }); + }); + + it('上游失败时返回不可用且不泄露异常内容', async () => { + const failing: MarketStructureSourceClient = { + ...source, + getSectorRows: async () => { + throw new Error('secret upstream URL and payload'); + }, + }; + const service = new MarketStructureService(failing); + const response = await service.getSectorRankings({ + category: 'industry', + sort: 'rise', + limit: 10, + }); + expect(response).toMatchObject({ status: 'unavailable', data: null }); + expect(response.warnings).toEqual(['行业排行数据不可用']); + }); +}); diff --git a/server/apps/api/src/market-overview/structure/market-structure.service.ts b/server/apps/api/src/market-overview/structure/market-structure.service.ts new file mode 100644 index 0000000..dd0e9cb --- /dev/null +++ b/server/apps/api/src/market-overview/structure/market-structure.service.ts @@ -0,0 +1,121 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { MarketOverviewStatus } from '../market-overview.dto'; +import { mapSectorRanking } from './market-structure.mapper'; +import type { + SectorRankingRecord, + StructureCategory, + StructureSort, + MarketStructureSourceClient, +} from './market-structure.types'; +import type { SectorRankingsResponseDto } from './market-structure.dto'; + +export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT'); + +export interface SectorRankingQuery { + category: StructureCategory; + sort: Extract; + limit: number; +} + +const CATEGORIES = new Set(['industry', 'concept', 'board']); +const SORTS = new Set(['rise', 'fall', 'turnover']); + +export function parseSectorRankingQuery( + category = 'industry', + sort = 'rise', + limit = '10', +): SectorRankingQuery { + if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效'); + if (!SORTS.has(sort as SectorRankingQuery['sort'])) throw new Error('排序参数无效'); + if (!/^\d+$/.test(limit)) throw new Error('数量参数无效'); + const parsedLimit = Number(limit); + if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) { + throw new Error('数量参数无效'); + } + return { + category: category as StructureCategory, + sort: sort as SectorRankingQuery['sort'], + limit: parsedLimit, + }; +} + +function snapshotMeta( + source: 'aktools', +): Pick { + const asOf = new Date().toISOString(); + return { + snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15), + asOf, + source, + }; +} + +function sortItems( + items: readonly SectorRankingRecord[], + sort: SectorRankingQuery['sort'], +): SectorRankingRecord[] { + const valueFor = (item: SectorRankingRecord): number => { + if (sort === 'turnover') return item.turnoverAmount ?? Number.NEGATIVE_INFINITY; + return item.changePercent ?? Number.NEGATIVE_INFINITY; + }; + const direction = sort === 'fall' ? 1 : -1; + return [...items].sort((left, right) => { + const difference = (valueFor(left) - valueFor(right)) * direction; + return difference || left.name.localeCompare(right.name, 'zh-CN'); + }); +} + +function statusFor(items: readonly SectorRankingRecord[], rawCount: number): MarketOverviewStatus { + if (!items.length) return 'unavailable'; + if (items.length < rawCount || items.some((item) => item.tradeDate === null)) return 'partial'; + return 'fresh'; +} + +@Injectable() +export class MarketStructureService { + constructor( + @Inject(MARKET_STRUCTURE_SOURCE_PORT) + private readonly source: MarketStructureSourceClient, + ) {} + + async getSectorRankings(query: SectorRankingQuery): Promise { + const meta = snapshotMeta('aktools'); + try { + const rows = await this.source.getSectorRows(query.category); + const items = rows.flatMap((row) => { + const mapped = mapSectorRanking(row, query.category, this.readTradeDate(row)); + return mapped ? [mapped] : []; + }); + const sortedAll = sortItems(items, query.sort); + const sorted = sortedAll.slice(0, query.limit); + const status = statusFor(sortedAll, rows.length); + return { + ...meta, + status, + data: + status === 'unavailable' + ? null + : { items: sorted, sort: query.sort, category: query.category }, + warnings: + status === 'fresh' + ? [] + : status === 'partial' + ? ['部分行业或题材排行字段不可用'] + : ['行业排行数据不可用'], + }; + } catch { + return { + ...meta, + status: 'unavailable', + data: null, + warnings: ['行业排行数据不可用'], + }; + } + } + + private readTradeDate(row: Readonly>): string | null { + const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate']; + return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null; + } +} diff --git a/server/openapi/openapi.json b/server/openapi/openapi.json index d1919d2..bf0e2cd 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -271,6 +271,66 @@ "required": ["code", "name", "changePercent", "direction", "tradeDate"], "type": "object" }, + "SectorRankingItemDto": { + "properties": { + "advancingCount": { "nullable": true, "type": "number" }, + "category": { + "enum": ["industry", "concept", "board"], + "type": "string" + }, + "changePercent": { "nullable": true, "type": "number" }, + "code": { "type": "string" }, + "decliningCount": { "nullable": true, "type": "number" }, + "leader": { "nullable": true, "type": "string" }, + "name": { "type": "string" }, + "tradeDate": { + "example": "20260825", + "nullable": true, + "type": "string" + }, + "turnoverAmount": { + "description": "亿元", + "nullable": true, + "type": "number" + } + }, + "required": ["category", "code", "name"], + "type": "object" + }, + "SectorRankingsDataDto": { + "properties": { + "category": { + "enum": ["industry", "concept", "board"], + "type": "string" + }, + "items": { + "items": { "$ref": "#/components/schemas/SectorRankingItemDto" }, + "type": "array" + }, + "sort": { "enum": ["rise", "fall", "turnover"], "type": "string" } + }, + "required": ["items", "sort", "category"], + "type": "object" + }, + "SectorRankingsResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/SectorRankingsDataDto" }], + "nullable": true, + "type": "object" + }, + "snapshotId": { "type": "string" }, + "source": { "enum": ["tdx", "aktools", "mixed"], "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" + }, "SectorsResponseDto": { "properties": { "asOf": { "format": "date-time", "type": "string" }, @@ -553,6 +613,47 @@ "tags": ["market-overview-detail"] } }, + "/api/v1/market-overview/sector-rankings": { + "get": { + "operationId": "MarketStructureController_getSectorRankings", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { "maximum": 100, "minimum": 1, "type": "number" } + }, + { + "in": "query", + "name": "sort", + "required": false, + "schema": { "enum": ["rise", "fall", "turnover"], "type": "string" } + }, + { + "in": "query", + "name": "category", + "required": false, + "schema": { + "enum": ["industry", "concept", "board"], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectorRankingsResponseDto" + } + } + }, + "description": "" + } + }, + "tags": ["market-overview-structure"] + } + }, "/api/v1/market-overview/sectors": { "get": { "operationId": "MarketOverviewController_getSectors", diff --git a/server/tools/export-openapi.ts b/server/tools/export-openapi.ts index e9561ff..f534d39 100644 --- a/server/tools/export-openapi.ts +++ b/server/tools/export-openapi.ts @@ -11,6 +11,12 @@ import { HealthController } from '../apps/api/src/health.controller'; import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller'; import { MarketDetailController } from '../apps/api/src/market-overview/detail/market-detail.controller'; import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller'; +import { MarketStructureController } from '../apps/api/src/market-overview/structure/market-structure.controller'; +import { createAktoolsStructureClient } from '../apps/api/src/market-overview/structure/aktools-structure.client'; +import { + MARKET_STRUCTURE_SOURCE_PORT, + MarketStructureService, +} from '../apps/api/src/market-overview/structure/market-structure.service'; import { HealthService, MYSQL_HEALTH_PORT, @@ -34,6 +40,7 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health MarketOverviewController, MarketDetailController, ShortTermMarketController, + MarketStructureController, ], providers: [ HealthService, @@ -41,6 +48,7 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } }, ShortTermMarketService, + MarketStructureService, { provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient({ @@ -49,6 +57,14 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health timeoutMs: 10_000, }), }, + { + provide: MARKET_STRUCTURE_SOURCE_PORT, + useValue: createAktoolsStructureClient({ + enabled: false, + baseUrl: 'http://127.0.0.1:8080', + timeoutMs: 10_000, + }), + }, { provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') }, { provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') }, ], diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index edebb53..e8fec08 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -84,6 +84,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/market-overview/sector-rankings': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketStructureController_getSectorRankings']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/market-overview/sectors': { parameters: { query?: never; @@ -322,6 +338,38 @@ export interface components { name: string; tradeDate: string; }; + SectorRankingItemDto: { + advancingCount?: number | null; + /** @enum {string} */ + category: 'industry' | 'concept' | 'board'; + changePercent?: number | null; + code: string; + decliningCount?: number | null; + leader?: string | null; + name: string; + /** @example 20260825 */ + tradeDate?: string | null; + /** @description 亿元 */ + turnoverAmount?: number | null; + }; + SectorRankingsDataDto: { + /** @enum {string} */ + category: 'industry' | 'concept' | 'board'; + items: components['schemas']['SectorRankingItemDto'][]; + /** @enum {string} */ + sort: 'rise' | 'fall' | 'turnover'; + }; + SectorRankingsResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['SectorRankingsDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx' | 'aktools' | 'mixed'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; SectorsResponseDto: { /** Format: date-time */ asOf: string; @@ -524,6 +572,29 @@ export interface operations { }; }; }; + MarketStructureController_getSectorRankings: { + parameters: { + query?: { + limit?: number; + sort?: 'rise' | 'fall' | 'turnover'; + category?: 'industry' | 'concept' | 'board'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SectorRankingsResponseDto']; + }; + }; + }; + }; MarketOverviewController_getSectors: { parameters: { query?: never;