敲完最后一块代码、把前端页面在手机浏览器里缩放检查完这个Java基于SpringBootVue的新农村风貌展示平台算是正式交付了。做这类项目的起因很简单镇里想把各村的自然风光、老建筑、特色农产品和旅游路线集中放到一个页面上让外面的人搜得到、看得明白也让村里自己有个能随时更新的宣传窗口。这个项目从一开始就有清晰的技术轮廓——后端 Java SpringBoot 提供接口和文件存储前端 Vue 做页面展示和后台管理中间走 RESTful API 对接。整体不算重但涉及到的点很全面包括数据库设计、分页查询、文件上传、视频流播放、地图定位、跨域处理、Nginx 部署等。如果你正在学 SpringBoot 和 Vue 的整合开发或者需要做一个信息展示类项目、课程设计、毕业设计这篇文章应该能提供一套完整可参考的实践方案。我会按一个真实项目的推进顺序来说从需求拆解到技术选型再到后端、前端、部署每个环节都带上关键代码和实际踩坑记录。1. 这个平台到底要做什么1.1 项目背景与需求来源新农村风貌展示平台这个名字听起来有点大落到实际需求上其实非常具体。我接到这个需求的时候对方先给我看了一堆现有的宣传材料各个村庄自己做的公众号推文、短视频账号、宣传折页内容都很丰富但是特别分散。游客想来村里玩想知道哪里有民宿、哪里能采摘、古建筑有哪些往往要翻好几个公众号信息还不一定最新。村里想更新一处新修的步道或农家乐也要找公众号运营去排版发布效率很低。所以整理下来核心诉求就是三句话第一有一个统一的对外展示窗口把各村的自然风貌、人文历史、特色产业集中呈现第二后台要能自主维护管理员可以自己上传图片、视频、文章不需要每次找开发人员改页面第三要同时兼顾 PC 和手机端因为基层居民和游客大部分是用手机访问的。这个背景决定了项目的基本形态一个信息展示为主、内容管理为辅的 Web 平台不追求复杂的业务逻辑但要求在展示效果、访问速度、内容维护便利性上做扎实。1.2 功能清单与核心需求拆解把需求翻译成功能模块我大致分成前台展示和后台管理两块前台对外后台对内。模块前台功能说明村落总览乡村列表、搜索、分类筛选每个村一张卡片展示封面图和一句话简介风貌详情图文详情、视频展示、位置地图点进卡片看完整内容包括图片画廊、介绍文章、宣传视频特色产业农产品、民宿、农家乐信息方便游客找到可体验、可购买的项目新闻动态公告、活动、动态文章后台发布前台按时间倒序展示后台管理内容发布、图片视频上传、数据统计管理员维护所有展示内容这里有一个很关键的取舍前台不要做登录注册。展示平台的核心是让信息被看到如果把内容藏到登录后面反而违背了初衷。游客可以直接浏览所有公开内容只有进入后台管理页面时才需要账号校验。这样既保证了体验又降低了复杂度。我当时的做法是在前台放一个候选接口列表首页、列表页、详情页都由这些接口支撑后台管理单独走一套接口用 JWT 做身份校验。文章等内容的阅读量也可以顺便统计虽然这个数据不一定很精确但对镇里来说能看出哪些内容受欢迎对后续运营有价值。2. 技术选型与整体架构设计2.1 为什么是 SpringBoot Vue这个项目选型基本没有太多纠结。一方面Java SpringBoot 在中小型 Web 项目里非常成熟一套 SpringBoot MyBatis MySQL 的组合开发速度快、资料多、招人也好招基层单位后续要维护或二次开发都容易找到人。另一方面Vue 在前端组件化和工程化上的体验很好生态里有 Element UI 这类现成后台组件库做管理页面可以省下大量时间。如果换成一个单体 JSP 项目当然也能做但前后端不分离的话页面维护和接口复用都会别扭。尤其这个项目有前台展示 后台管理两个界面它们长得完全不一样用一套模板硬混在一起开发体验很差。前后端分离的好处是前端只关心页面渲染和交互后端只提供数据接口两个端可以并行开发后续调整也互不影响。还有一个小考虑是部署环境。对方服务器上没有复杂的集群条件就是一个普通 Linux 云主机所以我没有引入 Docker、Kubernetes 这些容器化方案而是选择最简单可靠的方式后端 jar 包用 systemd 守护前端静态文件用 Nginx 托管。越简单的架构在基层环境的长期运行中越稳定排查问题也越容易。2.2 系统架构与工程目录设计整个系统的请求链路是浏览器访问 Vue 打包后的静态页面页面里的 ajax 请求走 /api 前缀由 Nginx 反向代理到后端 SpringBoot 服务的 8080 端口后端访问 MySQL 数据库并把数据以 JSON 格式返回。文件上传则直接写入服务器磁盘的一个 upload 目录通过静态资源映射对外提供访问。后端工程我用的是常规的包结构src/main/java/com/example/rural/ ├── config/ # 跨域、静态资源配置、MyBatis配置 ├── controller/ # 接口层 ├── entity/ # 实体类 ├── mapper/ # MyBatis映射接口与XML ├── service/ # 业务层 ├── common/ # 统一返回、异常处理、工具类 └── filter/ # 全局过滤器前端工程用 Vue CLI 创建目录上按页面维度组织src/ ├── api/ # 接口请求封装 ├── router/ # 路由配置 ├── views/ # 页面组件 ├── components/ # 复用组件 ├── utils/ # 工具函数 └── assets/ # 静态资源这种结构说不上多惊艳关键是清晰。后端按技术层次分包前端按页面和功能分包新接手的人能很快定位到代码。对于这种展示型项目工程结构的重要性不亚于编码本身因为后续很可能不是原开发者来做维护。2.3 数据库模型设计要点这个项目的表结构不复杂核心就几张表村落表 village、风貌内容表 scenery、文章表 article、视频表 video、用户表 admin_user、分类字典表 category。我挑几个设计要点说一下。村落表里除了村名、简介、封面图外我加了 sort 排序字段和 status 状态字段status 为 0 表示隐藏、1 表示展示。这样管理员可以在后台先编辑内容、暂不发布等准备齐全再一键上线排序字段也很有用比如镇里想重点推某个村排在最前面就行不需要改代码。风貌内容表和视频表都通过 village_id 与村落表关联这样设计是因为一个村会有多条风貌内容、多个视频属于一对多关系。文章表单独拆出来因为它不绑定某个村可能是镇级动态或活动公告。经纬度字段我直接存在 village 表里用 decimal(10, 6) 类型别用 float否则地图打点会偏得离谱。MyBatis 的 mapper 里我用了 MyBatis-Plus 的 LambdaQueryWrapper 来写查询简单查询不用拼 SQL分页用它的分页插件代码会干净很多。这个项目的数据量不大单表甚至不用加索引都能跑得很顺但我在 status、village_id 这种查询频率高的字段上还是加了普通索引算是养成习惯。3. SpringBoot 后端核心实现3.1 实体建模与 MyBatis 分页实体类我通常直接用 MyBatis-Plus 的注解风格字段和数据库列对应关系写在注解里这样就不用维护一大堆 XML 映射文件。Data TableName(village) public class Village { TableId(type IdType.AUTO) private Long id; private String name; private String intro; private String coverUrl; private BigDecimal longitude; private BigDecimal latitude; private Integer sort; private Integer status; private LocalDateTime createTime; }查询接口这里有个常见问题就是列表页不能一次把全部数据丢给前端数据多的时候页面会明显变慢。我在村落列表、文章列表这些接口上都做了分页。MyBatis-Plus 自带分页插件配置一个拦截器就行然后用 Page 对象接收参数Service public class VillageServiceImpl extends ServiceImplVillageMapper, Village { public IPageVillage pageVisible(int pageNum, int pageSize) { PageVillage page new Page(pageNum, pageSize); LambdaQueryWrapperVillage wrapper new LambdaQueryWrapperVillage() .eq(Village::getStatus, 1) .orderByDesc(Village::getSort) .orderByDesc(Village::getCreateTime); return this.page(page, wrapper); } }前端分页参数我统一用 pageNum 和 pageSize返回值里带上 total前端就能根据总条数生成分页控件。这里要注意一个细节如果第一页查出来 10 条用户翻到第 5 页时因为内容下架导致数据不足(pageNum - 1) * pageSize大于 total就要处理防抖动或直接回到第一页否则用户会看到空白列表。我在前端做了判断total 变化后如果当前页超出总页数自动跳回第一页。3.2 REST 接口设计与前后端联调接口设计遵循一个简单的原则按资源命名 URL用 HTTP 方法表达操作。方法路径说明GET/api/village/list村落分页列表GET/api/village/{id}村落详情含风貌内容GET/api/article/list文章分页列表GET/api/article/{id}文章详情POST/api/admin/village/save新增或保存村落管理端POST/api/admin/upload上传图片或视频DELETE/api/admin/village/{id}删除村落管理端前台接口和管理接口都挂在 /api 下但管理接口加了 /admin 路径方便通过拦截器做权限校验。所有接口的返回结构统一Data public class ResultT { private int code; private String msg; private T data; }code 为 0 表示成功非 0 表示业务异常。这个统一返回类很重要前端 axios 拦截器可以统一处理 code不需要每个接口写一遍判断。前后端联调时最常遇到的是跨域问题。浏览器限制前端地址和后端地址不一致时的请求解决思路有两个开发环境用 Vue CLI 的 devServer 代理把 /api 转发到后端生产环境用 Nginx 反代到后端。我在后端也开启了一组跨域配置主要为了方便调试和供一些外部工具调用。下面这段配置放在配置类里Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS); } }不过在正式项目里allowedOriginPatterns 不建议配成*最好限定到实际的前端域名否则等于把接口开放给任何来源的网页调用。我在部署前把这里的配置收敛成了指定域名。3.3 文件上传、静态资源映射与视频流支持内容展示平台最核心的资源就是图片和视频。上传这块我用 SpringBoot 自带的 MultipartFile 就能搞定关键在应用配置和磁盘路径的组织上。spring: servlet: multipart: max-file-size: 200MB max-request-size: 500MBmax-file-size 决定了单个文件能传多大。200MB 对普通乡村宣传视频足够对高码率长视频会偏紧可以根据实际情况调。需要注意的是如果前端一次上传多个文件max-request-size 要留出余地否则一次传 5 个 50MB 的图片都会报错。上传代码这里不复杂核心是生成唯一文件名并保存public String upload(MultipartFile file) { String original file.getOriginalFilename(); String ext original.substring(original.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) ext; File target new File(uploadDir / fileName); file.transferTo(target); return /upload/ fileName; }为什么要用 UUID 重命名因为不同用户上传的照片很可能重名直接覆盖会导致图片错乱而且用随机名可以避免文件名暴露信息省去很多命名坑。不过这里不建议把 uploadDir 写死我放在配置文件里生产环境指到数据盘路径避免系统盘被大量图片视频占满。图片和视频上传后要能被浏览器访问需要在 WebMvcConfigurer 里配置静态资源映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir /); }配置完成后上传的素材就能通过http://域名/upload/xxx.jpg直接访问了。视频播放这块我最初直接存 mp4 并用 HTML 的 video 标签播放实测发现体验一般文件大、网络稍慢就会一直卡缓冲也没有进度记忆。后来把正式宣传视频统一用 FFmpeg 转成了 HLS 流也就是 m3u8 加 ts 切片的形式。HLS 的好处是边下边播天然适合长视频和弱网环境前端用 hls.js 就能播放。切片文件同样存在 upload 目录下通过静态映射访问不需要额外部署流媒体服务器这个方案在中小型项目里非常实用。3.4 全局过滤器与接口安全细节内容展示平台虽然不涉及支付、敏感数据但作为公共服务项目基本的接口防护还是要做。我做了三件事XSS 过滤、参数校验、统一异常处理。XSS 过滤针对的是内容管理功能。管理员上传的图文内容会进入富文本编辑器如果没有过滤攻击者可以在内容里嵌入一段恶意 script游客访问时脚本就会在浏览器里执行。我用了一个全局过滤器把请求体中的script、iframe、onerror等危险关键字统一转义处理这里要注意富文本内容本身合法的 HTML 标签要保留不能一刀切把尖括号全转掉否则正文排版会乱。我的做法是维护一个黑名单只对危险标签和事件属性做转移处理。参数校验用 Spring 的 validation 注解在 Controller 入参上加 NotBlank、Size 之类PostMapping(/api/admin/village/save) public Result? save(Validated RequestBody VillageReq req) { ... }这样能挡住很多空指针和脏数据。我的体会是参数校验不只是为了正规更是为了减少联调时两边沟通成本。前端漏传一个字段后端报错信息明确说村名不能为空比看一串堆栈有用得多。统一异常处理用 RestControllerAdvice把业务异常和系统异常分别处理业务异常返回友好提示系统异常记录日志并返回固定文案避免把异常堆栈直接暴露给用户。安全这块还有一个容易被忽略的点管理端接口的 JWT 令牌过期后要返回 401前端要捕获这个状态自动跳转到登录页这个我在 Vue 的 axios 拦截器里写好了。4. Vue 前端实现与交互细节4.1 工程初始化、路由与请求封装前端我用 Vue 2 的生态做的准确说是 Vue CLI 4 Vue Router 3配合 Element UI 组件库。为什么没用 Vue 3我接手时团队现有项目都在 Vue 2 上组件库、插件都比较成熟直接上手没有学习成本。如果你从零开始用 Vue 3 Vite Element Plus 会更符合趋势但下面的组件化思路是一样的。先初始化工程并安装依赖npm install -g vue/cli vue create rural-front cd rural-front npm install vue-router3 element-ui axios这里有个最常见的坑npm 安装依赖时网络慢或者版本冲突。建议第一次装完依赖后把 node_modules 目录和 package-lock.json 缓存好后续换机器可以直接复现环境。我自己在配环境时卡过两次一次是 Node 版本太高导致 node-sass 编译失败换成 sass 的 JS 版本实现就解决了另一次是 Vue Router 装成了 4.x和 Vue 2 不兼容路由一启动就白屏。排查这种问题先看控制台报错不用急着搜方案。路由配置我按页面结构定义const routes [ { path: /, name: Home, component: () import(/views/Home.vue) }, { path: /village/:id, name: VillageDetail, component: () import(/views/VillageDetail.vue) }, { path: /article/:id, name: ArticleDetail, component: () import(/views/ArticleDetail.vue) }, { path: /admin/login, name: AdminLogin, component: () import(/views/admin/Login.vue) }, { path: /admin, component: () import(/views/admin/Layout.vue), children: [ { path: village, component: () import(/views/admin/VillageManage.vue) }, { path: article, component: () import(/views/admin/ArticleManage.vue) } ], meta: { requiresAuth: true } } ]详情页选用/village/:id这种动态路由而不是把 id 塞进 query 参数因为这样 URL 更语义化分享链接也更干净。组件里用this.$route.params.id读取参数并请求详情接口。请求封装我统一放在src/api/request.js创建 axios 实例并设置拦截器。拦截器做两件事请求头自动带上 token响应里统一判断 code非零弹提示401 跳登录页。request.interceptors.response.use( res { if (res.data.code 0) return res.data.data Message.error(res.data.msg) return Promise.reject(res.data) }, err { if (err.response err.response.status 401) { router.push(/admin/login) } return Promise.reject(err) } )4.2 首页风貌展示的组件拆分展示平台的首页决定了第一印象我花了比较多精力在这里。整体布局是顶部导航 大屏轮播图 村落卡片列表 最新动态 页脚。轮播图用 Element UI 的 el-carousel 组件图片数据从后台的 banner 接口取管理员可以随时换图。这里有个优化点图片宽度我限制在 1920 以内前端再用缩略图参数控制尺寸避免第一次加载首页要下载好几个 10MB 的图移动端用户根本受不了。村落卡片列表我拆成一个VillageCard.vue组件接收一个 village 对象内部渲染封面图、村名、一句话简介和入口按钮。这样列表页和首页都能复用它不算复杂的组件但减少了重复代码。列表排序按照后端返回的 sort 字段来后台可以控制展示顺序。这里我想强调一下组件化的分寸。展示型项目不需要把每个 div 都拆成组件拆得太碎反而让模板变得难读。我的标准是一个组件在页面中至少被复用两次或者它的内容独立性足够强比如轮播图、卡片、评论列表这类才值得单独拆出来。像首页这种一次性页面直接在页面模板里写反而更好维护。4.3 风貌视频播放与地图定位视频展示是风貌平台的一个亮点功能。我用 hls.js 封装了一个视频组件支持 m3u8 视频流也兼容 mp4 直链import Hls from hls.js export default { props: { src: String }, mounted() { if (this.src.includes(.m3u8) Hls.isSupported()) { const hls new Hls() hls.loadSource(this.src) hls.attachMedia(this.$refs.video) } else { this.$refs.video.src this.src } } }hls.js 的本质是让不支持 HLS 的浏览器也能播 m3u8 流。桌面端 Chrome、Firefox 原生不支持 .m3u8移动端 Safari 反而原生支持所以代码里要做能力判断。视频组件里我还加了封面图展示、播放按钮的加载状态避免点击后黑屏几秒让人以为坏了。地图定位我用的是腾讯地图 JavaScript API。在村落详情页放置一张地图把经纬度渲染成标注点击标注弹出村名。地图 api key 需要去腾讯位置服务官网申请申请过程不复杂就是实名认证后建一个应用拿到 key。这里有个经验开发阶段可以把 key 的域名白名单放开到 localhost上线后一定要改回线上域名否则密钥泄露以后别人可以盗用你的配额。定位在地图上后我还加了一个一键导航的跳转链接直接调用地图的 URL API 拉起导航。对游客来说知道村在哪里和能不能开车过去是两回事这个功能虽然实现起来只要一行代码但基层反馈的使用率很高。4.4 管理后台与发布流程后台管理是整个平台的生产工具设计原则是让非技术人员也能顺利操作。我用 Element UI 做了一套简单的后台布局左侧菜单、右侧内容区菜单项包括村落管理、内容管理、文章管理、上传记录。村落管理页面用 el-table 展示列表行内直接提供编辑、上下架、置顶、删除操作。编辑页面用 el-form 表单字段对齐数据库设计封面图用 el-upload 上传。这里要注意 el-upload 的 action 要指向后端的上传接口并且带上 token 请求头。上传成功后拿到图片 URL回填到表单的隐藏字段里最后保存时随表单一起提交。文章管理用富文本编辑器。我在选择上用了比较常规的方案富文本编辑器不直接存 HTML 里的图片而是通过一个自定义上传接口让编辑器里的图片先传到我们的服务器。这样文章发布后图片不会依赖第三方也不会失效。内容保存前前端会把危险标签过滤一遍后端还有一层 XSS 过滤器兜底双保险。整个发布流程走下来是管理员登录后台创建或编辑一个村落的展示资料上传封面、相册、视频填写简介和详情点保存后在前台立即可见。如果内容还没准备好就把状态设为隐藏等全部齐全再上架。这种内容可控的后台才是展示平台真正能长期运转的关键。5. 部署上线与实测踩坑记录5.1 打包部署与环境变量配置部署前先把两个端分别打包前端执行npm run build生成 dist 静态目录后端用 Maven 打包mvn clean package -DskipTests生成 jar 包。前端 dist 目录我整体传到服务器的 /usr/share/nginx/html 下Nginx 配置了单页应用的路由回退保证刷新详情页不出现 404server { listen 80; server_name rural.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { alias /data/rural/upload/; expires 7d; } }后端 jar 包我用 systemd 守护进程托管写一个简单的 service 文件设置启动命令和环境变量。服务器上要先装 JDK并配置 JAVA_HOME 环境变量这一步是很多新手卡住的地方。装完 JDK 后命令行执行java -version能正常输出版本就说明环境没问题。上传目录放在 /data/rural/upload独立于系统盘。这里有个教训我第一次部署直接把上传路径放到了 /root/upload结果磁盘满的时候把系统目录塞爆了机器直接卡住。后来赶紧迁到独立数据盘目录并在配置里把路径改成通过环境变量注入避免打包时写死。5.2 常见问题速查表这一路下来我整理了一张常见问题表都是实际遇到过并且确认过解决办法的。现象原因解决办法前端访问接口 404Nginx 没有把 /api 代理到后端检查 location /api/ 配置检查后端端口是否监听上传大视频报 Request Entity Too LargeNginx 默认 body 大小限制 1MB在 server 块加 client_max_body_size 200m刷新详情页 404没有配置 try_files 回退加try_files $uri $uri/ /index.html;前端跨域报错前后端域名不同且后端未配跨域开发环境用代理生产用 Nginx 反代图片能上传但前台打不开静态资源映射路径不对检查 addResourceLocations 的 file: 路径末尾斜杠m3u8 播放不了hls.js 未做能力检测或路径 404确认视频文件路径存在确认 m3u8 中 ts 路径相对正确列表翻页后数据变少内容下架或状态过滤条件变化前端在 total 变化时自动重置页码token 过期后页面卡住拦截器没有处理 401在响应拦截器里统一跳转登录页并清理本地 token5.3 一些真实的落地体会项目做下来我最深的感受是这种看起来简单的展示型项目真正花时间的往往不在技术难点而在细节和运维习惯。基层单位不会每天给你提新需求但只要平台上线了图片打不开、视频卡顿、后台传不了文件这类问题会比预想中出现得更频繁。稳定的静态资源访问路径、统一的上传目录、清楚的日志输出这些底子打好了后面会省心很多。还有一个容易被忽视的点是移动端适配。前台大部分用户是用手机访问的我在首页和详情页做了响应式布局图片用懒加载视频播放器使用移动端友好的控件。甚至我把封面图数量控制在一个范围内因为移动端用户不会像桌面端那样滑动一堆图精选五张以内、张张清晰比堆二十张模糊的照片效果好得多。在调试前端时Vue DevTools 插件是必须装的它能在浏览器里直接查看组件数据和路由状态排查问题效率翻倍。有一次视频一直播不出来我就是在 DevTools 里看到视频源 URL 拼接错误几分钟就定位了比盲猜快太多。如果你准备照着这个思路做一个类似的平台我建议先把内容从哪来、谁去维护、展示给谁看这三个问题想清楚。技术选型、代码结构都可以照抄参考但内容运营的定位决定了信息架构。信息架构顺了后面的开发就是执行层面的事情。最后再分享一个小技巧后台的一键上架/下架和排序功能虽然实现成本不高但实际使用频率非常高。我在几个类似项目里都发现内容维护者最怕的不是操作复杂而是功能不可控——发错了不能下架、想置顶又要找开发。所以做展示平台时把内容状态管理和排序做灵活一些用户会真心觉得这个系统好用。这大概也是这类项目交付后能被对方持续用起来而不是闲置的最重要原因。
