简介这是一套旨在模仿百度百科的百科建站系统核心目标是提供与百度百科相近的在线知识创建、编辑与查阅体验。资源面向需要搭建个人或团队知识库的站长、PHP开发者及百科类产品研究者可解决从零开发百科系统时的选型、部署和内容管理难题。资源包为zip格式大小约1.95MB压缩包内文件总数为0但从资源描述看内容围绕hdwiki这一基于PHP的开源百科建站系统展开并包含安装说明与document文档目录涉及环境配置、数据库设置、Web服务器部署及二次开发资料。目前已有384人学习下载。通过这套资料可掌握百科网站的词条分类、内容审核、用户权限管理等核心逻辑借助HDWiki快速搭建具备搜索、浏览、贡献功能的互动知识平台。资料尤其适合希望快速上线百科类网站的团队也适合作为PHP项目实战学习材料能显著缩短建站周期。 去年下半年我接到一个“百度百科系统”的开发需求做一个内部知识库产品同学的原话是“超级模仿百度”。我一开始以为这就是个常规的增删改查项目真动手之后才发现百科类网站最麻烦的地方根本不在 CRUD而在“搜得准、看得顺、改不乱”。这篇文章把从 0 到 1 的实现思路和踩坑记录写下来给正在做知识库、文档站、百科站的同学一个参考。1. 先定义“模仿边界”超级模仿百度到底模仿什么“超级模仿百度”这个名字其实挺误导人的。如果把它理解成把百度首页、搜索框、明星热搜、左侧导航全部照搬一遍那项目大概率会死。百度百科对用户的价值从来不是那套视觉界面而是“任何一个词条谁都能看、谁都能改、谁都能查到改动痕迹”这套产品逻辑。所以我拿到需求后做的第一件事不是画页面而是把“百度百科系统”拆成了三个闭环搜索闭环搜索框、联想词、结果列表、无结果引导内容闭环词条创建、编辑、历史版本、一键回滚组织闭环分类聚合、别名跳转、相关词条。这样拆完页面反而变简单了。真正花时间的是搜索词条命中的加权规则以及历史版本怎么保存才算完整。界面上只要做到“远看像百科、用起来顺手”项目目标就达了八成。1.1 技术选型先够用再考虑架构弹性我最终选的是一套非常常规的组合模块选型说明后端Java 17 Spring Boot 3.x MyBatis-Plus团队熟悉生态稳定前端Vue3 Vite Element Plus表单、列表类页面开发效率高数据库MySQL 8.0utf8mb4 存中文友好自带 ngram 全文索引缓存Redis 7放热搜词和联想词减轻数据库压力搜索MySQL 全文索引词条量在几十万级别时足够不上 Elasticsearch文件存储本地磁盘 Nginx 静态映射图片、附件单独存不塞数据库这套方案不是唯一答案。你用 Flask Vue或者 Node React核心逻辑都一样。重点是别在第一版就引入微服务、搜索引擎、消息队列这些重型组件。百科系统的瓶颈几乎永远在内容质量和数据关系上不在并发量上。真要等到词条量过百万再上 Elasticsearch那时候数据结构已经稳了迁移成本反而可控。2. 数据表先于页面词条、历史、别名决定系统能走多远百科系统里最核心的数据不是某个字段而是“词条”这个概念。页面排版可以换但表结构一旦设计错后面改起来非常痛苦。2.1 词条主表别把所有属性都做成一列我第一版设计词条表时差点把“中文名”“外文名”“别称”“属性值”这些全做成字段。后来想明白一个道理百度百科里每个分类的信息栏字段都不一样动物有门纲目科属软件有开发者、语言、平台。如果把属性做成固定列系统第一周就会死。我最终只保留了词条最通用的字段CREATE TABLE entry ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, title_key VARCHAR(100) NOT NULL, summary VARCHAR(500) NOT NULL DEFAULT , content MEDIUMTEXT, status TINYINT NOT NULL DEFAULT 0, view_count INT NOT NULL DEFAULT 0, created_by BIGINT NOT NULL, updated_by BIGINT, extra_json JSON, created_at DATETIME NOT NULL, updated_at DATETIME, UNIQUE KEY uk_title_key (title_key), KEY idx_updated_at (updated_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;几个值得说的点summary必须单独存。列表页、搜索摘要、关键词描述全都要用它直接从正文里截取的话性能差而且截出来往往不成句。title_key是唯一约束不是title。大小写、空格、全半角符号统一处理之后再落这个字段后面细说。extra_json用来存不同分类的自定义属性。比如“银杏”词条信息栏里的“拉丁学名”“科”“属”都放 JSON而不是建表。2.2 编辑历史表每条词条都要能“反悔”百科类系统必须有历史版本否则没有人敢编辑词条。需求里那句“超级模仿百度”其实模仿得最用力的是这里每个词条的每次修改都存快照谁在什么时候改了什么全部留痕。CREATE TABLE entry_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, entry_id BIGINT NOT NULL, version INT NOT NULL, content MEDIUMTEXT NOT NULL, editor_id BIGINT NOT NULL, edit_reason VARCHAR(200) NOT NULL DEFAULT , created_at DATETIME NOT NULL, UNIQUE KEY uk_entry_version (entry_id, version) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;历史表只存“变了什么”不存标题和分类这些元信息。这样词条页展示历史列表时只需要按entry_id倒序查回滚某个版本时把对应content复制回主表再插一条新历史逻辑非常清晰。2.3 别忽略别名表和分类关联新用户搜索时经常不记得确切词条名比如想查“西红柿”却搜“番茄”。如果每次都由运营去改词条标题项目会被累死。我加了一张很薄的entry_alias表字段就是alias和entry_id搜索命不中标题时再查别名表。分类则用标准的categoryentry_category多对多关联一个词条可以同时归入“植物”和“传统食物”页面端按分类聚合时才灵活。3. 把“百科味”做出来的搜索与联想百科系统的搜索和一般后台搜索不一样它讲究的是一种“即时反馈感”输入关键词下面要出联想回车之后结果要尽量把最相关的词条放在最上面。这一块是最能体现“超级模仿百度”价值的地方。3.1 第一版搜索MySQL 全文索引就够了不少教程上来就让你用LIKE %关键字%词条少的时候没问题一旦词条量上万全表扫描速度立刻露馅。MySQL 8.0 自带的ngram全文索引分词器处理中文搜索够用。建索引ALTER TABLE entry ADD FULLTEXT INDEX ft_entry_title_summary (title, summary) WITH PARSER ngram;搜索查询SELECT id, title, summary FROM entry WHERE status 1 AND MATCH(title, summary) AGAINST (#{kw} IN NATURAL LANGUAGE MODE) ORDER BY view_count DESC LIMIT 20;注意ngram默认的ngram_token_size是 2也就是单个汉字搜不中而且对“C”“.NET”这类符号型关键词不友好。我实际处理的办法是如果关键词长度 2或者带特殊符号就走title LIKE兜底正常中文词条走全文索引。3.2 联想词和热搜榜一个 Redis 就能搞定用户在搜索框输入“Pyt”时前端调用/api/wiki/suggest?keywordPyt后端查词条标题前缀再去匹配别名表SELECT e.id, e.title, e.summary FROM entry e LEFT JOIN entry_alias a ON a.entry_id e.id WHERE e.status 1 AND (e.title LIKE CONCAT(#{kw}, %) OR a.alias LIKE CONCAT(#{kw}, %)) ORDER BY e.view_count DESC LIMIT 8;热搜榜不用单独建表用 Redis 的 Sorted Set 记录搜索次数每次搜索执行ZINCRBY hot:keywords 1 关键词展示时ZREVRANGE hot:keywords 0 7 WITHSCORES这一套方案的好处是代码量极小效果却和百度百科的“大家都在搜”非常接近。前提是搜索关键词必须做去重和过滤空字符串和超长垃圾关键词直接丢弃不然三天后热榜就废了。3.3 搜索结果排序我用的是一段不太“高级”但好用的三段式查询经验越多的用户越看中“第一个结果是不是我要的词条”。所以我没一上来搞评分公式而是用 UNION 把结果分成三档SELECT 10 AS score, id, title, summary FROM entry WHERE status 1 AND title #{kw} UNION ALL SELECT 5 AS score, id, title, summary FROM entry WHERE status 1 AND title LIKE CONCAT(#{kw}, %) AND title #{kw} UNION ALL SELECT 1 AS score, id, title, summary FROM entry WHERE status 1 AND MATCH(title, summary) AGAINST (#{kw} IN NATURAL LANGUAGE MODE) AND title NOT LIKE CONCAT(#{kw}, %) ORDER BY score DESC, view_count DESC LIMIT 20;这一段逻辑很简单但已经很接近真实百科的搜索体验标题完全命中的永远排最前前缀命中次之正文模糊匹配靠后。等以后数据量大到需要个性化排序时再换 Elasticsearch 也来得及。4. 词条页和编辑器先把内容写“干净”再谈花哨百度百科的编辑页是很多人不敢模仿的地方因为它真的太复杂了参考资料、角标、信息栏模板、目录导航、图片注释每一个都是独立系统。4.1 我放弃了“所见即所得”第一版我尝试过 UEditor结果发现保存进来的 HTML 非常脏各种style、class、嵌套标签版本对比时根本看不出来改了什么。后来我换成了 Markdown 存储前端用编辑器 渲染器处理。方案优点缺点我的结论Markdown 源码 前端渲染存储干净、版本 diff 清晰、XSS 面小非技术用户上手有门槛内部知识库首选富文本 HTML编辑体验接近 WordHTML 脏、容易被注入不推荐 1.0 版本做Block 编辑器体验最好开发成本高词条量稳定后再考虑技术选型层面的取舍得说清楚百科类内容的价值在于“长期维护”Markdown 的纯文本本质决定了它在历史版本、差异对比、批量迁移这三件事上都比 HTML 省力得多。至于非技术用户不会 Markdown我给编辑器加了一排按钮加粗、标题、列表、图片绝大多数人日常编辑根本不会手动写符号。4.2 信息栏用键值对 JSON而不是硬编码字段前面提到的extra_json就是信息栏的数据来源比如“银杏”词条{ 中文名: 银杏, 拉丁学名: Ginkgo biloba L., 植物界: 银杏门, 属: 银杏属 }前端拿到这个对象后渲染成两列的描述列表。不同分类想展示不同字段只需要再存一个category_template规定每个分类显示哪些 key 的排序。这样做的最大价值是运营可以自己定义新分类不用每次改表。4.3 XSS 过滤这条我不希望你踩了才看用 Markdown 不等于绝对安全因为渲染出来的 HTML 最终还是要插入页面。我在前端渲染时统一过了一遍 DOMPurifyimport { marked } from marked; import DOMPurify from dompurify; const html DOMPurify.sanitize(marked.parse(content), { ALLOWED_TAGS: [h1, h2, p, ul, ol, li, a, img, strong, em, pre, code, blockquote, table, thead, tbody, tr, th, td], ALLOWED_ATTR: [href, title, src, alt] });如果后台编辑允许直接提交 HTML那后端必须再做一次过滤Java 项目我用的 Jsoup 白名单机制。规则很简单只放行常用标签其余一律剥离。这步不做等到有人往词条里塞一段恶意脚本再回头找人麻烦就晚了。5. 权限与历史版本可编辑系统最怕“改完找不回”百科类系统的“可编辑”是一把双刃剑。开放编辑会带来内容增长也会带来误操作和垃圾信息。我在这个项目里把权限和历史版本的关系理成了三个原则后面所有设计都是围绕它们来的。5.1 编辑保存先插历史再更新主表保存词条时很多人会直接 UPDATE 主表再插一条历史顺序拿反之后万一中间程序报错内容就丢了。我的 Service 层是这样的伪代码Transactional public void saveEntry(EditRequest dto) { Entry entry entryMapper.selectByIdForUpdate(dto.getEntryId()); int newVersion entry.getVersion() 1; entryHistoryMapper.insert(EntryHistory.builder() .entryId(entry.getId()) .version(newVersion) .content(dto.getContent()) .editorId(CurrentUser.getId()) .editReason(dto.getEditReason()) .build()); entryMapper.updateContentVersion( entry.getId(), dto.getContent(), newVersion); }注意版本号不是自增主键而是从主表读出来加一并用SELECT ... FOR UPDATE锁住当前词条避免两个人同时编辑导致版本号重复。回滚操作也简单找到要回滚的历史版本拿它的内容再次走一遍保存流程生成一个新版本。这样“回滚”本身也会留痕不会出现历史记录断档。5.2 状态机别把“审核状态”和“发布状态”混在一起主表里的status我定义得很克制0 草稿、1 已发布、2 已锁定。草稿只有创建者和管理员能看已发布是所有人都能看已锁定是管理员对某些争议词条做的保护锁定期间不能编辑。但如果你做的是一个开放型百科需要审核才能上线千万不要在主status上再加一个“待审核”状态。一旦一个词条已经发布用户又提交了新的修改这个词条到底是继续展示旧内容还是下线我建议单独加一个audit_status字段和主status解耦。已发布的词条即使有修改在审核中也继续展示当前版本不打断阅读体验。5.3 编辑理由和操作日志必须留我在保存接口里强制要求edit_reason非空理由是用户每次编辑时多写一句话能让后来的人在版本列表里快速判断该看哪个版本。同时后台再单独记录一份操作日志包括操作人、动作、时间、对象词条 ID不存正文只存元信息。这样定位问题的速度会快很多。6. 复刻百度百科时我踩过的三个不算技术但很疼的坑最后分享三个项目后期才暴露的问题每一个都不是高深难懂的 Bug但只要没提前处理都会让运营人员多花很多时间。6.1 “Python”和“python”是同一个词条词条标题的唯一性比想象中难维护。英文大小写、前后空格、中文全角符号都会导致同一个词条被创建两次。我的处理是在写入时统一生成title_key转小写、去首尾空格、半角转全角再转回半角。创建和更新接口都走同一套归一化逻辑数据库唯一索引加在title_key上而不是title上。这样“Python”和“python”会同时命中同一个词条而不是变成两个孤立页面。6.2 图片别用 base64 塞进数据库第一版编辑器插入图片时前端直接把图片转成 base64 存进了 Markdown 里。体验确实爽但数据库体积膨胀得飞快一个词条塞三五张图记录就几十 MB。后来全部改成先调/api/file/upload上传接口返回一个相对路径编辑器只把路径写进 Markdown。本地存储的话在 Nginx 里把/uploads/映射到磁盘目录即可。数据库里永远只存字符串不存图片字节。6.3 空搜索结果的引导很重要搜索一个不存在的词条时如果页面只显示“未找到”用户大概率直接走了。我在搜索接口里加了一个兜底逻辑没有结果时后端返回热门词条列表同时前端在页面上展示一个“创建该词条”的按钮。实操下来这个按钮带来的新词条创建量比在导航栏放“我要创建词条”高出好几倍。百科类产品的内容增长很多时候靠的就是用户搜索无果后顺手创建的行动。这个项目做完之后我对“超级模仿百度”这句话有了新的理解。真正值得模仿的不是某个像素级页面而是它把内容组织、用户编辑、历史追溯串成一条链路的方法。如果你也在做类似系统先把词条表、历史表、搜索与回滚这条主链路跑通其他的交互细节都是锦上添花。本文还有配套的精品资源点击获取