feat: 开发持仓、股票信息相关接口

This commit is contained in:
R524809
2026-01-12 17:38:55 +08:00
parent 67e4dc6382
commit 838a021ce5
46 changed files with 4407 additions and 12 deletions

View File

@@ -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: [],

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 字段不允许更新
}

View File

@@ -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<PositionResponseDto[]> {
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<Position> {
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<Position> {
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<void> {
return this.positionService.remove(+id, req.user.userId);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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<Position>,
) {}
/**
* 查询用户所有持仓(包含计算字段)
*/
async findAllByUserId(userId: number): Promise<PositionResponseDto[]> {
// 查询用户所有持仓
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<number> {
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<Position> {
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
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<Position> {
// 查找持仓
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<void> {
const position = await this.positionRepository.findOne({
where: { positionId, userId },
});
if (!position) {
throw new NotFoundException(`持仓不存在ID ${positionId}`);
}
await this.positionRepository.remove(position);
}
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateStockDailyPriceDto } from './create-stock-daily-price.dto';
export class UpdateStockDailyPriceDto extends PartialType(
CreateStockDailyPriceDto,
) {}

View File

@@ -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<StockDailyPrice> {
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<StockDailyPrice[]> {
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<StockDailyPrice> {
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<StockDailyPrice> {
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<StockDailyPrice[]> {
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<StockDailyPrice> {
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<StockDailyPrice[]> {
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<void> {
return this.stockDailyPriceService.remove(+id);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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<StockDailyPrice>,
) {}
/**
* 单独创建股票每日价格
*/
async create(
createStockDailyPriceDto: CreateStockDailyPriceDto,
): Promise<StockDailyPrice> {
// 检查同一股票同一日期是否已存在
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<StockDailyPrice[]> {
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<StockDailyPrice[]> {
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<StockDailyPrice> {
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<StockDailyPrice> {
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<StockDailyPrice[]> {
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<StockDailyPrice> {
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<void> {
const stockDailyPrice = await this.findOneById(id);
await this.stockDailyPriceRepository.remove(stockDailyPrice);
}
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateStockInfoDto } from './create-stock-info.dto';
export class UpdateStockInfoDto extends PartialType(CreateStockInfoDto) {}

View File

@@ -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<StockInfo> {
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<StockInfo[]> {
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<StockInfo> {
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<StockInfo[]> {
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<StockInfo> {
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<StockInfo> {
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<StockInfo> {
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<StockInfo[]> {
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<void> {
return this.stockInfoService.remove(+id);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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<StockInfo>,
) {}
/**
* 单独创建股票信息
*/
async create(createStockInfoDto: CreateStockInfoDto): Promise<StockInfo> {
// 检查同一市场的 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<StockInfo[]> {
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<StockInfo[]> {
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<StockInfo> {
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<StockInfo[]> {
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<StockInfo> = {};
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<string, 'ASC' | 'DESC'> = {};
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<StockInfo> {
const stockInfo = await this.stockInfoRepository.findOne({
where: { id },
});
if (!stockInfo) {
throw new NotFoundException(`未找到ID为 ${id} 的股票信息`);
}
return stockInfo;
}
/**
* 根据股票代码和市场查询单个股票信息
*/
async findOneByCode(stockCode: string, market: string): Promise<StockInfo> {
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<StockInfo> {
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<void> {
const stockInfo = await this.findOneById(id);
await this.stockInfoRepository.remove(stockInfo);
}
}

View File

@@ -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);
}
}

View File

@@ -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',
});
}),
);

View File

@@ -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;
}
/**