2
0
Quellcode durchsuchen

✨ feat(project): 集成阿里云RTC/IM服务并扩展股票交易数据功能

- 新增阿里云RTC和IM令牌生成API接口
- 集成MinIO对象存储、Redis缓存和ECharts图表库
- 新增教室数据、股票数据、日期备注、训练代码和提交记录等核心业务实体
- 实现股票历史数据API,支持外部数据源和本地缓存
- 扩展TypeORM实体配置,新增7个业务表结构
yourname vor 7 Monaten
Ursprung
Commit
aa798b32a4

+ 9 - 1
package.json

@@ -38,7 +38,15 @@
     "sirv": "^3.0.1",
     "socket.io": "^4.8.1",
     "socket.io-client": "^4.8.1",
-    "typeorm": "^0.3.25"
+    "typeorm": "^0.3.25",
+    "react-toastify": "^11.0.5",
+    "minio": "^8.0.5",
+    "ioredis": "^5.6.1",
+    "echarts": "^5.6.0",
+    "dotenv": "^16.5.0",
+    "decimal.js": "^10.6.0",
+    "aliyun-rtc-sdk": "^7.1.1",
+    "vod-js-sdk-v6": "1.7.1-beta.1"
   },
   "devDependencies": {
     "@tailwindcss/vite": "^4.1.11",

Datei-Diff unterdrückt, da er zu groß ist
+ 538 - 1
pnpm-lock.yaml


+ 205 - 0
src/server/api/aliyun/index.ts

@@ -0,0 +1,205 @@
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import * as process from 'node:process'
+
+// 配置信息
+const IM_APP_ID = process.env.IM_APP_ID || '';
+const IM_APP_KEY = process.env.IM_APP_KEY || '';
+const IM_APP_SIGN = process.env.IM_APP_SIGN || '';
+const RTC_APP_ID = process.env.RTC_APP_ID || '';
+const RTC_APP_KEY = process.env.RTC_APP_KEY || '';
+// 请求和响应Schema定义
+const CreateImTokenRequest = z.object({
+  role: z.string().openapi({
+    description: '用户角色',
+    example: 'teacher'
+  })
+});
+
+const ImTokenResponse = z.object({
+  nonce: z.string().openapi({ description: '随机字符串' }),
+  token: z.string().openapi({ description: 'IM认证令牌' }),
+  timestamp: z.number().openapi({ description: '时间戳' }),
+  appId: z.string().openapi({ description: '应用ID' }),
+  appSign: z.string().openapi({ description: '应用签名' })
+});
+
+const CreateRtcTokenRequest = z.object({
+  channelId: z.string().openapi({
+    description: '频道ID',
+    example: 'classroom_123'
+  })
+});
+
+const RtcTokenResponse = z.object({
+  token: z.string().openapi({ description: 'RTC认证令牌' }),
+  timestamp: z.number().openapi({ description: '时间戳' }),
+  appId: z.string().openapi({ description: '应用ID' })
+});
+
+const hex = (buffer: ArrayBuffer): string => {
+  const hexCodes = [];
+  const view = new DataView(buffer);
+  for (let i = 0; i < view.byteLength; i += 4) {
+    const value = view.getUint32(i);
+    const stringValue = value.toString(16);
+    const padding = '00000000';
+    const paddedValue = (padding + stringValue).slice(-padding.length);
+    hexCodes.push(paddedValue);
+  }
+  return hexCodes.join('');
+};
+
+const generateRTCToken = async (
+  channelId: string,
+  userId: string
+): Promise<{
+  token: string;
+  timestamp: number;
+}> => {
+  const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
+  const encoder = new TextEncoder();
+  const data = encoder.encode(`${RTC_APP_ID}${RTC_APP_KEY}${channelId}${userId}${timestamp}`);
+  const hash = await crypto.subtle.digest('SHA-256', data);
+  const token = hex(hash);
+  return {
+    token,
+    timestamp
+  }
+};
+
+const generateImToken = async (userId: string, role: string): Promise<{
+  nonce: string;
+  token: string;
+  timestamp: number;
+}> => {
+  const nonce = 'AK_4';
+  const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
+  const pendingShaStr = `${IM_APP_ID}${IM_APP_KEY}${userId}${nonce}${timestamp}${role}`;
+  const encoder = new TextEncoder();
+  const data = encoder.encode(pendingShaStr);
+  const hash = await crypto.subtle.digest('SHA-256', data);
+  const token = hex(hash);
+  return {
+    nonce,
+    token,
+    timestamp
+  }
+};
+
+// 创建IM Token路由
+const createImTokenRoute = createRoute({
+  method: 'post',
+  path: '/im_token',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateImTokenRequest }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '成功生成IM Token',
+      content: { 'application/json': { schema: ImTokenResponse } }
+    },
+    401: {
+      description: '未授权',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 创建RTC Token路由
+const createRtcTokenRoute = createRoute({
+  method: 'post',
+  path: '/rtc_token',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': { schema: CreateRtcTokenRequest }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '成功生成RTC Token',
+      content: { 'application/json': { schema: RtcTokenResponse } }
+    },
+    401: {
+      description: '未授权',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 创建API应用实例
+const app = new OpenAPIHono<AuthContext>()
+  .openapi(createImTokenRoute, async (c) => {
+    try {
+      const { role } = c.req.valid('json');
+      const user = c.get('user');
+
+      if (!user || typeof user !== 'object' || !('id' in user)) {
+        return c.json({ code: 401, message: '用户信息无效' }, 401);
+      }
+
+      const { nonce, token, timestamp } = await generateImToken(user.id.toString(), role);
+
+      return c.json({
+        nonce,
+        token,
+        timestamp,
+        appId: IM_APP_ID,
+        appSign: IM_APP_SIGN,
+      }, 200);
+    } catch (error) {
+      console.error('生成IM Token失败:', error);
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '生成IM Token失败'
+      }, 500);
+    }
+  })
+  .openapi(createRtcTokenRoute, async (c) => {
+    try {
+      const { channelId } = c.req.valid('json');
+      const user = c.get('user');
+
+      if (!user || typeof user !== 'object' || !('id' in user)) {
+        return c.json({ code: 401, message: '用户信息无效' }, 401);
+      }
+
+      if (!RTC_APP_ID || !RTC_APP_KEY) {
+        return c.json({ code: 500, message: '服务配置不完整' }, 500);
+      }
+
+      const { token, timestamp } = await generateRTCToken(channelId, user.id.toString());
+
+      return c.json({
+        token,
+        timestamp,
+        appId: RTC_APP_ID,
+      }, 200);
+    } catch (error) {
+      console.error('生成RTC Token失败:', error);
+      return c.json({
+        code: 500,
+        message: error instanceof Error ? error.message : '生成RTC Token失败'
+      }, 500);
+    }
+  });
+
+export default app;

+ 20 - 0
src/server/api/classroom-data/index.ts

@@ -0,0 +1,20 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { ClassroomData } from '@/server/modules/classroom/classroom-data.entity';
+import { ClassroomDataSchema, CreateClassroomDataDto, UpdateClassroomDataDto } from '@/server/modules/classroom/classroom-data.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const classroomDataRoutes = createCrudRoutes({
+  entity: ClassroomData,
+  createSchema: CreateClassroomDataDto,
+  updateSchema: UpdateClassroomDataDto,
+  getSchema: ClassroomDataSchema,
+  listSchema: ClassroomDataSchema,
+  searchFields: ['classroomNo', 'code'],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});
+
+export default classroomDataRoutes;

+ 16 - 0
src/server/api/date-notes/index.ts

@@ -0,0 +1,16 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { DateNotes } from '@/server/modules/stock/date-notes.entity';
+import { DateNotesSchema, CreateDateNotesDto, UpdateDateNotesDto } from '@/server/modules/stock/date-notes.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const dateNotesRoutes = createCrudRoutes({
+  entity: DateNotes,
+  createSchema: CreateDateNotesDto,
+  updateSchema: UpdateDateNotesDto,
+  getSchema: DateNotesSchema,
+  listSchema: DateNotesSchema,
+  searchFields: ['code', 'note'],
+  middleware: [authMiddleware]
+});
+
+export default dateNotesRoutes;

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

@@ -0,0 +1,132 @@
+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 StockHistoryItemSchema = z.object({
+  d: z.string().openapi({
+    description: '日期',
+    example: '2007-09-13'
+  }),
+  o: z.number().openapi({
+    description: '开盘价',
+    example: 40.99
+  }),
+  h: z.number().openapi({
+    description: '最高价',
+    example: 59
+  }),
+  l: z.number().openapi({
+    description: '最低价',
+    example: 40.99
+  }),
+  c: z.number().openapi({
+    description: '收盘价',
+    example: 53.11
+  }),
+  v: z.number().openapi({
+    description: '成交量',
+    example: 78854
+  }),
+  e: z.number().openapi({
+    description: '成交额',
+    example: 369834688.13
+  }),
+  zf: z.number().openapi({
+    description: '振幅',
+    example: 202.13
+  }),
+  hs: z.number().openapi({
+    description: '换手率',
+    example: 78.85
+  }),
+  zd: z.number().openapi({
+    description: '涨跌额',
+    example: 496.07
+  }),
+  zde: z.number().openapi({
+    description: '涨跌幅',
+    example: 44.2
+  }),
+  ud: z.string().openapi({
+    description: '最后更新时间',
+    example: '2025-06-29 02:13:11'
+  })
+});
+
+// 响应Schema
+const GetStockHistoryResponse = z.object({
+  data: z.array(StockHistoryItemSchema).openapi({
+    description: '股票历史K线数据列表',
+    example: [
+      {
+        d: "2007-09-13",
+        o: 40.99,
+        h: 59,
+        l: 40.99,
+        c: 53.11,
+        v: 78854,
+        e: 369834688.13,
+        zf: 202.13,
+        hs: 78.85,
+        zd: 496.07,
+        zde: 44.2,
+        ud: "2025-06-29 02:13:11"
+      }
+    ]
+  })
+});
+
+// 路由定义
+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;

+ 23 - 0
src/server/api/stock-data/index.ts

@@ -0,0 +1,23 @@
+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,
+  updateSchema: UpdateStockDataDto,
+  getSchema: StockDataSchema,
+  listSchema: StockDataSchema,
+  searchFields: ['code'],
+  middleware: [authMiddleware]
+});
+
+const app = new OpenAPIHono()
+  .route('/', stockDataRoutes)
+  .route('/history', getStockHistoryRoute);
+
+export default app;

+ 16 - 0
src/server/api/stock-xunlian-codes/index.ts

@@ -0,0 +1,16 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { StockXunlianCodes } from '@/server/modules/stock/stock-xunlian-codes.entity';
+import { StockXunlianCodesSchema, CreateStockXunlianCodesDto, UpdateStockXunlianCodesDto } from '@/server/modules/stock/stock-xunlian-codes.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const stockXunlianCodesRoutes = createCrudRoutes({
+  entity: StockXunlianCodes,
+  createSchema: CreateStockXunlianCodesDto,
+  updateSchema: UpdateStockXunlianCodesDto,
+  getSchema: StockXunlianCodesSchema,
+  listSchema: StockXunlianCodesSchema,
+  searchFields: ['code', 'stockName', 'name'],
+  middleware: [authMiddleware]
+});
+
+export default stockXunlianCodesRoutes;

+ 16 - 0
src/server/api/submission-records/index.ts

@@ -0,0 +1,16 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { SubmissionRecords } from '@/server/modules/submission/submission-records.entity';
+import { SubmissionRecordsSchema, CreateSubmissionRecordsDto, UpdateSubmissionRecordsDto } from '@/server/modules/submission/submission-records.entity';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const submissionRecordsRoutes = createCrudRoutes({
+  entity: SubmissionRecords,
+  createSchema: CreateSubmissionRecordsDto,
+  updateSchema: UpdateSubmissionRecordsDto,
+  getSchema: SubmissionRecordsSchema,
+  listSchema: SubmissionRecordsSchema,
+  searchFields: ['classroomNo', 'userId', 'code'],
+  middleware: [authMiddleware]
+});
+
+export default submissionRecordsRoutes;

+ 6 - 1
src/server/data-source.ts

@@ -5,6 +5,11 @@ import process from 'node:process'
 // 实体类导入
 import { UserEntity as User } from "./modules/users/user.entity"
 import { Role } from "./modules/users/role.entity"
+import { ClassroomData } from "./modules/classroom/classroom-data.entity"
+import { DateNotes } from "./modules/stock/date-notes.entity"
+import { StockData } from "./modules/stock/stock-data.entity"
+import { StockXunlianCodes } from "./modules/stock/stock-xunlian-codes.entity"
+import { SubmissionRecords } from "./modules/submission/submission-records.entity"
 
 export const AppDataSource = new DataSource({
   type: "mysql",
@@ -14,7 +19,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
-    User, Role, 
+    User, Role, ClassroomData, DateNotes, StockData, StockXunlianCodes, SubmissionRecords, 
   ],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 98 - 0
src/server/modules/classroom/classroom-data.entity.ts

@@ -0,0 +1,98 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+// 教室状态枚举
+export enum ClassroomStatus {
+  CLOSED = 0,  // 关闭
+  OPEN = 1     // 开放
+}
+
+// 教室状态中文映射
+export const ClassroomStatusNameMap: Record<ClassroomStatus, string> = {
+  [ClassroomStatus.CLOSED]: '关闭',
+  [ClassroomStatus.OPEN]: '开放'
+};
+
+@Entity('classroom_data')
+export class ClassroomData {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'classroom_no', type: 'varchar', length: 255, nullable: true, comment: '教室号' })
+  classroomNo!: string | null;
+
+  @Column({ name: 'training_date', type: 'timestamp', nullable: true, comment: '训练日期' })
+  trainingDate!: Date | null;
+
+  @Column({ name: 'holding_stock', type: 'varchar', length: 255, nullable: true, comment: '持股' })
+  holdingStock!: string | null;
+
+  @Column({ name: 'holding_cash', type: 'varchar', length: 255, nullable: true, comment: '持币' })
+  holdingCash!: string | null;
+
+  @Column({ name: 'price', type: 'varchar', length: 255, nullable: true, comment: '价格' })
+  price!: string | null;
+
+  @Column({ name: 'code', type: 'varchar', length: 255, nullable: true, comment: '代码' })
+  code!: string | null;
+
+  @Column({ name: 'status', type: 'enum', enum:ClassroomStatus, nullable: true, comment: '状态' })
+  status!: ClassroomStatus | null;
+
+  @Column({ name: 'spare', type: 'varchar', length: 255, nullable: true, comment: '备用' })
+  spare!: string | null;
+
+  @Column({ name: 'submit_user', type: 'varchar', length: 255, nullable: true, comment: '提交用户' })
+  submitUser!: string | null;
+
+  @Column({ name: 'created_by', type: 'int', nullable: true, comment: '创建用户ID' })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', nullable: true, comment: '更新用户ID' })
+  updatedBy!: number | null;
+
+  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
+  createdAt!: Date;
+
+  @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
+  updatedAt!: Date;
+}
+
+export const ClassroomDataSchema = z.object({
+  id: z.number().int().positive().openapi({ description: '数据ID', example: 1 }),
+  classroomNo: z.string().max(255).nullable().openapi({ description: '教室号', example: 'test01' }),
+  trainingDate: z.date().nullable().openapi({ description: '训练日期', example: '2025-05-21T08:00:00Z' }),
+  holdingStock: z.string().max(255).nullable().openapi({ description: '持股', example: '100股' }),
+  holdingCash: z.string().max(255).nullable().openapi({ description: '持币', example: '10000元' }),
+  price: z.string().max(255).nullable().openapi({ description: '价格', example: '15.68' }),
+  code: z.string().max(255).nullable().openapi({ description: '代码', example: '001339' }),
+  status: z.nativeEnum(ClassroomStatus).nullable().openapi({ description: '状态', example: ClassroomStatus.OPEN }),
+  spare: z.string().max(255).nullable().openapi({ description: '备用', example: '' }),
+  submitUser: z.string().max(255).nullable().openapi({ description: '提交用户', example: '' }),
+  createdBy: z.number().int().positive().nullable().openapi({ description: '创建用户ID', example: 1 }),
+  updatedBy: z.number().int().positive().nullable().openapi({ description: '更新用户ID', example: 1 }),
+  createdAt: z.date().openapi({ description: '创建时间', example: '2025-05-21T16:44:36Z' }),
+  updatedAt: z.date().openapi({ description: '更新时间', example: '2025-05-21T21:22:06Z' })
+});
+
+export const CreateClassroomDataDto = z.object({
+  classroomNo: z.string().max(255).optional().nullable().openapi({ description: '教室号', example: 'test01' }),
+  trainingDate: z.coerce.date().optional().nullable().openapi({ description: '训练日期', example: '2025-05-21T08:00:00Z' }),
+  holdingStock: z.string().max(255).optional().nullable().openapi({ description: '持股', example: '100股' }),
+  holdingCash: z.string().max(255).optional().nullable().openapi({ description: '持币', example: '10000元' }),
+  price: z.string().max(255).optional().nullable().openapi({ description: '价格', example: '15.68' }),
+  code: z.string().max(255).optional().nullable().openapi({ description: '代码', example: '001339' }),
+  status: z.nativeEnum(ClassroomStatus).optional().nullable().openapi({ description: '状态', example: ClassroomStatus.OPEN }),
+  spare: z.string().max(255).optional().nullable().openapi({ description: '备用', example: '' })
+});
+
+export const UpdateClassroomDataDto = z.object({
+  classroomNo: z.string().max(255).optional().nullable().openapi({ description: '教室号', example: 'test01' }),
+  trainingDate: z.coerce.date().optional().nullable().openapi({ description: '训练日期', example: '2025-05-21T08:00:00Z' }),
+  holdingStock: z.string().max(255).optional().nullable().openapi({ description: '持股', example: '100股' }),
+  holdingCash: z.string().max(255).optional().nullable().openapi({ description: '持币', example: '10000元' }),
+  price: z.string().max(255).optional().nullable().openapi({ description: '价格', example: '15.68' }),
+  code: z.string().max(255).optional().nullable().openapi({ description: '代码', example: '001339' }),
+  status: z.nativeEnum(ClassroomStatus).optional().nullable().openapi({ description: '状态', example: ClassroomStatus.OPEN }),
+  spare: z.string().max(255).optional().nullable().openapi({ description: '备用', example: '' })
+});

+ 9 - 0
src/server/modules/classroom/classroom-data.service.ts

@@ -0,0 +1,9 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { ClassroomData } from './classroom-data.entity';
+
+export class ClassroomDataService extends GenericCrudService<ClassroomData> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, ClassroomData);
+  }
+}

+ 45 - 0
src/server/modules/stock/date-notes.entity.ts

@@ -0,0 +1,45 @@
+import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+@Entity('date_notes')
+@Index(['code', 'noteDate'], { unique: true })
+export class DateNotes {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'code', type: 'varchar', length: 255, nullable: false, comment: '股票代码' })
+  code!: string;
+
+  @Column({ name: 'note_date', type: 'timestamp', nullable: false, comment: '备注日期' })
+  noteDate!: Date;
+
+  @Column({ name: 'note', type: 'varchar', length: 255, nullable: false, comment: '备注内容' })
+  note!: string;
+
+  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
+  createdAt!: Date;
+
+  @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
+  updatedAt!: Date;
+}
+
+export const DateNotesSchema = z.object({
+  id: z.number().int().positive().openapi({ description: 'ID', example: 1 }),
+  code: z.string().max(255).openapi({ description: '股票代码', example: '001339' }),
+  noteDate: z.date().openapi({ description: '备注日期', example: '2024-11-07T08:00:00Z' }),
+  note: z.string().max(255).openapi({ description: '备注内容', example: 'test01' }),
+  createdAt: z.date().openapi({ description: '创建时间', example: '2025-05-22T16:17:13Z' }),
+  updatedAt: z.date().openapi({ description: '更新时间', example: '2025-05-22T16:17:13Z' })
+});
+
+export const CreateDateNotesDto = z.object({
+  code: z.string().max(255).openapi({ description: '股票代码', example: '001339' }),
+  noteDate: z.coerce.date().openapi({ description: '备注日期', example: '2024-11-07T08:00:00Z' }),
+  note: z.string().max(255).openapi({ description: '备注内容', example: 'test01' })
+});
+
+export const UpdateDateNotesDto = z.object({
+  code: z.string().max(255).optional().openapi({ description: '股票代码', example: '001339' }),
+  noteDate: z.coerce.date().optional().openapi({ description: '备注日期', example: '2024-11-07T08:00:00Z' }),
+  note: z.string().max(255).optional().openapi({ description: '备注内容', example: 'test01' })
+});

+ 9 - 0
src/server/modules/stock/date-notes.service.ts

@@ -0,0 +1,9 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { DateNotes } from './date-notes.entity';
+
+export class DateNotesService extends GenericCrudService<DateNotes> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, DateNotes);
+  }
+}

+ 66 - 0
src/server/modules/stock/stock-data.entity.ts

@@ -0,0 +1,66 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+@Entity('stock_data')
+export class StockData {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'code', type: 'varchar', length: 255, nullable: false, comment: '股票代码' })
+  code!: string;
+
+  @Column({ name: 'data', type: 'json', nullable: false, comment: '股票数据' })
+  data!: Record<string, any>;
+
+  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
+  createdAt!: Date;
+
+  @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
+  updatedAt!: Date;
+}
+
+export const StockDataSchema = z.object({
+  id: z.number().int().positive().openapi({ description: 'ID', example: 1 }),
+  code: z.string().max(255).openapi({ description: '股票代码', example: '001339' }),
+  data: z.record(z.any()).openapi({ 
+    description: '股票数据', 
+    example: { 
+      date: '2025-05-21', 
+      open: 15.68, 
+      close: 16.25, 
+      high: 16.50, 
+      low: 15.50, 
+      volume: 1250000 
+    } 
+  }),
+  createdAt: z.date().openapi({ description: '创建时间', example: '2025-05-21T16:44:36Z' }),
+  updatedAt: z.date().openapi({ description: '更新时间', example: '2025-05-21T21:22:06Z' })
+});
+
+export const CreateStockDataDto = StockDataSchema.omit({
+  id: true,
+  createdAt: true,
+  updatedAt: true
+}).partial().extend({
+  data: z.string().transform((val) => {
+    try {
+      return JSON.parse(val)
+    } catch (error) {
+      return {}
+    }
+  })
+});
+
+export const UpdateStockDataDto = StockDataSchema.omit({
+  id: true,
+  createdAt: true,
+  updatedAt: true
+}).partial().extend({
+  data: z.string().transform((val) => {
+    try {
+      return JSON.parse(val)
+    } catch (error) {
+      return {}
+    }
+  })
+});

+ 124 - 0
src/server/modules/stock/stock-data.service.ts

@@ -0,0 +1,124 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+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.updated_at >= :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', 'updated_at'],
+          ['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 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;
+  }
+}

+ 62 - 0
src/server/modules/stock/stock-xunlian-codes.entity.ts

@@ -0,0 +1,62 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+@Entity('stock_xunlian_codes')
+export class StockXunlianCodes {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'code', type: 'varchar', length: 255, nullable: false, comment: '股票代码' })
+  code!: string;
+
+  @Column({ name: 'stock_name', type: 'varchar', length: 255, nullable: false, comment: '股票名称' })
+  stockName!: string;
+
+  @Column({ name: 'name', type: 'varchar', length: 255, nullable: false, comment: '案例名称' })
+  name!: string;
+
+  @Column({ name: 'type', type: 'varchar', length: 255, nullable: true, comment: '案例类型' })
+  type!: string | null;
+
+  @Column({ name: 'description', type: 'varchar', length: 255, nullable: true, comment: '案例描述' })
+  description!: string | null;
+
+  @Column({ name: 'trade_date', type: 'timestamp', nullable: false, comment: '交易日期' })
+  tradeDate!: Date;
+
+  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
+  createdAt!: Date;
+
+  @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
+  updatedAt!: Date;
+}
+
+export const StockXunlianCodesSchema = z.object({
+  id: z.number().int().positive().openapi({ description: 'ID', example: 1 }),
+  code: z.string().max(255).openapi({ description: '股票代码', example: '001339' }),
+  stockName: z.string().max(255).openapi({ description: '股票名称', example: 'test01' }),
+  name: z.string().max(255).openapi({ description: '案例名称', example: 'test222' }),
+  type: z.string().max(255).nullable().openapi({ description: '案例类型', example: '技术分析' }),
+  description: z.string().max(255).nullable().openapi({ description: '案例描述', example: '这是一个测试案例' }),
+  tradeDate: z.date().openapi({ description: '交易日期', example: '2025-05-21T08:00:00Z' }),
+  createdAt: z.date().openapi({ description: '创建时间', example: '2025-05-22T16:58:01Z' }),
+  updatedAt: z.date().openapi({ description: '更新时间', example: '2025-05-22T17:19:32Z' })
+});
+
+export const CreateStockXunlianCodesDto = z.object({
+  code: z.string().max(255).openapi({ description: '股票代码', example: '001339' }),
+  stockName: z.string().max(255).openapi({ description: '股票名称', example: 'test01' }),
+  name: z.string().max(255).openapi({ description: '案例名称', example: 'test222' }),
+  type: z.string().max(255).nullable().openapi({ description: '案例类型', example: '技术分析' }),
+  description: z.string().max(255).nullable().optional().openapi({ description: '案例描述', example: '这是一个测试案例' }),
+  tradeDate: z.coerce.date().openapi({ description: '交易日期', example: '2025-05-21T08:00:00Z' })
+});
+
+export const UpdateStockXunlianCodesDto = z.object({
+  code: z.string().max(255).optional().openapi({ description: '股票代码', example: '001339' }),
+  stockName: z.string().max(255).optional().openapi({ description: '股票名称', example: 'test01' }),
+  name: z.string().max(255).optional().openapi({ description: '案例名称', example: 'test222' }),
+  type: z.string().max(255).nullable().optional().openapi({ description: '案例类型', example: '技术分析' }),
+  description: z.string().max(255).nullable().optional().openapi({ description: '案例描述', example: '这是一个测试案例' }),
+  tradeDate: z.coerce.date().optional().openapi({ description: '交易日期', example: '2025-05-21T08:00:00Z' })
+});

+ 9 - 0
src/server/modules/stock/stock-xunlian-codes.service.ts

@@ -0,0 +1,9 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { StockXunlianCodes } from './stock-xunlian-codes.entity';
+
+export class StockXunlianCodesService extends GenericCrudService<StockXunlianCodes> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, StockXunlianCodes);
+  }
+}

+ 92 - 0
src/server/modules/submission/submission-records.entity.ts

@@ -0,0 +1,92 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+@Entity('submission_records')
+export class SubmissionRecords {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'classroom_no', type: 'varchar', length: 255, nullable: true, comment: '教室号' })
+  classroomNo!: string | null;
+
+  @Column({ name: 'user_id', type: 'varchar', length: 255, nullable: true, comment: '用户id' })
+  userId!: string | null;
+
+  @Column({ name: 'nickname', type: 'varchar', length: 255, nullable: true, comment: '昵称' })
+  nickname!: string | null;
+
+  @Column({ name: 'score', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '成绩' })
+  score!: number | null;
+
+  @Column({ name: 'code', type: 'varchar', length: 255, nullable: true, comment: '代码' })
+  code!: string | null;
+
+  @Column({ name: 'training_date', type: 'timestamp', nullable: true, comment: '训练日期' })
+  trainingDate!: Date | null;
+
+  @Column({ name: 'mark', type: 'varchar', length: 255, nullable: true, comment: '标记' })
+  mark!: string | null;
+
+  @Column({ name: 'status', type: 'int', nullable: true, comment: '状态' })
+  status!: number | null;
+
+  @Column({ name: 'holding_stock', type: 'varchar', length: 255, nullable: true, comment: '持股' })
+  holdingStock!: string | null;
+
+  @Column({ name: 'holding_cash', type: 'varchar', length: 255, nullable: true, comment: '持币' })
+  holdingCash!: string | null;
+
+  @Column({ name: 'price', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '价格' })
+  price!: number | null;
+
+  @Column({ name: 'profit_amount', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '收益金额' })
+  profitAmount!: number | null;
+
+  @Column({ name: 'profit_percent', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '收益率' })
+  profitPercent!: number | null;
+
+  @Column({ name: 'total_profit_amount', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '累计收益金额' })
+  totalProfitAmount!: number | null;
+
+  @Column({ name: 'total_profit_percent', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '累计收益率' })
+  totalProfitPercent!: number | null;
+
+  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
+  createdAt!: Date;
+
+  @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
+  updatedAt!: Date;
+}
+
+export const SubmissionRecordsSchema = z.object({
+  id: z.number().int().positive().openapi({ description: '数据ID', example: 1 }),
+  classroomNo: z.string().max(255).nullable().openapi({ description: '教室号', example: 'class01' }),
+  userId: z.string().max(255).nullable().openapi({ description: '用户id', example: '1001' }),
+  nickname: z.string().max(255).nullable().openapi({ description: '昵称', example: 'student1' }),
+  score: z.number().nullable().openapi({ description: '成绩', example: 95.5 }),
+  code: z.string().max(255).nullable().openapi({ description: '代码', example: '001339' }),
+  trainingDate: z.date().nullable().openapi({ description: '训练日期', example: '2025-05-21T08:00:00Z' }),
+  mark: z.string().max(255).nullable().openapi({ description: '标记', example: '优秀' }),
+  status: z.number().nullable().openapi({ description: '状态', example: 1 }),
+  holdingStock: z.string().max(255).nullable().openapi({ description: '持股', example: '100股' }),
+  holdingCash: z.string().max(255).nullable().openapi({ description: '持币', example: '10000元' }),
+  price: z.number().nullable().openapi({ description: '价格', example: 15.68 }),
+  profitAmount: z.number().nullable().openapi({ description: '收益金额', example: 500.00 }),
+  profitPercent: z.number().nullable().openapi({ description: '收益率', example: 5.25 }),
+  totalProfitAmount: z.number().nullable().openapi({ description: '累计收益金额', example: 2500.00 }),
+  totalProfitPercent: z.number().nullable().openapi({ description: '累计收益率', example: 12.5 }),
+  createdAt: z.date().openapi({ description: '创建时间', example: '2025-05-21T16:44:36Z' }),
+  updatedAt: z.date().openapi({ description: '更新时间', example: '2025-05-21T21:22:06Z' })
+});
+
+export const CreateSubmissionRecordsDto = SubmissionRecordsSchema.omit({
+  id: true,
+  createdAt: true,
+  updatedAt: true
+}).partial();
+
+export const UpdateSubmissionRecordsDto = SubmissionRecordsSchema.omit({
+  id: true,
+  createdAt: true,
+  updatedAt: true
+}).partial();

+ 9 - 0
src/server/modules/submission/submission-records.service.ts

@@ -0,0 +1,9 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { SubmissionRecords } from './submission-records.entity';
+
+export class SubmissionRecordsService extends GenericCrudService<SubmissionRecords> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, SubmissionRecords);
+  }
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.