ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot 3 + Vue 3 图片相册分享系统全栈实战:从上传到分享的完整实现

2026/9/9 22:12:34 拓冰建站 浏览量
Spring Boot 3 + Vue 3 图片相册分享系统全栈实战:从上传到分享的完整实现 前后端分离的项目这两年基本成了标配但真正把 Spring Boot 3 Vue 3 这套组合从头到尾跑一个完整业务闭环还是得亲手做个像样的项目。最近我花了大概两周时间把一个图片相册分享系统视觉内容服务平台从老技术栈整个翻新了一遍后端 Spring Boot 3前端 Vue 3 Vite TypeScript从需求拆解、数据库设计、接口联调到部署上线的全流程都走完了。这篇就把这套系统的完整实现方案写出来包括技术选型背后的逻辑、核心模块的代码设计、关键参数的设置依据以及那些文档里不会写但实际操作中一定会遇到的坑。如果你正准备做类似项目或者手上还有老项目计划升级到 Spring Boot 3 / Vue 3这篇文章应该能帮你省下不少排查时间。1. 项目概述与整体设计思路1.1 这个系统到底解决什么问题图片相册分享系统说通俗点就是一个个人图床 相册管理 一键分享的视觉内容服务平台。核心业务场景可以归纳成几条用户注册登录后拥有自己的图片空间可以创建多个相册比如旅行日记产品素材库对图片做分类管理支持单张/批量上传图片系统自动生成缩略图和图片宽高信息相册内以瀑布流或网格形式浏览图片兼顾美观和性能相册支持生成分享链接分享给朋友或同事对方无需登录就能浏览分享链接可以设置有效期过期自动失效这个系统的难点不在 CRUD而在图片本身上传怎么处理大文件、列表怎么保证加载速度、分享怎么控制访问权限这几件事是图片类应用的通用问题。把这个项目做完你会发现这些能力是可以横向复用到电商图库、社交平台、内容管理系统里的这是它作为视觉内容服务平台的价值所在。1.2 为什么最终选 Spring Boot 3 Vue 3选型之前我把老技术栈Spring Boot 2.x Vue 2和新技术栈做了对比结论很明确除非团队 JDK 版本实在升不上去否则直接用 3 是更划算的长期决策。先从后端说Spring Boot 3 有几个关键变化强制要求 JDK 17这意味着可以放心用record、sealed class、switch表达式这些新语法代码确实能少写不少样板包名从javax.*全面迁到jakarta.*这个迁移影响面很大很多老依赖都会不兼容但也倒逼项目依赖更新到安全版本Spring Security 6 的配置方式变化明显基于 Lambda DSL 的写法比之前的链式调用更清晰官方开始支持 GraalVM 原生镜像虽然目前生产环境用得还不多但给未来预留了技术空间前端方面Vue 3 的组合式 API 对我来说是最大的吸引力。Vue 2 时代逻辑复用主要靠 mixin项目一大就经常出现命名冲突和来源不明的数据Vue 3 把逻辑拆到setup函数里配合computed、watch、自定义组合函数代码的归属感和可维护性都明显更好。再叠加 TypeScript 支持、Proxy 响应式、Tree-shaking 这些性能优化Vue 3 已经没有理由不选。1.3 整体架构与前端路由设计整体架构是标准的前后端分离前端 Vue 3 用 Vite 构建通过 Axios 调后端 RESTful API后端 Spring Boot 3 提供接口MyBatis-Plus 操作 MySQL图片文件保存在服务器本地目录数据库只存 URL 路径和元数据。前端的路由结构在设计阶段就要把哪些页面需要登录、哪些页面完全公开定清楚/login、/register公开页面/主布局需要登录子路由包含相册列表、相册详情、上传页面/s/:code分享落地页公开访问通过分享码拉取相册数据这里我踩了一个不算坑但容易忽略的设计细节分享落地页不能被全局的登录守卫拦住否则 B 用户拿到分享链接还要先登录分享就失去意义了。所以路由守卫里要加一个meta.public标记对公开页面做放行。2. 后端核心实现Spring Boot 3 落地细节2.1 工程初始化与关键依赖配置Spring Boot 3 的项目初始化推荐直接用 start.spring.io 生成省得手工配版本。这里有一个很重要的版本约束JDK 必须 17我本机用的 OpenJDK 17Maven 建议 3.6否则插件解析会报错。pom.xml 里除了spring-boot-starter-web我额外加了几个常用依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyJWT 方面我第一版用了网上大量教程里的jjwt 0.9.1结果启动后用一次就报java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter。原因是 JDK 17 移除了 JAXB 模块而旧版 jjwt 还在依赖它。后来我把 jjwt 升到0.12.x才彻底解决新版 API 也顺手给你参考// jjwt 0.12.x 推荐写法 SecretKey key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); String token Jwts.builder() .subject(username) .claim(userId, userId) .claim(role, role) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() expire)) .signWith(key) .compact(); // 解析 Claims claims Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload();提示如果你照着老教程用jjwt 0.9.1遇到DatatypeConverter报错最快的解决方式是升级 jjwt 版本不要往项目里塞 jaxb-api 的补丁依赖那只是治标不治本。application.yml里传参和 JWT 配置也提前统一了设计spring: datasource: url: jdbc:mysql://localhost:3306/photo_album?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 50MB max-request-size: 100MB mybatis-plus: configuration: 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 jwt: secret: your-secret-key-should-be-long-enough expire: 604800注意的是jwt.expire单位是秒604800 秒正好 7 天。这个参数不要拍脑袋填要考虑用户使用习惯——纯图片浏览场景的会话期望通常是一周以内不用重复登录。2.2 数据库设计与核心表结构图片相册系统的表结构比普通业务系统要多一层思考图片的元数据宽高、大小、缩略图路径直接决定了前端瀑布流能不能渲染所以这些字段必须存下来。表我设计了四张user用户表album相册表photo图片表share_info分享信息表建表 SQL 的关键部分如下CREATE TABLE album ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, name varchar(100) NOT NULL, description varchar(500) DEFAULT NULL, cover_url varchar(255) DEFAULT NULL, status tinyint DEFAULT 1 COMMENT 1正常 0禁用, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id) ); CREATE TABLE photo ( id bigint NOT NULL AUTO_INCREMENT, album_id bigint NOT NULL, url varchar(255) NOT NULL, thumb_url varchar(255) DEFAULT NULL, width int DEFAULT NULL, height int DEFAULT NULL, size bigint DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_album_id (album_id) ); CREATE TABLE share_info ( id bigint NOT NULL AUTO_INCREMENT, album_id bigint NOT NULL, share_code varchar(32) NOT NULL, expire_time datetime DEFAULT NULL COMMENT NULL表示永久, visit_count int DEFAULT 0, status tinyint DEFAULT 1, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_share_code (share_code) );几个设计考量说明一下photo表单独存thumb_url列表页只用缩略图详情页再加载原图这一步对性能影响巨大share_info.share_code加唯一索引因为这里是小表高频查询唯一索引几乎不占额外成本我开着 MyBatis-Plus 的全局逻辑删除配置字段删了都走deleted1这样误删相册还能恢复图片文件优先标记删除而不是物理删除实体类用 MyBatis-Plus 的注解标注主键策略和表名Data TableName(photo) public class Photo { TableId(type IdType.AUTO) private Long id; private Long albumId; private String url; private String thumbUrl; private Integer width; private Integer height; private Long size; private LocalDateTime createdAt; }2.3 图片上传与缩略图生成图片上传是我这次重构里改动最大的模块。先明确几个参数单张文件最大 50MB、单次请求最大 100MB、支持 jpg/png/webp/gif。接口设计上我选择轻接口方案——上传接口只做文件保存和元数据提取不做业务绑定前端在调用时再传albumId这样后面做头像上传、封面上传也能复用。核心实现逻辑PostMapping(/api/photo/upload) public ResultPhotoVO upload( RequestParam(file) MultipartFile file, RequestParam(albumId) Long albumId) { // 1. 校验文件类型 String contentType file.getContentType(); if (contentType null || !contentType.startsWith(image/)) { throw new BusinessException(只支持图片文件); } // 2. 生成存储路径 /uploads/20241023/{uuid}.jpg String dateDir LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String ext getExtension(file.getOriginalFilename()); String fileName UUID.randomUUID().toString().replace(-, ) . ext; String relativePath dateDir / fileName; File dest new File(uploadPath, relativePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest.getAbsoluteFile()); // 3. 生成缩略图 读取宽高 BufferedImage source ImageIO.read(dest); int width source.getWidth(); int height source.getHeight(); String thumbPath generateThumbnail(dest, dateDir, fileName, width, height); // 4. 组装入库 Photo photo new Photo(); photo.setAlbumId(albumId); photo.setUrl(/uploads/ relativePath); photo.setThumbUrl(/uploads/ thumbPath); photo.setWidth(width); photo.setHeight(height); photo.setSize(file.getSize()); photoService.save(photo); return Result.success(convertToVO(photo)); }缩略图生成我最初用 Thumbnator 库后来发现如果只是做等比缩放Java 自带的 ImageIO 完全够用还省一个依赖private String generateThumbnail(File src, String dateDir, String fileName, int srcWidth, int srcHeight) { // 目标宽度统一 800px高度按比例计算 int targetWidth 800; if (srcWidth targetWidth) { return dateDir /thumb_ fileName; // 小图直接用原图逻辑不真的生成 } int targetHeight (int) (srcHeight * (targetWidth / (double) srcWidth)); BufferedImage thumb new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g2d thumb.createGraphics(); // 开启高质量缩放 g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.drawImage(ImageIO.read(src), 0, 0, targetWidth, targetHeight, null); g2d.dispose(); String thumbRelative dateDir /thumb_ fileName; ImageIO.write(thumb, jpg, new File(uploadPath, thumbRelative)); return thumbRelative; }这里有个细节很关键如果原图宽度已经小于 800px就直接复用原图路径不额外生成缩略图避免白白浪费磁盘空间和 CPU。我这个方法里为了逻辑清晰用的ImageIO.read(src)实际可以优化成只读一次BufferedImage传进来避免重复解码。静态资源映射也要配好否则上传的图片通过 URL 访问不到Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${file.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // file: 后面必须跟绝对路径末尾要加分隔符 registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadPath /); } }2.4 JWT 认证与 Spring Security 6 配置Spring Security 6 和 5.x 的配置差异很大第一次上手容易在SecurityFilterChain和WebSecurityConfigurerAdapter之间搞混。6.x 里必须声明SecurityFilterChainBean并且推荐用 Lambda DSL 风格Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf - csrf.disable()) .cors(cors - cors.configure(http)) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/register, /api/share/**, /uploads/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }然后写一个认证过滤器核心工作就是从请求头解析 Bearer Token解析出用户信息后塞进SecurityContextHolder这样 Controller 里通过AuthenticationPrincipal或SecurityContextHolder.getContext().getAuthentication()就能拿到当前用户Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtils jwtUtils; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtUtils.parseToken(token); Long userId claims.get(userId, Long.class); String role claims.get(role, String.class); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken( userId, null, List.of(new SimpleGrantedAuthority(ROLE_ role)) ); SecurityContextHolder.getContext().setAuthentication(auth); } catch (Exception e) { // token 无效或过期不设置认证信息后续接口会被安全链拦截 } } filterChain.doFilter(request, response); } }这个过滤器里我故意把异常吞掉不直接返回 401原因是有些图片资源是公开的/uploads/**已在放行列表过滤器的职责只是尝试解析认证信息真正的权限拦截交给安全链的authorizeHttpRequests处理。2.5 分享链接的生成与访问控制分享功能是这个系统和其他纯相册管理工具拉开差距的地方。后端提供一个生成分享的接口输入相册 ID 和可选的过期时间返回一个短码分享链接。分享码我生成了 8 位随机串这里刻意不用雪花 ID 或自增 ID 做码值因为直接暴露自增 ID 会让人猜到系统里有多少个相册有信息泄露风险public String generateShareCode() { String chars ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789; // 去掉容易混淆的 0/O/1/l/I避免用户口头分享时读错 StringBuilder sb new StringBuilder(); SecureRandom random new SecureRandom(); for (int i 0; i 8; i) { sb.append(chars.charAt(random.nextInt(chars.length()))); } return sb.toString(); }访问分享的接口是公开的GetMapping(/api/share/{code}) public ResultShareAlbumVO getShareAlbum(PathVariable String code) { ShareInfo share shareService.getByCode(code); if (share null || share.getStatus() ! 1) { throw new BusinessException(分享不存在或已取消); } if (share.getExpireTime() ! null share.getExpireTime().isBefore(LocalDateTime.now())) { throw new BusinessException(分享链接已过期); } // 访问计数 1这里用 update 语句递增不用先查再改 shareService.incrementVisitCount(share.getId()); // 返回相册 图片列表不包含原图 URL 的授权信息时只给缩略图和元数据 Album album albumService.getById(share.getAlbumId()); ListPhoto photos photoService.lambdaQuery() .eq(Photo::getAlbumId, album.getId()) .orderByDesc(Photo::getCreatedAt) .list(); return Result.success(buildShareVO(album, photos)); }关于过期时间我设计成expire_time为 NULL 表示永久分享不为 NULL 才做校验。这种设计比默认 7 天更灵活因为运营同学可能会拿它做长期素材库的外部分享。如果你要做内部可见的分享可以再给share_info加一个target_user_id字段访问时校验当前登录用户不过这会牺牲分享链接免登录的便利性要根据真实场景权衡。3. 前端核心实现Vue 3 组合式 API 实践3.1 Vite 工程化与开发代理配置前端我用 Vite 初始化模板选的vue-ts因为 TypeScript 在组合式 API 场景下能够很好地约束 props 和 emit 的类型这在组件一多的时候优势特别明显。npm create vitelatest photo-album-web -- --template vue-ts cd photo-album-web npm install npm install pinia vue-router axios element-plus开发环境的跨域问题直接用 Vite 代理解决前端代码里所有的请求路径都以/api开头Vite 启动的时候会自动转发到后端的 8080 端口浏览器全程只和 5173 通信不存在跨域// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, src) } }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })注意Vite 代理只解决开发环境生产环境千万别依赖它。上线的时候让 Nginx 把/api/的反向代理和后端配置好前端页面用相对路径请求/api即可。3.2 Pinia 状态管理与 setup 语法糖Vue 3 的状态管理我选了 Pinia和 Vuex 4 相比它更轻量、TypeScript 类型推导也更自然。全局状态拆成user和album两个 store把业务逻辑和视图解耦。userstore 的核心逻辑// stores/user.ts import { defineStore } from pinia import { ref } from vue export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const userInfo refUserInfo | null(null) async function login(username: string, password: string) { const res await loginApi({ username, password }) token.value res.data.token localStorage.setItem(token, res.data.token) userInfo.value await getUserInfoApi() } function logout() { token.value userInfo.value null localStorage.removeItem(token) localStorage.removeItem(refreshToken) } return { token, userInfo, login, logout } })页面里用组合式 API 调用就非常直白script setup langts import { useUserStore } from /stores/user import { useRouter } from vue-router const userStore useUserStore() const router useRouter() const form reactive({ username: , password: }) async function handleLogin() { await userStore.login(form.username, form.password) router.push((route.query.redirect as string) || /) } /scriptVue 3 里还有一个高频场景就是子组件向父组件通信defineEmits的 TypeScript 用法我给你写清楚!-- AlbumDeleteDialog.vue -- script setup langts const props defineProps{ visible: boolean albumId: number }() const emit defineEmits{ (e: update:visible, value: boolean): void (e: deleted, albumId: number): void }() async function confirmDelete() { await deleteAlbumApi(props.albumId) emit(deleted, props.albumId) emit(update:visible, false) } /script组件间需要跨层级共享数据时可以用provide/inject。比如我在图片预览组件里provide(photoList, photos)深层的水印组件里inject(photoList)就能拿到列表避免一层层传 props。3.3 路由设计与登录守卫前端路由的守卫逻辑直接决定了谁能看到什么。我在router/index.ts里的实现import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /register, component: () import(/views/Register.vue) }, { path: /, component: () import(/layout/MainLayout.vue), meta: { requiresAuth: true }, children: [ { path: albums, component: () import(/views/AlbumList.vue) }, { path: album/:id, component: () import(/views/AlbumDetail.vue), props: true }, { path: upload, component: () import(/views/UploadView.vue) } ] }, { path: /s/:code, component: () import(/views/ShareView.vue), meta: { public: true } } ] router.beforeEach((to) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { return { path: /login, query: { redirect: to.fullPath } } } })meta.public标记的分享页天然绕过登录守卫加上redirect参数后用户登录完会被带回原本想访问的页面这个体验细节在真实项目里很加分。3.4 瀑布流组件与图片懒加载瀑布流组件是这个项目前端最有技术含量的部分。我先试了 CSScolumns实现代码确实少但分页加载时会出现新数据填充到最后一列末端的视觉错乱。后来改成 JS 动态分配列逻辑就是维护每列当前高度新数据来了往最矮的那列塞script setup langts import { ref, onMounted, nextTick } from vue const photos refPhoto[]([]) const columns refPhoto[][]([[], [], [], []]) const columnHeights ref([0, 0, 0, 0]) const colCount 4 function assignColumn(photo: Photo) { // 找到当前高度最小的列 const minHeight Math.min(...columnHeights.value) const idx columnHeights.value.indexOf(minHeight) columns.value[idx].push(photo) // 根据图片宽高和列宽估算加载后的高度 const colWidth containerWidth.value / colCount const scale colWidth / photo.width columnHeights.value[idx] photo.height * scale margin } async function loadMore() { const newPhotos await getPhotosApi(page.value) newPhotos.forEach(p assignColumn(p)) } /script template div classwaterfall-container div v-for(col, idx) in columns :keyidx classwaterfall-column div v-forphoto in col :keyphoto.id classwaterfall-item img :srcphoto.thumbUrl :widthphoto.width :heightphoto.height loadinglazy / /div /div /div /template这里的关键技巧assignColumn里估算高度要依赖数据库存的width和height字段。这也是为什么我在后端坚持要存图片宽高——没有这两个字段瀑布流只能用columns方案凑合视觉效果和滚动加载的体验都会打折扣。图片懒加载我直接用了 HTML 原生属性loadinglazy不需要额外引入懒加载库。桌面端现代浏览器都支持实测下来滚动页面时不在视口内的图片完全不发请求首屏加载速度提升非常明显。3.5 上传组件的封装与进度展示上传组件我封装在PhotoUpload.vue里基于 Element Plus 的el-upload但改用自定义http-request这样能精确控制上传过程和进度回调script setup langts import axios from axios import { ElMessage } from element-plus const props defineProps{ albumId: number }() const emit defineEmits{ (e: uploaded, photo: Photo): void }() async function customUpload(options: any) { const { file, onProgress, onSuccess, onError } options // 前端先做一次类型和大小校验避免白传 if (!file.type.startsWith(image/)) { ElMessage.warning(只支持上传图片文件) onError(new Error(类型不支持)) return } if (file.size 50 * 1024 * 1024) { ElMessage.warning(单张图片不能超过50MB) onError(new Error(文件过大)) return } const formData new FormData() formData.append(file, file) formData.append(albumId, String(props.albumId)) try { const res await axios.post(/api/photo/upload, formData, { onUploadProgress: (e) { if (e.total) { const percent Math.round((e.loaded / e.total) * 100) onProgress({ percent }) } } }) emit(uploaded, res.data.data) onSuccess(res.data) } catch (err) { ElMessage.error(上传失败请重试) onError(err) } } /script template el-upload drag multiple :auto-uploadtrue :http-requestcustomUpload :show-file-listtrue div classupload-tip拖拽图片到此处或点击上传/div /el-upload /template这里为什么选择自定义http-request因为默认的action方式是让组件内部直接用 XMLHttpRequest 提交无法方便地读取响应体里的图片对象改成自定义后上传成功后直接把后端返回的Photo对象通过emit(uploaded)抛给父组件父组件可以立刻把新图片追加到瀑布流里不需要再刷新整个列表。4. 核心功能闭环从上传到分享的完整流程4.1 一次完整的用户操作链路我们把整个系统串起来走一遍。假设用户 A 要创建一个旅行日记相册并分享给朋友 BA 打开/register注册后端校验用户名唯一密码用 BCrypt 加密入库注册后自动登录前端拿到 JWT 存在 localStorageA 在/albums页面点击新建相册输入名称和描述后端落库A 进入相册详情页点上传批量拖入 20 张照片前端逐张调用上传接口后端生成原图 缩略图 元数据瀑布流组件实时追加展示A 点击分享选择有效期 7 天后端生成短码x8k2fj9aA 把链接http://localhost:5173/s/x8k2fj9a发给 BB 在浏览器打开链接ShareView.vue通过分享码调/api/share/x8k2fj9a后端校验码有效且未过期返回相册名、描述、图片列表缩略图B 在公开落地页浏览无需登录后端同步把visit_count