浏览代码

✨ feat(mobile): 新增PDF预览和知识交互功能

- 在SilverWisdomDetailPage中实现移动端PDF兼容性检测和预览功能
- 支持微信/QQ/IOS等浏览器的特殊处理,提供下载替代方案
- 新增PDF加载状态和错误处理,优化用户体验
- 实现知识点赞、收藏等交互功能的前端接口
- 创建完整的知识交互后端API,包括状态查询、创建/取消交互
- 新增用户收藏列表查询接口
- 建立silver_knowledge_interactions表结构,支持like和favorite类型
yourname 7 月之前
父节点
当前提交
d323360e07

+ 302 - 11
src/client/mobile/pages/SilverWisdomDetailPage.tsx

@@ -44,6 +44,9 @@ const SilverWisdomDetailPage: React.FC = () => {
   const navigate = useNavigate();
   const navigate = useNavigate();
   const [isLiked, setIsLiked] = useState(false);
   const [isLiked, setIsLiked] = useState(false);
   const [isBookmarked, setIsBookmarked] = useState(false);
   const [isBookmarked, setIsBookmarked] = useState(false);
+  const [pdfLoading, setPdfLoading] = useState(false);
+  const [pdfError, setPdfError] = useState(false);
+  const [showPdfPreview, setShowPdfPreview] = useState(true);
 
 
   const { data, isLoading, error } = useSilverWisdomDetail(Number(id));
   const { data, isLoading, error } = useSilverWisdomDetail(Number(id));
 
 
@@ -77,6 +80,149 @@ const SilverWisdomDetailPage: React.FC = () => {
     }
     }
   };
   };
 
 
+  // 提前定义所有函数,避免条件渲染中的Hook调用问题
+  const getFileExtension = (filename: string) => {
+    return filename.split('.').pop()?.toLowerCase() || '';
+  };
+
+  const getFileIcon = (filename: string) => {
+    const ext = getFileExtension(filename);
+    switch (ext) {
+      case 'pdf':
+        return '📄';
+      case 'doc':
+      case 'docx':
+        return '📝';
+      case 'xls':
+      case 'xlsx':
+        return '📊';
+      case 'ppt':
+      case 'pptx':
+        return '📊';
+      case 'jpg':
+      case 'jpeg':
+      case 'png':
+      case 'gif':
+        return '🖼️';
+      default:
+        return '📎';
+    }
+  };
+
+  const formatDate = (date: string) => {
+    return dayjs(date).format('YYYY年MM月DD日 HH:mm');
+  };
+
+  const getReadTime = (content: string) => {
+    const wordsPerMinute = 200;
+    const wordCount = content.length;
+    return Math.ceil(wordCount / wordsPerMinute);
+  };
+
+  const getCompatibilityMessage = () => {
+    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
+    const isWechat = /MicroMessenger/i.test(navigator.userAgent);
+    const isQQ = /QQ/i.test(navigator.userAgent);
+    
+    if (isWechat || isQQ) {
+      return '微信/QQ内置浏览器暂不支持PDF预览,请使用系统浏览器打开';
+    }
+    if (isIOS && !checkPdfSupport()) {
+      return '建议使用Safari浏览器获得最佳PDF预览体验';
+    }
+    return null;
+  };
+
+  // 移动端PDF兼容性检测
+  const checkPdfSupport = () => {
+    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
+    const isWechat = /MicroMessenger/i.test(navigator.userAgent);
+    const isQQ = /QQ/i.test(navigator.userAgent);
+    
+    // 禁用微信、QQ内置浏览器的PDF预览
+    if (isWechat || isQQ) {
+      return false;
+    }
+    
+    // iOS Safari支持较好,但部分版本有问题
+    if (isIOS) {
+      return navigator.userAgent.includes('Safari') &&
+             !navigator.userAgent.includes('CriOS') && // Chrome iOS
+             !navigator.userAgent.includes('FxiOS');   // Firefox iOS
+    }
+    
+    return true;
+  };
+
+  const getResponsivePreviewHeight = () => {
+    const screenHeight = window.innerHeight;
+    return Math.min(320, screenHeight * 0.35); // 35%屏幕高度,最大320px
+  };
+
+  const handleDownload = () => {
+    if (data?.attachment) {
+      // 创建临时链接下载,避免直接打开可能的安全问题
+      const link = document.createElement('a');
+      link.href = data.attachment;
+      link.download = data.attachmentName || '附件';
+      link.target = '_blank';
+      link.rel = 'noopener noreferrer';
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+    }
+  };
+
+  const handleShare = async () => {
+    if (!data) return;
+    
+    try {
+      if (navigator.share) {
+        await navigator.share({
+          title: data.title,
+          text: data.content.substring(0, 100) + '...',
+          url: window.location.href,
+        });
+      } else {
+        // 降级处理:复制到剪贴板
+        await navigator.clipboard.writeText(window.location.href);
+        alert('链接已复制到剪贴板');
+      }
+    } catch (error) {
+      console.error('分享失败:', error);
+    }
+  };
+
+  const handlePdfPreview = () => {
+    if (!data?.attachment) return;
+    
+    const isSupported = checkPdfSupport();
+    if (!isSupported) {
+      // 不支持的浏览器直接提供下载
+      setShowPdfPreview(false);
+      handleDownload();
+      return;
+    }
+    
+    setShowPdfPreview(true);
+    setPdfLoading(true);
+    setPdfError(false);
+  };
+
+  // 使用useEffect检查PDF支持并设置预览状态
+  React.useEffect(() => {
+    if (data?.attachment && getFileExtension(data.attachmentName || '') === 'pdf') {
+      const isSupported = checkPdfSupport();
+      setShowPdfPreview(isSupported);
+      // 如果支持,初始化加载状态
+      if (isSupported) {
+        setPdfLoading(true);
+        setPdfError(false);
+      }
+    }
+  }, [data?.attachment, data?.attachmentName]);
+
+  // 处理加载状态
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: COLORS.ink.light }}>
       <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: COLORS.ink.light }}>
@@ -88,12 +234,13 @@ const SilverWisdomDetailPage: React.FC = () => {
     );
     );
   }
   }
 
 
+  // 处理错误状态
   if (error || !data) {
   if (error || !data) {
     return (
     return (
       <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: COLORS.ink.light }}>
       <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: COLORS.ink.light }}>
         <div className="text-center">
         <div className="text-center">
           <p style={{ color: COLORS.text.primary }}>获取知识详情失败</p>
           <p style={{ color: COLORS.text.primary }}>获取知识详情失败</p>
-          <button 
+          <button
             onClick={() => navigate('/silver-wisdom')}
             onClick={() => navigate('/silver-wisdom')}
             className="mt-4 px-4 py-2 rounded-full text-white transition-colors"
             className="mt-4 px-4 py-2 rounded-full text-white transition-colors"
             style={{ backgroundColor: COLORS.ink.dark }}
             style={{ backgroundColor: COLORS.ink.dark }}
@@ -105,7 +252,7 @@ const SilverWisdomDetailPage: React.FC = () => {
     );
     );
   }
   }
 
 
-  const knowledge = data || {};
+  const knowledge = data;
 
 
   // 解析标签
   // 解析标签
   const tags = knowledge.tags ? String(knowledge.tags).split(',').map(tag => tag.trim()).filter(tag => tag) : [];
   const tags = knowledge.tags ? String(knowledge.tags).split(',').map(tag => tag.trim()).filter(tag => tag) : [];
@@ -142,6 +289,34 @@ const SilverWisdomDetailPage: React.FC = () => {
     }
     }
   };
   };
 
 
+  // 获取兼容性提示信息
+  const getCompatibilityMessage = () => {
+    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
+    const isWechat = /MicroMessenger/i.test(navigator.userAgent);
+    const isQQ = /QQ/i.test(navigator.userAgent);
+    
+    if (isWechat || isQQ) {
+      return '微信/QQ内置浏览器暂不支持PDF预览,请使用系统浏览器打开';
+    }
+    if (isIOS && !checkPdfSupport()) {
+      return '建议使用Safari浏览器获得最佳PDF预览体验';
+    }
+    return null;
+  };
+
+  // 使用useEffect检查PDF支持并设置预览状态
+  React.useEffect(() => {
+    if (knowledge.attachment && getFileExtension(knowledge.attachmentName || '') === 'pdf') {
+      const isSupported = checkPdfSupport();
+      setShowPdfPreview(isSupported);
+      // 如果支持,初始化加载状态
+      if (isSupported) {
+        setPdfLoading(true);
+        setPdfError(false);
+      }
+    }
+  }, [knowledge.attachment, knowledge.attachmentName]);
+
   const getFileExtension = (filename: string) => {
   const getFileExtension = (filename: string) => {
     return filename.split('.').pop()?.toLowerCase() || '';
     return filename.split('.').pop()?.toLowerCase() || '';
   };
   };
@@ -180,6 +355,48 @@ const SilverWisdomDetailPage: React.FC = () => {
     return Math.ceil(wordCount / wordsPerMinute);
     return Math.ceil(wordCount / wordsPerMinute);
   };
   };
 
 
+  // 移动端PDF兼容性检测
+  const checkPdfSupport = () => {
+    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
+    const isWechat = /MicroMessenger/i.test(navigator.userAgent);
+    const isQQ = /QQ/i.test(navigator.userAgent);
+    
+    // 禁用微信、QQ内置浏览器的PDF预览
+    if (isWechat || isQQ) {
+      return false;
+    }
+    
+    // iOS Safari支持较好,但部分版本有问题
+    if (isIOS) {
+      return navigator.userAgent.includes('Safari') &&
+             !navigator.userAgent.includes('CriOS') && // Chrome iOS
+             !navigator.userAgent.includes('FxiOS');   // Firefox iOS
+    }
+    
+    return true;
+  };
+
+  const getResponsivePreviewHeight = () => {
+    const screenHeight = window.innerHeight;
+    return Math.min(320, screenHeight * 0.35); // 35%屏幕高度,最大320px
+  };
+
+  const handlePdfPreview = () => {
+    if (!knowledge.attachment) return;
+    
+    const isSupported = checkPdfSupport();
+    if (!isSupported) {
+      // 不支持的浏览器直接提供下载
+      setShowPdfPreview(false);
+      handleDownload();
+      return;
+    }
+    
+    setShowPdfPreview(true);
+    setPdfLoading(true);
+    setPdfError(false);
+  };
+
   return (
   return (
     <div className="min-h-screen" style={{ backgroundColor: COLORS.ink.light }}>
     <div className="min-h-screen" style={{ backgroundColor: COLORS.ink.light }}>
       {/* 头部导航 */}
       {/* 头部导航 */}
@@ -320,15 +537,89 @@ const SilverWisdomDetailPage: React.FC = () => {
                 {/* 文件预览 - 仅对支持的格式显示 */}
                 {/* 文件预览 - 仅对支持的格式显示 */}
                 {getFileExtension(knowledge.attachmentName || '') === 'pdf' && (
                 {getFileExtension(knowledge.attachmentName || '') === 'pdf' && (
                   <div className="mt-4 border-t pt-4" style={{ borderColor: COLORS.ink.medium }}>
                   <div className="mt-4 border-t pt-4" style={{ borderColor: COLORS.ink.medium }}>
-                    <p className="text-sm mb-2" style={{ color: COLORS.text.secondary }}>
-                      文件预览:
-                    </p>
-                    <iframe
-                      src={knowledge.attachment}
-                      className="w-full h-64 rounded-lg border"
-                      style={{ borderColor: COLORS.ink.medium }}
-                      title={knowledge.attachmentName || 'PDF预览'}
-                    />
+                    {getCompatibilityMessage() && (
+                      <div className="p-3 rounded-lg mb-3"
+                        style={{ backgroundColor: 'rgba(255,248,220,0.8)', borderColor: COLORS.accent.red }}
+                      >
+                        <p className="text-xs" style={{ color: COLORS.accent.red }}>
+                          {getCompatibilityMessage()}
+                        </p>
+                      </div>
+                    )}
+                    
+                    {showPdfPreview && (
+                      <>
+                        <div className="flex items-center justify-between mb-2">
+                          <p className="text-sm" style={{ color: COLORS.text.secondary }}>
+                            文件预览:
+                          </p>
+                          <button
+                            onClick={handleDownload}
+                            className="text-xs px-2 py-1 rounded"
+                            style={{
+                              backgroundColor: COLORS.ink.medium,
+                              color: COLORS.text.primary
+                            }}
+                          >
+                            下载查看
+                          </button>
+                        </div>
+                        
+                        {pdfLoading && (
+                          <div className="w-full h-48 flex items-center justify-center rounded-lg border"
+                            style={{ borderColor: COLORS.ink.medium, backgroundColor: 'rgba(255,255,255,0.5)' }}
+                          >
+                            <div className="text-center">
+                              <div className="animate-spin rounded-full h-6 w-6 border-b-2 mx-auto"
+                                style={{ borderColor: COLORS.ink.dark }}
+                              ></div>
+                              <p className="text-xs mt-2" style={{ color: COLORS.text.secondary }}>
+                                PDF加载中...
+                              </p>
+                            </div>
+                          </div>
+                        )}
+                        
+                        {pdfError && (
+                          <div className="w-full h-48 flex items-center justify-center rounded-lg border"
+                            style={{ borderColor: COLORS.ink.medium, backgroundColor: 'rgba(255,255,255,0.5)' }}
+                          >
+                            <div className="text-center">
+                              <p className="text-sm" style={{ color: COLORS.text.primary }}>
+                                预览加载失败
+                              </p>
+                              <button
+                                onClick={handleDownload}
+                                className="text-xs mt-2 px-3 py-1 rounded"
+                                style={{
+                                  backgroundColor: COLORS.ink.dark,
+                                  color: 'white'
+                                }}
+                              >
+                                下载查看
+                              </button>
+                            </div>
+                          </div>
+                        )}
+                        
+                        {!pdfLoading && !pdfError && (
+                          <iframe
+                            src={knowledge.attachment}
+                            className="w-full rounded-lg border"
+                            style={{
+                              borderColor: COLORS.ink.medium,
+                              height: `${getResponsivePreviewHeight()}px`
+                            }}
+                            title={knowledge.attachmentName || 'PDF预览'}
+                            onLoad={() => setPdfLoading(false)}
+                            onError={() => {
+                              setPdfLoading(false);
+                              setPdfError(true);
+                            }}
+                          />
+                        )}
+                      </>
+                    )}
                   </div>
                   </div>
                 )}
                 )}
               </div>
               </div>

+ 294 - 18
src/server/api/silver-users/knowledge-interactions/index.ts

@@ -1,24 +1,300 @@
-import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
-import { SilverKnowledgeInteraction } from '@/server/modules/silver-users/silver-knowledge-interaction.entity';
-import { 
-  SilverKnowledgeInteractionSchema, 
-  CreateSilverKnowledgeInteractionDto, 
-  UpdateSilverKnowledgeInteractionDto 
-} from '@/server/modules/silver-users/silver-knowledge-interaction.entity';
+import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
+import { SilverKnowledgeInteractionService } from '@/server/modules/silver-users/knowledge-interaction.service';
+import { AppDataSource } from '@/server/data-source';
 import { authMiddleware } from '@/server/middleware/auth.middleware';
 import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { AuthContext } from '@/server/types/context';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { InteractionType } from '@/server/modules/silver-users/knowledge-interaction.entity';
 
 
-const interactionRoutes = createCrudRoutes({
-  entity: SilverKnowledgeInteraction,
-  createSchema: CreateSilverKnowledgeInteractionDto,
-  updateSchema: UpdateSilverKnowledgeInteractionDto,
-  getSchema: SilverKnowledgeInteractionSchema,
-  listSchema: SilverKnowledgeInteractionSchema,
-  searchFields: ['content'],
-  relations: ['user', 'knowledge'],
+// 创建交互请求Schema
+const CreateInteractionRequest = z.object({
+  knowledgeId: z.number().int().positive().openapi({
+    description: '知识ID',
+    example: 1
+  }),
+  interactionType: z.enum([InteractionType.LIKE, InteractionType.FAVORITE]).openapi({
+    description: '交互类型',
+    example: 'like'
+  })
+});
+
+// 交互状态响应Schema
+const InteractionStatusResponse = z.object({
+  isLiked: z.boolean().openapi({ description: '是否已点赞', example: true }),
+  isFavorited: z.boolean().openapi({ description: '是否已收藏', example: false }),
+  likeCount: z.number().int().nonnegative().openapi({ description: '点赞数量', example: 42 }),
+  favoriteCount: z.number().int().nonnegative().openapi({ description: '收藏数量', example: 15 })
+});
+
+// 获取交互状态
+const getInteractionStatusRoute = createRoute({
+  method: 'get',
+  path: '/status/{knowledgeId}',
   middleware: [authMiddleware],
   middleware: [authMiddleware],
-  userTracking: {
-    createdByField: 'userId'
+  request: {
+    params: z.object({
+      knowledgeId: z.string().openapi({
+        param: { name: 'knowledgeId', in: 'path' },
+        description: '知识ID',
+        example: '1'
+      })
+    })
+  },
+  responses: {
+    200: {
+      description: '成功获取交互状态',
+      content: {
+        'application/json': {
+          schema: InteractionStatusResponse
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 创建或更新交互
+const createInteractionRoute = createRoute({
+  method: 'post',
+  path: '/toggle',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': {
+          schema: CreateInteractionRequest
+        }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '成功创建或更新交互',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean().openapi({ example: true }),
+            message: z.string().openapi({ example: '操作成功' }),
+            data: z.object({
+              isActive: z.number().openapi({ example: 1 })
+            })
+          })
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 取消交互
+const cancelInteractionRoute = createRoute({
+  method: 'delete',
+  path: '/cancel',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': {
+          schema: z.object({
+            knowledgeId: z.number().int().positive().openapi({
+              description: '知识ID',
+              example: 1
+            }),
+            interactionType: z.enum([InteractionType.LIKE, InteractionType.FAVORITE]).openapi({
+              description: '交互类型',
+              example: 'like'
+            })
+          })
+        }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '成功取消交互',
+      content: {
+        'application/json': {
+          schema: z.object({
+            success: z.boolean().openapi({ example: true }),
+            message: z.string().openapi({ example: '取消成功' })
+          })
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 获取用户收藏列表
+const getUserFavoritesRoute = createRoute({
+  method: 'get',
+  path: '/favorites',
+  middleware: [authMiddleware],
+  request: {
+    query: z.object({
+      page: z.coerce.number().int().positive().default(1).openapi({
+        description: '页码',
+        example: 1
+      }),
+      pageSize: z.coerce.number().int().positive().default(10).openapi({
+        description: '每页数量',
+        example: 10
+      })
+    })
+  },
+  responses: {
+    200: {
+      description: '成功获取用户收藏列表',
+      content: {
+        'application/json': {
+          schema: z.object({
+            data: z.array(z.any()),
+            pagination: z.object({
+              total: z.number().openapi({ example: 100 }),
+              current: z.number().openapi({ example: 1 }),
+              pageSize: z.number().openapi({ example: 10 })
+            })
+          })
+        }
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 路由实例
+const app = new OpenAPIHono<AuthContext>();
+
+// 获取交互状态
+app.openapi(getInteractionStatusRoute, async (c) => {
+  try {
+    const { knowledgeId } = c.req.valid('param');
+    const user = c.get('user');
+    
+    const service = new SilverKnowledgeInteractionService(AppDataSource);
+    
+    const [status, stats] = await Promise.all([
+      service.getKnowledgeInteractionStatus(user.id, Number(knowledgeId)),
+      service.getKnowledgeStats(Number(knowledgeId))
+    ]);
+
+    return c.json({
+      isLiked: status.isLiked,
+      isFavorited: status.isFavorited,
+      likeCount: stats.likeCount,
+      favoriteCount: stats.favoriteCount
+    });
+  } catch (error) {
+    const { code = 500, message = '获取交互状态失败' } = error as Error & { code?: number };
+    return c.json({ code, message }, code);
+  }
+});
+
+// 创建或更新交互
+app.openapi(createInteractionRoute, async (c) => {
+  try {
+    const body = await c.req.json();
+    const user = c.get('user');
+    
+    const service = new SilverKnowledgeInteractionService(AppDataSource);
+    
+    await service.toggleInteraction(
+      user.id,
+      body.knowledgeId,
+      body.interactionType,
+      true
+    );
+
+    return c.json({
+      success: true,
+      message: '操作成功',
+      data: { isActive: 1 }
+    });
+  } catch (error) {
+    const { code = 500, message = '操作失败' } = error as Error & { code?: number };
+    return c.json({ code, message }, code);
+  }
+});
+
+// 取消交互
+app.openapi(cancelInteractionRoute, async (c) => {
+  try {
+    const body = await c.req.json();
+    const user = c.get('user');
+    
+    const service = new SilverKnowledgeInteractionService(AppDataSource);
+    
+    await service.toggleInteraction(
+      user.id,
+      body.knowledgeId,
+      body.interactionType,
+      false
+    );
+
+    return c.json({
+      success: true,
+      message: '取消成功'
+    });
+  } catch (error) {
+    const { code = 500, message = '取消失败' } = error as Error & { code?: number };
+    return c.json({ code, message }, code);
+  }
+});
+
+// 获取用户收藏列表
+app.openapi(getUserFavoritesRoute, async (c) => {
+  try {
+    const query = c.req.valid('query');
+    const user = c.get('user');
+    
+    const service = new SilverKnowledgeInteractionService(AppDataSource);
+    
+    const { data, total } = await service.getUserFavorites(
+      user.id,
+      query.page,
+      query.pageSize
+    );
+
+    return c.json({
+      data,
+      pagination: {
+        total,
+        current: query.page,
+        pageSize: query.pageSize
+      }
+    });
+  } catch (error) {
+    const { code = 500, message = '获取收藏列表失败' } = error as Error & { code?: number };
+    return c.json({ code, message }, code);
   }
   }
 });
 });
 
 
-export default interactionRoutes;
+export default app;

+ 72 - 0
src/server/modules/silver-users/knowledge-interaction.entity.ts

@@ -0,0 +1,72 @@
+import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+export enum InteractionType {
+  LIKE = 'like',
+  FAVORITE = 'favorite'
+}
+
+@Entity('silver_knowledge_interactions')
+@Index(['userId', 'knowledgeId', 'interactionType'], { unique: true })
+export class SilverKnowledgeInteraction {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'user_id', type: 'int', unsigned: true })
+  userId!: number;
+
+  @Column({ name: 'knowledge_id', type: 'int', unsigned: true })
+  knowledgeId!: number;
+
+  @Column({ 
+    name: 'interaction_type', 
+    type: 'enum', 
+    enum: InteractionType,
+    comment: '交互类型:like-点赞,favorite-收藏'
+  })
+  interactionType!: InteractionType;
+
+  @Column({ name: 'is_active', type: 'tinyint', default: 1 })
+  isActive!: number;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt!: Date;
+}
+
+// Zod Schema定义
+export const SilverKnowledgeInteractionSchema = z.object({
+  id: z.number().int().positive().openapi({ description: '交互记录ID' }),
+  userId: z.number().int().positive().openapi({ description: '用户ID' }),
+  knowledgeId: z.number().int().positive().openapi({ description: '知识ID' }),
+  interactionType: z.enum([InteractionType.LIKE, InteractionType.FAVORITE]).openapi({ 
+    description: '交互类型', 
+    example: 'like' 
+  }),
+  isActive: z.number().int().min(0).max(1).openapi({ 
+    description: '是否激活', 
+    example: 1 
+  }),
+  createdAt: z.date().openapi({ description: '创建时间' }),
+  updatedAt: z.date().openapi({ description: '更新时间' })
+});
+
+export const CreateInteractionDto = z.object({
+  knowledgeId: z.number().int().positive().openapi({ 
+    description: '知识ID', 
+    example: 1 
+  }),
+  interactionType: z.enum([InteractionType.LIKE, InteractionType.FAVORITE]).openapi({ 
+    description: '交互类型', 
+    example: 'like' 
+  })
+});
+
+export const UpdateInteractionDto = z.object({
+  isActive: z.number().int().min(0).max(1).optional().openapi({ 
+    description: '是否激活', 
+    example: 0 
+  })
+});

+ 235 - 0
src/server/modules/silver-users/knowledge-interaction.service.ts

@@ -0,0 +1,235 @@
+import { DataSource, Repository, In } from 'typeorm';
+import { SilverKnowledgeInteraction, InteractionType } from './knowledge-interaction.entity';
+import { AppError } from '@/server/utils/errorHandler';
+
+export class SilverKnowledgeInteractionService {
+  private repository: Repository<SilverKnowledgeInteraction>;
+
+  constructor(dataSource: DataSource) {
+    this.repository = dataSource.getRepository(SilverKnowledgeInteraction);
+  }
+
+  /**
+   * 创建或更新交互记录
+   */
+  async toggleInteraction(
+    userId: number,
+    knowledgeId: number,
+    interactionType: InteractionType,
+    isActive: boolean = true
+  ): Promise<SilverKnowledgeInteraction> {
+    const existing = await this.repository.findOne({
+      where: {
+        userId,
+        knowledgeId,
+        interactionType
+      }
+    });
+
+    if (existing) {
+      existing.isActive = isActive ? 1 : 0;
+      existing.updatedAt = new Date();
+      return this.repository.save(existing);
+    }
+
+    const interaction = this.repository.create({
+      userId,
+      knowledgeId,
+      interactionType,
+      isActive: isActive ? 1 : 0
+    });
+
+    return this.repository.save(interaction);
+  }
+
+  /**
+   * 获取用户的交互状态
+   */
+  async getUserInteractions(
+    userId: number,
+    knowledgeIds: number[]
+  ): Promise<Record<number, Record<InteractionType, boolean>>> {
+    const interactions = await this.repository.find({
+      where: {
+        userId,
+        knowledgeId: In(knowledgeIds),
+        isActive: 1
+      }
+    });
+
+    const result: Record<number, Record<InteractionType, boolean>> = {};
+    
+    knowledgeIds.forEach(id => {
+      result[id] = {
+        [InteractionType.LIKE]: false,
+        [InteractionType.FAVORITE]: false
+      };
+    });
+
+    interactions.forEach(interaction => {
+      if (!result[interaction.knowledgeId]) {
+        result[interaction.knowledgeId] = {
+          [InteractionType.LIKE]: false,
+          [InteractionType.FAVORITE]: false
+        };
+      }
+      result[interaction.knowledgeId][interaction.interactionType] = true;
+    });
+
+    return result;
+  }
+
+  /**
+   * 获取单个知识的交互状态
+   */
+  async getKnowledgeInteractionStatus(
+    userId: number,
+    knowledgeId: number
+  ): Promise<{
+    isLiked: boolean;
+    isFavorited: boolean;
+  }> {
+    const interactions = await this.repository.find({
+      where: {
+        userId,
+        knowledgeId,
+        isActive: 1
+      }
+    });
+
+    const result = {
+      isLiked: false,
+      isFavorited: false
+    };
+
+    interactions.forEach(interaction => {
+      if (interaction.interactionType === InteractionType.LIKE) {
+        result.isLiked = true;
+      } else if (interaction.interactionType === InteractionType.FAVORITE) {
+        result.isFavorited = true;
+      }
+    });
+
+    return result;
+  }
+
+  /**
+   * 获取知识的统计信息
+   */
+  async getKnowledgeStats(knowledgeId: number): Promise<{
+    likeCount: number;
+    favoriteCount: number;
+  }> {
+    const [likeCount, favoriteCount] = await Promise.all([
+      this.repository.count({
+        where: {
+          knowledgeId,
+          interactionType: InteractionType.LIKE,
+          isActive: 1
+        }
+      }),
+      this.repository.count({
+        where: {
+          knowledgeId,
+          interactionType: InteractionType.FAVORITE,
+          isActive: 1
+        }
+      })
+    ]);
+
+    return { likeCount, favoriteCount };
+  }
+
+  /**
+   * 批量获取知识的统计信息
+   */
+  async getKnowledgeStatsBatch(knowledgeIds: number[]): Promise<Record<number, {
+    likeCount: number;
+    favoriteCount: number;
+  }>> {
+    const interactions = await this.repository
+      .createQueryBuilder('interaction')
+      .select([
+        'interaction.knowledgeId',
+        'interaction.interactionType',
+        'COUNT(*) as count'
+      ])
+      .where('interaction.knowledgeId IN (:...knowledgeIds)', { knowledgeIds })
+      .andWhere('interaction.isActive = 1')
+      .groupBy('interaction.knowledgeId, interaction.interactionType')
+      .getRawMany();
+
+    const result: Record<number, { likeCount: number; favoriteCount: number }> = {};
+    
+    knowledgeIds.forEach(id => {
+      result[id] = { likeCount: 0, favoriteCount: 0 };
+    });
+
+    interactions.forEach(row => {
+      const knowledgeId = Number(row.interaction_knowledgeId);
+      const type = String(row.interaction_interactionType) as InteractionType;
+      const count = Number(row.count);
+
+      if (!result[knowledgeId]) {
+        result[knowledgeId] = { likeCount: 0, favoriteCount: 0 };
+      }
+
+      if (type === InteractionType.LIKE) {
+        result[knowledgeId].likeCount = count;
+      } else if (type === InteractionType.FAVORITE) {
+        result[knowledgeId].favoriteCount = count;
+      }
+    });
+
+    return result;
+  }
+
+  /**
+   * 取消交互
+   */
+  async cancelInteraction(
+    userId: number,
+    knowledgeId: number,
+    interactionType: InteractionType
+  ): Promise<boolean> {
+    const result = await this.repository.update(
+      {
+        userId,
+        knowledgeId,
+        interactionType
+      },
+      {
+        isActive: 0,
+        updatedAt: new Date()
+      }
+    );
+
+    return result.affected ? result.affected > 0 : false;
+  }
+
+  /**
+   * 获取用户的收藏列表
+   */
+  async getUserFavorites(
+    userId: number,
+    page: number = 1,
+    pageSize: number = 10
+  ): Promise<{
+    data: SilverKnowledgeInteraction[];
+    total: number;
+  }> {
+    const [data, total] = await this.repository.findAndCount({
+      where: {
+        userId,
+        interactionType: InteractionType.FAVORITE,
+        isActive: 1
+      },
+      relations: ['knowledge'],
+      order: { createdAt: 'DESC' },
+      skip: (page - 1) * pageSize,
+      take: pageSize
+    });
+
+    return { data, total };
+  }
+}