瀏覽代碼

✨ feat(stock): add stock history data API endpoint

- 创建股票历史数据获取接口,支持通过股票代码查询历史数据
- 实现StockDataService服务,包含getStockHistory等多个数据处理方法
- 添加路径参数验证和响应格式定义
- 集成认证中间件确保接口安全访问
- 实现数据缓存机制,优先从数据库获取,缺失时调用外部API获取并缓存

✨ feat(stock-service): enhance stock data service functionality

- 添加getStockMemos方法获取股票备忘录数据
- 实现getLatestStockData方法获取最新股票数据
- 开发getMultipleStockHistories方法支持批量获取多只股票数据
- 添加数据缓存和更新机制,优化外部API调用效率
- 完善错误处理和日志记录功能
yourname 5 月之前
父節點
當前提交
baf6309ac6

+ 65 - 0
src/server/api/stock-data/history/[code]/get.ts

@@ -0,0 +1,65 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { AppDataSource } from '@/server/data-source';
+import { StockDataService } from '@/server/modules/stock/stock-data.service';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+
+// 路径参数Schema
+const GetStockHistoryParams = z.object({
+  code: z.string().openapi({
+    param: { name: 'code', in: 'path' },
+    example: '001339',
+    description: '股票代码'
+  })
+});
+
+// 响应Schema
+const GetStockHistoryResponse = z.object({
+  data: z.any().openapi({
+    description: '股票历史数据',
+    example: []
+  })
+});
+
+// 路由定义
+const routeDef = createRoute({
+  method: 'get',
+  path: '/{code}',
+  middleware: [authMiddleware],
+  request: {
+    params: GetStockHistoryParams
+  },
+  responses: {
+    200: {
+      description: '成功获取股票历史数据',
+      content: { 'application/json': { schema: GetStockHistoryResponse } }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 路由实现
+const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
+  try {
+    const { code } = c.req.valid('param');
+    const service = new StockDataService(AppDataSource);
+    
+    const data = await service.getStockHistory(code);
+    
+    return c.json({ data }, 200);
+  } catch (error) {
+    const { code = 500, message = '获取股票历史数据失败' } = error as Error & { code?: number };
+    return c.json({ code, message }, 500);
+  }
+});
+
+export default app;

+ 7 - 0
src/server/api/stock-data/history/[code]/index.ts

@@ -0,0 +1,7 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import getStockHistoryRoute from './get';
+
+const app = new OpenAPIHono()
+  .route('/', getStockHistoryRoute);
+
+export default app;

+ 8 - 1
src/server/api/stock-data/index.ts

@@ -1,8 +1,11 @@
 import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { OpenAPIHono } from '@hono/zod-openapi';
 import { StockData } from '@/server/modules/stock/stock-data.entity';
 import { StockDataSchema, CreateStockDataDto, UpdateStockDataDto } from '@/server/modules/stock/stock-data.entity';
 import { authMiddleware } from '@/server/middleware/auth.middleware';
+import getStockHistoryRoute from './history/[code]/get';
 
+// 基础CRUD路由
 const stockDataRoutes = createCrudRoutes({
   entity: StockData,
   createSchema: CreateStockDataDto,
@@ -13,4 +16,8 @@ const stockDataRoutes = createCrudRoutes({
   middleware: [authMiddleware]
 });
 
-export default stockDataRoutes;
+const app = new OpenAPIHono()
+  .route('/', stockDataRoutes)
+  .route('/history', getStockHistoryRoute);
+
+export default app;

+ 161 - 1
src/server/modules/stock/stock-data.service.ts

@@ -1,9 +1,169 @@
 import { GenericCrudService } from '@/server/utils/generic-crud.service';
-import { DataSource } from 'typeorm';
+import { DataSource, Repository } from 'typeorm';
 import { StockData } from './stock-data.entity';
+import debug from 'debug';
+import dayjs from 'dayjs';
+
+const log = {
+  api: debug('backend:api:stock'),
+  db: debug('backend:db:stock'),
+};
 
 export class StockDataService extends GenericCrudService<StockData> {
   constructor(dataSource: DataSource) {
     super(dataSource, StockData);
   }
+
+  /**
+   * 获取股票历史数据
+   * 优先从数据库获取,如果没有则调用外部API
+   * @param code 股票代码
+   * @returns 股票历史数据
+   */
+  async getStockHistory(code: string = '001339'): Promise<any> {
+    try {
+      // 查询数据库中是否存在今天的数据
+      const today = dayjs().format('YYYY-MM-DD');
+      const existingData = await this.repository
+        .createQueryBuilder('stock')
+        .where('stock.code = :code', { code })
+        .andWhere('stock.updatedAt >= :today', { today: `${today} 00:00:00` })
+        .getOne();
+
+      if (existingData) {
+        log.db(`Found existing data for ${code} on ${today}`);
+        return existingData.data;
+      }
+
+      // 如果没有今天的数据,调用外部API
+      log.api(`Fetching fresh data for ${code} from external API`);
+      const dh = 'dn'; // 固定值
+      
+      const license = process.env.STOCK_API_LICENSE;
+      if (!license) {
+        throw new Error('STOCK_API_LICENSE environment variable not set');
+      }
+
+      const apiUrl = `http://api.mairui.club/hszbl/fsjy/${code}/${dh}/${license}`;
+      const response = await fetch(apiUrl, {
+        method: 'GET',
+        headers: {
+          'Accept': 'application/json'
+        }
+      });
+
+      if (!response.ok) {
+        throw new Error(`API request failed with status ${response.status}`);
+      }
+
+      const newData = await response.json();
+
+      // 更新或插入数据库
+      const stockData = new StockData();
+      stockData.code = code;
+      stockData.data = newData;
+      
+      await this.repository
+        .createQueryBuilder()
+        .insert()
+        .values(stockData)
+        .orUpdate(
+          ['data', 'updatedAt'],
+          ['code']
+        )
+        .execute();
+
+      log.api(`Successfully saved fresh data for ${code}`);
+      return newData;
+
+    } catch (error) {
+      log.api('Error getting stock history:', error);
+      throw error;
+    }
+  }
+
+  /**
+   * 获取股票备忘录数据
+   * @param code 股票代码
+   * @returns 股票备忘录列表
+   */
+  async getStockMemos(code?: string): Promise<Array<{
+    date: string;
+    memo: string;
+    [key: string]: string;
+  }>> {
+    try {
+      if (!code) {
+        return [];
+      }
+
+      const notes = await this.repository.manager.query(`
+        SELECT 
+          note_date as date,
+          note as memo
+        FROM date_notes
+        WHERE code = ?
+        ORDER BY note_date ASC
+      `, [code]);
+
+      if (!notes || notes.length === 0) {
+        return [];
+      }
+
+      // 转换数据格式
+      const formattedNotes = notes.map((note: any) => ({
+        date: dayjs(note.date).format('YYYY-MM-DD'),
+        memo: note.memo,
+        日期: dayjs(note.date).format('YYYY-MM-DD'),
+        提示: note.memo
+      }));
+
+      log.db(`Retrieved ${formattedNotes.length} memos for ${code}`);
+      return formattedNotes;
+
+    } catch (error) {
+      log.db('Error fetching memo data:', error);
+      throw error;
+    }
+  }
+
+  /**
+   * 获取股票最新数据
+   * @param code 股票代码
+   * @returns 股票最新数据
+   */
+  async getLatestStockData(code: string): Promise<StockData | null> {
+    try {
+      const stockData = await this.repository
+        .createQueryBuilder('stock')
+        .where('stock.code = :code', { code })
+        .orderBy('stock.updatedAt', 'DESC')
+        .getOne();
+
+      return stockData;
+    } catch (error) {
+      log.db('Error fetching latest stock data:', error);
+      throw error;
+    }
+  }
+
+  /**
+   * 获取多个股票的历史数据
+   * @param codes 股票代码数组
+   * @returns 股票历史数据映射
+   */
+  async getMultipleStockHistories(codes: string[]): Promise<Record<string, any>> {
+    const results: Record<string, any> = {};
+    
+    for (const code of codes) {
+      try {
+        results[code] = await this.getStockHistory(code);
+      } catch (error) {
+        log.api(`Error fetching data for ${code}:`, error);
+        results[code] = null;
+      }
+    }
+    
+    return results;
+  }
 }