资讯详情

Spring Boot+Vue前后端分离开发:摄影知识网站实战解析

📅 2026/10/19 6:17:16 | 华诺云谱 👁 阅读
Spring Boot+Vue前后端分离开发:摄影知识网站实战解析
我一直觉得内容型网站最值得做的第一个业务就是摄影知识。它既有明确的分类结构又有大量的图片展示还天然依赖关键词检索——这三个需求放在一起几乎能把前后端分离开发中遇到的典型问题都覆盖到。所以我围绕 Spring Boot 做后端、Vue 做前端把这个摄影知识网站从需求到上线完整走了一遍。这篇文章没有停留在简单 CRUD 上重点在你真正动手时会卡住的那些细节。先说我的整体判断这个项目的核心不是技术炫技而是把内容这件事组织好。下面按我实际开发的顺序来讲顺便把数据库设计、接口约定、联调部署这些环节里翻过车的地方都交代清楚。1. 先从需求说起摄影知识网站到底要解决什么问题1.1 内容型网站的三条主线分类、检索、阅读摄影知识天然是金字塔结构。构图、用光、色彩、器材、后期每个大类下面还有小类比如用光下面可以拆出自然光、人造光、混合光。用户进来通常带着两种目的一种是想系统学某个方向一种是想搜一个具体问题。前者依赖分类导航后者依赖站内搜索。两个都不能瘸腿。分类这块要注意层级别太深。我第一版设计了三层分类结果前端导航和面包屑都要跟着处理层级关系分类管理后台也麻烦。后来改成两层一级分类固定二级分类作为子标签式的存在只在列表页筛选时出现。这样数据库表结构简单用户也不会迷路。检索是摄影知识网站比较容易做糙的地方。用户搜索的词往往很口语化比如夜景怎么拍“背景怎么虚化”“长曝光用什么参数”。这些词既不是分类名也不一定出现在标题里如果只匹配标题召回率会很低。我的做法是同时匹配标题、摘要、标签必要时再匹配正文的前几百个字符。文章量不大时用 LIKE 完全够不用急着上搜索中间件。阅读体验这条线被很多人低估。摄影文章的图片多、尺寸大排版稍不注意就会很乱。我要求在列表页只用缩略图详情页正文里的图片按需加载图片要有圆角和统一的最大宽度。参数描述尽量用表格或卡片样式呈现而不是堆在正文里。后面第五部分我会专门讲参数块怎么存。1.2 为什么前后端分离是这类项目最舒服的架构做内容站很容易陷入一个误区后台管理页面和前台展示页面都想放在一个单体工程里用模板引擎直接渲染。需求简单的时候这样很省事但一旦开始迭代就难受。前台改个图片懒加载后台的样式也跟着受影响运营要发文章开发要改代码两边完全缠在一起。前后端分离最直接的收益是分工干净。后端只出 JSON 接口前端不管数据从哪来、权限怎么校验只负责把页面画好。摄影知识网站的内容更新频率其实不低运营后台要经常上传图片、改文章这时候前端和后端并行开发、独立部署就很舒服。另一个隐藏收益是接口复用。我做完 Web 端后原本想加一个小程序版本后来虽然没有真做但接口层面几乎不需要改动。文章列表、详情、分类、搜索这些都是纯数据接口拿过去换个前端壳子就能用。如果你以后有移动端或小程序的想法前后端分离等于提前铺好了路。代价也不是没有。跨域、联调、Token 传递、Nginx 配置这些多出来的环节普通管理系统里根本碰不到。但这些问题都属于一次性成本配置好了就稳定运转。综合下来这种架构对内容型网站是净收益。2. 技术选型与整体设计Spring Boot 负责什么Vue 负责什么2.1 后端技术栈与选型理由后端我用的 Spring Boot 2.7.x搭配 JDK 8。很多人问为什么不上 3.x我的回答是项目不需要高性能新特性而 2.7 的生态兼容性最稳网上踩坑资料也多。如果你是新项目且没有历史包袱用 3.x 配合 JDK 17 也完全可以只是要注意部分框架版本对齐。持久层选了 MyBatis-Plus没有用 Spring Data JPA。原因很实际内容站的统计查询多比如按分类统计文章数、按标签聚合内容、按浏览量排序这些 SQL 用 MyBatis 写起来更直观。MyBatis-Plus 自带分页插件、条件构造器、逻辑删除省了很多重复劳动。数据库用 MySQL 8.0字符集统一 utf8mb4建表时就把排序规则设好避免后面中文乱码。接口文档我用的是 Knife4j也就是 swagger 的增强版。前后端分离的项目接口文档必须稳定输出不然联调阶段光靠口头沟通会疯掉。Knife4j 可以直接在浏览器里调试接口前端拿到接口地址后基本能自助对着文档联调。登录认证用了 JWT。这个项目其实大部分内容是公开的但后台管理需要权限。JWT 的好处是无状态后端不用维护 Session接口可以随便横向扩展。用户表和管理员表我没有分开建统一用一张 user 表加 role 字段区分第一版够用。2.2 前端技术栈与选型理由前端用的是 Vue 3 Vite Element Plus Pinia Vue Router Axios。Vue 3 的组合式 API 写起来比 Vue 2 清爽很多尤其是处理文章列表、详情页这种带多个数据请求的页面逻辑可以按功能拆分。Vite 在开发环境的启动速度是真的快改完代码热更新几乎秒开对调试体验提升很大。Element Plus 主要用于后台管理部分前台浏览页面我尽量自定义样式避免一看就是管理系统的味道。Pinia 负责存登录状态、用户信息、全局配置。Axios 做了一层简单封装请求拦截器自动加 Authorization 头响应拦截器统一解包 data遇到业务错误码直接弹提示。这样业务代码里不用每处都写错误处理。路由方面用了 history 模式URL 好看但也带来了部署时刷新 404 的问题。这个坑第四部分单独讲前端配置和 Nginx 配置缺一不可。2.3 数据库表设计文章、分类、标签、图集表结构我第一版设计得有点复杂后来砍掉不少。最终保留的核心表大概是这些表名用途关键字段category文章分类id, parent_id, name, sortarticle文章主体id, category_id, title, summary, content, cover, view_count, like_count, is_published, create_time, update_timetag标签id, namearticle_tag文章标签关联article_id, tag_idbanner首页轮播id, image_url, link_url, sortuser用户/管理员id, username, password, nickname, rolearticle_param摄影参数id, article_id, param_key, param_value文章正文我用 longtext 存储。内容采用 Markdown 格式前端用编辑器转为 HTML 展示这样排版简单也避免直接存 HTML 带来的 XSS 风险。封面图、内容里的图片统一放在外部存储路径数据库里只存 URL 或相对路径。为什么专门留一张 article_param 表而不是把参数塞进文章正文因为摄影文章的曝光参数具有结构化特征光圈、快门、ISO、白平衡这些字段未来可以做参数搜索比如找所有 f/1.8 光圈的教程。单独成表查询和聚合都非常方便。3. 后端核心实现文章发布与浏览链接的关键环节3.1 统一返回体与异常处理前后端分离最怕接口返回格式五花八门。我定义了一个通用 Result 类所有接口统一返回 { code, message, data } 结构。code 为 200 表示成功400 表示业务异常401 表示未登录500 表示系统错误。前端在响应拦截器里只判断 code其他一概不认。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); return r; } }异常处理我用 RestControllerAdvice 统一接管不把堆栈信息直接抛给前端。业务异常比如文章不存在、上传格式不支持可以在 Service 层手动抛出 BizException由全局处理器转成对应的 code 和 message。真正需要注意的点是全局异常处理里一定要记录日志尤其 500 错误不然线上出了问题你只能一脸懵。我见过不少项目把异常吞掉前端只收到一个系统错误后端日志里什么都没有排查起来非常痛苦。3.2 分类筛选、标签聚合、关键词搜索的 SQL 组织分类筛选最简单where category_id ? 加分页就行。但要注意的是如果分类有父子层级前端传过来一个子分类 id 时不要只过滤它本身关联到父分类的文章也可以展示。这里我用了一个笨办法查询时先判断当前分类有没有子分类有的话把子分类 id 列表一起放进 IN 条件。标签聚合要走三表关联。比如点击长曝光标签拉出所有打了这个标签的文章SELECT a.* FROM article a INNER JOIN article_tag at ON a.id at.article_id INNER JOIN tag t ON at.tag_id t.id WHERE t.name 长曝光 ORDER BY a.create_time DESC搜索接口的 SQL 长这样SELECT * FROM article WHERE is_published 1 AND (title LIKE CONCAT(%, #{keyword}, %) OR summary LIKE CONCAT(%, #{keyword}, %) OR tags LIKE CONCAT(%, #{keyword}, %)) ORDER BY create_time DESC我第一版把 tags 字段直接冗余在了 article 表里用逗号拼接搜索和展示都方便。标签聚合需求不高的时候这种方式比三表关联更省事。如果以后标签要做得复杂再拆关联表也行。浏览量更新这里要提醒一下不要写成先 select 再 update。高并发下两个请求读到的都是旧值最后只会 1。直接用一条自增 SQLUPDATE article SET view_count view_count 1 WHERE id #{id}这样既保证原子性又少一次查询。3.3 图片上传与访问映射的落地方式摄影网站的图片是命脉上传功能我单独做了一整套。文件存储没有放在项目 classpath 里而是独立目录方便备份和迁移。接收方式就是 MultipartFile校验文件类型和后缀并限制大小。public String uploadImage(MultipartFile file) { if (file.isEmpty()) { throw new BizException(文件不能为空); } String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); ListString allowed Arrays.asList(.jpg, .jpeg, .png, .gif, .webp); if (!allowed.contains(suffix.toLowerCase())) { throw new BizException(不支持的图片格式); } // 使用 UUID 生成文件名避免中文和重复名问题 String fileName UUID.randomUUID() suffix; // 按日期分目录避免单个目录文件过多 String datePath LocalDate.now().toString().replace(-, ); Path path Paths.get(uploadPath, datePath, fileName); Files.createDirectories(path.getParent()); file.transferTo(path); // 返回给前端的访问路径 return /upload/ datePath / fileName; }访问映射要单独配置让 /upload/** 指向本地磁盘目录。不加这步前端访问图片地址会 404。配置方式有两种一种是 Spring 的 resources 静态映射一种是 WebMvcConfigurer 的 addResourceHandlers。我更喜欢后者逻辑更清晰。Configuration public class UploadConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }图片上传成功后前端拿到相对路径 /upload/20250101/xxx.jpg。开发环境直接用后端地址拼生产环境通过 Nginx 统一加前缀。这样即便以后换对象存储前端代码也不需要大改。4. 前端页面与后端接口的对接细节4.1 跨域问题到底怎么配才不烦人前后端分离第一道坎就是跨域。开发环境我首选 Vite 的 proxy 代理而不是直接开启后端 CORS。原理很简单浏览器请求的是前端开发服务器地址由 Vite 代理向后端转发浏览器认为请求是同源的根本不触发跨域。// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }后端我也配了一套 CORS主要用来兜底比如前端直接用 IP 访问后端接口的场景。配置时要特别注意两点一是自定义请求头比如 Authorization必须出现在 allowedHeaders 里二是预检请求 OPTIONS 不能被拦截否则前端跨域请求会飘红。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }生产环境更推荐用 Nginx 做反向代理前端静态资源也由 Nginx 托管所有 /api/ 请求转发到后端。这样浏览器和接口同源跨域问题彻底消失。这个方案最省心也是我最终线上的配置方式。4.2 文章列表、详情、图片列表的交互设计前端接口调用我统一放在一个 api.js 文件里每个接口一个函数命名跟后端保持一致。首页需要拉 banner、最新文章、热门文章、热门标签四个接口我用 Promise.all 并行请求避免串行等待拖慢首屏。文章列表页用卡片网格每张卡片包含封面缩略图、标题、摘要、浏览数和发布时间。点击分类 Tab 时切换 categoryId 重新拉数据。分页走的是 MyBatis-Plus 的 Page 对象前端传 current 和 size后端返回 records 和 total再配一个分页组件就能跑起来。详情页是最花心思的。面包屑导航显示当前分类路径标题下面展示作者、发布时间和浏览量正文区渲染 Markdown文章底部放标签列表。图片区域我单独做了灯箱组件点击图片放大支持左右切换这对摄影作品展示很重要。点赞功能第一版没有做登录限制只做了简单计数。前端防抖之后调用接口后端用 update 自增避免连点导致频繁请求。收藏功能需要用户表我放在后台管理权限做完以后才补上。4.3 前端路由与刷新404这个问题我实际部署时踩过一次。Vue Router 用 history 模式后开发环境一点问题没有部署到服务器才发现用户在首页点进详情页正常但直接刷新详情页就 404。原因很简单Web 服务器里没有对应的真实文件路径请求发给后端了后端没有这个路由只能返回 404。解决方法是 Nginx 配置 try_files让所有前端路由都回退到 index.html把路由解析交给前端location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }还有一个容易被忽视的细节Vite 构建出的静态资源默认使用绝对路径 /assets/xxx.js。如果网站部署在域名根路径没问题但如果部署在子目录比如 /photo/就要在 vite.config.js 里配置 base: /photo/否则资源全部 404。项目不大时建议直接部署在根路径省掉这份麻烦。5. 摄影知识的特殊内容处理5.1 参数块与图文混排的结构化存储摄影教程和普通文章最大的区别在于它有一套标准化的拍摄参数。光圈、快门、ISO、白平衡、焦距、测光模式这些信息如果混在正文里读者很难一眼看到关键数据。我的方案是文章正文之外额外在后端存一份参数表。每个参数变成一行记录包含参数名和参数值。前端加载文章详情时同时请求文章详情接口和参数列表接口页面上用一个样式鲜明的卡片区域展示参数。这样有几个好处参数不会因为改正文被误删参数可以单独做搜索筛选以后想导出相册参数也方便。正文的图文混排也有讲究。摄影文章的图片通常有构图示意、对比效果、原图展示我要求所有图片上传后引用绝对路径或完整路径不要用相对路径写死。图片和文字之间留出间隔图片下方可以写一段图注这部分我用 Markdown 的引用语法前端渲染成带样式的说明文字。5.2 图片性能懒加载、压缩、CDN 思考内容型网站最怕一个页面几十张原图直接加载用户等得心慌。我的处理策略分三层。上传层保存图片时同步生成一个压缩缩略图列表页只用缩略图详情页首屏的封面也用缩略图只有用户点击放大时再加载原图。这里用的工具是 Thumbnailator代码大概三行Thumbnails.of(inputStream) .size(800, 600) .outputQuality(0.8) .toFile(thumbnailPath);展示层列表页图片加懒加载我直接用了原生 loadinglazy 属性简单可靠。详情页正文里的图片也用 loading 属性并给容器设置固定宽高比防止图片加载过程中页面上下跳动。存储层第一版图片全部在本地磁盘单机够用。如果文章量上去建议把上传接口改造成对接对象存储数据库里保存的还是相对路径前端拼接 CDN 域名。对象存储的优势不只是容量更在于图片的访问不占用应用服务器带宽Nginx 也不用单独做缓存。5.3 后台管理方案的取舍第一版我对后台没有做得很重核心就四个功能登录、文章管理、分类管理、图片上传。很多内容站其实不需要复杂的工作流运营就一个人发布、编辑、撤下这几个操作做好就够。登录用 JWT。用户输入用户名密码后端校验成功后返回 Token前端存在本地存储里。响应拦截器检测到 401 时自动跳转登录页。路由守卫里对后台页面做校验没登录就重定向。这个方案不用写服务端 Session接口天然支持状态隔离。文章管理页面有文章列表、新建按钮和编辑入口。新建页面包含标题、分类、封面、摘要、正文、标签、是否发布几个字段。上传图片组件复用同一个上传接口选择图片后返回 URL 并插入正文。因为用 Element Plus整套管理界面开发速度很快。我不建议第一版就拆分独立的后台系统比如单独起一个 admin 前端工程。项目初期内容不多维护成本翻倍收益却很低。等运营需求复杂了再拆分也不迟因为你用的是前后端分离架构后台和前台本来就是同一套接口拆分更多是工程结构调整不是推倒重来。6. 实测中踩过的几个坑与排查过程6.1 LocalDateTime 序列化与前端时区后台接口返回的文章创建时间是 LocalDateTime前端一开始拿到是个数组类似 [2025, 1, 1, 10, 30, 0]完全没法直接用。问题出在 Jackson 序列化 LocalDateTime 时默认格式不友好。全局配置一个简单的序列化规则就能解决Bean public Jackson2ObjectMapperBuilderCustomizer customizeJackson() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss) )); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss) )); }; }这个配置还有个隐藏好处时间字段变成字符串后前端展示直接拿来用不用再做 dayjs 格式化。有一次我见过同事在响应拦截器统一加 8 小时就是因为他发现后端返回的时间比前端早 8 小时实际原因是数据库时区配置不对和序列化没关系。排查时要记得区分是时区问题还是格式问题。6.2 MySQL 的 ONLY_FULL_GROUP_BY写分类统计接口时遇到一个经典报错Expression #2 of SELECT list is not in GROUP BY clause。这个 SQL 想在分组统计文章数的同时查出分类名称SELECT c.name, COUNT(a.id) FROM category c LEFT JOIN article a ON a.category_id c.id GROUP BY c.idMySQL 5.7 以上默认开启了 ONLY_FULL_GROUP_BYselect 的字段必须出现在 group by 子句里或者被聚合函数包裹。解决方案有两种。一种是调整 SQL让 select 里只保留 group by 的字段和聚合函数SELECT c.id, COUNT(a.id) AS cnt FROM category c LEFT JOIN article a ON a.category_id c.id GROUP BY c.id然后通过关联查询把分类名称补上或者干脆在查询后由前端根据 id 映射名称。另一种是临时关掉这个模式但我不推荐因为会影响全局 SQL 的规范性。处理这类问题最好的养习惯就是报错时先把业务 SQL 放到数据库工具里跑通再翻译到 ORM而不是直接在代码里试。6.3 文件上传大小限制与临时目录开发环境上传一个 3MB 的摄影图片报错控制台提示超过了限制。Spring Boot 默认上传大小是 1MB需要在配置里调大spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB还有一个更隐蔽的坑上传路径如果配成临时目录比如默认的 /tmp/upload服务器重启后文件可能还在但一段时间后系统清理临时目录图片就神秘消失了。生产环境一定要把图片目录配置到独立的数据盘不要用系统临时目录。我上线时把这个目录放到应用外部并用环境变量注入路径这样换服务器只要改配置不用动代码。图片上传成功但访问 404 的排查思路先看文件是否真实落在磁盘再看访问路径和虚拟映射是否一致最后检查权限。很多时候是开发时设置的绝对路径和服务器路径不一致导致路径对不上。一点收尾的个人体会这个项目做完我最大的感受是内容型网站的难点从来不在单个接口有多复杂而在于内容结构的组织方式和前后端协作的契约意识。摄影参数拆成结构化数据、图片上传统一走一套流程、接口返回格式一开始就定死这几件事做好后面开发会非常顺。如果后面还想继续扩展我觉得可以做三个方向第一个是 EXIF 读取上传图片后自动解析拍摄参数减少手工录入第二个是同款镜头或同题材文章的关联推荐第三个是把标签升级成真正意义上的专题比如夜景拍摄全攻略这类聚合页对摄影知识网站的价值会非常高。最后分享一个小技巧不要用 create_time 做最新文章排序用 update_time 更合理。文章修订后更新 update_time列表自然沉到最前发布草稿时不要更新这个字段避免列表频繁跳动。这个细节对内容运营的体感影响很大。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑