ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于SpringBoot和Vue的H5知识共享平台开发实践

基于SpringBoot和Vue的H5知识共享平台开发实践 简介基于Spring Boot与Vue构建的H5知识共享平台源码包采用前后端分离架构前端为VueElement UI的H5页面后端以Spring Boot、MyBatis、MySQL、ES、Redis、MQ为核心并区分用户端与管理端。平台支持用户注册登录、发布图文或视频、后台审核、浏览点赞评价并借助MQ异步发送邮件通知完整呈现内容发布到审核上线的业务闭环项目特意关闭转发分享功能代码中已预留实现便于需要时开启。压缩包约60MB共2000个文件以js、json、ts、java、vue等前后端工程文件为主也包含HTML样式与文档等辅助内容目录结构清晰。已有222人学习下载适合具备一定Java与Vue基础的学生用于课程设计、毕业设计参考或个人网站二次开发。1. H5知识共享平台为什么选 SpringBoot Vue 这套组合一个几十人规模的技术团队要做内部知识库需求通常很具体文档能上传、视频能播放、内容能按类目检索、不同角色能看到不同范围的内容而且要在手机浏览器、微信公众号、企业微信甚至App内嵌的WebView里都能正常打开。如果直接做原生App双端开发和发版周期都太长如果只做静态页面权限和文件存储又无从谈起。最后基本都会落到“前后端分离的H5”这个形态上。SpringBoot把接口、鉴权、文件存储和搜索结果的管理收敛在一个工程里Vue负责页面渲染和路由控制H5则让一套代码同时被微信生态和App容器复用。这套组合真正的价值不是技术栈新颖而是团队人力资源最省后端用SpringBoot快速出接口前端用Vue只管用户界面两个角色只要谈拢JSON结构就能并行。适合企业内部知识库、中小团队内部文档站以及课程设计级别的移动端共享平台。2. 先用 IDEA 把 SpringBoot 后端工程跑起来目录、配置与第一个接口2.1 用 IDEA 创建 SpringBoot 项目时依赖勾选和版本选择后端工程用 IDEA 的 Spring Initializr 生成最快。打开 IDEA选择 New Project左侧选 Spring InitializrGroup 填com.exampleArtifact 填knowledge-platformType 选 MavenJava 版本按本机环境选 17 或 21 均可。依赖这一步不要全勾够用即可Spring Web 提供 MVC 能力Validation 负责参数校验MySQL Driver 连数据库Lombok 减少实体类样板代码。后续需要操作 Redis 时再手动加spring-boot-starter-data-redis不需要在一开始就把全家桶塞进工程里。版本选择上SpringBoot 3.x 是目前的主流但要注意 3.x 的包名从javax换成了jakarta网上很多旧教程的import javax.servlet会直接编译失败如果团队里其他成员更熟悉 SpringBoot 2.7继续用 2.7 也没问题不需要盲目追新。pom.xml 里核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这段配置里最需要注意的是没有显式写版本号全部交给spring-boot-starter-parent做依赖管理避免自己维护一堆版本互相冲突。如果项目里需要 MyBatis-Plus追加mybatis-plus-spring-boot3-starter注意选择适配 SpringBoot3 的mybatis-plus版本否则启动时会因为被 Spring 容器扫描到不兼容的自动配置类而直接报错。创建完工程后先跑一次mvn spring-boot:run确认空的 SpringBoot 应用能启动再继续往下写代码。2.2 application.yml 多环境配置与连接池参数SpringBoot 配置里面最容易出问题的是数据库连接。开发、测试、生产环境的数据源地址不一样建议拆成application.yml、application-dev.yml、application-prod.yml三份文件。主配置里只放公共项环境相关的内容放到对应的配置文件中启动时通过SPRING_PROFILES_ACTIVE环境变量指定当前环境。以下是一份开发环境的 yml 配置server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/knowledge_platform?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:root} hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 servlet: multipart: max-file-size: 100MB max-request-size: 200MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0配置项里几个点需要单独说明。context-path: /api表示所有接口都挂在/api前缀下前端联调时不用在代码里写死环境地址也方便后续在网关层统一转发。数据库密码用${DB_PASSWORD:root}这种占位符写法形如“SpringBoot yml 密文”的诉求生产环境建议把密码放到环境变量或配置中心不要明文提交到 Git。HikariCP 连接池的maximum-pool-size不是越大越好对 MySQL 单库来说 20 已经足够过大会增加数据库端线程切换开销。map-underscore-to-camel-case开启后数据表的create_time会自动映射到 Java 字段createTime少写很多TableField注解。多文件上传的限制写在spring.servlet.multipart里知识平台里通常会有视频和 PDFmax-file-size直接给到 100MB。2.3 文章列表接口的 Controller、参数与统一返回体后端第一个要写的接口是文章分页列表。这里用 MyBatis-Plus 的BaseMapper简化数据访问实体类Article对应article表字段包括id、title、category_id、author_id、cover_url、content、create_time、deleted。Controller 层代码尽量保持薄只做参数接收和结果包装业务判断放到 Service 中。RestController RequestMapping(/article) RequiredArgsConstructor public class ArticleController { private final ArticleService articleService; GetMapping(/page) public ResultPageResultArticleVO page(Valid ArticleQuery query) { PageResultArticleVO page articleService.pageByCondition(query); return Result.ok(page); } }Data public class ArticleQuery { private long page 1; private long size 10; private Long categoryId; private String keyword; }查询参数用ArticleQuery对象接收前端传参时直接使用GET /api/article/page?page1size10categoryId3keywordSpringBoot。分页参数page从 1 开始size默认 10避免未传参时查全表。categoryId和keyword都是可空字段在 Service 拼条件时需要用if判断是否为 null不能直接把空值拼进 SQL。ResultT是统一返回体包含code、message、data三个字段前端 Axios 拦截器只需要判断code 200就能确定业务是否成功。这个接口参数对应的具体含义见下表。参数名类型必填说明pagelong否页码默认 1sizelong否每页条数默认 10最大建议不超过 50categoryIdLong否类目 ID为空时查询全部分类keywordString否标题模糊搜索关键词3. Vue3 前端与后端联调路由、状态、Axios 请求封装3.1 用 Vite 初始化 Vue3 工程并按模块划分目录前端工程使用 Vite 创建命令是npm create vitelatest knowledge-h5 -- --template vue。这个模板生成的是 Vue3 组合式 API 的基础结构不包含路由和状态管理还需要额外安装依赖。cd knowledge-h5 npm install npm install vue-router4 pinia axios element-plus element-plus/icons-vue npm run dev依赖安装完毕后在src下按功能划分目录api放接口定义router放路由配置store放 Pinia 状态views放页面组件layout放底部导航和头部布局。如果开发机没有外网访问 npm 源可以先执行npm config set registry切换到镜像源再执行 install这一步是 Vue 安装依赖最常见的卡点之一。3.2 vue-router 路由配置与 H5 页面的懒加载H5 页面的路由设计和 PC 后台不同底部 Tab 栏通常只有首页、知识库、我的三个入口其余详情页、上传页都从 Tab 页面通过路由跳转进入。路由配置使用懒加载把页面组件的 import 写进一个函数里在浏览器访问对应路径时才加载对应资源减少首屏加载时间。import { createRouter, createWebHashHistory } from vue-router const routes [ { path: /, component: () import(/layout/H5Layout.vue), children: [ { path: , redirect: /home }, { path: /home, name: Home, component: () import(/views/HomePage.vue) }, { path: /library, name: Library, component: () import(/views/LibraryPage.vue) } ] }, { path: /article/detail, name: ArticleDetail, component: () import(/views/ArticleDetail.vue) } ] const router createRouter({ history: createWebHashHistory(), routes }) export default router这里选择createWebHashHistory而不是createWebHistory是因为 H5 页面会被嵌入到微信、钉钉或原生 App 的 WebView 中这些容器刷新页面时不一定能正确回传 URL 给后端服务哈希路由能够保证任意页面刷新都不会出现 404。路由参数最常见的问题是混淆query和paramsquery是 URL 上的问号参数?id3刷新后仍然在地址栏里params只能在push时通过 name 方式传递刷新后参数不在了。详情页跳转建议用query因为 H5 端分享链接时需要完整 URL只有params的话别人拿到链接会打不开。3.3 Axios 实例的拦截器与 Token 失效处理联调阶段最简单的是直接把http://localhost:8080/api写进代码但这在真机和微信里会直接失败因为手机访问不了电脑的 localhost。前端工程一般建立统一的 Axios 实例用环境变量管理后端地址。import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(access_token) window.location.href /login } return Promise.reject(error) } ) export default service请求拦截器的作用是在每次 HTTP 请求发出前从 localStorage 取出登录时保存的 token拼到Authorization请求头里。后端的鉴权过滤器会校验这个头识别当前登录用户是谁。响应拦截器做两件事业务码code ! 200时统一弹错误信息避免每个页面重复写处理逻辑HTTP 状态码 401 表示 token 过期或被吊销此时清理本地存储并跳回登录页。这样封装后页面里的调用就变得很干净只需要关注业务成功后的data。4. 知识内容的上传、存储与全文检索在前后端是如何落地的4.1 用 MultipartFile 接收文档上传并生成访问链接知识平台的文件上传不能只传文本需要支持用户上传 PDF、Word、图片和视频。后端接口用MultipartFile接收文件流把文件写到本地磁盘目录然后在数据库里保存一个/files/xxx.pdf的相对路径最终由 Nginx 或者网关层对/files/**做静态资源映射。如果公司有对象存储服务这一层可以替换为把流上传到对象存储然后回传一个内网 URL接口结构不用改。上传接口的常见写法如下PostMapping(/upload) public ResultFileUploadVO upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalFilename); String fileName UUID.randomUUID().toString().replace(-, ) . ext; Path path Paths.get(uploadDir, fileName); file.transferTo(path); FileUploadVO vo new FileUploadVO(); vo.setUrl(/files/ fileName); vo.setSize(file.getSize()); return Result.ok(vo); }UUID.randomUUID()生成文件名是为了避免用户上传重名文件时互相覆盖也防止文件名里带中文和特殊字符导致访问链接异常。file.transferTo是 Spring 提供的方法可以接受一个File或者Path对象内部处理临时文件的复制。这里必须手动校验文件后缀白名单只允许jpg/png/mp4/pdf/docx否则恶意用户上传一个.jsp或者.html一旦被当作脚本执行整个 H5 页面都会受影响。uploadDir在配置文件里指定生产环境不要放在src/main/resources下面否则打成的 jar 包里的路径是只读的。4.2 文章分页查询与类目过滤的条件拼接列表页通常需要在类目、关键词、时间三个维度过滤。MyBatis-Plus 的LambdaQueryWrapper可以用链式方法拼条件代码写起来比 XML 里的动态 SQL 直观不少。为了统一处理分页和过滤Service 里定义一个pageByCondition方法。public PageResultArticleVO pageByCondition(ArticleQuery query) { PageArticle page new Page(query.getPage(), query.getSize()); LambdaQueryWrapperArticle wrapper new LambdaQueryWrapper(); wrapper.eq(query.getCategoryId() ! null, Article::getCategoryId, query.getCategoryId()) .like(StringUtils.hasText(query.getKeyword()), Article::getTitle, query.getKeyword()) .orderByDesc(Article::getCreateTime); PageArticle result articleMapper.selectPage(page, wrapper); return PageResult.of(result.getRecords(), result.getTotal(), query.getPage(), query.getSize()); }LambdaQueryWrapper的第一个参数是布尔表达式条件为 true 时才会把对应条件拼到 SQL 上这样categoryId为空时就不生成WHERE category_id null这种无效语句。like方法做的是%keyword%模糊匹配只适合对小数据量的标题字段做快速查询。这里有一个性能隐患需要提前知道selectPage返回的 total 会额外执行一条count(*)当数据量超过几十万时这条 count 查询会成为瓶颈届时要手动改造为只统计 ID 数量或者使用 ES 的 total 关系。类目过滤的典型路由参数结构如下表。场景路径参数示例全部文章/article/pagepage1size10按分类筛选/article/pagecategoryIdjava标题搜索/article/pagekeywordSpringBoot4.3 搜索从 LIKE 到全文索引的升级节奏当知识平台的文档数量增长到几千篇时LIKE %关键字%依然可用但一旦文章内容也参与搜索用户搜索的就不是“标题里包含什么”而是“正文里提到过什么”这个场景下标准的LIKE就不再适合了。MySQL 提供了FULLTEXT全文索引可以在文章标题和正文上建立全文索引查询时使用MATCH ... AGAINST。在数据表上执行的建索引语句如下ALTER TABLE article ADD FULLTEXT INDEX ft_article_content (title, content) WITH PARSER ngram;WITH PARSER ngram是 MySQL 5.7 以上对中文全文检索的支持方式ngram 分词器把连续的中文文本切成固定长度的字符序列解决中文没有空格分词的问题。查询时不再使用LIKE改为SELECT id, title FROM article WHERE MATCH(title, content) AGAINST(SpringBoot IN NATURAL LANGUAGE MODE);这个方案的边界很清晰MySQL 全文索引不支持中文同义词、拼音纠错、相关度排序权重调整公司内部知识库如果只是搜标题和简短摘要在数据量 10 万以内够用。如果搜索词包含英文缩写、版本号比如 JDK 17、中英混合全文索引的召回率会明显下降这时候再迁移到 Elasticsearch 或者 MeiliSearch 不迟。我一般建议小团队先上 MySQL 全文索引把content字段存一份到独立的搜索表里后续迁 ES 时不用改业务表的 schema。5. H5 页面在微信、钉钉、App 内嵌环境下的适配与踩坑5.1 viewport 与底部安全区域的适配H5 页面和 PC 页面最大的差别在于视口尺寸。Vue 页面默认的 HTML 结构在移动端会出现横向滚动和字体缩放的问题。在index.html里把 viewport 写全是最基础但也是最容易漏掉的适配。meta nameviewport contentwidthdevice-width, initial-scale1, maximum-scale1, minimum-scale1, user-scalableno /widthdevice-width让布局视口等于设备宽度maximum-scale1和user-scalableno禁止用户双指缩放页面。对于底部有操作栏的页面还需要考虑带有 Home 指示条的全面屏手机CSS 里加一句安全区域适配.bottom-bar { padding-bottom: env(safe-area-inset-bottom); }env(safe-area-inset-bottom)是 iOS 实际高度Android 的 WebView 对手指操作区的处理不太统一需要配合viewport-fitcover一起用否则这个变量可能为 0。App 内嵌 H5 时外层 Android 的 WebView 默认会启用夜间模式和系统字体缩放如果页面字体异常变大可以在 WebView 侧关闭 textZoom 并将 forceDark 关掉这些问题在前端 CSS 里是调不回来的。5.2 微信公众号内获取地理位置的鉴权流程知识共享平台有一个常见需求是根据用户位置推荐附近的文档这在 H5 端不能直接调用浏览器navigator.geolocation微信浏览器必须走微信 JS-SDK 的wx.getLocation。调用前第一步是在后端生成签名串前端再执行wx.config。public WxJsConfig createJsConfig(String url) { String nonceStr UUID.randomUUID().toString().substring(0, 16); long timestamp System.currentTimeMillis() / 1000; String sign sha1(jsapi_ticket ticket noncestr nonceStr timestamp timestamp url url); WxJsConfig config new WxJsConfig(); config.setAppId(appId); config.setNonceStr(nonceStr); config.setTimestamp(timestamp); config.setSignature(sign); return config; }signature的生成算法固定参与签名的四个参数jsapi_ticket、noncestr、timestamp、url顺序不能错url必须是当前页面完整的location.href去掉 hash 部分后的地址。jsapi_ticket的有效期也是 7200 秒获取后要在后端做缓存不能每次请求都去微信接口拉取。前端拿到签名后调用wx.config({ debug: false, appId: res.appId, timestamp: res.timestamp, nonceStr: res.nonceStr, signature: res.signature, jsApiList: [getLocation] })wx.config只是注入配置实际触发定位是后面调用wx.getLocation这个 API 会先弹窗询问用户授权用户拒绝后会在fail回调里返回错误。调试时看到的错误码 40163 表示 code 已过期3301 表示签名不对优先检查参与签名的 URL 和当前页面的 URL 是否完全一致这是最常见的问题多见于 H5 在 WebView 里被二次跳转后自行改了地址。5.3 Vue 播放 m3u8 视频流video.js hls.js 的开箱组合知识共享平台里常见一类需求是上传的讲课视频在 H5 页面直接播放。早期 PC 时代用 Flash 播放 m3u8现在浏览器和 WebView 都不支持要靠 Media Source Extensions 实现流式播放。目前方案成熟的是video.js配hls.js插件先安装依赖npm install video.js hls.jsVue 组件里需要手动初始化播放器代码片段如下template video refvideoEl classvideo-js vjs-big-play-centered controls playsinline/video /template script setup import { onMounted, ref } from vue import videojs from video.js import video.js/dist/video-js.css const props defineProps({ src: String }) const videoEl ref(null) onMounted(() { const player videojs(videoEl.value, { sources: [{ src: props.src, type: application/x-mpegURL }] }) }) /scriptplaysinline这个属性在 iOS Safari 里很关键不加它视频会自动进入全屏播放模式。type必须指定为application/x-mpegURLvideo.js 和 hls.js 才知道这个地址按 HLS 协议去解析。生产环境常见的问题不是播放器本身而是后端返回 m3u8 文件时要加响应头Access-Control-Allow-Origin: *HLS 的 ts 分片会发起独立的 HTTP 请求如果静态文件服务没有配置 CORS浏览器控制台会报跨域错误但页面不白屏不容易定位。另外注意 m3u8 的视频源如果是在内网里H5 页面部署在公网播放请求也需要走内网穿透或专线不要在业务接口里用HttpURLConnection去拉流再吐给前端这样内存占用会直接拖垮后端服务。5.4 小程序内嵌 H5 的返回按钮与 App 内嵌时的 token 注入知识平台经常被嵌入到微信小程序和钉钉的工作台中。小程序里用web-view的 H5 页面左上角默认有一个返回箭头但如果 H5 内部有history.go(-1)或者页面 JS 调用了浏览器历史相关 API返回箭头会消失或行为错乱。H5 页面不应主动管理历史栈尽量让用户通过页面内的按钮跳回首页。App 内嵌 H5 的场景里token 不能出现在 URL 参数上因为 URL 会被系统 WebView 缓存泄露风险很高。常见做法是原生 App 通过 WebView 的JSBridge注入一个方法H5 端通过window.xxxSDK.getToken()拿到登录态。如果原生端没有提供接口可以在首次加载 expect 时通过 sessionStorage 传递但这种方式在页面刷新后就会失效只能作为临时联调方案。不同容器的差异化表现见下表。容器token 传递方式定位能力常见问题微信浏览器公众号网页授权需 JS-SDK 签名签名 URL 不一致微信小程序 web-viewURL/JSBridge小程序组件提供H5 返回箭头消失钉钉工作台钉钉免登 code需调用钉钉 JSAPI权限配置不当报无权限原生 App WebView原生注入对象原生定位接口Android 6.0 以上需要动态申请权限钉钉环境里如果 H5 调用了录音或定位能力控制台会报类似no permission info for action:device.audio.startrecord的错误这是钉钉后台的权限配置没打开不是代码问题通知管理员在应用权限管理里勾选对应权限即可。6. 用 SpringCache 和 Redis 把 H5 文章列表接口从 800ms 压到 50msH5 知识库的首页打开时前端会一次请求分类、推荐文章、公告三个接口如果每次请求都打到 MySQL数据库压力非常大。我常用的做法是给列表接口加一层 SpringCache 缓存用 Redis 作为缓存存储把热点数据在缓存里放 5 分钟后端代码改动量非常小。第一步在启动类上开启缓存支持SpringBootApplication EnableCaching public class KnowledgeApplication { public static void main(String[] args) { SpringApplication.run(KnowledgeApplication.class, args); } }然后改造 Service 里的分页查询方法加一个Cacheable注解如图所示核心改动只有注解Override Cacheable(value articlePage, key #query.page _ #query.size _ #query.categoryId _ #query.keyword) public PageResultArticleVO pageByCondition(ArticleQuery query) { // 原有 MyBatis-Plus 查询逻辑不变 }Cacheable的执行顺序是第一次调用时先查数据库把结果存进 Redis第二次及以后调用如果 key 存在就直接返回缓存不执行方法体。key 由分页参数、类目 ID、关键词拼接而成这意味着淘宝首页那种大而全的列表页会被分成几百个缓存键命中率取决于真实场景的查询分布。对于内部知识平台用户通常只集中在几个默认分类和热门关键词这样的缓存设计命中率很高。更新文章内容时要主动删除对应的缓存否则修改不生效CacheEvict(value articlePage, key #query.page _ #query.size _ #query.categoryId _ #query.keyword)CacheEvict放在更新方法和删除方法上执行时机是在方法执行成功后清除缓存。这里的 key 表达式必须与Cacheable的 key 完全一致否则删的是不存在的 key旧缓存永远不被清理。更稳妥的方式是使用CacheManager.clear(articlePage)在文章增删改时把整个分页缓存清空虽然会短暂丢失热点数据但能保证一致性不复杂。压测验证直接用本机的ab命令完成不需要复杂工具ab -n 5000 -c 50 -H Authorization: Bearer $TOKEN \ http://localhost:8080/api/article/page?page1size10压测命令里-n 5000表示总共发送 5000 个请求-c 50表示同时 50 个并发。压测结果重点看Requests per second吞吐量和Percentage of requests served within a certain time响应时间分布。优化前 5000 个请求平均耗时通常在 500~800 毫秒因为每次请求都要执行count(*)和分页查询加缓存后的第一次请求仍然是几百毫秒之后的请求响应时间会下降到 30 到 50 毫秒吞吐量从每秒几十提升到几百数据库端可以看到慢查询日志里不再出现高频的分页 SQL。缓存只解决读多写少的场景。知识库的文章一旦频繁被用户评论、收藏每篇文章内容被修改的概率并不高5 分钟过期时间是业务可接受的值。如果以后加了搜索功能这类列表缓存的思路依然适用只是缓存 key 要加一个新的维度query字符串并监控 Redis 内存使用量。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进