feat: 更新持仓

This commit is contained in:
R524809
2026-01-16 18:01:41 +08:00
parent b7972153cc
commit aa313b7605
16 changed files with 1757 additions and 39 deletions

View File

@@ -18,4 +18,7 @@ ADMIN_USERNAME=joey
ADMIN_PASSWORD=joey5628
ADMIN_EMAIL=zhangyi5628@126.com
ADMIN_NICKNAME=思考的Joey
ADMIN_ROLE=super_admin
ADMIN_ROLE=super_admin
STORAGE_PATH=./uploads # 存储路径(默认:./uploads
STORAGE_BASE_URL=http://localhost:3200/uploads # 访问URL默认http://localhost:3200/uploads

View File

@@ -16,33 +16,48 @@ async function bootstrap() {
bodyParser: false, // 禁用默认 bodyParser使用自定义配置
});
// 配置静态文件服务
// 配置 CORS必须在其他中间件之前
app.enableCors({
origin: true, // 允许所有域名
credentials: true, // 允许携带凭证
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
// 配置静态文件服务(需要在 CORS 之后,以便静态文件也能应用 CORS
const storagePath =
configService.get<string>('STORAGE_PATH') || './uploads';
app.useStaticAssets(join(process.cwd(), storagePath), {
prefix: '/uploads/',
setHeaders: (res) => {
// 为静态文件添加 CORS 头
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
res.setHeader('Access-Control-Allow-Origin', '*');
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
},
});
// 安全头设置(必须在其他中间件之前
app.use(helmet());
// 安全头设置(必须在 CORS 之后
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' }, // 允许跨域资源访问
}),
);
// 速率限制
app.use(
rateLimit({
windowMs: 60 * 1000, // 1 分钟
max: 100, // 限制每个 IP 在 windowMs 时间内最多 10000 个请求
max: 100, // 限制每个 IP 在 windowMs 时间内最多 100 个请求
}),
);
// 请求体解析(设置大小限制为 10mb
app.use(bodyParser.json({ limit: '10mb' }));
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
app.enableCors();
/* app.enableCors({
// 允许的域名
origin: ['http://localhost:3200'],
}); */
app.setGlobalPrefix('api');
// 响应压缩(在 helmet 之后)
app.use(compression());

View File

@@ -12,14 +12,14 @@ import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePositionDto {
@ApiProperty({
description: '券商ID',
@ApiPropertyOptional({
description: '券商ID(可选)',
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@IsNotEmpty()
brokerId: number;
brokerId?: number;
@ApiProperty({
description: '资产类型',
@@ -31,18 +31,18 @@ export class CreatePositionDto {
@IsIn(['stock', 'fund', 'cash', 'bond', 'other'])
assetType: string;
@ApiProperty({
description: '资产代码(股票代码、基金代码等)',
@ApiPropertyOptional({
description: '资产代码(股票代码、基金代码等),现金和其他类型可为空',
example: '600519',
maxLength: 50,
})
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(50)
symbol: string;
symbol?: string;
@ApiProperty({
description: '资产名称',
description: '资产名称,现金类型固定为"现金"',
example: '贵州茅台',
maxLength: 100,
})

View File

@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class SearchAssetDto {
@ApiProperty({
description: '搜索关键词(股票代码或名称)',
example: '600519',
maxLength: 50,
})
@IsString()
@IsNotEmpty()
@MaxLength(50)
keyword: string;
@ApiProperty({
description: '资产类型过滤(可选)',
example: 'stock',
enum: ['stock', 'fund', 'bond'],
required: false,
})
@IsOptional()
@IsString()
assetType?: string;
@ApiProperty({
description: '返回结果数量限制',
example: 10,
default: 10,
required: false,
})
@IsOptional()
limit?: number;
}
export class AssetSearchResult {
@ApiProperty({ description: '股票代码', example: '600519' })
symbol: string;
@ApiProperty({ description: '股票名称', example: '贵州茅台' })
name: string;
@ApiProperty({ description: '市场代码', example: 'sh' })
market: string;
@ApiProperty({ description: '资产类型', example: 'stock' })
assetType: string;
}

View File

@@ -10,6 +10,7 @@ import {
HttpStatus,
UseGuards,
Request,
Query,
} from '@nestjs/common';
import {
ApiTags,
@@ -17,11 +18,13 @@ import {
ApiResponse,
ApiParam,
ApiBearerAuth,
ApiQuery,
} 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 { AssetSearchResult } from './dto/search-asset.dto';
import { Position } from './position.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { User } from '../user/user.entity';
@@ -33,6 +36,47 @@ import { User } from '../user/user.entity';
export class PositionController {
constructor(private readonly positionService: PositionService) {}
/**
* 搜索资产(股票代码或名称)
*/
@Get('search')
@ApiOperation({
summary: '搜索资产',
description: '根据关键词搜索股票代码或名称,支持字符串匹配',
})
@ApiQuery({
name: 'keyword',
description: '搜索关键词(股票代码或名称)',
example: '600519',
required: true,
})
@ApiQuery({
name: 'assetType',
description: '资产类型过滤(可选)',
example: 'stock',
enum: ['stock', 'fund', 'bond'],
required: false,
})
@ApiQuery({
name: 'limit',
description: '返回结果数量限制',
example: 10,
required: false,
})
@ApiResponse({
status: 200,
description: '搜索成功',
type: [AssetSearchResult],
})
@ApiResponse({ status: 400, description: '请求参数错误' })
async searchAssets(
@Query('keyword') keyword: string,
@Query('assetType') assetType?: string,
@Query('limit') limit?: number,
): Promise<AssetSearchResult[]> {
return this.positionService.searchAssets(keyword, assetType, limit);
}
/**
* 查询用户所有持仓(包含计算字段)
*/

View File

@@ -31,20 +31,22 @@ export class Position {
@Index()
userId: number;
@ApiProperty({
description: '券商ID',
@ApiPropertyOptional({
description: '券商ID(可选)',
example: 1,
})
@Column({
name: 'broker_id',
type: 'bigint',
nullable: true,
transformer: {
to: (value: number) => value,
from: (value: string) => (value ? parseInt(value, 10) : null),
to: (value: number | null | undefined) => value ?? null,
from: (value: string | null) =>
value ? parseInt(value, 10) : null,
},
})
@Index()
brokerId: number;
brokerId?: number;
@ApiProperty({
description: '资产类型',

View File

@@ -3,23 +3,180 @@ import {
NotFoundException,
ConflictException,
Logger,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, FindOptionsWhere } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { readFileSync } from 'fs';
import { join } from 'path';
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';
import { AssetSearchResult } from './dto/search-asset.dto';
@Injectable()
export class PositionService {
private readonly logger = new Logger(PositionService.name);
private stockDataCache: Record<string, string> | null = null;
private stockDataCacheTime: number = 0;
private readonly CACHE_TTL = 60 * 60 * 1000; // 缓存1小时
constructor(
@InjectRepository(Position)
private readonly positionRepository: Repository<Position>,
private readonly configService: ConfigService,
) {}
/**
* 从HTTP地址加载股票数据
*/
private async loadStockData(): Promise<Record<string, string>> {
const now = Date.now();
// 如果缓存有效,直接返回
if (
this.stockDataCache &&
now - this.stockDataCacheTime < this.CACHE_TTL
) {
return this.stockDataCache;
}
try {
// 从本地文件系统读取uploads目录已配置为静态文件服务
// 获取uploads目录路径
const storagePath =
this.configService.get<string>('STORAGE_PATH') || './uploads';
const stockDataPath = join(
process.cwd(),
storagePath,
'stock',
'stock-data.json',
);
this.logger.log(`正在从 ${stockDataPath} 加载股票数据...`);
// 读取文件
const fileContent = readFileSync(stockDataPath, 'utf-8');
const data = JSON.parse(fileContent);
// 更新缓存
this.stockDataCache = data;
this.stockDataCacheTime = now;
this.logger.log(
`股票数据加载成功,共 ${Object.keys(data).length} 个市场`,
);
return data;
} catch (error) {
this.logger.error(
`加载股票数据失败: ${error.message}`,
error.stack,
);
throw new BadRequestException(`加载股票数据失败: ${error.message}`);
}
}
/**
* 搜索资产(股票代码或名称)
*/
async searchAssets(
keyword: string,
assetType?: string,
limit: number = 10,
): Promise<AssetSearchResult[]> {
if (!keyword || keyword.trim().length === 0) {
return [];
}
try {
const stockData = await this.loadStockData();
const results: AssetSearchResult[] = [];
const keywordLower = keyword.toLowerCase().trim();
// 市场映射:市场代码 -> 资产类型
const marketToAssetType: Record<string, string> = {
sh: 'stock',
sz: 'stock',
bj: 'stock',
hk: 'stock',
us: 'stock',
};
// 遍历所有市场
for (const [market, stockList] of Object.entries(stockData)) {
// 如果指定了资产类型,跳过不匹配的市场
if (assetType && marketToAssetType[market] !== assetType) {
continue;
}
// 解析股票列表格式代码_名称|代码_名称|...
const stocks = stockList.split('|');
for (const stock of stocks) {
if (!stock || stock.trim().length === 0) {
continue;
}
const [symbol, ...nameParts] = stock.split('_');
const name = nameParts.join('_'); // 处理名称中可能包含下划线的情况
if (!symbol || !name) {
continue;
}
// 字符串匹配:代码或名称包含关键词(不区分大小写)
const symbolMatch = symbol
.toLowerCase()
.includes(keywordLower);
const nameMatch = name.toLowerCase().includes(keywordLower);
if (symbolMatch || nameMatch) {
// 计算匹配度(完全匹配 > 前缀匹配 > 包含匹配)
let score = 0;
if (symbol.toLowerCase() === keywordLower) {
score = 100; // 代码完全匹配
} else if (name.toLowerCase() === keywordLower) {
score = 90; // 名称完全匹配
} else if (
symbol.toLowerCase().startsWith(keywordLower)
) {
score = 80; // 代码前缀匹配
} else if (
name.toLowerCase().startsWith(keywordLower)
) {
score = 70; // 名称前缀匹配
} else {
score = symbolMatch ? 60 : 50; // 包含匹配
}
results.push({
symbol,
name,
market,
assetType: marketToAssetType[market] || 'stock',
score, // 用于排序(内部使用)
} as AssetSearchResult & { score: number });
}
}
}
// 按匹配度排序,然后限制结果数量
const sortedResults = (
results as (AssetSearchResult & { score: number })[]
)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ score, ...rest }) => rest as AssetSearchResult); // 移除score字段
return sortedResults;
} catch (error) {
this.logger.error(`搜索资产失败: ${error.message}`, error.stack);
throw new BadRequestException(`搜索资产失败: ${error.message}`);
}
}
/**
* 查询用户所有持仓(包含计算字段)
*/
@@ -92,11 +249,20 @@ export class PositionService {
// 检查唯一性约束:同一用户同一券商同一资产只能有一条持仓
const whereCondition: FindOptionsWhere<Position> = {
userId,
brokerId: createPositionDto.brokerId,
symbol: createPositionDto.symbol,
symbol: createPositionDto.symbol || '',
assetType: createPositionDto.assetType,
};
// 处理 brokerId 字段:如果为 undefined 或 null查询 null 值
if (
createPositionDto.brokerId !== undefined &&
createPositionDto.brokerId !== null
) {
whereCondition.brokerId = createPositionDto.brokerId;
} else {
whereCondition.brokerId = IsNull();
}
// 处理 market 字段:如果为 undefined 或空字符串,查询 null 值
if (createPositionDto.market) {
whereCondition.market = createPositionDto.market;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,439 @@
# 资源上传文档
## 概述
本系统提供了完整的文件上传和管理功能支持本地存储未来可扩展至云存储。主要用于上传券商Logo、用户头像等资源文件。
## 目录结构
```
storage/
├── dto/
│ └── upload-file.dto.ts # 上传文件DTO
├── interfaces/
│ └── storage-provider.interface.ts # 存储提供者接口
├── providers/
│ └── local-storage.provider.ts # 本地存储实现
├── storage.controller.ts # 存储控制器
├── storage.module.ts # 存储模块
└── storage.service.ts # 存储服务
```
## 环境配置
### 必需的环境变量
`.env` 文件中配置以下环境变量:
```env
# 存储类型local本地存储未来可扩展为 qiniu、oss 等
STORAGE_TYPE=local
# 文件存储路径(相对于项目根目录)
STORAGE_PATH=./uploads
# 文件访问基础URL用于生成文件访问链接
STORAGE_BASE_URL=http://localhost:3200/uploads
```
### 配置说明
| 变量名 | 说明 | 默认值 | 示例 |
| ------------------ | --------------- | ------------------------------- | --------------------------------- |
| `STORAGE_TYPE` | 存储类型 | `local` | `local``qiniu`(未来支持) |
| `STORAGE_PATH` | 文件存储路径 | `./uploads` | `./uploads``/var/www/uploads` |
| `STORAGE_BASE_URL` | 文件访问基础URL | `http://localhost:3200/uploads` | `https://api.example.com/uploads` |
### 生产环境配置建议
```env
# 生产环境配置示例
STORAGE_TYPE=local
STORAGE_PATH=/var/www/invest-mind/uploads
STORAGE_BASE_URL=https://api.example.com/uploads
```
## 静态文件服务配置
系统在 `main.ts` 中自动配置了静态文件服务,无需额外配置:
```typescript
// 配置静态文件服务
const storagePath = configService.get<string>('STORAGE_PATH') || './uploads';
app.useStaticAssets(join(process.cwd(), storagePath), {
prefix: '/uploads/',
});
```
这意味着所有存储在 `STORAGE_PATH` 目录下的文件都可以通过 `/uploads/` 前缀访问。
## API 接口
### 1. 管理员上传文件
**接口地址:** `POST /api/storage/upload`
**权限要求:** 需要管理员权限(`admin``super_admin`
**请求格式:** `multipart/form-data`
**请求参数:**
| 参数名 | 类型 | 必填 | 说明 | 可选值 |
| ---------- | ------ | ---- | ------------ | ------------------------ |
| `file` | File | 是 | 要上传的文件 | - |
| `folder` | string | 否 | 存储文件夹 | `broker``user``temp` |
| `filename` | string | 否 | 自定义文件名 | - |
**文件限制:**
- 最大文件大小5MB
- 允许的文件类型:`image/jpeg``image/jpg``image/png``image/gif``image/webp`
**请求示例:**
```bash
curl -X POST http://localhost:3200/api/storage/upload \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "file=@/path/to/image.jpg" \
-F "folder=broker" \
-F "filename=custom-name.jpg"
```
**响应示例:**
```json
{
"path": "broker/1234567890-abcdef-broker-logo.jpg",
"url": "http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg",
"filename": "1234567890-abcdef-broker-logo.jpg",
"size": 102400,
"mimetype": "image/jpeg"
}
```
### 2. 用户上传头像
**接口地址:** `POST /api/storage/upload/avatar`
**权限要求:** 无需鉴权(公开接口)
**请求格式:** `multipart/form-data`
**请求参数:**
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | ---- | ---------------- |
| `file` | File | 是 | 要上传的头像文件 |
**文件限制:**
- 最大文件大小2MB
- 允许的文件类型:`image/jpeg``image/jpg``image/png``image/gif``image/webp`
- 文件固定存储在 `user` 文件夹
**请求示例:**
```bash
curl -X POST http://localhost:3200/api/storage/upload/avatar \
-F "file=@/path/to/avatar.jpg"
```
**响应示例:**
```json
{
"path": "user/1234567890-abcdef-avatar.jpg",
"url": "http://localhost:3200/uploads/user/1234567890-abcdef-avatar.jpg",
"filename": "1234567890-abcdef-avatar.jpg",
"size": 51200,
"mimetype": "image/jpeg"
}
```
### 3. 删除文件
**接口地址:** `DELETE /api/storage/{path}`
**权限要求:** 需要管理员权限(`admin``super_admin`
**路径参数:**
| 参数名 | 类型 | 说明 | 示例 |
| ------ | ------ | ------------ | ------------------------------------------ |
| `path` | string | 文件相对路径 | `broker/1234567890-abcdef-broker-logo.jpg` |
**请求示例:**
```bash
curl -X DELETE http://localhost:3200/api/storage/broker/1234567890-abcdef-broker-logo.jpg \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
**响应:**
- 成功:`204 No Content`
- 文件不存在:`404 Not Found`
- 权限不足:`403 Forbidden`
## 文件夹类型说明
系统支持三种文件夹类型,用于分类存储不同类型的文件:
| 文件夹 | 用途 | 说明 |
| -------- | ------------ | ------------------------------ |
| `broker` | 券商相关文件 | 用于存储券商Logo等基础数据文件 |
| `user` | 用户相关文件 | 用于存储用户头像等个人文件 |
| `temp` | 临时文件 | 用于存储临时文件,可定期清理 |
## 文件访问
### 访问方式
上传成功后系统会返回文件的访问URL可以通过以下方式访问
1. **直接访问URL**:使用返回的 `url` 字段直接访问
```
http://localhost:3200/uploads/broker/1234567890-abcdef-broker-logo.jpg
```
2. **通过静态文件服务**:所有文件都通过 `/uploads/` 前缀提供静态文件服务
```
http://localhost:3200/uploads/{folder}/{filename}
```
### 文件命名规则
如果不指定自定义文件名,系统会自动生成文件名,格式为:
```
{timestamp}-{random}-{originalname}
```
例如:
- 原始文件名:`logo.jpg`
- 生成文件名:`1234567890-abcdef-logo.jpg`
其中:
- `timestamp`:时间戳(毫秒)
- `random`8字节随机十六进制字符串
- `originalname`:原始文件名(去除特殊字符)
### 文件名清理规则
系统会自动清理文件名中的特殊字符:
- 保留:字母、数字、下划线 `_`、连字符 `-`
- 替换:其他特殊字符会被替换为下划线 `_`
## 文件存储结构
本地存储的文件结构如下:
```
uploads/
├── broker/ # 券商相关文件
│ └── 1234567890-abcdef-broker-logo.jpg
├── user/ # 用户相关文件
│ └── 1234567890-abcdef-avatar.jpg
└── temp/ # 临时文件
└── 1234567890-abcdef-temp-file.jpg
```
## 安全特性
### 1. 路径遍历防护
删除文件时会进行安全检查,防止路径遍历攻击:
```typescript
// 安全检查:确保文件路径在 basePath 内
const resolvedPath = path.resolve(fullPath);
const resolvedBasePath = path.resolve(this.basePath);
if (!resolvedPath.startsWith(resolvedBasePath)) {
throw new Error('非法文件路径');
}
```
### 2. 文件类型验证
- 仅允许上传图片文件jpeg、jpg、png、gif、webp
- 通过 MIME 类型和文件扩展名双重验证
### 3. 文件大小限制
- 管理员上传:最大 5MB
- 用户头像:最大 2MB
### 4. 权限控制
- 管理员上传和删除操作需要管理员权限
- 用户头像上传无需鉴权(用于注册场景)
## 使用示例
### JavaScript/TypeScript 示例
```typescript
// 上传文件
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('folder', 'broker');
formData.append('filename', 'custom-name.jpg');
const response = await fetch('http://localhost:3200/api/storage/upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
const result = await response.json();
console.log('文件URL:', result.url);
```
### React 示例
```tsx
import { useState } from 'react';
function FileUpload() {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<any>(null);
const handleUpload = async () => {
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('folder', 'broker');
try {
const response = await fetch(
'http://localhost:3200/api/storage/upload',
{
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: formData,
},
);
const data = await response.json();
setResult(data);
} catch (error) {
console.error('上传失败:', error);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<button onClick={handleUpload} disabled={uploading}>
{uploading ? '上传中...' : '上传'}
</button>
{result && (
<div>
<p>上传成功!</p>
<img src={result.url} alt="上传的文件" />
</div>
)}
</div>
);
}
```
## 扩展性
### 添加新的存储提供者
系统采用策略模式可以轻松添加新的存储提供者如七牛云、阿里云OSS等
1. 实现 `IStorageProvider` 接口
2. 在 `StorageService` 中添加新的存储类型判断
3. 配置相应的环境变量
示例:
```typescript
// providers/qiniu-storage.provider.ts
export class QiniuStorageProvider implements IStorageProvider {
// 实现接口方法
}
// storage.service.ts
switch (storageType) {
case 'qiniu':
this.provider = new QiniuStorageProvider(configService);
break;
}
```
## 常见问题
### 1. 文件上传失败
**问题:** 上传文件时返回 400 错误
**解决方案:**
- 检查文件大小是否超过限制管理员5MB头像2MB
- 检查文件类型是否为支持的图片格式
- 检查文件是否损坏
### 2. 文件无法访问
**问题:** 上传成功但无法通过URL访问文件
**解决方案:**
- 检查 `STORAGE_BASE_URL` 配置是否正确
- 检查静态文件服务是否正常启动
- 检查文件是否实际存在于 `STORAGE_PATH` 目录
- 检查文件权限是否正确
### 3. 删除文件失败
**问题:** 删除文件时返回 404 或 403 错误
**解决方案:**
- 404检查文件路径是否正确文件是否存在
- 403检查是否有管理员权限
- 检查文件路径是否包含非法字符
### 4. 生产环境配置
**问题:** 如何在生产环境配置文件存储
**解决方案:**
1. 设置 `STORAGE_PATH` 为绝对路径(如 `/var/www/uploads`
2. 设置 `STORAGE_BASE_URL` 为生产域名(如 `https://api.example.com/uploads`
3. 确保目录有写入权限:`chmod -R 755 /var/www/uploads`
4. 考虑使用云存储服务(未来支持)
## 相关文件
- `storage.controller.ts` - API 控制器
- `storage.service.ts` - 存储服务
- `local-storage.provider.ts` - 本地存储实现
- `storage-provider.interface.ts` - 存储提供者接口
- `main.ts` - 静态文件服务配置
## 更新日志
- 初始版本:支持本地存储、文件上传、文件删除功能