diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4523e53..91c64e6 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,9 @@ import { BrokerModule } from './modules/broker/broker.module'; import { UserModule } from './modules/user/user.module'; import { AuthModule } from './modules/auth/auth.module'; import { StorageModule } from './modules/storage/storage.module'; +import { StockInfoModule } from './modules/stock-info/stock-info.module'; +import { StockDailyPriceModule } from './modules/stock-daily-price/stock-daily-price.module'; +import { PositionModule } from './modules/position/position.module'; @Module({ imports: [ @@ -22,6 +25,9 @@ import { StorageModule } from './modules/storage/storage.module'; UserModule, AuthModule, StorageModule, + StockInfoModule, + StockDailyPriceModule, + PositionModule, ], controllers: [], providers: [], diff --git a/apps/api/src/modules/position/dto/create-position.dto.ts b/apps/api/src/modules/position/dto/create-position.dto.ts new file mode 100644 index 0000000..b505740 --- /dev/null +++ b/apps/api/src/modules/position/dto/create-position.dto.ts @@ -0,0 +1,133 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsNumber, + IsBoolean, + MaxLength, + IsIn, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreatePositionDto { + @ApiProperty({ + description: '券商ID', + example: 1, + }) + @Type(() => Number) + @IsNumber() + @IsNotEmpty() + brokerId: number; + + @ApiProperty({ + description: '资产类型', + example: 'stock', + enum: ['stock', 'fund', 'cash', 'bond', 'other'], + }) + @IsString() + @IsNotEmpty() + @IsIn(['stock', 'fund', 'cash', 'bond', 'other']) + assetType: string; + + @ApiProperty({ + description: '资产代码(股票代码、基金代码等)', + example: '600519', + maxLength: 50, + }) + @IsString() + @IsNotEmpty() + @MaxLength(50) + symbol: string; + + @ApiProperty({ + description: '资产名称', + example: '贵州茅台', + maxLength: 100, + }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ + description: '市场(A股/港股/美股等)', + example: 'sh', + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + market?: string; + + @ApiProperty({ + description: '持仓份额/数量', + example: 100, + }) + @Type(() => Number) + @IsNumber() + @Min(0) + shares: number; + + @ApiProperty({ + description: '成本价(每股/每份)', + example: 1600.0, + }) + @Type(() => Number) + @IsNumber() + @Min(0.0001) + costPrice: number; + + @ApiPropertyOptional({ + description: '最新市场价(系统自动更新)', + example: 1850.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + currentPrice?: number; + + @ApiPropertyOptional({ + description: '货币类型', + example: 'CNY', + default: 'CNY', + maxLength: 10, + }) + @IsOptional() + @IsString() + @MaxLength(10) + currency?: string; + + @ApiPropertyOptional({ + description: '汇率(用于多货币)', + example: 1.0, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + exchangeRate?: number; + + @ApiPropertyOptional({ + description: '是否自动更新价格(付费用户功能)', + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + autoPriceUpdate?: boolean; + + @ApiPropertyOptional({ + description: '状态', + example: 'active', + enum: ['active', 'suspended', 'delisted'], + default: 'active', + }) + @IsOptional() + @IsString() + @IsIn(['active', 'suspended', 'delisted']) + status?: string; +} diff --git a/apps/api/src/modules/position/dto/position-response.dto.ts b/apps/api/src/modules/position/dto/position-response.dto.ts new file mode 100644 index 0000000..f1b604b --- /dev/null +++ b/apps/api/src/modules/position/dto/position-response.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Position } from '../position.entity'; + +export class PositionResponseDto extends Position { + @ApiProperty({ + description: '持仓成本(持仓份额 × 成本价)', + example: 160000.0, + }) + costValue: number; + + @ApiProperty({ + description: '持仓市值(持仓份额 × 最新市场价)', + example: 185000.0, + }) + marketValue: number; + + @ApiProperty({ + description: '持仓盈亏(持仓市值 - 持仓成本)', + example: 25000.0, + }) + profit: number; + + @ApiProperty({ + description: '持仓盈利比例(%)', + example: 15.625, + }) + profitPercent: number; + + @ApiProperty({ + description: '持仓天数(当前时间 - 创建时间)', + example: 365, + }) + holdingDays: number; + + @ApiProperty({ + description: '当前持仓占用户总资产的百分比(%)', + example: 25.5, + }) + assetPercent: number; +} diff --git a/apps/api/src/modules/position/dto/update-position.dto.ts b/apps/api/src/modules/position/dto/update-position.dto.ts new file mode 100644 index 0000000..3604236 --- /dev/null +++ b/apps/api/src/modules/position/dto/update-position.dto.ts @@ -0,0 +1,73 @@ +import { + IsString, + IsOptional, + IsNumber, + IsIn, + Min, + MaxLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdatePositionDto { + @ApiPropertyOptional({ + description: '券商ID', + example: 1, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + brokerId?: number; + + @ApiPropertyOptional({ + description: '成本价(每股/每份)', + example: 1600.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.0001) + costPrice?: number; + + @ApiPropertyOptional({ + description: '最新市场价(系统自动更新)', + example: 1850.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + currentPrice?: number; + + @ApiPropertyOptional({ + description: '货币类型', + example: 'CNY', + maxLength: 10, + }) + @IsOptional() + @IsString() + @MaxLength(10) + currency?: string; + + @ApiPropertyOptional({ + description: '汇率(用于多货币)', + example: 1.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + exchangeRate?: number; + + @ApiPropertyOptional({ + description: '状态', + example: 'active', + enum: ['active', 'suspended', 'delisted'], + }) + @IsOptional() + @IsString() + @IsIn(['active', 'suspended', 'delisted']) + status?: string; + + // 注意:assetType, symbol, name, market 字段不允许更新 +} diff --git a/apps/api/src/modules/position/position.controller.ts b/apps/api/src/modules/position/position.controller.ts new file mode 100644 index 0000000..3e0abd0 --- /dev/null +++ b/apps/api/src/modules/position/position.controller.ts @@ -0,0 +1,124 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + HttpCode, + HttpStatus, + UseGuards, + Request, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { PositionService } from './position.service'; +import { CreatePositionDto } from './dto/create-position.dto'; +import { UpdatePositionDto } from './dto/update-position.dto'; +import { PositionResponseDto } from './dto/position-response.dto'; +import { Position } from './position.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { User } from '../user/user.entity'; + +@ApiTags('position') +@Controller('position') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class PositionController { + constructor(private readonly positionService: PositionService) {} + + /** + * 查询用户所有持仓(包含计算字段) + */ + @Get() + @ApiOperation({ + summary: '查询用户所有持仓', + description: + '查询当前登录用户的所有持仓信息,包含计算字段:持仓成本、持仓市值、持仓盈亏、持仓盈利比例、持仓天数、占用户总资产百分比', + }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: [PositionResponseDto], + }) + @ApiResponse({ status: 401, description: '未授权' }) + findAll(@Request() req: { user: User }): Promise { + return this.positionService.findAllByUserId(req.user.userId); + } + + /** + * 创建持仓 + */ + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: '创建持仓', + description: '为当前登录用户创建新的持仓记录', + }) + @ApiResponse({ + status: 201, + description: '创建成功', + type: Position, + }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 409, description: '持仓已存在' }) + create( + @Request() req: { user: User }, + @Body() createPositionDto: CreatePositionDto, + ): Promise { + return this.positionService.create(req.user.userId, createPositionDto); + } + + /** + * 更新持仓 + */ + @Patch(':id') + @ApiOperation({ + summary: '更新持仓', + description: + '更新持仓信息,只能更新:成本价、最新市场价、货币类型、券商、状态、汇率。资产类型、资产代码和名称、市场不可修改。', + }) + @ApiParam({ name: 'id', description: '持仓ID', type: Number }) + @ApiResponse({ + status: 200, + description: '更新成功', + type: Position, + }) + @ApiResponse({ status: 404, description: '持仓不存在' }) + update( + @Request() req: { user: User }, + @Param('id') id: string, + @Body() updatePositionDto: UpdatePositionDto, + ): Promise { + return this.positionService.update( + +id, + req.user.userId, + updatePositionDto, + ); + } + + /** + * 删除持仓 + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: '删除持仓', + description: '删除指定的持仓记录', + }) + @ApiParam({ name: 'id', description: '持仓ID', type: Number }) + @ApiResponse({ status: 204, description: '删除成功' }) + @ApiResponse({ status: 404, description: '持仓不存在' }) + remove( + @Request() req: { user: User }, + @Param('id') id: string, + ): Promise { + return this.positionService.remove(+id, req.user.userId); + } +} diff --git a/apps/api/src/modules/position/position.entity.ts b/apps/api/src/modules/position/position.entity.ts new file mode 100644 index 0000000..f86a387 --- /dev/null +++ b/apps/api/src/modules/position/position.entity.ts @@ -0,0 +1,179 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +@Entity('positions') +@Unique(['userId', 'brokerId', 'symbol', 'market', 'assetType']) +export class Position { + @ApiProperty({ description: '持仓ID', example: 1 }) + @PrimaryGeneratedColumn({ name: 'position_id' }) + positionId: number; + + @ApiProperty({ + description: '用户ID', + example: 1, + }) + @Column({ name: 'user_id', type: 'bigint' }) + @Index() + userId: number; + + @ApiProperty({ + description: '券商ID', + example: 1, + }) + @Column({ name: 'broker_id', type: 'bigint' }) + @Index() + brokerId: number; + + @ApiProperty({ + description: '资产类型', + example: 'stock', + enum: ['stock', 'fund', 'cash', 'bond', 'other'], + }) + @Column({ + name: 'asset_type', + type: 'varchar', + length: 20, + }) + @Index() + assetType: string; + + @ApiProperty({ + description: '资产代码(股票代码、基金代码等)', + example: '600519', + maxLength: 50, + }) + @Column({ name: 'symbol', type: 'varchar', length: 50 }) + @Index() + symbol: string; + + @ApiProperty({ + description: '资产名称', + example: '贵州茅台', + maxLength: 100, + }) + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @ApiPropertyOptional({ + description: '市场(A股/港股/美股等)', + example: 'sh', + maxLength: 20, + }) + @Column({ name: 'market', type: 'varchar', length: 20, nullable: true }) + market?: string; + + @ApiProperty({ + description: '持仓份额/数量', + example: 100, + }) + @Column({ + name: 'shares', + type: 'decimal', + precision: 18, + scale: 4, + default: 0, + }) + shares: number; + + @ApiProperty({ + description: '成本价(每股/每份)', + example: 1600.0, + }) + @Column({ + name: 'cost_price', + type: 'decimal', + precision: 18, + scale: 4, + }) + costPrice: number; + + @ApiPropertyOptional({ + description: '最新市场价(系统自动更新)', + example: 1850.0, + }) + @Column({ + name: 'current_price', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + currentPrice?: number; + + @ApiProperty({ + description: '货币类型', + example: 'CNY', + default: 'CNY', + maxLength: 10, + }) + @Column({ + name: 'currency', + type: 'varchar', + length: 10, + default: 'CNY', + }) + currency: string; + + @ApiPropertyOptional({ + description: '汇率(用于多货币)', + example: 1.0, + default: 1, + }) + @Column({ + name: 'exchange_rate', + type: 'decimal', + precision: 10, + scale: 6, + default: 1, + }) + exchangeRate?: number; + + @ApiProperty({ + description: '是否自动更新价格(付费用户功能)', + example: false, + default: false, + }) + @Column({ + name: 'auto_price_update', + type: 'boolean', + default: false, + }) + autoPriceUpdate: boolean; + + @ApiProperty({ + description: '状态', + example: 'active', + enum: ['active', 'suspended', 'delisted'], + default: 'active', + }) + @Column({ + name: 'status', + type: 'varchar', + length: 20, + default: 'active', + }) + @Index() + status: string; + + @ApiProperty({ + description: '创建时间', + example: '2024-01-01T00:00:00.000Z', + }) + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @ApiProperty({ + description: '更新时间', + example: '2024-01-01T00:00:00.000Z', + }) + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/api/src/modules/position/position.module.ts b/apps/api/src/modules/position/position.module.ts new file mode 100644 index 0000000..d09c3e0 --- /dev/null +++ b/apps/api/src/modules/position/position.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PositionService } from './position.service'; +import { PositionController } from './position.controller'; +import { Position } from './position.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Position])], + controllers: [PositionController], + providers: [PositionService], + exports: [PositionService], +}) +export class PositionModule {} diff --git a/apps/api/src/modules/position/position.service.ts b/apps/api/src/modules/position/position.service.ts new file mode 100644 index 0000000..f05c95b --- /dev/null +++ b/apps/api/src/modules/position/position.service.ts @@ -0,0 +1,180 @@ +import { + Injectable, + NotFoundException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Position } from './position.entity'; +import { CreatePositionDto } from './dto/create-position.dto'; +import { UpdatePositionDto } from './dto/update-position.dto'; +import { PositionResponseDto } from './dto/position-response.dto'; + +@Injectable() +export class PositionService { + private readonly logger = new Logger(PositionService.name); + + constructor( + @InjectRepository(Position) + private readonly positionRepository: Repository, + ) {} + + /** + * 查询用户所有持仓(包含计算字段) + */ + async findAllByUserId(userId: number): Promise { + // 查询用户所有持仓 + const positions = await this.positionRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + + // 计算用户总资产(所有active持仓的市值总和) + const totalAsset = await this.calculateUserTotalAsset(userId); + + // 计算每个持仓的额外字段 + const result: PositionResponseDto[] = positions.map((position) => { + const costValue = + Number(position.shares) * Number(position.costPrice); + const marketValue = position.currentPrice + ? Number(position.shares) * Number(position.currentPrice) + : 0; + const profit = marketValue - costValue; + const profitPercent = + costValue > 0 ? (profit / costValue) * 100 : 0; + const holdingDays = Math.floor( + (Date.now() - position.createdAt.getTime()) / + (1000 * 60 * 60 * 24), + ); + const assetPercent = + totalAsset > 0 ? (marketValue / totalAsset) * 100 : 0; + + return { + ...position, + costValue, + marketValue, + profit, + profitPercent, + holdingDays, + assetPercent, + }; + }); + + return result; + } + + /** + * 计算用户总资产(所有active持仓的市值总和) + */ + private async calculateUserTotalAsset(userId: number): Promise { + const positions = await this.positionRepository.find({ + where: { userId, status: 'active' }, + }); + + let totalAsset = 0; + for (const position of positions) { + if (position.currentPrice) { + const marketValue = + Number(position.shares) * Number(position.currentPrice); + totalAsset += marketValue; + } + } + + return totalAsset; + } + + /** + * 创建持仓 + */ + async create( + userId: number, + createPositionDto: CreatePositionDto, + ): Promise { + // 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓 + const existing = await this.positionRepository.findOne({ + where: { + userId, + brokerId: createPositionDto.brokerId, + symbol: createPositionDto.symbol, + market: createPositionDto.market || null, + assetType: createPositionDto.assetType, + }, + }); + + if (existing) { + throw new ConflictException( + `该持仓已存在:${createPositionDto.name} (${createPositionDto.symbol})`, + ); + } + + // 创建持仓 + const position = this.positionRepository.create({ + ...createPositionDto, + userId, + currency: createPositionDto.currency || 'CNY', + exchangeRate: createPositionDto.exchangeRate || 1, + autoPriceUpdate: createPositionDto.autoPriceUpdate || false, + status: createPositionDto.status || 'active', + }); + + return this.positionRepository.save(position); + } + + /** + * 更新持仓(只能更新允许的字段) + */ + async update( + positionId: number, + userId: number, + updatePositionDto: UpdatePositionDto, + ): Promise { + // 查找持仓 + const position = await this.positionRepository.findOne({ + where: { positionId, userId }, + }); + + if (!position) { + throw new NotFoundException(`持仓不存在:ID ${positionId}`); + } + + // 只更新允许的字段 + if (updatePositionDto.brokerId !== undefined) { + position.brokerId = updatePositionDto.brokerId; + } + if (updatePositionDto.costPrice !== undefined) { + position.costPrice = updatePositionDto.costPrice; + } + if (updatePositionDto.currentPrice !== undefined) { + position.currentPrice = updatePositionDto.currentPrice; + } + if (updatePositionDto.currency !== undefined) { + position.currency = updatePositionDto.currency; + } + if (updatePositionDto.exchangeRate !== undefined) { + position.exchangeRate = updatePositionDto.exchangeRate; + } + if (updatePositionDto.status !== undefined) { + position.status = updatePositionDto.status; + } + + // 注意:assetType, symbol, name, market 字段不允许更新 + + return this.positionRepository.save(position); + } + + /** + * 删除持仓 + */ + async remove(positionId: number, userId: number): Promise { + const position = await this.positionRepository.findOne({ + where: { positionId, userId }, + }); + + if (!position) { + throw new NotFoundException(`持仓不存在:ID ${positionId}`); + } + + await this.positionRepository.remove(position); + } +} diff --git a/apps/api/src/modules/stock-daily-price/dto/batch-create-stock-daily-price.dto.ts b/apps/api/src/modules/stock-daily-price/dto/batch-create-stock-daily-price.dto.ts new file mode 100644 index 0000000..23dbae8 --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/dto/batch-create-stock-daily-price.dto.ts @@ -0,0 +1,29 @@ +import { IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { CreateStockDailyPriceDto } from './create-stock-daily-price.dto'; + +export class BatchCreateStockDailyPriceDto { + @ApiProperty({ + description: '股票每日价格列表', + type: [CreateStockDailyPriceDto], + example: [ + { + stockCode: '600519', + stockName: '贵州茅台', + market: 'sh', + tradeDate: '2024-01-01', + openPrice: 1000.0, + closePrice: 1050.0, + highPrice: 1060.0, + lowPrice: 995.0, + volume: 1000000, + amount: 1050000000.0, + }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateStockDailyPriceDto) + prices: CreateStockDailyPriceDto[]; +} diff --git a/apps/api/src/modules/stock-daily-price/dto/create-stock-daily-price.dto.ts b/apps/api/src/modules/stock-daily-price/dto/create-stock-daily-price.dto.ts new file mode 100644 index 0000000..e8a2a03 --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/dto/create-stock-daily-price.dto.ts @@ -0,0 +1,177 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + MaxLength, + IsDateString, + IsNumber, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateStockDailyPriceDto { + @ApiProperty({ + description: '股票代码', + example: '600519', + maxLength: 20, + }) + @IsString() + @IsNotEmpty() + @MaxLength(20) + stockCode: string; + + @ApiProperty({ + description: '股票名称', + example: '贵州茅台', + maxLength: 100, + }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + stockName: string; + + @ApiProperty({ + description: '市场标识', + example: 'sh', + maxLength: 20, + }) + @IsString() + @IsNotEmpty() + @MaxLength(20) + market: string; + + @ApiProperty({ + description: '交易日期', + example: '2024-01-01', + }) + @IsDateString() + @IsNotEmpty() + tradeDate: string; + + @ApiPropertyOptional({ + description: '开盘价', + example: 1000.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + openPrice?: number; + + @ApiProperty({ + description: '收盘价', + example: 1050.0, + }) + @Type(() => Number) + @IsNumber() + @Min(0) + closePrice: number; + + @ApiPropertyOptional({ + description: '最高价', + example: 1060.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + highPrice?: number; + + @ApiPropertyOptional({ + description: '最低价', + example: 995.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + lowPrice?: number; + + @ApiPropertyOptional({ + description: '成交量(单位:手)', + example: 1000000, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + volume?: number; + + @ApiPropertyOptional({ + description: '成交额(单位:元)', + example: 1050000000.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + amount?: number; + + @ApiPropertyOptional({ + description: '涨跌额', + example: 50.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + changeAmount?: number; + + @ApiPropertyOptional({ + description: '涨跌幅(%)', + example: 5.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + changePercent?: number; + + @ApiPropertyOptional({ + description: '60日涨跌幅(%)', + example: 15.5, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + changePercent60day?: number; + + @ApiPropertyOptional({ + description: '年初至今涨跌幅(%)', + example: 25.8, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + changePercentYtd?: number; + + @ApiPropertyOptional({ + description: '换手率(%)', + example: 2.5, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + turnoverRate?: number; + + @ApiPropertyOptional({ + description: '市盈率', + example: 35.5, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + peRatio?: number; + + @ApiPropertyOptional({ + description: '市净率', + example: 8.5, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + pbRatio?: number; + + @ApiPropertyOptional({ + description: '总市值(单位:元)', + example: 2000000000000.0, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + marketCap?: number; +} diff --git a/apps/api/src/modules/stock-daily-price/dto/paginated-response.dto.ts b/apps/api/src/modules/stock-daily-price/dto/paginated-response.dto.ts new file mode 100644 index 0000000..0fba8f6 --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/dto/paginated-response.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StockDailyPrice } from '../stock-daily-price.entity'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; + +/** + * 股票每日价格分页响应数据 + */ +export class PaginatedStockDailyPriceData { + @ApiProperty({ + description: '股票每日价格列表', + type: [StockDailyPrice], + }) + list: StockDailyPrice[]; + + @ApiProperty({ description: '分页信息', type: PaginationInfo }) + pagination: PaginationInfo; +} diff --git a/apps/api/src/modules/stock-daily-price/dto/query-stock-daily-price.dto.ts b/apps/api/src/modules/stock-daily-price/dto/query-stock-daily-price.dto.ts new file mode 100644 index 0000000..1301483 --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/dto/query-stock-daily-price.dto.ts @@ -0,0 +1,102 @@ +import { + IsOptional, + IsString, + IsNumber, + Min, + IsDateString, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class QueryStockDailyPriceDto { + @ApiPropertyOptional({ + description: '股票代码', + example: '600519', + }) + @IsOptional() + @IsString() + stockCode?: string; + + @ApiPropertyOptional({ + description: '股票名称(模糊查询)', + example: '茅台', + }) + @IsOptional() + @IsString() + stockName?: string; + + @ApiPropertyOptional({ + description: '市场标识', + example: 'sh', + }) + @IsOptional() + @IsString() + market?: string; + + @ApiPropertyOptional({ + description: '交易日期(模糊查询,支持日期范围)', + example: '2024-01', + }) + @IsOptional() + @IsString() + tradeDate?: string; + + @ApiPropertyOptional({ + description: '起始日期', + example: '2024-01-01', + }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ + description: '结束日期', + example: '2024-01-31', + }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ + description: '页码', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量', + example: 10, + minimum: 1, + default: 10, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number = 10; + + @ApiPropertyOptional({ + description: '排序字段', + example: 'tradeDate', + default: 'tradeDate', + }) + @IsOptional() + @IsString() + sortBy?: string = 'tradeDate'; + + @ApiPropertyOptional({ + description: '排序方向', + example: 'DESC', + enum: ['ASC', 'DESC'], + default: 'DESC', + }) + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/apps/api/src/modules/stock-daily-price/dto/update-stock-daily-price.dto.ts b/apps/api/src/modules/stock-daily-price/dto/update-stock-daily-price.dto.ts new file mode 100644 index 0000000..7497b5c --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/dto/update-stock-daily-price.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateStockDailyPriceDto } from './create-stock-daily-price.dto'; + +export class UpdateStockDailyPriceDto extends PartialType( + CreateStockDailyPriceDto, +) {} diff --git a/apps/api/src/modules/stock-daily-price/stock-daily-price.controller.ts b/apps/api/src/modules/stock-daily-price/stock-daily-price.controller.ts new file mode 100644 index 0000000..188e93d --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/stock-daily-price.controller.ts @@ -0,0 +1,259 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { StockDailyPriceService } from './stock-daily-price.service'; +import { CreateStockDailyPriceDto } from './dto/create-stock-daily-price.dto'; +import { UpdateStockDailyPriceDto } from './dto/update-stock-daily-price.dto'; +import { QueryStockDailyPriceDto } from './dto/query-stock-daily-price.dto'; +import { BatchCreateStockDailyPriceDto } from './dto/batch-create-stock-daily-price.dto'; +import { PaginatedStockDailyPriceData } from './dto/paginated-response.dto'; +import { StockDailyPrice } from './stock-daily-price.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('stock-daily-price') +@Controller('stock-daily-price') +export class StockDailyPriceController { + constructor( + private readonly stockDailyPriceService: StockDailyPriceService, + ) {} + + /** + * 单独创建股票每日价格 + */ + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: '创建股票每日价格', + description: '创建单个股票的每日价格记录', + }) + @ApiResponse({ + status: 201, + description: '创建成功', + type: StockDailyPrice, + }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 409, description: '价格记录已存在' }) + create( + @Body() createStockDailyPriceDto: CreateStockDailyPriceDto, + ): Promise { + return this.stockDailyPriceService.create(createStockDailyPriceDto); + } + + /** + * 批量创建股票每日价格 + */ + @Post('batch') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: '批量创建股票每日价格', + description: '一次性创建多个股票的每日价格记录', + }) + @ApiResponse({ + status: 201, + description: '批量创建成功', + type: [StockDailyPrice], + }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 409, description: '存在重复的价格记录' }) + batchCreate( + @Body() batchCreateStockDailyPriceDto: BatchCreateStockDailyPriceDto, + ): Promise { + return this.stockDailyPriceService.batchCreate( + batchCreateStockDailyPriceDto, + ); + } + + /** + * 查询股票每日价格列表(支持分页和多种查询条件) + */ + @Get() + @ApiOperation({ + summary: '查询股票每日价格列表', + description: + '支持分页查询,查询条件:股票代码、股票名称(模糊)、市场、日期(模糊)', + }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: PaginatedStockDailyPriceData, + }) + findAll(@Query() queryDto: QueryStockDailyPriceDto): Promise<{ + list: StockDailyPrice[]; + pagination: any; + }> { + return this.stockDailyPriceService.findAll(queryDto); + } + + /** + * 根据 ID 查询单个股票每日价格 + */ + @Get(':id') + @ApiOperation({ + summary: '根据ID查询股票每日价格', + description: '根据价格记录ID查询单个股票每日价格', + }) + @ApiParam({ name: 'id', description: '价格记录ID', type: Number }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: StockDailyPrice, + }) + @ApiResponse({ status: 404, description: '未找到价格记录' }) + findOneById(@Param('id') id: string): Promise { + return this.stockDailyPriceService.findOneById(+id); + } + + /** + * 根据股票代码、市场和日期查询单个股票每日价格 + */ + @Get('code/:stockCode') + @ApiOperation({ + summary: '根据股票代码查询股票每日价格', + description: '根据股票代码、市场和日期查询单个股票每日价格', + }) + @ApiParam({ name: 'stockCode', description: '股票代码', type: String }) + @ApiQuery({ name: 'market', description: '市场标识', required: true }) + @ApiQuery({ name: 'tradeDate', description: '交易日期', required: true }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: StockDailyPrice, + }) + @ApiResponse({ status: 404, description: '未找到价格记录' }) + findOneByCode( + @Param('stockCode') stockCode: string, + @Query('market') market: string, + @Query('tradeDate') tradeDate: string, + ): Promise { + return this.stockDailyPriceService.findOneByCode( + stockCode, + market, + tradeDate, + ); + } + + /** + * 根据股票代码查询单只股票的所有价格记录 + */ + @Get('stock/:stockCode') + @ApiOperation({ + summary: '根据股票代码查询所有价格记录', + description: '根据股票代码和市场查询单只股票的所有价格记录', + }) + @ApiParam({ name: 'stockCode', description: '股票代码', type: String }) + @ApiQuery({ name: 'market', description: '市场标识', required: true }) + @ApiQuery({ name: 'limit', description: '返回记录数限制', required: false }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: [StockDailyPrice], + }) + findByStockCode( + @Param('stockCode') stockCode: string, + @Query('market') market: string, + @Query('limit') limit?: string, + ): Promise { + return this.stockDailyPriceService.findByStockCode( + stockCode, + market, + limit ? +limit : undefined, + ); + } + + /** + * 更新股票每日价格 + */ + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @ApiOperation({ + summary: '更新股票每日价格', + description: '根据ID更新股票每日价格记录', + }) + @ApiParam({ name: 'id', description: '价格记录ID', type: Number }) + @ApiResponse({ + status: 200, + description: '更新成功', + type: StockDailyPrice, + }) + @ApiResponse({ status: 404, description: '未找到价格记录' }) + @ApiResponse({ status: 409, description: '价格记录冲突' }) + update( + @Param('id') id: string, + @Body() updateStockDailyPriceDto: UpdateStockDailyPriceDto, + ): Promise { + return this.stockDailyPriceService.update( + +id, + updateStockDailyPriceDto, + ); + } + + /** + * 批量更新股票每日价格 + */ + @Patch('batch') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @ApiOperation({ + summary: '批量更新股票每日价格', + description: '批量更新股票每日价格记录', + }) + @ApiResponse({ + status: 200, + description: '更新成功', + type: [StockDailyPrice], + }) + @ApiResponse({ status: 404, description: '未找到价格记录' }) + batchUpdate( + @Body() updateDtos: UpdateStockDailyPriceDto[], + ): Promise { + return this.stockDailyPriceService.batchUpdate(updateDtos); + } + + /** + * 删除股票每日价格 + */ + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: '删除股票每日价格', + description: '根据ID删除股票每日价格记录', + }) + @ApiParam({ name: 'id', description: '价格记录ID', type: Number }) + @ApiResponse({ status: 204, description: '删除成功' }) + @ApiResponse({ status: 404, description: '未找到价格记录' }) + remove(@Param('id') id: string): Promise { + return this.stockDailyPriceService.remove(+id); + } +} diff --git a/apps/api/src/modules/stock-daily-price/stock-daily-price.entity.ts b/apps/api/src/modules/stock-daily-price/stock-daily-price.entity.ts new file mode 100644 index 0000000..61ff37b --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/stock-daily-price.entity.ts @@ -0,0 +1,234 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +@Entity('stock_daily_price') +@Unique(['stockCode', 'market', 'tradeDate']) +export class StockDailyPrice { + @ApiProperty({ description: '主键ID', example: 1 }) + @PrimaryGeneratedColumn({ name: 'id' }) + id: number; + + @ApiProperty({ + description: '股票代码', + example: '600519', + maxLength: 20, + }) + @Column({ name: 'stock_code', type: 'varchar', length: 20 }) + @Index() + stockCode: string; + + @ApiProperty({ + description: '股票名称', + example: '贵州茅台', + maxLength: 100, + }) + @Column({ name: 'stock_name', type: 'varchar', length: 100 }) + @Index() + stockName: string; + + @ApiProperty({ + description: '市场标识', + example: 'sh', + maxLength: 20, + }) + @Column({ name: 'market', type: 'varchar', length: 20 }) + @Index() + market: string; + + @ApiProperty({ + description: '交易日期', + example: '2024-01-01', + }) + @Column({ name: 'trade_date', type: 'date' }) + @Index() + tradeDate: Date; + + @ApiPropertyOptional({ + description: '开盘价', + example: 1000.0, + }) + @Column({ + name: 'open_price', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + openPrice?: number; + + @ApiProperty({ + description: '收盘价', + example: 1050.0, + }) + @Column({ + name: 'close_price', + type: 'decimal', + precision: 18, + scale: 4, + }) + closePrice: number; + + @ApiPropertyOptional({ + description: '最高价', + example: 1060.0, + }) + @Column({ + name: 'high_price', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + highPrice?: number; + + @ApiPropertyOptional({ + description: '最低价', + example: 995.0, + }) + @Column({ + name: 'low_price', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + lowPrice?: number; + + @ApiPropertyOptional({ + description: '成交量(单位:手)', + example: 1000000, + }) + @Column({ name: 'volume', type: 'bigint', nullable: true }) + volume?: number; + + @ApiPropertyOptional({ + description: '成交额(单位:元)', + example: 1050000000.0, + }) + @Column({ + name: 'amount', + type: 'decimal', + precision: 20, + scale: 2, + nullable: true, + }) + amount?: number; + + @ApiPropertyOptional({ + description: '涨跌额', + example: 50.0, + }) + @Column({ + name: 'change_amount', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + changeAmount?: number; + + @ApiPropertyOptional({ + description: '涨跌幅(%)', + example: 5.0, + }) + @Column({ + name: 'change_percent', + type: 'decimal', + precision: 10, + scale: 6, + nullable: true, + }) + changePercent?: number; + + @ApiPropertyOptional({ + description: '60日涨跌幅(%)', + example: 15.5, + }) + @Column({ + name: 'change_percent_60day', + type: 'decimal', + precision: 10, + scale: 6, + nullable: true, + }) + changePercent60day?: number; + + @ApiPropertyOptional({ + description: '年初至今涨跌幅(%)', + example: 25.8, + }) + @Column({ + name: 'change_percent_ytd', + type: 'decimal', + precision: 10, + scale: 6, + nullable: true, + }) + changePercentYtd?: number; + + @ApiPropertyOptional({ + description: '换手率(%)', + example: 2.5, + }) + @Column({ + name: 'turnover_rate', + type: 'decimal', + precision: 10, + scale: 6, + nullable: true, + }) + turnoverRate?: number; + + @ApiPropertyOptional({ + description: '市盈率', + example: 35.5, + }) + @Column({ + name: 'pe_ratio', + type: 'decimal', + precision: 12, + scale: 4, + nullable: true, + }) + peRatio?: number; + + @ApiPropertyOptional({ + description: '市净率', + example: 8.5, + }) + @Column({ + name: 'pb_ratio', + type: 'decimal', + precision: 12, + scale: 4, + nullable: true, + }) + pbRatio?: number; + + @ApiPropertyOptional({ + description: '总市值(单位:元)', + example: 2000000000000.0, + }) + @Column({ + name: 'market_cap', + type: 'decimal', + precision: 20, + scale: 2, + nullable: true, + }) + marketCap?: number; + + @ApiProperty({ + description: '创建时间', + example: '2024-01-01T00:00:00.000Z', + }) + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/api/src/modules/stock-daily-price/stock-daily-price.module.ts b/apps/api/src/modules/stock-daily-price/stock-daily-price.module.ts new file mode 100644 index 0000000..99a8b95 --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/stock-daily-price.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StockDailyPriceService } from './stock-daily-price.service'; +import { StockDailyPriceController } from './stock-daily-price.controller'; +import { StockDailyPrice } from './stock-daily-price.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([StockDailyPrice])], + controllers: [StockDailyPriceController], + providers: [StockDailyPriceService], + exports: [StockDailyPriceService], +}) +export class StockDailyPriceModule {} diff --git a/apps/api/src/modules/stock-daily-price/stock-daily-price.service.ts b/apps/api/src/modules/stock-daily-price/stock-daily-price.service.ts new file mode 100644 index 0000000..1883c2d --- /dev/null +++ b/apps/api/src/modules/stock-daily-price/stock-daily-price.service.ts @@ -0,0 +1,361 @@ +import { + Injectable, + NotFoundException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { StockDailyPrice } from './stock-daily-price.entity'; +import { CreateStockDailyPriceDto } from './dto/create-stock-daily-price.dto'; +import { UpdateStockDailyPriceDto } from './dto/update-stock-daily-price.dto'; +import { QueryStockDailyPriceDto } from './dto/query-stock-daily-price.dto'; +import { BatchCreateStockDailyPriceDto } from './dto/batch-create-stock-daily-price.dto'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; + +@Injectable() +export class StockDailyPriceService { + private readonly logger = new Logger(StockDailyPriceService.name); + + constructor( + @InjectRepository(StockDailyPrice) + private readonly stockDailyPriceRepository: Repository, + ) {} + + /** + * 单独创建股票每日价格 + */ + async create( + createStockDailyPriceDto: CreateStockDailyPriceDto, + ): Promise { + // 检查同一股票同一日期是否已存在 + const existing = await this.stockDailyPriceRepository.findOne({ + where: { + stockCode: createStockDailyPriceDto.stockCode, + market: createStockDailyPriceDto.market, + tradeDate: new Date(createStockDailyPriceDto.tradeDate), + }, + }); + + if (existing) { + throw new ConflictException( + `股票 ${createStockDailyPriceDto.stockCode} (${createStockDailyPriceDto.market}) 在日期 ${createStockDailyPriceDto.tradeDate} 的价格记录已存在`, + ); + } + + const stockDailyPrice = this.stockDailyPriceRepository.create({ + ...createStockDailyPriceDto, + tradeDate: new Date(createStockDailyPriceDto.tradeDate), + }); + + return this.stockDailyPriceRepository.save(stockDailyPrice); + } + + /** + * 批量创建股票每日价格 + */ + async batchCreate( + batchCreateStockDailyPriceDto: BatchCreateStockDailyPriceDto, + ): Promise { + const prices = batchCreateStockDailyPriceDto.prices.map((dto) => + this.stockDailyPriceRepository.create({ + ...dto, + tradeDate: new Date(dto.tradeDate), + }), + ); + + // 检查是否有重复的 stock_code + market + trade_date 组合 + const uniqueKeys = prices.map((p) => ({ + stockCode: p.stockCode, + market: p.market, + tradeDate: p.tradeDate, + })); + + // 检查批量数据内部是否有重复 + const uniquePairs = new Set( + uniqueKeys.map( + (k) => + `${k.stockCode}-${k.market}-${k.tradeDate.toISOString()}`, + ), + ); + if (uniquePairs.size !== uniqueKeys.length) { + throw new ConflictException( + '批量数据中存在重复的股票代码、市场和日期组合', + ); + } + + // 检查数据库中是否已存在 + const existingPrices = await this.stockDailyPriceRepository.find({ + where: uniqueKeys.map((k) => ({ + stockCode: k.stockCode, + market: k.market, + tradeDate: k.tradeDate, + })), + }); + + if (existingPrices.length > 0) { + const conflicts = existingPrices.map( + (p) => + `${p.stockCode} (${p.market}) - ${p.tradeDate.toISOString().split('T')[0]}`, + ); + throw new ConflictException( + `以下价格记录已存在:${conflicts.join('、')}`, + ); + } + + return this.stockDailyPriceRepository.save(prices); + } + + /** + * 批量更新股票每日价格 + */ + async batchUpdate( + updateDtos: UpdateStockDailyPriceDto[], + ): Promise { + const updatedPrices: StockDailyPrice[] = []; + + for (const updateDto of updateDtos) { + // 必须包含 stockCode、market 和 tradeDate 来定位记录 + if ( + !updateDto.stockCode || + !updateDto.market || + !updateDto.tradeDate + ) { + throw new ConflictException( + '批量更新时,每条记录必须包含 stockCode、market 和 tradeDate', + ); + } + + const existing = await this.stockDailyPriceRepository.findOne({ + where: { + stockCode: updateDto.stockCode, + market: updateDto.market, + tradeDate: new Date(updateDto.tradeDate), + }, + }); + + if (!existing) { + throw new NotFoundException( + `未找到价格记录:${updateDto.stockCode} (${updateDto.market}) - ${updateDto.tradeDate}`, + ); + } + + // 更新字段 + Object.assign(existing, { + ...updateDto, + tradeDate: updateDto.tradeDate + ? new Date(updateDto.tradeDate) + : existing.tradeDate, + }); + + const saved = await this.stockDailyPriceRepository.save(existing); + updatedPrices.push(saved); + } + + return updatedPrices; + } + + /** + * 查询股票每日价格(支持多种查询条件和分页) + */ + async findAll(queryDto: QueryStockDailyPriceDto): Promise<{ + list: StockDailyPrice[]; + pagination: PaginationInfo; + }> { + // 分页参数 + const page = queryDto.page || 1; + const limit = queryDto.limit || 10; + const skip = (page - 1) * limit; + + // 排序字段映射 + const sortBy = queryDto.sortBy || 'tradeDate'; + const sortOrder = queryDto.sortOrder || 'DESC'; + + // 构建查询 + let query = this.stockDailyPriceRepository.createQueryBuilder('price'); + + if (queryDto.stockCode) { + query = query.andWhere('price.stock_code = :stockCode', { + stockCode: queryDto.stockCode, + }); + } + + if (queryDto.stockName) { + query = query.andWhere('price.stock_name LIKE :stockName', { + stockName: `%${queryDto.stockName}%`, + }); + } + + if (queryDto.market) { + query = query.andWhere('price.market = :market', { + market: queryDto.market, + }); + } + + // 日期查询:支持精确日期、日期范围或模糊查询 + if (queryDto.startDate && queryDto.endDate) { + query = query.andWhere( + 'price.trade_date BETWEEN :startDate AND :endDate', + { + startDate: queryDto.startDate, + endDate: queryDto.endDate, + }, + ); + } else if (queryDto.startDate) { + query = query.andWhere('price.trade_date >= :startDate', { + startDate: queryDto.startDate, + }); + } else if (queryDto.endDate) { + query = query.andWhere('price.trade_date <= :endDate', { + endDate: queryDto.endDate, + }); + } else if (queryDto.tradeDate) { + // 模糊查询日期(如 '2024-01' 匹配 2024-01-XX) + query = query.andWhere('price.trade_date::text LIKE :tradeDate', { + tradeDate: `${queryDto.tradeDate}%`, + }); + } + + // 获取总数 + const total = await query.getCount(); + + // 添加排序和分页 + query = query + .orderBy(`price.${sortBy}`, sortOrder) + .addOrderBy('price.id', 'ASC') + .skip(skip) + .take(limit); + + const list = await query.getMany(); + + // 计算总页数 + const total_page = Math.ceil(total / limit); + + return { + list, + pagination: { + total, + total_page, + page_size: limit, + current_page: page, + }, + }; + } + + /** + * 根据 ID 查询单个股票每日价格 + */ + async findOneById(id: number): Promise { + const stockDailyPrice = await this.stockDailyPriceRepository.findOne({ + where: { id }, + }); + + if (!stockDailyPrice) { + throw new NotFoundException(`未找到ID为 ${id} 的价格记录`); + } + + return stockDailyPrice; + } + + /** + * 根据股票代码、市场和日期查询单个股票每日价格 + */ + async findOneByCode( + stockCode: string, + market: string, + tradeDate: string, + ): Promise { + const stockDailyPrice = await this.stockDailyPriceRepository.findOne({ + where: { + stockCode, + market, + tradeDate: new Date(tradeDate), + }, + }); + + if (!stockDailyPrice) { + throw new NotFoundException( + `未找到价格记录:${stockCode} (${market}) - ${tradeDate}`, + ); + } + + return stockDailyPrice; + } + + /** + * 根据股票代码查询单只股票的所有价格记录 + */ + async findByStockCode( + stockCode: string, + market: string, + limit?: number, + ): Promise { + const query = this.stockDailyPriceRepository + .createQueryBuilder('price') + .where('price.stock_code = :stockCode', { stockCode }) + .andWhere('price.market = :market', { market }) + .orderBy('price.trade_date', 'DESC') + .addOrderBy('price.id', 'ASC'); + + if (limit) { + query.take(limit); + } + + return query.getMany(); + } + + /** + * 更新股票每日价格 + */ + async update( + id: number, + updateStockDailyPriceDto: UpdateStockDailyPriceDto, + ): Promise { + const stockDailyPrice = await this.findOneById(id); + + // 如果更新 stock_code、market 或 trade_date,检查是否冲突 + if ( + 'stockCode' in updateStockDailyPriceDto || + 'market' in updateStockDailyPriceDto || + 'tradeDate' in updateStockDailyPriceDto + ) { + const newCode = + updateStockDailyPriceDto.stockCode ?? stockDailyPrice.stockCode; + const newMarket = + updateStockDailyPriceDto.market ?? stockDailyPrice.market; + const newDate = updateStockDailyPriceDto.tradeDate + ? new Date(updateStockDailyPriceDto.tradeDate) + : stockDailyPrice.tradeDate; + + const existing = await this.stockDailyPriceRepository.findOne({ + where: { + stockCode: newCode, + market: newMarket, + tradeDate: newDate, + }, + }); + + if (existing && existing.id !== id) { + throw new ConflictException( + `股票 ${newCode} (${newMarket}) 在日期 ${newDate.toISOString().split('T')[0]} 的价格记录已存在`, + ); + } + } + + Object.assign(stockDailyPrice, { + ...updateStockDailyPriceDto, + tradeDate: updateStockDailyPriceDto.tradeDate + ? new Date(updateStockDailyPriceDto.tradeDate) + : stockDailyPrice.tradeDate, + }); + return this.stockDailyPriceRepository.save(stockDailyPrice); + } + + /** + * 删除股票每日价格 + */ + async remove(id: number): Promise { + const stockDailyPrice = await this.findOneById(id); + await this.stockDailyPriceRepository.remove(stockDailyPrice); + } +} diff --git a/apps/api/src/modules/stock-info/dto/batch-create-stock-info.dto.ts b/apps/api/src/modules/stock-info/dto/batch-create-stock-info.dto.ts new file mode 100644 index 0000000..79845c5 --- /dev/null +++ b/apps/api/src/modules/stock-info/dto/batch-create-stock-info.dto.ts @@ -0,0 +1,31 @@ +import { IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { CreateStockInfoDto } from './create-stock-info.dto'; + +export class BatchCreateStockInfoDto { + @ApiProperty({ + description: '股票信息列表', + type: [CreateStockInfoDto], + example: [ + { + stockCode: '600519', + stockName: '贵州茅台', + market: 'sh', + fullName: '贵州茅台酒股份有限公司', + industry: '白酒', + }, + { + stockCode: '000001', + stockName: '平安银行', + market: 'sz', + fullName: '平安银行股份有限公司', + industry: '银行', + }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateStockInfoDto) + stocks: CreateStockInfoDto[]; +} diff --git a/apps/api/src/modules/stock-info/dto/create-stock-info.dto.ts b/apps/api/src/modules/stock-info/dto/create-stock-info.dto.ts new file mode 100644 index 0000000..f2c6493 --- /dev/null +++ b/apps/api/src/modules/stock-info/dto/create-stock-info.dto.ts @@ -0,0 +1,80 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + MaxLength, + IsDateString, + IsIn, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateStockInfoDto { + @ApiProperty({ + description: '股票代码', + example: '600519', + maxLength: 20, + }) + @IsString() + @IsNotEmpty() + @MaxLength(20) + stockCode: string; + + @ApiProperty({ + description: '股票名称', + example: '贵州茅台', + maxLength: 100, + }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + stockName: string; + + @ApiProperty({ + description: '市场标识', + example: 'sh', + maxLength: 20, + }) + @IsString() + @IsNotEmpty() + @MaxLength(20) + market: string; + + @ApiPropertyOptional({ + description: '公司全称', + example: '贵州茅台酒股份有限公司', + maxLength: 200, + }) + @IsOptional() + @IsString() + @MaxLength(200) + fullName?: string; + + @ApiPropertyOptional({ + description: '所属行业', + example: '白酒', + maxLength: 100, + }) + @IsOptional() + @IsString() + @MaxLength(100) + industry?: string; + + @ApiPropertyOptional({ + description: '上市日期', + example: '2001-08-27', + }) + @IsOptional() + @IsDateString() + listingDate?: string; + + @ApiPropertyOptional({ + description: '状态', + example: 'active', + enum: ['active', 'suspended', 'delisted'], + default: 'active', + }) + @IsOptional() + @IsString() + @IsIn(['active', 'suspended', 'delisted']) + status?: string; +} diff --git a/apps/api/src/modules/stock-info/dto/paginated-response.dto.ts b/apps/api/src/modules/stock-info/dto/paginated-response.dto.ts new file mode 100644 index 0000000..a1fe706 --- /dev/null +++ b/apps/api/src/modules/stock-info/dto/paginated-response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StockInfo } from '../stock-info.entity'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; + +/** + * 股票信息分页响应数据 + */ +export class PaginatedStockInfoData { + @ApiProperty({ description: '股票信息列表', type: [StockInfo] }) + list: StockInfo[]; + + @ApiProperty({ description: '分页信息', type: PaginationInfo }) + pagination: PaginationInfo; +} diff --git a/apps/api/src/modules/stock-info/dto/query-stock-info.dto.ts b/apps/api/src/modules/stock-info/dto/query-stock-info.dto.ts new file mode 100644 index 0000000..46ff951 --- /dev/null +++ b/apps/api/src/modules/stock-info/dto/query-stock-info.dto.ts @@ -0,0 +1,72 @@ +import { IsOptional, IsString, IsNumber, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class QueryStockInfoDto { + @ApiPropertyOptional({ + description: '股票代码', + example: '600519', + }) + @IsOptional() + @IsString() + stockCode?: string; + + @ApiPropertyOptional({ + description: '股票名称(模糊查询)', + example: '茅台', + }) + @IsOptional() + @IsString() + stockName?: string; + + @ApiPropertyOptional({ + description: '市场标识', + example: 'sh', + }) + @IsOptional() + @IsString() + market?: string; + + @ApiPropertyOptional({ + description: '页码', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量', + example: 10, + minimum: 1, + default: 10, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number = 10; + + @ApiPropertyOptional({ + description: '排序字段', + example: 'createdAt', + default: 'createdAt', + }) + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @ApiPropertyOptional({ + description: '排序方向', + example: 'DESC', + enum: ['ASC', 'DESC'], + default: 'DESC', + }) + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/apps/api/src/modules/stock-info/dto/update-stock-info.dto.ts b/apps/api/src/modules/stock-info/dto/update-stock-info.dto.ts new file mode 100644 index 0000000..c0bddfd --- /dev/null +++ b/apps/api/src/modules/stock-info/dto/update-stock-info.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateStockInfoDto } from './create-stock-info.dto'; + +export class UpdateStockInfoDto extends PartialType(CreateStockInfoDto) {} diff --git a/apps/api/src/modules/stock-info/stock-info.controller.ts b/apps/api/src/modules/stock-info/stock-info.controller.ts new file mode 100644 index 0000000..200e0b8 --- /dev/null +++ b/apps/api/src/modules/stock-info/stock-info.controller.ts @@ -0,0 +1,260 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { StockInfoService } from './stock-info.service'; +import { CreateStockInfoDto } from './dto/create-stock-info.dto'; +import { UpdateStockInfoDto } from './dto/update-stock-info.dto'; +import { QueryStockInfoDto } from './dto/query-stock-info.dto'; +import { BatchCreateStockInfoDto } from './dto/batch-create-stock-info.dto'; +import { PaginatedStockInfoData } from './dto/paginated-response.dto'; +import { StockInfo } from './stock-info.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('stock-info') +@Controller('stock-info') +export class StockInfoController { + constructor(private readonly stockInfoService: StockInfoService) {} + + /** + * 单独创建股票信息 + */ + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: '创建股票信息', + description: '创建单个股票基本信息', + }) + @ApiResponse({ + status: 201, + description: '创建成功', + type: StockInfo, + }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 409, description: '股票代码已存在' }) + create(@Body() createStockInfoDto: CreateStockInfoDto): Promise { + return this.stockInfoService.create(createStockInfoDto); + } + + /** + * 批量创建股票信息 + */ + @Post('batch') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: '批量创建股票信息', + description: '一次性创建多个股票基本信息', + }) + @ApiResponse({ + status: 201, + description: '批量创建成功', + type: [StockInfo], + }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 409, description: '存在重复的股票代码' }) + batchCreate( + @Body() batchCreateStockInfoDto: BatchCreateStockInfoDto, + ): Promise { + return this.stockInfoService.batchCreate(batchCreateStockInfoDto); + } + + /** + * Upsert:存在则更新,不存在则新增 + */ + @Post('upsert') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '创建或更新股票信息', + description: '如果股票代码已存在则更新,不存在则新增', + }) + @ApiResponse({ + status: 200, + description: '操作成功', + type: StockInfo, + }) + upsert(@Body() createStockInfoDto: CreateStockInfoDto): Promise { + return this.stockInfoService.upsert(createStockInfoDto); + } + + /** + * 批量 Upsert + */ + @Post('batch-upsert') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '批量创建或更新股票信息', + description: '批量操作:存在则更新,不存在则新增', + }) + @ApiResponse({ + status: 200, + description: '操作成功', + type: [StockInfo], + }) + batchUpsert( + @Body() batchCreateStockInfoDto: BatchCreateStockInfoDto, + ): Promise { + return this.stockInfoService.batchUpsert(batchCreateStockInfoDto); + } + + /** + * 查询股票信息列表(支持分页和多种查询条件) + */ + @Get() + @ApiOperation({ + summary: '查询股票信息列表', + description: + '支持分页查询,查询条件:股票代码、股票名称(模糊)、市场类型', + }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: PaginatedStockInfoData, + }) + findAll(@Query() queryDto: QueryStockInfoDto): Promise<{ + list: StockInfo[]; + pagination: any; + }> { + return this.stockInfoService.findAll(queryDto); + } + + /** + * 根据 ID 查询单个股票信息 + */ + @Get(':id') + @ApiOperation({ + summary: '根据ID查询股票信息', + description: '根据股票信息ID查询单个股票信息', + }) + @ApiParam({ name: 'id', description: '股票信息ID', type: Number }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: StockInfo, + }) + @ApiResponse({ status: 404, description: '未找到股票信息' }) + findOneById(@Param('id') id: string): Promise { + return this.stockInfoService.findOneById(+id); + } + + /** + * 根据股票代码和市场查询单个股票信息 + */ + @Get('code/:stockCode') + @ApiOperation({ + summary: '根据股票代码查询股票信息', + description: '根据股票代码和市场查询单个股票信息', + }) + @ApiParam({ name: 'stockCode', description: '股票代码', type: String }) + @ApiQuery({ name: 'market', description: '市场标识', required: true }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: StockInfo, + }) + @ApiResponse({ status: 404, description: '未找到股票信息' }) + findOneByCode( + @Param('stockCode') stockCode: string, + @Query('market') market: string, + ): Promise { + return this.stockInfoService.findOneByCode(stockCode, market); + } + + /** + * 更新股票信息 + */ + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @ApiOperation({ + summary: '更新股票信息', + description: '根据ID更新股票基本信息', + }) + @ApiParam({ name: 'id', description: '股票信息ID', type: Number }) + @ApiResponse({ + status: 200, + description: '更新成功', + type: StockInfo, + }) + @ApiResponse({ status: 404, description: '未找到股票信息' }) + @ApiResponse({ status: 409, description: '股票代码冲突' }) + update( + @Param('id') id: string, + @Body() updateStockInfoDto: UpdateStockInfoDto, + ): Promise { + return this.stockInfoService.update(+id, updateStockInfoDto); + } + + /** + * 批量更新股票信息 + */ + @Patch('batch') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @ApiOperation({ + summary: '批量更新股票信息', + description: '批量更新股票基本信息', + }) + @ApiResponse({ + status: 200, + description: '更新成功', + type: [StockInfo], + }) + @ApiResponse({ status: 404, description: '未找到股票信息' }) + batchUpdate( + @Body() updateDtos: UpdateStockInfoDto[], + ): Promise { + return this.stockInfoService.batchUpdate(updateDtos); + } + + /** + * 删除股票信息 + */ + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'super_admin') + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: '删除股票信息', + description: '根据ID删除股票基本信息', + }) + @ApiParam({ name: 'id', description: '股票信息ID', type: Number }) + @ApiResponse({ status: 204, description: '删除成功' }) + @ApiResponse({ status: 404, description: '未找到股票信息' }) + remove(@Param('id') id: string): Promise { + return this.stockInfoService.remove(+id); + } +} diff --git a/apps/api/src/modules/stock-info/stock-info.entity.ts b/apps/api/src/modules/stock-info/stock-info.entity.ts new file mode 100644 index 0000000..c6ceb9f --- /dev/null +++ b/apps/api/src/modules/stock-info/stock-info.entity.ts @@ -0,0 +1,97 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +@Entity('stock_info') +@Unique(['stockCode', 'market']) +export class StockInfo { + @ApiProperty({ description: '主键ID', example: 1 }) + @PrimaryGeneratedColumn({ name: 'id' }) + id: number; + + @ApiProperty({ + description: '股票代码', + example: '600519', + maxLength: 20, + }) + @Column({ name: 'stock_code', type: 'varchar', length: 20 }) + @Index() + stockCode: string; + + @ApiProperty({ + description: '股票名称', + example: '贵州茅台', + maxLength: 100, + }) + @Column({ name: 'stock_name', type: 'varchar', length: 100 }) + @Index() + stockName: string; + + @ApiProperty({ + description: '市场标识', + example: 'sh', + maxLength: 20, + }) + @Column({ name: 'market', type: 'varchar', length: 20 }) + @Index() + market: string; + + @ApiPropertyOptional({ + description: '公司全称', + example: '贵州茅台酒股份有限公司', + maxLength: 200, + }) + @Column({ name: 'full_name', type: 'varchar', length: 200, nullable: true }) + fullName?: string; + + @ApiPropertyOptional({ + description: '所属行业', + example: '白酒', + maxLength: 100, + }) + @Column({ name: 'industry', type: 'varchar', length: 100, nullable: true }) + industry?: string; + + @ApiPropertyOptional({ + description: '上市日期', + example: '2001-08-27', + }) + @Column({ name: 'listing_date', type: 'date', nullable: true }) + listingDate?: Date; + + @ApiProperty({ + description: '状态', + example: 'active', + enum: ['active', 'suspended', 'delisted'], + default: 'active', + }) + @Column({ + name: 'status', + type: 'varchar', + length: 20, + default: 'active', + }) + @Index() + status: string; + + @ApiProperty({ + description: '创建时间', + example: '2024-01-01T00:00:00.000Z', + }) + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @ApiProperty({ + description: '更新时间', + example: '2024-01-01T00:00:00.000Z', + }) + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/api/src/modules/stock-info/stock-info.module.ts b/apps/api/src/modules/stock-info/stock-info.module.ts new file mode 100644 index 0000000..c8d794a --- /dev/null +++ b/apps/api/src/modules/stock-info/stock-info.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StockInfoService } from './stock-info.service'; +import { StockInfoController } from './stock-info.controller'; +import { StockInfo } from './stock-info.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([StockInfo])], + controllers: [StockInfoController], + providers: [StockInfoService], + exports: [StockInfoService], +}) +export class StockInfoModule {} diff --git a/apps/api/src/modules/stock-info/stock-info.service.ts b/apps/api/src/modules/stock-info/stock-info.service.ts new file mode 100644 index 0000000..6789ea4 --- /dev/null +++ b/apps/api/src/modules/stock-info/stock-info.service.ts @@ -0,0 +1,367 @@ +import { + Injectable, + NotFoundException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { StockInfo } from './stock-info.entity'; +import { CreateStockInfoDto } from './dto/create-stock-info.dto'; +import { UpdateStockInfoDto } from './dto/update-stock-info.dto'; +import { QueryStockInfoDto } from './dto/query-stock-info.dto'; +import { BatchCreateStockInfoDto } from './dto/batch-create-stock-info.dto'; +import { PaginationInfo } from '@/common/dto/pagination.dto'; + +@Injectable() +export class StockInfoService { + private readonly logger = new Logger(StockInfoService.name); + + constructor( + @InjectRepository(StockInfo) + private readonly stockInfoRepository: Repository, + ) {} + + /** + * 单独创建股票信息 + */ + async create(createStockInfoDto: CreateStockInfoDto): Promise { + // 检查同一市场的 stock_code 是否已存在 + const existing = await this.stockInfoRepository.findOne({ + where: { + stockCode: createStockInfoDto.stockCode, + market: createStockInfoDto.market, + }, + }); + + if (existing) { + throw new ConflictException( + `市场 "${createStockInfoDto.market}" 中已存在代码为 "${createStockInfoDto.stockCode}" 的股票`, + ); + } + + const stockInfo = this.stockInfoRepository.create({ + ...createStockInfoDto, + status: createStockInfoDto.status ?? 'active', + listingDate: createStockInfoDto.listingDate + ? new Date(createStockInfoDto.listingDate) + : undefined, + }); + + return this.stockInfoRepository.save(stockInfo); + } + + /** + * 批量创建股票信息 + */ + async batchCreate( + batchCreateStockInfoDto: BatchCreateStockInfoDto, + ): Promise { + const stocks = batchCreateStockInfoDto.stocks.map((dto) => + this.stockInfoRepository.create({ + ...dto, + status: dto.status ?? 'active', + listingDate: dto.listingDate + ? new Date(dto.listingDate) + : undefined, + }), + ); + + // 检查是否有重复的 stock_code + market 组合 + const codeMarketPairs = stocks.map((s) => ({ + stockCode: s.stockCode, + market: s.market, + })); + + const existingStocks = await this.stockInfoRepository.find({ + where: codeMarketPairs.map((pair) => ({ + stockCode: pair.stockCode, + market: pair.market, + })), + }); + + if (existingStocks.length > 0) { + const conflicts = existingStocks.map( + (s) => `${s.stockCode} (${s.market})`, + ); + throw new ConflictException( + `以下股票已存在:${conflicts.join('、')}`, + ); + } + + // 检查批量数据内部是否有重复 + const uniquePairs = new Set( + codeMarketPairs.map((p) => `${p.stockCode}-${p.market}`), + ); + if (uniquePairs.size !== codeMarketPairs.length) { + throw new ConflictException( + '批量数据中存在重复的股票代码和市场组合', + ); + } + + return this.stockInfoRepository.save(stocks); + } + + /** + * 批量更新股票信息 + */ + async batchUpdate(updateDtos: UpdateStockInfoDto[]): Promise { + const updatedStocks: StockInfo[] = []; + + for (const updateDto of updateDtos) { + // 必须包含 stockCode 和 market 来定位记录 + if (!updateDto.stockCode || !updateDto.market) { + throw new ConflictException( + '批量更新时,每条记录必须包含 stockCode 和 market', + ); + } + + const existing = await this.stockInfoRepository.findOne({ + where: { + stockCode: updateDto.stockCode, + market: updateDto.market, + }, + }); + + if (!existing) { + throw new NotFoundException( + `未找到股票:${updateDto.stockCode} (${updateDto.market})`, + ); + } + + // 更新字段 + if (updateDto.stockName !== undefined) { + existing.stockName = updateDto.stockName; + } + if (updateDto.fullName !== undefined) { + existing.fullName = updateDto.fullName; + } + if (updateDto.industry !== undefined) { + existing.industry = updateDto.industry; + } + if (updateDto.listingDate !== undefined) { + existing.listingDate = updateDto.listingDate + ? new Date(updateDto.listingDate) + : undefined; + } + if (updateDto.status !== undefined) { + existing.status = updateDto.status; + } + + const saved = await this.stockInfoRepository.save(existing); + updatedStocks.push(saved); + } + + return updatedStocks; + } + + /** + * Upsert:存在则更新,不存在则新增 + */ + async upsert(createStockInfoDto: CreateStockInfoDto): Promise { + const existing = await this.stockInfoRepository.findOne({ + where: { + stockCode: createStockInfoDto.stockCode, + market: createStockInfoDto.market, + }, + }); + + if (existing) { + // 更新现有记录 + Object.assign(existing, { + ...createStockInfoDto, + listingDate: createStockInfoDto.listingDate + ? new Date(createStockInfoDto.listingDate) + : existing.listingDate, + status: createStockInfoDto.status ?? existing.status, + }); + return this.stockInfoRepository.save(existing); + } else { + // 创建新记录 + return this.create(createStockInfoDto); + } + } + + /** + * 批量 Upsert + */ + async batchUpsert( + batchCreateStockInfoDto: BatchCreateStockInfoDto, + ): Promise { + const results: StockInfo[] = []; + + for (const dto of batchCreateStockInfoDto.stocks) { + const result = await this.upsert(dto); + results.push(result); + } + + return results; + } + + /** + * 查询股票信息(支持多种查询条件和分页) + */ + async findAll(queryDto: QueryStockInfoDto): Promise<{ + list: StockInfo[]; + pagination: PaginationInfo; + }> { + const where: FindOptionsWhere = {}; + + if (queryDto.stockCode) { + where.stockCode = queryDto.stockCode; + } + + if (queryDto.market) { + where.market = queryDto.market; + } + + // 分页参数 + const page = queryDto.page || 1; + const limit = queryDto.limit || 10; + const skip = (page - 1) * limit; + + // 排序字段映射 + const sortBy = queryDto.sortBy || 'createdAt'; + const sortOrder = queryDto.sortOrder || 'DESC'; + + // 构建排序对象 + const order: Record = {}; + if (sortBy === 'createdAt') { + order.createdAt = sortOrder; + } else if (sortBy === 'updatedAt') { + order.updatedAt = sortOrder; + } else if (sortBy === 'stockCode') { + order.stockCode = sortOrder; + } else if (sortBy === 'stockName') { + order.stockName = sortOrder; + } else { + order.createdAt = 'DESC'; + } + // 添加默认排序 + order.id = 'ASC'; + + // 查询数据(如果 stockName 存在,使用 Like 进行模糊查询) + let query = this.stockInfoRepository.createQueryBuilder('stock_info'); + + if (queryDto.stockCode) { + query = query.andWhere('stock_info.stock_code = :stockCode', { + stockCode: queryDto.stockCode, + }); + } + + if (queryDto.stockName) { + query = query.andWhere('stock_info.stock_name LIKE :stockName', { + stockName: `%${queryDto.stockName}%`, + }); + } + + if (queryDto.market) { + query = query.andWhere('stock_info.market = :market', { + market: queryDto.market, + }); + } + + // 获取总数 + const total = await query.getCount(); + + // 添加排序和分页 + query = query + .orderBy(`stock_info.${sortBy}`, sortOrder) + .addOrderBy('stock_info.id', 'ASC') + .skip(skip) + .take(limit); + + const list = await query.getMany(); + + // 计算总页数 + const total_page = Math.ceil(total / limit); + + return { + list, + pagination: { + total, + total_page, + page_size: limit, + current_page: page, + }, + }; + } + + /** + * 根据 ID 查询单个股票信息 + */ + async findOneById(id: number): Promise { + const stockInfo = await this.stockInfoRepository.findOne({ + where: { id }, + }); + + if (!stockInfo) { + throw new NotFoundException(`未找到ID为 ${id} 的股票信息`); + } + + return stockInfo; + } + + /** + * 根据股票代码和市场查询单个股票信息 + */ + async findOneByCode(stockCode: string, market: string): Promise { + const stockInfo = await this.stockInfoRepository.findOne({ + where: { stockCode, market }, + }); + + if (!stockInfo) { + throw new NotFoundException(`未找到股票:${stockCode} (${market})`); + } + + return stockInfo; + } + + /** + * 更新股票信息 + */ + async update( + id: number, + updateStockInfoDto: UpdateStockInfoDto, + ): Promise { + const stockInfo = await this.findOneById(id); + + // 如果更新 stock_code 或 market,检查是否冲突 + if ( + 'stockCode' in updateStockInfoDto || + 'market' in updateStockInfoDto + ) { + const newCode = updateStockInfoDto.stockCode ?? stockInfo.stockCode; + const newMarket = updateStockInfoDto.market ?? stockInfo.market; + + const existing = await this.stockInfoRepository.findOne({ + where: { + stockCode: newCode, + market: newMarket, + }, + }); + + if (existing && existing.id !== id) { + throw new ConflictException( + `市场 "${newMarket}" 中已存在代码为 "${newCode}" 的股票`, + ); + } + } + + Object.assign(stockInfo, { + ...updateStockInfoDto, + listingDate: updateStockInfoDto.listingDate + ? new Date(updateStockInfoDto.listingDate) + : stockInfo.listingDate, + }); + return this.stockInfoRepository.save(stockInfo); + } + + /** + * 删除股票信息 + */ + async remove(id: number): Promise { + const stockInfo = await this.findOneById(id); + await this.stockInfoRepository.remove(stockInfo); + } +} diff --git a/apps/api/src/modules/storage/storage.controller.ts b/apps/api/src/modules/storage/storage.controller.ts index ba694b9..225da7f 100644 --- a/apps/api/src/modules/storage/storage.controller.ts +++ b/apps/api/src/modules/storage/storage.controller.ts @@ -220,7 +220,7 @@ export class StorageController { /** * 删除文件(需要管理员权限) */ - @Delete(':path(*)') + @Delete('*path') @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'super_admin') @@ -239,6 +239,10 @@ export class StorageController { @ApiResponse({ status: 401, description: '未授权' }) @ApiResponse({ status: 403, description: '权限不足' }) async delete(@Param('path') filePath: string) { - await this.storageService.delete(filePath); + // 移除路径开头的 /,如果有的话 + const cleanPath = filePath.startsWith('/') + ? filePath.substring(1) + : filePath; + await this.storageService.delete(cleanPath); } } diff --git a/apps/api/src/modules/user/user.seeder.ts b/apps/api/src/modules/user/user.seeder.ts index 4639d51..3d89b96 100644 --- a/apps/api/src/modules/user/user.seeder.ts +++ b/apps/api/src/modules/user/user.seeder.ts @@ -167,7 +167,7 @@ export class UserSeeder implements OnModuleInit { nickname: userData.nickname, role: userData.role, status: 'active', - }); + }); }), ); @@ -239,13 +239,13 @@ export class UserSeeder implements OnModuleInit { return this.userRepository.create({ username: mockUser.username, - passwordHash, + passwordHash, email: mockUser.email, nickname: mockUser.nickname, phone: mockUser.phone, role: 'user', - status: 'active', - }); + status: 'active', + }); }), ); diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts index be82648..bb97772 100644 --- a/apps/api/src/modules/user/user.service.ts +++ b/apps/api/src/modules/user/user.service.ts @@ -193,11 +193,18 @@ export class UserService { take: limit, }); + // 移除 passwordHash 字段 + const listWithoutPassword = list.map((user) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword as User; + }); + // 计算总页数 const total_page = Math.ceil(total / limit); return { - list, + list: listWithoutPassword, pagination: { total, total_page, @@ -247,7 +254,10 @@ export class UserService { throw new NotFoundException(`未找到ID为 ${id} 的用户`); } - return user; + // 移除 passwordHash 字段 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword as User; } /** diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index dd4cb3d..780bec9 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -63,7 +63,7 @@ const MainLayout = () => { key: 'profile', icon: , label: '个人资料', - disabled: true, // 暂时禁用 + onClick: () => navigate('/user-info'), }, { type: 'divider', diff --git a/apps/web/src/layouts/menuConfig.tsx b/apps/web/src/layouts/menuConfig.tsx index a242c1e..2aa1ae7 100644 --- a/apps/web/src/layouts/menuConfig.tsx +++ b/apps/web/src/layouts/menuConfig.tsx @@ -6,6 +6,8 @@ import { DashboardOutlined, BankOutlined, UserOutlined, + StockOutlined, + LineChartOutlined, } from '@ant-design/icons'; import type { ReactNode } from 'react'; @@ -62,6 +64,15 @@ export const routeMenuConfig: RouteMenuConfig[] = [ subtitle: '回顾过去是为了更好应对将来', group: 'main', }, + { + path: '/user-info', + key: '/user-info', + icon: , + label: '个人资料', + title: '个人资料', + subtitle: '查看和编辑个人信息', + group: 'main', + }, { path: '/user', key: '/user', @@ -82,6 +93,26 @@ export const routeMenuConfig: RouteMenuConfig[] = [ group: 'admin', requireAdmin: true, }, + { + path: '/stock-info', + key: '/stock-info', + icon: , + label: '股票信息', + title: '股票信息', + subtitle: '管理股票基本信息', + group: 'admin', + requireAdmin: true, + }, + { + path: '/stock-daily-price', + key: '/stock-daily-price', + icon: , + label: '股票价格', + title: '股票价格', + subtitle: '查看股票每日价格数据', + group: 'admin', + requireAdmin: true, + }, { path: '/seo', key: '/seo', diff --git a/apps/web/src/pages/stock-daily-price/StockDailyPricePage.css b/apps/web/src/pages/stock-daily-price/StockDailyPricePage.css new file mode 100644 index 0000000..c3e8de4 --- /dev/null +++ b/apps/web/src/pages/stock-daily-price/StockDailyPricePage.css @@ -0,0 +1,28 @@ +.stock-daily-price-page { + padding: 0; +} + +.stock-daily-price-search-form { + margin-bottom: 16px; +} + +.stock-daily-price-search-form .ant-form-item { + margin-bottom: 16px; +} + +/* 表格样式优化 */ +.stock-daily-price-page .ant-table { + background: #fff; +} + +.stock-daily-price-page .ant-table-thead > tr > th { + background: #fafafa; + font-weight: 600; +} + +/* 响应式 */ +@media (max-width: 768px) { + .stock-daily-price-search-form .ant-form-item { + margin-bottom: 12px; + } +} diff --git a/apps/web/src/pages/stock-daily-price/StockDailyPricePage.tsx b/apps/web/src/pages/stock-daily-price/StockDailyPricePage.tsx new file mode 100644 index 0000000..e4e45db --- /dev/null +++ b/apps/web/src/pages/stock-daily-price/StockDailyPricePage.tsx @@ -0,0 +1,384 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Table, + Button, + Input, + Select, + Space, + Card, + Form, + Row, + Col, + App as AntdApp, + Tag, + DatePicker, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { SearchOutlined, ReloadOutlined } from '@ant-design/icons'; +import { stockDailyPriceService } from '@/services/stock-daily-price'; +import type { StockDailyPrice, QueryStockDailyPriceRequest } from '@/types/stock-daily-price'; +import { MARKET_OPTIONS, getMarketText } from '@/types/stock-daily-price'; +import dayjs from 'dayjs'; +import './StockDailyPricePage.css'; + +const { Option } = Select; +const { RangePicker } = DatePicker; + +const StockDailyPricePage = () => { + const { message: messageApi } = AntdApp.useApp(); + const [prices, setPrices] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: 0, + }); + const [form] = Form.useForm(); + const formRef = useRef({}); + + // 初始化:默认查询最近7天 + useEffect(() => { + const endDate = dayjs(); + const startDate = endDate.subtract(6, 'day'); // 最近7天(包含今天) + form.setFieldsValue({ + dateRange: [startDate, endDate], + }); + formRef.current = { + startDate: startDate.format('YYYY-MM-DD'), + endDate: endDate.format('YYYY-MM-DD'), + }; + loadData(formRef.current, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 加载数据 + const loadData = async (params?: QueryStockDailyPriceRequest, resetPage = false) => { + setLoading(true); + try { + const currentPage = resetPage ? 1 : pagination.current; + const pageSize = pagination.pageSize; + + const queryParams: QueryStockDailyPriceRequest = { + page: currentPage, + limit: pageSize, + sortBy: 'tradeDate', + sortOrder: 'DESC', + ...formRef.current, + ...params, + }; + + const response = await stockDailyPriceService.getStockDailyPriceList(queryParams); + + setPrices(response.list); + setPagination((prev) => ({ + ...prev, + current: response.pagination.current_page, + pageSize: response.pagination.page_size, + total: response.pagination.total, + })); + } catch (error: any) { + messageApi.error(error.message || '加载股票价格列表失败'); + } finally { + setLoading(false); + } + }; + + // 当分页改变时,重新加载数据 + useEffect(() => { + if (pagination.current > 0 && pagination.pageSize > 0) { + loadData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination.current, pagination.pageSize]); + + // 查询 + const handleSearch = () => { + const values = form.getFieldsValue(); + const queryParams: QueryStockDailyPriceRequest = { + stockCode: values.stockCode || undefined, + stockName: values.stockName || undefined, + market: values.market || undefined, + }; + + // 处理日期范围 + if (values.dateRange && values.dateRange.length === 2) { + queryParams.startDate = values.dateRange[0].format('YYYY-MM-DD'); + queryParams.endDate = values.dateRange[1].format('YYYY-MM-DD'); + } + + formRef.current = queryParams; + loadData(queryParams, true); + }; + + // 重置 + const handleReset = () => { + form.resetFields(); + // 重置为默认的最近7天 + const endDate = dayjs(); + const startDate = endDate.subtract(6, 'day'); + form.setFieldsValue({ + dateRange: [startDate, endDate], + }); + formRef.current = { + startDate: startDate.format('YYYY-MM-DD'), + endDate: endDate.format('YYYY-MM-DD'), + }; + loadData(formRef.current, true); + }; + + // 格式化价格 + const formatPrice = (price?: number) => { + if (price === null || price === undefined) return '-'; + return price.toFixed(2); + }; + + // 格式化金额 + const formatAmount = (amount?: number) => { + if (amount === null || amount === undefined) return '-'; + if (amount >= 100000000) { + return `${(amount / 100000000).toFixed(2)}亿`; + } + if (amount >= 10000) { + return `${(amount / 10000).toFixed(2)}万`; + } + return amount.toFixed(2); + }; + + // 格式化成交量 + const formatVolume = (volume?: number) => { + if (volume === null || volume === undefined) return '-'; + if (volume >= 10000) { + return `${(volume / 10000).toFixed(2)}万手`; + } + return `${volume}手`; + }; + + // 表格列定义 + const columns: ColumnsType = [ + { + title: '股票代码', + dataIndex: 'stockCode', + key: 'stockCode', + width: 120, + fixed: 'left', + }, + { + title: '股票名称', + dataIndex: 'stockName', + key: 'stockName', + width: 150, + fixed: 'left', + }, + { + title: '市场', + dataIndex: 'market', + key: 'market', + width: 100, + render: (market: string) => {getMarketText(market)}, + }, + { + title: '交易日期', + dataIndex: 'tradeDate', + key: 'tradeDate', + width: 120, + render: (date: Date) => dayjs(date).format('YYYY-MM-DD'), + }, + { + title: '开盘价', + dataIndex: 'openPrice', + key: 'openPrice', + width: 100, + align: 'right', + render: formatPrice, + }, + { + title: '收盘价', + dataIndex: 'closePrice', + key: 'closePrice', + width: 100, + align: 'right', + render: formatPrice, + }, + { + title: '最高价', + dataIndex: 'highPrice', + key: 'highPrice', + width: 100, + align: 'right', + render: formatPrice, + }, + { + title: '最低价', + dataIndex: 'lowPrice', + key: 'lowPrice', + width: 100, + align: 'right', + render: formatPrice, + }, + { + title: '涨跌额', + dataIndex: 'changeAmount', + key: 'changeAmount', + width: 100, + align: 'right', + render: (amount?: number) => { + if (amount === null || amount === undefined) return '-'; + const color = amount >= 0 ? '#ff4d4f' : '#52c41a'; + return {formatPrice(amount)}; + }, + }, + { + title: '涨跌幅', + dataIndex: 'changePercent', + key: 'changePercent', + width: 100, + align: 'right', + render: (percent?: number) => { + if (percent === null || percent === undefined) return '-'; + const color = percent >= 0 ? '#ff4d4f' : '#52c41a'; + return ( + + {percent >= 0 ? '+' : ''} + {percent.toFixed(2)}% + + ); + }, + }, + { + title: '成交量', + dataIndex: 'volume', + key: 'volume', + width: 120, + align: 'right', + render: formatVolume, + }, + { + title: '成交额', + dataIndex: 'amount', + key: 'amount', + width: 150, + align: 'right', + render: formatAmount, + }, + { + title: '换手率', + dataIndex: 'turnoverRate', + key: 'turnoverRate', + width: 100, + align: 'right', + render: (rate?: number) => { + if (rate === null || rate === undefined) return '-'; + return `${rate.toFixed(2)}%`; + }, + }, + { + title: '市盈率', + dataIndex: 'peRatio', + key: 'peRatio', + width: 100, + align: 'right', + render: formatPrice, + }, + { + title: '市净率', + dataIndex: 'pbRatio', + key: 'pbRatio', + width: 100, + align: 'right', + render: formatPrice, + }, + ]; + + return ( +
+ + {/* 查询表单 */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* 表格 */} + `共 ${total} 条`, + onChange: (page, pageSize) => { + setPagination((prev) => ({ + ...prev, + current: page, + pageSize: pageSize || 10, + })); + }, + }} + scroll={{ x: 1600 }} + /> + + + ); +}; + +export default StockDailyPricePage; diff --git a/apps/web/src/pages/stock-daily-price/index.ts b/apps/web/src/pages/stock-daily-price/index.ts new file mode 100644 index 0000000..0b4780d --- /dev/null +++ b/apps/web/src/pages/stock-daily-price/index.ts @@ -0,0 +1 @@ +export { default } from './StockDailyPricePage'; diff --git a/apps/web/src/pages/stock-info/StockInfoPage.css b/apps/web/src/pages/stock-info/StockInfoPage.css new file mode 100644 index 0000000..413df6c --- /dev/null +++ b/apps/web/src/pages/stock-info/StockInfoPage.css @@ -0,0 +1,28 @@ +.stock-info-page { + padding: 0; +} + +.stock-info-search-form { + margin-bottom: 16px; +} + +.stock-info-search-form .ant-form-item { + margin-bottom: 16px; +} + +/* 表格样式优化 */ +.stock-info-page .ant-table { + background: #fff; +} + +.stock-info-page .ant-table-thead > tr > th { + background: #fafafa; + font-weight: 600; +} + +/* 响应式 */ +@media (max-width: 768px) { + .stock-info-search-form .ant-form-item { + margin-bottom: 12px; + } +} diff --git a/apps/web/src/pages/stock-info/StockInfoPage.tsx b/apps/web/src/pages/stock-info/StockInfoPage.tsx new file mode 100644 index 0000000..cdf68d9 --- /dev/null +++ b/apps/web/src/pages/stock-info/StockInfoPage.tsx @@ -0,0 +1,250 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Table, + Button, + Input, + Select, + Space, + Card, + Form, + Row, + Col, + App as AntdApp, + Tag, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { SearchOutlined, ReloadOutlined } from '@ant-design/icons'; +import { stockInfoService } from '@/services/stock-info'; +import type { StockInfo, QueryStockInfoRequest } from '@/types/stock-info'; +import { MARKET_OPTIONS, getMarketText, STATUS_OPTIONS, getStatusText } from '@/types/stock-info'; +import dayjs from 'dayjs'; +import './StockInfoPage.css'; + +const { Option } = Select; + +const StockInfoPage = () => { + const { message: messageApi } = AntdApp.useApp(); + const [stocks, setStocks] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: 0, + }); + const [form] = Form.useForm(); + const formRef = useRef({}); + + // 加载数据 + const loadData = async (params?: QueryStockInfoRequest, resetPage = false) => { + setLoading(true); + try { + const currentPage = resetPage ? 1 : pagination.current; + const pageSize = pagination.pageSize; + + const queryParams: QueryStockInfoRequest = { + page: currentPage, + limit: pageSize, + sortBy: 'createdAt', + sortOrder: 'DESC', + ...formRef.current, + ...params, + }; + + const response = await stockInfoService.getStockInfoList(queryParams); + + setStocks(response.list); + setPagination((prev) => ({ + ...prev, + current: response.pagination.current_page, + pageSize: response.pagination.page_size, + total: response.pagination.total, + })); + } catch (error: any) { + messageApi.error(error.message || '加载股票信息列表失败'); + } finally { + setLoading(false); + } + }; + + // 初始加载 + useEffect(() => { + loadData({}, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 当分页改变时,重新加载数据 + useEffect(() => { + if (pagination.current > 0 && pagination.pageSize > 0) { + loadData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination.current, pagination.pageSize]); + + // 查询 + const handleSearch = () => { + const values = form.getFieldsValue(); + formRef.current = { + stockCode: values.stockCode || undefined, + stockName: values.stockName || undefined, + market: values.market || undefined, + }; + loadData(formRef.current, true); + }; + + // 重置 + const handleReset = () => { + form.resetFields(); + formRef.current = {}; + loadData({}, true); + }; + + // 表格列定义 + const columns: ColumnsType = [ + { + title: '股票代码', + dataIndex: 'stockCode', + key: 'stockCode', + width: 120, + }, + { + title: '股票名称', + dataIndex: 'stockName', + key: 'stockName', + width: 200, + }, + { + title: '市场', + dataIndex: 'market', + key: 'market', + width: 100, + render: (market: string) => {getMarketText(market)}, + }, + { + title: '公司全称', + dataIndex: 'fullName', + key: 'fullName', + width: 300, + ellipsis: true, + }, + { + title: '所属行业', + dataIndex: 'industry', + key: 'industry', + width: 150, + }, + { + title: '上市日期', + dataIndex: 'listingDate', + key: 'listingDate', + width: 120, + render: (date: Date) => (date ? dayjs(date).format('YYYY-MM-DD') : '-'), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: string) => { + const colorMap: Record = { + active: 'success', + suspended: 'warning', + delisted: 'error', + }; + return {getStatusText(status)}; + }, + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + render: (date: Date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'), + }, + ]; + + return ( +
+ + {/* 查询表单 */} +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {/* 表格 */} +
`共 ${total} 条`, + onChange: (page, pageSize) => { + setPagination((prev) => ({ + ...prev, + current: page, + pageSize: pageSize || 10, + })); + }, + }} + scroll={{ x: 1200 }} + /> + + + ); +}; + +export default StockInfoPage; diff --git a/apps/web/src/pages/stock-info/index.ts b/apps/web/src/pages/stock-info/index.ts new file mode 100644 index 0000000..e194e89 --- /dev/null +++ b/apps/web/src/pages/stock-info/index.ts @@ -0,0 +1 @@ +export { default } from './StockInfoPage'; diff --git a/apps/web/src/pages/user/UserInfoPage.css b/apps/web/src/pages/user/UserInfoPage.css new file mode 100644 index 0000000..419a341 --- /dev/null +++ b/apps/web/src/pages/user/UserInfoPage.css @@ -0,0 +1,15 @@ +.user-info-content { + padding: 24px 0; +} + +.avatar-wrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.info-field-text { + font-size: 14px; + line-height: 1.5715; + color: rgba(0, 0, 0, 0.88); +} diff --git a/apps/web/src/pages/user/UserInfoPage.tsx b/apps/web/src/pages/user/UserInfoPage.tsx new file mode 100644 index 0000000..64ca155 --- /dev/null +++ b/apps/web/src/pages/user/UserInfoPage.tsx @@ -0,0 +1,479 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Tabs, + Card, + Avatar, + Form, + Input, + Button, + Upload, + Image, + Space, + App as AntdApp, + Modal, +} from 'antd'; +import type { TabsProps } from 'antd'; +import { UserOutlined, UploadOutlined } from '@ant-design/icons'; +import { authService } from '@/services/auth'; +import { userService } from '@/services/user'; +import { storageService } from '@/services/storage'; +import type { UserInfo } from '@/types/user'; +import type { UploadFile } from 'antd/es/upload'; +import type { RcFile } from 'antd/es/upload'; +import dayjs from 'dayjs'; +import './UserInfoPage.css'; + +/** + * 计算使用天数 + * @param createdAt 创建时间 + * @returns 使用天数字符串,如 "365天" 或 "1年30天" + */ +const calculateUsageDays = (createdAt: Date | string): string => { + const created = dayjs(createdAt); + const now = dayjs(); + const days = now.diff(created, 'day'); + + if (days < 365) { + return `${days}天`; + } + + const years = Math.floor(days / 365); + const remainingDays = days % 365; + return `${years}年${remainingDays}天`; +}; + +const UserInfoPage = () => { + const { message: messageApi } = AntdApp.useApp(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [editing, setEditing] = useState(false); + const [avatarFileList, setAvatarFileList] = useState([]); + const [passwordForm] = Form.useForm(); + const [infoForm] = Form.useForm(); + const isLoadingRef = useRef(false); + + // 加载用户信息 + const loadUserInfo = async () => { + // 防止重复请求 + if (isLoadingRef.current) { + return; + } + + const currentUser = authService.getUser(); + if (!currentUser) { + messageApi.error('未找到用户信息'); + return; + } + + isLoadingRef.current = true; + setLoading(true); + try { + const userData = await userService.getUserById(currentUser.userId); + console.log('userData:', userData); + setUser(userData); + infoForm.setFieldsValue({ + nickname: userData.nickname || '', + phone: userData.phone || '', + email: userData.email || '', + }); + // 设置头像文件列表 + if (userData.avatarUrl) { + setAvatarFileList([ + { + uid: '-1', + name: 'avatar', + status: 'done', + url: userData.avatarUrl, + }, + ]); + } + } catch (error: any) { + messageApi.error(error.message || '加载用户信息失败'); + } finally { + setLoading(false); + isLoadingRef.current = false; + } + }; + + useEffect(() => { + loadUserInfo(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 上传头像 + const handleAvatarUpload = async ( + file: RcFile, + onSuccess?: (response: any) => void, + onError?: (error: any) => void + ) => { + setUploading(true); + try { + const response = await storageService.uploadAvatar(file); + // 更新用户头像 + if (user) { + await userService.updateUser(user.userId, { + avatarUrl: response.url, + }); + messageApi.success('头像上传成功'); + await loadUserInfo(); + onSuccess?.(response); + } + } catch (error: any) { + messageApi.error(error.message || '头像上传失败'); + onError?.(error); + } finally { + setUploading(false); + } + }; + + // 头像上传配置 + const avatarUploadProps = { + name: 'file', + listType: 'picture' as const, + maxCount: 1, + fileList: avatarFileList, + accept: 'image/*', + customRequest: async (options: any) => { + const { file, onSuccess, onError } = options; + await handleAvatarUpload(file as RcFile, onSuccess, onError); + }, + beforeUpload: (file: File) => { + const isImage = file.type.startsWith('image/'); + if (!isImage) { + messageApi.error('只能上传图片文件!'); + return false; + } + const isLt2M = file.size / 1024 / 1024 < 2; + if (!isLt2M) { + messageApi.error('图片大小不能超过 2MB!'); + return false; + } + return true; + }, + onRemove: () => { + setAvatarFileList([]); + }, + onChange: ({ fileList: newFileList }: { fileList: UploadFile[] }) => { + setAvatarFileList(newFileList); + }, + }; + + // 进入编辑模式 + const handleEdit = () => { + if (user) { + infoForm.setFieldsValue({ + nickname: user.nickname || '', + phone: user.phone || '', + email: user.email || '', + }); + setEditing(true); + } + }; + + // 取消编辑 + const handleCancelEdit = () => { + setEditing(false); + infoForm.resetFields(); + }; + + // 处理更新按钮点击(先验证表单,再显示确认框) + const handleUpdateClick = async () => { + try { + // 先验证表单 + const values = await infoForm.validateFields(); + if (!user) return; + + // 验证通过,显示确认框 + Modal.confirm({ + title: '确认更新', + content: '确定要更新个人信息吗?', + onOk: async () => { + try { + await userService.updateUser(user.userId, { + nickname: values.nickname || undefined, + phone: values.phone || undefined, + email: values.email || undefined, + }); + + messageApi.success('个人信息更新成功'); + setEditing(false); + await loadUserInfo(); + } catch (error: any) { + messageApi.error(error.message || '更新失败'); + } + }, + }); + } catch (error: any) { + // 验证失败,不显示确认框 + if (error.errorFields) { + return; + } + } + }; + + // 修改密码 + const handleChangePassword = async () => { + try { + const values = await passwordForm.validateFields(); + if (!user) return; + + await userService.changePassword(user.userId, { + oldPassword: values.oldPassword, + newPassword: values.newPassword, + }); + + messageApi.success('密码修改成功'); + passwordForm.resetFields(); + } catch (error: any) { + if (error.errorFields) { + return; + } + messageApi.error(error.message || '密码修改失败'); + } + }; + + if (!user) { + return 加载中...; + } + + // Tab配置 + const tabItems: TabsProps['items'] = [ + { + key: 'info', + label: '个人信息', + children: ( +
+
+ {/* 头像 */} + +
+ {user.avatarUrl ? ( + + ) : ( + } /> + )} +
+ {editing ? ( + + + + ) : ( + + 头像 + + )} +
+
+
+ {/* 昵称 */} + + {editing ? ( + + + + ) : ( + {user.nickname || '-'} + )} + + + {/* 用户名(不可编辑) */} + + {user.username} + + + {/* 电话 */} + + {editing ? ( + + + + ) : ( + {user.phone || '-'} + )} + + + {/* 邮箱 */} + + {editing ? ( + + + + ) : ( + {user.email} + )} + + + {/* 注册时间(不可编辑) */} + + + {dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + + {/* 使用天数(不可编辑) */} + + + {calculateUsageDays(user.createdAt)} + + + + {/* 操作按钮 */} + + {editing ? ( + + + + + ) : ( + + )} + + +
+ ), + }, + { + key: 'password', + label: '修改密码', + children: ( +
+ + + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('newPassword') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + + + + + + + + ), + }, + ]; + + return ( +
+ + + +
+ ); +}; + +export default UserInfoPage; diff --git a/apps/web/src/pages/user/index.ts b/apps/web/src/pages/user/index.ts index da07147..85676c6 100644 --- a/apps/web/src/pages/user/index.ts +++ b/apps/web/src/pages/user/index.ts @@ -1 +1,2 @@ -export { default } from './UserPage'; +export { default as UserPage } from './UserPage'; +export { default as UserInfoPage } from './UserInfoPage'; diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index b7d5483..1b1772f 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -8,7 +8,10 @@ const AssetsPage = lazy(() => import('../pages/AssetsPage')); const PlansPage = lazy(() => import('../pages/PlansPage')); const ReviewPage = lazy(() => import('../pages/ReviewPage')); const BrokerPage = lazy(() => import('../pages/broker')); -const UserPage = lazy(() => import('@/pages/user')); +const UserPage = lazy(() => import('@/pages/user/UserPage')); +const UserInfoPage = lazy(() => import('@/pages/user/UserInfoPage')); +const StockInfoPage = lazy(() => import('@/pages/stock-info')); +const StockDailyPricePage = lazy(() => import('@/pages/stock-daily-price')); export const router = createBrowserRouter([ { @@ -49,6 +52,18 @@ export const router = createBrowserRouter([ path: 'user', element: , }, + { + path: 'user-info', + element: , + }, + { + path: 'stock-info', + element: , + }, + { + path: 'stock-daily-price', + element: , + }, ], }, { diff --git a/apps/web/src/services/stock-daily-price.ts b/apps/web/src/services/stock-daily-price.ts new file mode 100644 index 0000000..46316fb --- /dev/null +++ b/apps/web/src/services/stock-daily-price.ts @@ -0,0 +1,37 @@ +import { api } from './api'; +import type { + StockDailyPrice, + QueryStockDailyPriceRequest, + PaginatedStockDailyPriceResponse, + ApiResponse, +} from '@/types/stock-daily-price'; + +/** + * 股票每日价格服务 + */ +class StockDailyPriceService { + /** + * 查询股票每日价格列表(分页) + */ + async getStockDailyPriceList( + params: QueryStockDailyPriceRequest + ): Promise { + const response = await api.get>( + '/stock-daily-price', + { params } + ); + if ('list' in response && 'pagination' in response) { + return response as PaginatedStockDailyPriceResponse; + } + return (response as ApiResponse).data; + } + + /** + * 根据ID查询股票每日价格 + */ + async getStockDailyPriceById(id: number): Promise { + return await api.get(`/stock-daily-price/${id}`); + } +} + +export const stockDailyPriceService = new StockDailyPriceService(); diff --git a/apps/web/src/services/stock-info.ts b/apps/web/src/services/stock-info.ts new file mode 100644 index 0000000..6b73c28 --- /dev/null +++ b/apps/web/src/services/stock-info.ts @@ -0,0 +1,34 @@ +import { api } from './api'; +import type { + StockInfo, + QueryStockInfoRequest, + PaginatedStockInfoResponse, + ApiResponse, +} from '@/types/stock-info'; + +/** + * 股票信息服务 + */ +class StockInfoService { + /** + * 查询股票信息列表(分页) + */ + async getStockInfoList(params: QueryStockInfoRequest): Promise { + const response = await api.get>('/stock-info', { + params, + }); + if ('list' in response && 'pagination' in response) { + return response as PaginatedStockInfoResponse; + } + return (response as ApiResponse).data; + } + + /** + * 根据ID查询股票信息 + */ + async getStockInfoById(id: number): Promise { + return await api.get(`/stock-info/${id}`); + } +} + +export const stockInfoService = new StockInfoService(); diff --git a/apps/web/src/services/user.ts b/apps/web/src/services/user.ts index cd1509e..a8a6d3a 100644 --- a/apps/web/src/services/user.ts +++ b/apps/web/src/services/user.ts @@ -25,7 +25,11 @@ class UserService { * 根据ID查询用户 */ async getUserById(id: number): Promise { - return await api.get(`/user/${id}`); + const response = await api.get>(`/user/${id}`); + if (response.code === 0 && response.data) { + return response.data as User; + } + throw new Error(response.message || '获取用户信息失败'); } /** @@ -41,6 +45,31 @@ class UserService { async deleteUser(id: number): Promise { await api.delete(`/user/${id}`); } + + /** + * 更新用户信息 + */ + async updateUser( + id: number, + data: { + nickname?: string; + phone?: string; + avatarUrl?: string; + email?: string; + } + ): Promise { + return await api.patch(`/user/${id}`, data); + } + + /** + * 修改密码 + */ + async changePassword( + id: number, + data: { oldPassword: string; newPassword: string } + ): Promise { + await api.patch(`/user/${id}/password`, data); + } } export const userService = new UserService(); diff --git a/apps/web/src/types/stock-daily-price.ts b/apps/web/src/types/stock-daily-price.ts new file mode 100644 index 0000000..142e4e6 --- /dev/null +++ b/apps/web/src/types/stock-daily-price.ts @@ -0,0 +1,79 @@ +/** + * 股票每日价格接口 + */ +export interface StockDailyPrice { + id: number; + stockCode: string; + stockName: string; + market: string; + tradeDate: Date; + openPrice?: number; + closePrice: number; + highPrice?: number; + lowPrice?: number; + volume?: number; + amount?: number; + changeAmount?: number; + changePercent?: number; + turnoverRate?: number; + peRatio?: number; + pbRatio?: number; + marketCap?: number; + createdAt: Date; +} + +/** + * 查询股票每日价格请求参数 + */ +export interface QueryStockDailyPriceRequest { + stockCode?: string; + stockName?: string; + market?: string; + tradeDate?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * 分页响应数据 + */ +export interface PaginatedStockDailyPriceResponse { + list: StockDailyPrice[]; + pagination: { + total: number; + total_page: number; + page_size: number; + current_page: number; + }; +} + +/** + * API 响应格式 + */ +export interface ApiResponse { + code: number; + message: string; + data: T; +} + +/** + * 市场选项 + */ +export const MARKET_OPTIONS = [ + { label: '上海', value: 'sh' }, + { label: '深圳', value: 'sz' }, + { label: '北京', value: 'bj' }, + { label: '香港', value: 'hk' }, +] as const; + +/** + * 获取市场显示文本 + */ +export const getMarketText = (market: string): string => { + const option = MARKET_OPTIONS.find((opt) => opt.value === market); + return option ? option.label : market; +}; diff --git a/apps/web/src/types/stock-info.ts b/apps/web/src/types/stock-info.ts new file mode 100644 index 0000000..cb7bc49 --- /dev/null +++ b/apps/web/src/types/stock-info.ts @@ -0,0 +1,85 @@ +/** + * 股票基本信息接口 + */ +export interface StockInfo { + id: number; + stockCode: string; + stockName: string; + market: string; + fullName?: string; + industry?: string; + listingDate?: Date; + status: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * 查询股票信息请求参数 + */ +export interface QueryStockInfoRequest { + stockCode?: string; + stockName?: string; + market?: string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +/** + * 分页响应数据 + */ +export interface PaginatedStockInfoResponse { + list: StockInfo[]; + pagination: { + total: number; + total_page: number; + page_size: number; + current_page: number; + }; +} + +/** + * API 响应格式 + */ +export interface ApiResponse { + code: number; + message: string; + data: T; +} + +/** + * 市场选项 + */ +export const MARKET_OPTIONS = [ + { label: '上海', value: 'sh' }, + { label: '深圳', value: 'sz' }, + { label: '北京', value: 'bj' }, + { label: '香港', value: 'hk' }, +] as const; + +/** + * 状态选项 + */ +export const STATUS_OPTIONS = [ + { label: '正常', value: 'active' }, + { label: '停牌', value: 'suspended' }, + { label: '退市', value: 'delisted' }, +] as const; + +/** + * 获取市场显示文本 + */ +export const getMarketText = (market: string): string => { + const option = MARKET_OPTIONS.find((opt) => opt.value === market); + return option ? option.label : market; +}; + +/** + * 获取状态显示文本 + */ +export const getStatusText = (status: string): string => { + const option = STATUS_OPTIONS.find((opt) => opt.value === status); + return option ? option.label : status; +};