我一直觉得,内容型网站最值得做的第一个业务就是摄影知识。它既有明确的分类结构,又有大量的图片展示,还天然依赖关键词检索——这三个需求放在一起,几乎能把前后端分离开发中遇到的典型问题都覆盖到。所以我围绕 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, sort |
| article | 文章主体 | id, category_id, title, summary, content, cover, view_count, like_count, is_published, create_time, update_time |
| tag | 标签 | id, name |
| article_tag | 文章标签关联 | article_id, tag_id |
| banner | 首页轮播 | id, image_url, link_url, sort |
| user | 用户/管理员 | id, username, password, nickname, role |
| article_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 Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(Integer code, String message) { Result<T> 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。直接用一条自增 SQL:
UPDATE 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(".")); List<String> 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);展示层:列表页图片加懒加载,我直接用了原生 loading="lazy" 属性,简单可靠。详情页正文里的图片也用 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_BY,select 的字段必须出现在 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,列表自然沉到最前;发布草稿时不要更新这个字段,避免列表频繁跳动。这个细节对内容运营的体感影响很大。