这个平台项目我做完有一阵子了,最近整理技术笔记的时候翻出来,觉得里面有不少可以复用的思路。当时是帮某艺术机构搭一套基于 SpringBoot + Vue 的艺术作品展示平台,需求很典型:线上展厅、作品集浏览、艺术家入驻、作品上传与审核、用户点赞评论。前后端分离架构,三周完成主体开发,最后部署到一台云服务器。如果你也在做作品展示、文创、艺术教育类的Web平台,这篇文章从需求拆解、表设计、接口规范到部署踩坑都有涉及,可以直接对照着做。
1. 项目定位与整体设计思路
1.1 这个平台到底解决了什么问题
这类展示平台的核心场景不是简单的"放一堆图片",而是要把艺术作品的展示流程线上化。线下办展需要场地、布展、导览,成本高且辐射范围有限,线上平台则要让作品能被更多人看到、能被检索、能被互动,同时还要解决版权和审核问题。所以我给这个项目拆出了三个核心闭环:
- 作品生产端:艺术家注册入驻、上传作品、维护个人主页。
- 作品运营端:管理员审核作品、分类管理、数据统计、推荐位配置。
- 作品消费端:访客浏览、搜索筛选、点赞收藏、评论交流。
这三个闭环覆盖了"内容从哪来、怎么管理、如何被消费"的完整链路。很多人在做类似项目时容易犯的错是只做展示不做管理,导致后台上传全靠手工改数据库,这在实际运营中完全不可接受。
1.2 需求分析阶段的关键取舍
需求评审时和机构方来回磨了两轮,最后定了几个关键决定。第一个是前台以浏览体验为优先,不搞强制注册登录,游客可以看作品、看详情,但点赞、评论、收藏必须登录。第二个是后台上传的作品必须经过审核才能出现在前台,避免出现不合规内容。第三个是移动端优先,因为实际看展的人大部分用手机。
这三个决定直接影响后续的表设计和接口设计。比如游客可浏览意味着列表接口不能依赖用户身份,审核状态必须单独用一个字段控制展示逻辑,移动端优先则意味着图片压缩和懒加载要提前规划,不能等上线了再优化。
1.3 为什么选 SpringBoot + Vue 这对组合
选型时对比过几个方案:纯模板渲染(比如 Thymeleaf + JQuery)、Node全栈、SpringBoot + Vue。最后定前者是因为这个项目需要同时承担PC和移动端的访问,前后端分离后一套接口可以喂两套前端(后期如果做小程序也能直接复用)。而且机构方后续可能要接入排期系统、库存系统,SpringBoot的生态对接这些东西省事。Vue这边选了Vue 3 + Vite + Element Plus,组件库成熟,出页面速度快,团队成员熟悉度高。
说到底,选型不是追求技术新颖,而是看团队熟不熟、生态全不全、后期好不好维护。SpringBoot的约定优于配置极大减少了样板代码,Vue的响应式机制做图片列表这类交互密集的页面比传统的字符串拼接舒服太多了。
2. 数据库设计:核心表结构与字段规划
2.1 五张核心表的建模思路
数据库是这个项目最值得提前花时间的部分。当时设计时围绕用户、作品、交互、审核四条线建了五张核心表。
用户表做了角色区分,用 role 字段控制 USER、ARTIST、ADMIN 三类身份,这样避免建多张用户表导致关联复杂。艺术家信息(简介、代表作链接、个人标签)当时选择直接冗余在用户表的扩展字段里,没有单独拆表。拆表的劣势是每次查艺术家详情都要join,而且很多艺术家其实只发三五件作品,拆表的收益不高。当然了,如果你的平台要做复杂的艺术家认证审核流程,那单独建模是必要的。
作品表是核心中的核心,字段设计上踩过一个坑:最初把分类直接做成字符串字段,后来发现统计"每个分类下有多少作品"这种需求时特别难受,最后还是拆了分类表用外键关联。
交互这块,点赞和收藏当时没有分开建两张表,用了同一张 user_artwork_interaction 表,通过 type 字段区分。后来验证这个设计遇到了性能瓶颈,但前期数据量小完全够用,而且用户取消点赞、重新点赞这类操作逻辑也统一了。评论表单独建,因为评论有分页加载和嵌套回复的扩展需求。
2.2 作品表字段设计与状态机
作品表部分字段如下:
CREATE TABLE artwork ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '上传者ID', title VARCHAR(100) NOT NULL COMMENT '作品标题', category_id BIGINT NOT NULL COMMENT '分类ID', style VARCHAR(50) COMMENT '艺术风格', description TEXT COMMENT '作品描述', image_url VARCHAR(255) NOT NULL COMMENT '原图地址', thumbnail_url VARCHAR(255) COMMENT '缩略图地址', width INT COMMENT '作品宽度(cm)', height INT COMMENT '作品高度(cm)', create_year VARCHAR(20) COMMENT '创作年份', status TINYINT DEFAULT 0 COMMENT '0待审核 1已展示 2已下架 3审核驳回', view_count INT DEFAULT 0, like_count INT DEFAULT 0, created_at DATETIME, updated_at DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;status 字段是整个平台流转的核心。从上传到展示的完整链路是:艺术家创建作品记录(status=0,待审核)→ 管理员在后台审核(通过则 status=1,驳回则 status=3 并填驳回原因)→ 运营可手动下架(status=2)。前台列表只查询 status=1 的数据,这个规则在后端接口和前端页面都要严格执行,不能只在页面上过滤,否则接口被直接调用时就露馅了。
2.3 图片存储方案的选择
图片处理是作品展示平台最重要的技术点之一。当时机构方没有对象存储的预算,所以我选了本地存储 + Nginx 静态映射的方案。图片上传路径按日期分目录,比如/data/artwork/2025/06/,每天一个文件夹,避免单目录文件过多。
缩略图处理用了 Java 的 Thumbnator 库,上传原图后自动生成宽度800的等比缩略图。这里有几个参数值得注意:缩略图质量参数设置为0.85,实测体积能压缩70%以上,画质肉眼几乎无差别;原图保留用于详情页的放大查看。如果直接用原图做列表缩略图,首页一次加载20张图每张3MB,移动端直接卡死,这也是很多类似项目翻车的地方。
3. 后端接口设计与核心逻辑实现
3.1 RESTful 接口设计与统一返回结构
后端接口遵循 RESTful 规范,统一以/api开头,按模块划分资源路径。用户模块/api/user/**,作品模块/api/artwork/**,后台管理模块/api/admin/**,上传模块/api/upload/**。
所有接口的返回结构统一封装成 Result 对象,包含 code、message、data 三个字段:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }这个统一返回结构很重要,前端 Axios 拦截器只需要判断 code 是否为 200 即可,异常处理都在全局异常处理器里统一转换成 Result,避免前端到处 try-catch。业务异常用自定义 BusinessException 抛出,参数校验异常用 Spring 的 BindException 捕获,这样接口层非常干净。
3.2 登录认证:JWT 方案与权限拦截
登录认证选了 JWT(JSON Web Token)方案,没有引入 Spring Security 全家桶,而是基于拦截器实现了一个轻量级认证框架,原因在于项目权限模型比较简单,用 Security 反而增加配置复杂度。具体实现分四步:
- 用户登录成功后,后端生成 JWT 返回给前端,Token 中只存放 userId 和 role,不存敏感信息。
- 前端将 Token 存储在 localStorage,每次 Axios 请求时从拦截器里取出并放到 Authorization 请求头。
- 后端定义一个 AuthInterceptor,拦截除白名单外的所有
/api/**请求,解析并校验 Token,将用户信息放入 ThreadLocal。 - 需要管理员权限的接口,在业务层判断 ThreadLocal 中用户角色的值,不是 ADMIN 直接抛权限异常。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); // 这里实际项目应从缓存中校验 Token 是否有效, // 避免用户注销后 Token 仍能使用 if (!StringUtils.hasText(token)) { throw new BusinessException(401, "未登录"); } // 解析 token,将 userId 存入 UserContext UserContext.set(userId); return true; } }这里必须提醒一个坑:JWT 无状态特性导致注销和封禁无法即时生效。当时做用户封禁功能时发现,被封禁用户的 Token 依然能通过校验,因为后台只验证了签名有效性。后来加了一层 Token 黑名单机制,保存 Token 的 jti 标识,注销或封禁时写入 Redis 黑名单。如果你用 JWT 且涉及用户封禁功能,这个一定要提前考虑,不能只靠过期时间。
3.3 分页查询与筛选条件的组合处理
作品列表是访问最频繁的接口,我用了 MyBatis-Plus 的 LambdaQueryWrapper 做动态条件拼接,支持分类、风格、创作年份和标题关键字四个筛选维度。分页参数 page 和 pageSize 做了上限控制,pageSize 最大 40,防止别人传个 10000 把数据库打爆。
public PageResult<ArtworkVO> listArtworks(ArtworkQuery query) { LambdaQueryWrapper<Artwork> wrapper = Wrappers.lambdaQuery(); wrapper.eq(Artwork::getStatus, 1) // 只查已展示状态 .eq(query.getCategoryId() != null, Artwork::getCategoryId, query.getCategoryId()) .like(StringUtils.hasText(query.getKeyword()), Artwork::getTitle, query.getKeyword()); wrapper.orderByDesc(Artwork::getCreatedAt); Page<Artwork> page = artworkMapper.selectPage( new Page<>(query.getPage(), query.getPageSize()), wrapper); // 转换 VO,补充缩略图完整路径、作者信息等 return convertToPageResult(page); }这里有个性能细节:列表接口不要 select *,只查列表所需的字段。因为作品表里 description 可能是几百上千字的大字段,列表页用不到,全查出来会显著增加传输体积和数据库 IO。用wrapper.select()指定查询列,或者干脆建 VO 用自定义 SQL 只取需要的列。
3.4 图片上传接口与文件校验
上传接口是后端最容易出安全问题的地方,简单说三点实践。第一,文件类型校验不能只判断扩展名,要读取文件头(Magic Number)判断真实类型,防止改后缀上传脚本文件。第二,文件大小限制要通过 Spring 的多文件配置和业务层双重校验,单张图片我限制为5MB。第三,上传后的文件名绝对不能用用户原始文件名,用 UUID + 时间戳重新生成,防止路径穿越。
spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MB上传成功后将访问路径返回给前端,前端拿这个路径拼到作品表单数据里一起提交,这样作品创建和图片上传是分离的。如果先传图再建记录,万一用户中途放弃会留下大量孤儿图片文件,后续要定期清理临时目录。
4. 前端核心页面与交互实现要点
4.1 Vue 项目结构与路由设计
前端用的 Vue 3 Composition API 写法。项目结构按模块划分,不是按页面类型划分:
src/ ├── api/ # 接口请求封装 │ ├── artwork.js │ ├── user.js │ └── admin.js ├── router/ # 路由配置 ├── stores/ # Pinia 状态 │ ├── user.js │ └── app.js ├── views/ │ ├── home/ # 前台首页 │ ├── artwork/ # 作品列表、详情 │ ├── user/ # 个人中心、登录注册 │ ├── artist/ # 艺术家主页 │ └── admin/ # 后台管理 └── components/ # 通用组件路由加了一个全局前置守卫,判断访问后台管理页面时用户是否已登录且角色为管理员,否则重定向到登录页。这里有个交互细节:守卫只做前端路由级拦截,真正安全还是要靠后端接口权限控制,前端拦截只是用户体验层面的东西,不能让用户看到闪现的空白页。
4.2 画廊列表页的滚动加载与性能优化
作品列表页没有用传统分页组件,而是用了滚动到底自动加载的模式,更符合画廊浏览场景。用 VueUse 的useIntersectionObserver监听底部占位元素的可见性,触发加载下一页:
<script setup> const page = ref(1) const list = ref([]) const loading = ref(false) const finished = ref(false) const loadMore = async () => { if (loading.value || finished.value) return loading.value = true const res = await getArtworkList({ page: page.value, pageSize: 20 }) list.value.push(...res.data.records) page.value++ if (page.value > res.data.pages) finished.value = true loading.value = false } </script>图片懒加载用了原生loading="lazy"属性,这是性价比最高的方案,不需要额外引库。列表图片外层包裹了固定宽高比的容器,用aspect-ratioCSS 属性预留空间,避免滚动时页面布局反复跳动。另外一个经验:桌面端网格列数用 CSS Grid 自适应,但移动端强制两列展示,这两列模式下缩略图尺寸控制在 400px 内,移动网络环境下加载速度会快很多。
4.3 作品详情页的图片放大与交互反馈
详情页的核心是查看大图。原图文件往往有几MB,直接铺在页面上会卡,所以我用了图片分档加载策略。详情页默认展示缩略图,提供一个"查看原图"按钮,点击后再加载原图地址,加载期间显示骨架屏。原图打开后支持双击或者点击左右区域放大查看细节,实现方式是简单的 overlay 弹层,没有引入复杂的查看器库。这里的小技巧是弹层中图片不能用width: 100%直接铺满,应该按图片原始宽高等比缩放,超出屏幕时允许滚动或缩放,否则竖构图的画被横屏撑开后完全没法看。
点赞和收藏按钮做了防重复提交处理。用户点击点赞后立即更新 UI 状态并置灰,同时向接口发送请求,如果请求失败再回滚 UI。这个"乐观更新"模式在移动端网络抖动时体验远好于等接口返回再更新。
4.4 后台管理页面的表格与统计数据
后台管理用的是 Element Plus 的 Table 组件,配合标签页做审核流程。这里值得一提的不是表格本身,而是管理员的快速审阅设计。审核列表每行支持"通过/驳回"两个操作,驳回时弹窗填写原因,前端会把原因带上,前端创作者中心会展示审驳原因方便修改后重新提交。另外用 ECharts 做了三个统计图:作品分类分布饼图、近30天作品上传趋势折线图、用户增长柱状图。统计接口用 SQL group by 聚合,注意聚合查询一定要在数据库层完成,不要在 Java 内存里循环算,数据量上来之后性能差别很大。
5. 测试部署与高频问题排查
5.1 前后端分离部署的具体方案
部署环境用的是单台云服务器,4核8G配置,跑了 Nginx、MySQL 8.0、后端 jar 包三个进程,常规访问量完全够用。
后端直接mvn package打成 jar 包,用 systemd 管理进程,systemctl start artwork-server就能启动。重点说 Nginx 配置,两个核心点:静态资源映射和前端路由 history 模式处理。
server { listen 80; server_name your-domain.com; # 前端页面 location / { root /data/artwork-web/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 作品图片静态资源 location /files/ { alias /data/artwork-upload/; expires 7d; add_header Cache-Control "public, max-age=604800"; } }try_files那行是关键,没有它 Vue Router 的 history 模式在刷新非首页路径时会直接 404。部署完最好手动刷新几个深层路径测试一遍,这是最常见的部署后翻车点。
5.2 跨域、时间与上传问题排查实录
项目开发期和上线初期遇到的高频问题,整理成了一份速查表:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 前端请求接口报 CORS 错误 | 前后端分离后源不同 | Nginx 代理同源部署,后端无需额外开启 CORS |
| 数据库时间比实际晚8小时 | MySQL 时区与服务器不一致 | JDBC URL 加serverTimezone=Asia/Shanghai |
| 上传大图后列表加载慢 | 缩略图未生成或质量过高 | Thumbnator 压缩到800px宽、质量0.85 |
| 刷新页面404 | history 路由未配置 fallback | Nginxtry_files指向 index.html |
| 审核后前台看不到作品 | Redis/本地缓存未失效 | 审核操作后主动清理对应缓存 key |
| Token 过期后接口报401白屏 | 前端未统一处理401跳转 | Axios 响应拦截器捕获401自动跳登录页 |
跨域问题这里多说一句,开发环境我用了 Vite 的 proxy 配置代理/api到后端服务,生产环境用 Nginx 同源转发,整个项目完全没有在 Spring 里配置过 CORS 跨域。很多初学者喜欢在后端加@CrossOrigin注解或者 CORS 过滤器,这在生产环境配合 Nginx 部署时反而容易出问题,建议从方案上消除跨域而不是解决跨域。
图片上传排查遇到过一个有意思的坑:某次上传的图片在浏览器里能看到,但缩略图生成失败,日志报NullPointerException。排查后发现是用户上传的图片文件名带中文,Thumbnator 读取时编码出问题。后面统一在生成缩略图前用 UUID 重命名原文件,这个隐患消除了。你如果也做文件处理,强烈建议任何涉及文件名的逻辑统一用英文/数字命名。
5.3 上线后的数据统计与性能观察
平台上线后跑了两个月,核心观察数据可以给后续做类似项目的人参考。作品列表接口单次查询耗时在 30ms-80ms 之间,20条数据列表页的完整请求响应约 120ms,符合预期。访问量高峰期在晚上 8 点到 10 点,QPS 峰值约 40,4核8G的服务器 CPU 占用峰值 30%,MySQL 慢查询日志中没有出现超过 500ms 的查询。
这个量级下其实还没有到需要上 Redis 缓存的阶段,但我当时给列表接口和首页接口加了基于 Spring Cache 的本地缓存,TTL 设置 5 分钟,效果显著,列表接口的响应直接降到 10ms 以内。如果你的平台预期访问量更大,建议把缓存替换成 Redis 并处理缓存穿透问题,否则高并发下数据库压力会很明显。
写在最后
这个项目做下来,我最大的感受是艺术品展示平台的技术难点不在"高深",而在"细致"。图片处理、状态流转、权限控制、部署路由,每一个点单独看都不复杂,但串起来就能看出一个项目是否真的经过实战打磨。如果你正在做或者计划做类似的项目,建议先花两天时间把表结构和状态流转画清楚,再动手写代码,这比上来就敲 CRUD 省的时间多得多。
另外分享一个个人习惯:每次给这种内容展示类平台做上线前的最后检查,我都会把首页、详情页、上传页面全部模拟一遍弱网环境测试(Chrome 开发者工具可以限速),因为艺术作品类网站的体验瓶颈几乎都出在图片加载上。这个测试动作帮我提前发现过好几次大图加载导致的页面卡死问题,你也值得试一试。