Sfoglia il codice sorgente

✨ feat(chat): 实现聊天消息持久化功能

- 添加聊天消息实体定义,支持文本、图片和系统消息类型
- 实现标准CRUD接口,支持分页、搜索和筛选功能
- 添加按课堂ID查询历史消息的专用接口
- 实现用户操作追踪,自动记录创建和更新用户ID
- 提供类型安全的API客户端和服务类方法

📝 docs(chat): 添加聊天消息持久化功能使用指南

- 详细说明API端点、数据结构和使用示例
- 提供客户端调用代码示例和服务类方法文档
- 说明筛选功能和错误处理机制
- 列出注意事项和性能优化建议
yourname 6 mesi fa
parent
commit
c1c1be2d6e

+ 216 - 0
CHAT_MESSAGE_GUIDE.md

@@ -0,0 +1,216 @@
+# 聊天消息持久化功能使用指南
+
+## 功能概述
+
+本功能提供了完整的聊天消息持久化解决方案,包括:
+- 聊天消息实体定义
+- 标准CRUD操作接口
+- 历史消息查询功能
+- 用户操作追踪
+- 类型安全的API调用
+
+## API端点
+
+### 1. 标准CRUD接口
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| GET | `/api/v1/chat-messages` | 获取聊天消息列表(支持分页、搜索、筛选) |
+| POST | `/api/v1/chat-messages` | 创建新的聊天消息 |
+| GET | `/api/v1/chat-messages/{id}` | 获取单个聊天消息详情 |
+| PUT | `/api/v1/chat-messages/{id}` | 更新聊天消息 |
+| DELETE | `/api/v1/chat-messages/{id}` | 删除聊天消息 |
+
+### 2. 历史消息查询接口
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| GET | `/api/v1/chat-messages/history` | 按课堂ID查询历史消息 |
+
+## 数据结构
+
+### 聊天消息实体 (ChatMessage)
+
+```typescript
+interface ChatMessage {
+  id: number;                    // 消息ID
+  classId: string;              // 课堂ID
+  type: 'text' | 'image' | 'system'; // 消息类型
+  content: string;              // 消息内容
+  senderId: string | null;      // 发送者ID
+  senderName: string | null;    // 发送者名称
+  timestamp: number;            // 消息时间戳(毫秒)
+  createdBy: number | null;     // 创建用户ID
+  updatedBy: number | null;     // 更新用户ID
+  createdAt: Date;              // 创建时间
+  updatedAt: Date;              // 更新时间
+}
+```
+
+## 使用示例
+
+### 1. 创建聊天消息
+
+```typescript
+import { chatMessageClient } from '@/client/api';
+
+// 创建文本消息
+const response = await chatMessageClient.$post({
+  json: {
+    classId: 'class_123456',
+    type: 'text',
+    content: '大家好,这是一条测试消息',
+    senderId: 'user_123',
+    senderName: '张三',
+    timestamp: Date.now()
+  }
+});
+
+if (response.status === 201) {
+  const message = await response.json();
+  console.log('消息创建成功:', message);
+}
+```
+
+### 2. 查询历史消息
+
+```typescript
+// 查询特定课堂的历史消息
+const response = await chatMessageClient.history.$get({
+  query: {
+    classId: 'class_123456',
+    page: 1,
+    pageSize: 50
+  }
+});
+
+if (response.status === 200) {
+  const result = await response.json();
+  console.log('历史消息:', result.data);
+  console.log('分页信息:', result.pagination);
+}
+```
+
+### 3. 获取消息列表
+
+```typescript
+// 获取所有消息(支持搜索和筛选)
+const response = await chatMessageClient.$get({
+  query: {
+    page: 1,
+    pageSize: 10,
+    keyword: '测试',
+    filters: JSON.stringify({
+      type: 'text',
+      timestamp: { gte: 1704067200000 }
+    })
+  }
+});
+
+if (response.status === 200) {
+  const result = await response.json();
+  console.log('消息列表:', result.data);
+}
+```
+
+## 服务类方法
+
+### ChatMessageService
+
+```typescript
+// 根据课堂ID获取历史记录
+const [messages, total] = await chatMessageService.getHistoryByClassId(
+  'class_123456', 
+  1, // page
+  50 // pageSize
+);
+
+// 获取最新消息
+const latestMessages = await chatMessageService.getLatestMessages(
+  'class_123456', 
+  20 // limit
+);
+
+// 创建单条消息
+const message = await chatMessageService.createMessage(messageData, userId);
+
+// 批量创建消息
+const messages = await chatMessageService.createMessages(
+  messageArray, 
+  userId
+);
+```
+
+## 类型定义
+
+### 客户端类型
+
+```typescript
+import type { InferResponseType, InferRequestType } from 'hono/client';
+
+// 响应类型
+type ChatMessageResponse = InferResponseType<typeof chatMessageClient.$get, 200>;
+type ChatMessageDetail = InferResponseType<typeof chatMessageClient[':id']['$get'], 200>;
+
+// 请求类型
+type CreateChatMessageRequest = InferRequestType<typeof chatMessageClient.$post>['json'];
+type UpdateChatMessageRequest = InferRequestType<typeof chatMessageClient[':id']['$put']>['json'];
+```
+
+## 筛选功能
+
+支持多种筛选操作:
+
+```typescript
+// 精确匹配
+filters: JSON.stringify({ type: 'text' })
+
+// 模糊搜索
+filters: JSON.stringify({ content: '%关键词%' })
+
+// 范围查询
+filters: JSON.stringify({ 
+  timestamp: { 
+    gte: 1704067200000, // 开始时间
+    lte: 1704153600000  // 结束时间
+  }
+})
+
+// IN查询
+filters: JSON.stringify({ 
+  type: ['text', 'system'] 
+})
+```
+
+## 注意事项
+
+1. **认证要求**: 所有API都需要有效的JWT token
+2. **用户追踪**: 创建和更新操作会自动记录操作人ID
+3. **时间戳**: 使用毫秒时间戳,便于前端显示和处理
+4. **消息类型**: 支持 text、image、system 三种类型
+5. **分页**: 所有列表接口都支持分页,默认pageSize为10
+
+## 错误处理
+
+所有API都返回统一的错误格式:
+
+```json
+{
+  "code": 错误代码,
+  "message": "错误描述",
+  "errors": "详细错误信息(可选)"
+}
+```
+
+常见错误代码:
+- 400: 参数验证失败
+- 401: 未授权访问
+- 404: 资源不存在
+- 500: 服务器内部错误
+
+## 性能优化
+
+1. 为常用查询字段(classId、timestamp)添加数据库索引
+2. 使用分页避免返回大量数据
+3. 合理设置搜索字段,避免全表扫描
+4. 使用缓存机制存储频繁访问的数据

+ 5 - 1
src/client/api.ts

@@ -3,7 +3,7 @@ import type {
   AuthRoutes, UserRoutes, RoleRoutes, FileRoutes,
   ClassroomDataRoutes, SubmissionRecordsRoutes,
   StockDataRoutes, StockXunlianCodesRoutes, DateNotesRoutes, AliyunRoutes,
-  WechatAuthRoutes
+  WechatAuthRoutes, ChatMessageRoutes
 } from '@/server/api';
 import { axiosFetch } from './utils/axios-fetch';
 
@@ -50,3 +50,7 @@ export const aliyunClient = hc<AliyunRoutes>('/', {
 export const wechatAuthClient = hc<WechatAuthRoutes>('/', {
   fetch: axiosFetch,
 }).api.v1.auth.wechat;
+
+export const chatMessageClient = hc<ChatMessageRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['chat-messages'];

+ 3 - 0
src/server/api.ts

@@ -12,6 +12,7 @@ import stockXunlianCodesRoutes from './api/stock-xunlian-codes/index'
 import dateNotesRoutes from './api/date-notes/index'
 import aliyunRoute from './api/aliyun/index'
 import wechatRoutes from './api/auth/wechat/index'
+import chatMessageRoutes from './api/chat-messages/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -93,6 +94,7 @@ const stockXunlianCodesApi = api.route('/api/v1/stock-xunlian-codes', stockXunli
 const dateNotesApi = api.route('/api/v1/date-notes', dateNotesRoutes)
 const aliyunApi = api.route('/api/v1/aliyun', aliyunRoute)
 const wechatAuthApi = api.route('/api/v1/auth/wechat', wechatRoutes)
+const chatMessageApi = api.route('/api/v1/chat-messages', chatMessageRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
@@ -105,6 +107,7 @@ export type StockXunlianCodesRoutes = typeof stockXunlianCodesApi
 export type DateNotesRoutes = typeof dateNotesApi
 export type AliyunRoutes = typeof aliyunApi
 export type WechatAuthRoutes = typeof wechatAuthApi
+export type ChatMessageRoutes = typeof chatMessageApi
 
 app.route('/', api)
 export default app

+ 73 - 0
src/server/api/chat-messages/history.ts

@@ -0,0 +1,73 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from '@hono/zod-openapi';
+import { ChatMessageSchema, HistoryQuerySchema } from '@/server/modules/chat/chat-message.schema';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { ChatMessageService } from '@/server/modules/chat/chat-message.service';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+// 历史消息查询路由
+const routeDef = createRoute({
+  method: 'get',
+  path: '/history',
+  middleware: [authMiddleware],
+  request: {
+    query: HistoryQuerySchema
+  },
+  responses: {
+    200: {
+      description: '成功获取聊天历史',
+      content: {
+        'application/json': {
+          schema: z.object({
+            data: z.array(ChatMessageSchema),
+            pagination: z.object({
+              total: z.number().openapi({ example: 100, description: '总记录数' }),
+              current: z.number().openapi({ example: 1, description: '当前页码' }),
+              pageSize: z.number().openapi({ example: 50, description: '每页数量' })
+            })
+          })
+        }
+      }
+    },
+    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 query = c.req.valid('query');
+    const { classId, page, pageSize } = query;
+    
+    const chatMessageService = new ChatMessageService(AppDataSource);
+    const [data, total] = await chatMessageService.getHistoryByClassId(
+      classId,
+      page,
+      pageSize
+    );
+    
+    return c.json({
+      data,
+      pagination: {
+        total,
+        current: page,
+        pageSize
+      }
+    }, 200);
+  } catch (error) {
+    return c.json({
+      code: 500,
+      message: error instanceof Error ? error.message : '获取聊天历史失败'
+    }, 500);
+  }
+});
+
+export default app;

+ 33 - 0
src/server/api/chat-messages/index.ts

@@ -0,0 +1,33 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { ChatMessage } from '@/server/modules/chat/chat-message.entity';
+import { 
+  ChatMessageSchema, 
+  CreateChatMessageDto, 
+  UpdateChatMessageDto
+} from '@/server/modules/chat/chat-message.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import historyRoute from './history';
+
+// 创建基础CRUD路由
+const crudRoutes = createCrudRoutes({
+  entity: ChatMessage,
+  createSchema: CreateChatMessageDto,
+  updateSchema: UpdateChatMessageDto,
+  getSchema: ChatMessageSchema,
+  listSchema: ChatMessageSchema,
+  searchFields: ['content', 'senderName'],
+  relations: [],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});
+
+// 创建聚合路由
+const app = new OpenAPIHono()
+  .route('/', crudRoutes)
+  .route('/', historyRoute);
+
+export default app;

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

@@ -11,6 +11,7 @@ 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"
 import { File } from "./modules/files/file.entity"
+import { ChatMessage } from "./modules/chat/chat-message.entity"
 
 export const AppDataSource = new DataSource({
   type: "mysql",
@@ -20,7 +21,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
-    User, Role, File, ClassroomData, DateNotes, StockData, StockXunlianCodes, SubmissionRecords, 
+    User, Role, File, ClassroomData, DateNotes, StockData, StockXunlianCodes, SubmissionRecords, ChatMessage
   ],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 74 - 0
src/server/modules/chat/chat-message.entity.ts

@@ -0,0 +1,74 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+
+@Entity('chat_messages')
+export class ChatMessage {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'class_id', type: 'varchar', length: 255, comment: '课堂ID' })
+  classId!: string;
+
+  @Column({ 
+    name: 'type', 
+    type: 'enum', 
+    enum: ['text', 'image', 'system'], 
+    comment: '消息类型' 
+  })
+  type!: 'text' | 'image' | 'system';
+
+  @Column({ name: 'content', type: 'text', comment: '消息内容' })
+  content!: string;
+
+  @Column({ 
+    name: 'sender_id', 
+    type: 'varchar', 
+    length: 255, 
+    nullable: true, 
+    comment: '发送者ID' 
+  })
+  senderId!: string | null;
+
+  @Column({ 
+    name: 'sender_name', 
+    type: 'varchar', 
+    length: 255, 
+    nullable: true, 
+    comment: '发送者名称' 
+  })
+  senderName!: string | null;
+
+  @Column({ name: 'timestamp', type: 'bigint', comment: '消息时间戳' })
+  timestamp!: number;
+
+  // 操作人跟踪字段
+  @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;
+
+  @CreateDateColumn({ 
+    name: 'created_at',
+    type: 'timestamp', 
+    default: () => 'CURRENT_TIMESTAMP' 
+  })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ 
+    name: 'updated_at',
+    type: 'timestamp', 
+    default: () => 'CURRENT_TIMESTAMP',
+    onUpdate: 'CURRENT_TIMESTAMP' 
+  })
+  updatedAt!: Date;
+}

+ 140 - 0
src/server/modules/chat/chat-message.schema.ts

@@ -0,0 +1,140 @@
+import { z } from '@hono/zod-openapi';
+
+// 基础消息Schema
+export const ChatMessageSchema = z.object({
+  id: z.number().int().positive().openapi({
+    example: 1,
+    description: '消息ID'
+  }),
+  classId: z.string().openapi({
+    example: 'class_123456',
+    description: '课堂ID'
+  }),
+  type: z.enum(['text', 'image', 'system']).openapi({
+    example: 'text',
+    description: '消息类型'
+  }),
+  content: z.string().openapi({
+    example: '这是一条文本消息',
+    description: '消息内容'
+  }),
+  senderId: z.string().nullable().openapi({
+    example: 'user_123',
+    description: '发送者ID'
+  }),
+  senderName: z.string().nullable().openapi({
+    example: '张三',
+    description: '发送者名称'
+  }),
+  timestamp: z.number().int().positive().openapi({
+    example: 1704067200000,
+    description: '消息时间戳'
+  }),
+  createdBy: z.number().int().positive().nullable().openapi({
+    example: 1,
+    description: '创建用户ID'
+  }),
+  updatedBy: z.number().int().positive().nullable().openapi({
+    example: 1,
+    description: '更新用户ID'
+  }),
+  createdAt: z.coerce.date().openapi({
+    example: '2024-01-01T12:00:00Z',
+    description: '创建时间'
+  }),
+  updatedAt: z.coerce.date().openapi({
+    example: '2024-01-01T12:00:00Z',
+    description: '更新时间'
+  })
+});
+
+// 创建消息DTO
+export const CreateChatMessageDto = z.object({
+  classId: z.string().min(1, '课堂ID不能为空').openapi({
+    example: 'class_123456',
+    description: '课堂ID'
+  }),
+  type: z.enum(['text', 'image', 'system']).openapi({
+    example: 'text',
+    description: '消息类型'
+  }),
+  content: z.string().min(1, '消息内容不能为空').openapi({
+    example: '这是一条文本消息',
+    description: '消息内容'
+  }),
+  senderId: z.string().nullable().optional().openapi({
+    example: 'user_123',
+    description: '发送者ID'
+  }),
+  senderName: z.string().nullable().optional().openapi({
+    example: '张三',
+    description: '发送者名称'
+  }),
+  timestamp: z.coerce.number().int().positive().openapi({
+    example: 1704067200000,
+    description: '消息时间戳(毫秒)'
+  })
+});
+
+// 更新消息DTO
+export const UpdateChatMessageDto = z.object({
+  classId: z.string().min(1, '课堂ID不能为空').optional().openapi({
+    example: 'class_123456',
+    description: '课堂ID'
+  }),
+  type: z.enum(['text', 'image', 'system']).optional().openapi({
+    example: 'text',
+    description: '消息类型'
+  }),
+  content: z.string().min(1, '消息内容不能为空').optional().openapi({
+    example: '这是一条文本消息',
+    description: '消息内容'
+  }),
+  senderId: z.string().nullable().optional().openapi({
+    example: 'user_123',
+    description: '发送者ID'
+  }),
+  senderName: z.string().nullable().optional().openapi({
+    example: '张三',
+    description: '发送者名称'
+  }),
+  timestamp: z.coerce.number().int().positive().optional().openapi({
+    example: 1704067200000,
+    description: '消息时间戳'
+  })
+});
+
+// 消息列表响应Schema
+export const ChatMessageListResponse = z.object({
+  data: z.array(ChatMessageSchema),
+  pagination: z.object({
+    total: z.number().openapi({
+      example: 100,
+      description: '总记录数'
+    }),
+    current: z.number().openapi({
+      example: 1,
+      description: '当前页码'
+    }),
+    pageSize: z.number().openapi({
+      example: 10,
+      description: '每页数量'
+    })
+  })
+});
+
+// 历史消息查询参数
+export const HistoryQuerySchema = z.object({
+  classId: z.string().min(1, '课堂ID不能为空').openapi({
+    example: 'class_123456',
+    description: '课堂ID'
+  }),
+  page: z.coerce.number().int().positive().default(1).openapi({
+    example: 1,
+    description: '页码'
+  }),
+  pageSize: z.coerce.number().int().positive().default(50).openapi({
+    example: 50,
+    description: '每页数量'
+  })
+});

+ 81 - 0
src/server/modules/chat/chat-message.service.ts

@@ -0,0 +1,81 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { ChatMessage } from './chat-message.entity';
+
+export class ChatMessageService extends GenericCrudService<ChatMessage> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, ChatMessage, {
+      userTracking: {
+        createdByField: 'createdBy',
+        updatedByField: 'updatedBy'
+      }
+    });
+  }
+
+  /**
+   * 根据课堂ID获取聊天消息历史记录
+   * @param classId 课堂ID
+   * @param page 页码
+   * @param pageSize 每页数量
+   * @returns 聊天消息列表和总数
+   */
+  async getHistoryByClassId(
+    classId: string,
+    page: number = 1,
+    pageSize: number = 50
+  ): Promise<[ChatMessage[], number]> {
+    return this.getList(
+      page,
+      pageSize,
+      undefined,
+      undefined,
+      { classId },
+      [],
+      { timestamp: 'DESC' }
+    );
+  }
+
+  /**
+   * 获取最新的聊天消息
+   * @param classId 课堂ID
+   * @param limit 限制数量
+   * @returns 最新的聊天消息列表
+   */
+  async getLatestMessages(classId: string, limit: number = 20): Promise<ChatMessage[]> {
+    const [messages] = await this.getList(
+      1,
+      limit,
+      undefined,
+      undefined,
+      { classId },
+      [],
+      { timestamp: 'DESC' }
+    );
+    return messages;
+  }
+
+  /**
+   * 创建聊天消息
+   * @param data 消息数据
+   * @param userId 用户ID
+   * @returns 创建的聊天消息
+   */
+  async createMessage(data: Partial<ChatMessage>, userId?: number): Promise<ChatMessage> {
+    return this.create(data, userId);
+  }
+
+  /**
+   * 批量创建聊天消息
+   * @param messages 消息数据数组
+   * @param userId 用户ID
+   * @returns 创建的聊天消息数组
+   */
+  async createMessages(messages: Partial<ChatMessage>[], userId?: number): Promise<ChatMessage[]> {
+    const results: ChatMessage[] = [];
+    for (const message of messages) {
+      const result = await this.create(message, userId);
+      results.push(result);
+    }
+    return results;
+  }
+}