简介:本资源是一份面向计算机专业本科生的毕业设计论文文档,聚焦大学生二手电子产品交易平台的系统化设计与实现,适用于Java Web开发、前后端分离项目实践及毕业论文参考场景。全文基于Vue+SpringBoot技术栈展开,涵盖平台需求分析、系统架构设计、前后端功能模块实现、数据挖掘应用、信息管理系统集成及安全性优化等核心内容,并附有摘要、关键词、目录、绪论、技术选型、系统实现与展望等完整论文结构。资源为单个DOCX文件,共1个,大小3.35MB,格式规范、排版清晰,便于直接用于论文撰写或技术方案学习。目前已有193人下载学习,读者可获取完整的毕业设计逻辑脉络、可复用的技术实现思路、数据处理与平台优化方法,以及针对校园二手交易场景的落地化解决方案。
1. 用 Vue + Spring Boot 搭建大学生二手电子产品商城,不是堆功能,而是控流程、保数据、防踩坑
一个计算机专业本科生在做毕业设计时,常被“商城系统”四个字吓住——以为要重造淘宝。其实真正落地的大学生二手电子商城,核心不在高并发或秒杀,而在闭环交易流:学生发布闲置手机/笔记本(带实拍图+基础参数),同学浏览筛选(按品牌、成色、价格区间)、发起询价或一口价下单,卖家确认后生成简易订单,双方线下交付并标记完成。这个过程里,Vue 负责让页面响应快、表单校验严、图片上传稳;Spring Boot 则要扛住多用户同时发帖、查库存、改状态,还要把用户身份、商品状态、订单生命周期管清楚。它适合 Java 基础扎实、已学过 Spring MVC 和 Vue 基础语法的学生——不需要你写分布式事务,但必须能看懂@Transactional为什么加在 service 层,也得会用v-model.lazy防止输入框频繁触发请求。本文不讲“如何从零安装 Node.js”,而是聚焦:怎么让 Vue 页面和 Spring Boot 后端真正对话起来、怎么避免跨域调试时接口全 404、怎么用最少代码实现“学生只能删自己发布的商品”这类权限逻辑。
2. 前后端分离架构下,Vue 与 Spring Boot 的通信边界与数据契约必须明确定义
2.1 为什么必须前后端分离?——避开毕业答辩时最常被问的架构合理性问题
很多学生用 Thymeleaf 或 JSP 直接渲染页面,看似简单,但答辩时老师会追问:“如果后期要加微信小程序端,你的商品列表接口还能复用吗?” 此时若答“要重写 Controller”,就暴露了架构短板。而 Vue + Spring Boot 的分离模式,天然定义了清晰的数据契约:前端只关心 JSON 字段名和状态码,后端只暴露 RESTful 接口。比如/api/products?status=on_sale&minPrice=500&maxPrice=2000返回标准 JSON:
{ "code": 200, "message": "success", "data": { "list": [ { "id": 1024, "title": "iPhone 12 128G 黑色 成色95%", "price": 2800.00, "brand": "Apple", "condition": "95%", "images": ["https://oss.example.com/img/1024_1.jpg"], "publisherId": 2021001, "createdAt": "2024-03-15T10:22:33" } ], "total": 1 } }提示:这个 JSON 结构不是随便定的。
code和message是统一响应体(ResponseResult 类),所有 Controller 方法返回ResponseResult<List<ProductVO>>,而非裸List<ProductVO>。这样前端 Axios 拦截器才能统一处理登录过期(code=401)或参数错误(code=400),避免每个.then()里重复写if (res.code !== 200) alert(res.message)。
2.2 Vue 端:用 Axios 封装请求,用 Composition API 管理商品列表状态
在src/api/product.js中封装商品相关请求,强制携带 token(从 localStorage 读取):
// src/api/product.js import axios from 'axios' const apiClient = axios.create({ baseURL: 'http://localhost:8080/api', // Spring Boot 启动端口 timeout: 10000, headers: { 'Content-Type': 'application/json' } }) // 请求拦截器:自动添加 Authorization 头 apiClient.interceptors.request.use(config => { const token = localStorage.getItem('user_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一错误处理 apiClient.interceptors.response.use( response => response.data, // 只返回 data 字段,剥离 code/message 包装 error => { if (error.response?.status === 401) { localStorage.removeItem('user_token') window.location.href = '/login' } return Promise.reject(error) } ) export const productApi = { // 获取商品列表(支持分页和筛选) list: (params) => apiClient.get('/products', { params }), // 发布新商品 create: (data) => apiClient.post('/products', data), // 根据 ID 获取商品详情 detail: (id) => apiClient.get(`/products/${id}`) }在src/views/ProductList.vue中使用 Composition API 管理状态:
<!-- src/views/ProductList.vue --> <template> <div class="product-list"> <h2>二手电子产品</h2> <div class="filter-bar"> <input v-model="filters.brand" placeholder="品牌(如:华为、小米)" /> <select v-model="filters.condition"> <option value="">全部成色</option> <option value="90%">90%以上</option> <option value="80%">80%-89%</option> </select> <button @click="loadProducts">搜索</button> </div> <div class="product-grid"> <ProductCard v-for="item in products" :key="item.id" :product="item" /> </div> <Pagination :current="pageInfo.current" :total="pageInfo.total" @change="handlePageChange" /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { productApi } from '@/api/product' import ProductCard from '@/components/ProductCard.vue' import Pagination from '@/components/Pagination.vue' const products = ref([]) const pageInfo = ref({ current: 1, total: 0 }) const filters = ref({ brand: '', condition: '' }) // 加载商品列表 const loadProducts = async () => { try { const res = await productApi.list({ ...filters.value, page: pageInfo.value.current, size: 12 }) products.value = res.data.list || [] pageInfo.value.total = res.data.total || 0 } catch (err) { console.error('加载商品失败:', err) } } const handlePageChange = (page) => { pageInfo.value.current = page loadProducts() } onMounted(() => { loadProducts() }) </script>2.2.1 关键参数说明与避坑点
| 参数 | 说明 | 为什么重要 | 常见误用 |
|---|---|---|---|
baseURL | Vue 调用接口的根路径,必须与 Spring Boot 的server.port和spring.mvc.servlet.path一致 | 若设为http://localhost:8080而后端实际跑在8081,所有请求 404 | 写死8080却没改application.yml中的server.port |
Authorization头 | Bearer Token 认证,Spring Security 依赖此头识别用户 | 没加此头,后端@PreAuthorize("hasRole('STUDENT')")直接拒掉所有请求 | 在main.js全局设置axios.defaults.headers.common['Authorization'],但登录后 token 变化未同步 |
params对象传参 | GET 请求参数自动拼到 URL,如{ brand: 'Apple' } → ?brand=Apple | 避免手动拼 URL 字符串,防止特殊字符(空格、&)编码错误 | 用JSON.stringify()把对象转字符串再传,导致后端接收为{"brand":"Apple"}字符串而非对象 |
2.3 Spring Boot 端:用 RESTController + Lombok + MyBatis-Plus 快速构建可验证接口
在pom.xml中确保关键依赖版本兼容(以 Spring Boot 2.7.18 为例,避免springboot版本太高导致 MyBatis-Plus 不兼容):
<dependencies> <!-- Web 核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus(比原生 MyBatis 少写 70% XML) --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> <!-- 注意:3.5.x 适配 Spring Boot 2.x --> </dependency> <!-- Lombok(省去 getter/setter) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>定义商品实体类(Lombok 自动注入 getter/setter/toString):
// src/main/java/com/example/ecommerce/entity/Product.java import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; @Data @TableName("t_product") public class Product { @TableId(type = IdType.AUTO) private Long id; private String title; // 商品标题 private BigDecimal price; // 价格 private String brand; // 品牌(索引字段) private String condition; // 成色,如 "95%" private String images; // JSON 字符串,如 ["url1","url2"] private Long publisherId; // 发布者学号(关联 user 表) private Integer status; // 0-草稿 1-上架 2-已售出 private LocalDateTime createdAt; }编写 Controller,严格遵循 RESTful 规范,并用@Valid校验入参:
// src/main/java/com/example/ecommerce/controller/ProductController.java import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.ecommerce.entity.Product; import com.example.ecommerce.entity.ResponseResult; import com.example.ecommerce.service.ProductService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import java.util.List; @RestController @RequestMapping("/api/products") public class ProductController { @Autowired private ProductService productService; // GET /api/products?page=1&size=12&brand=Apple&condition=95% @GetMapping public ResponseResult<Page<Product>> list( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "12") Integer size, String brand, String condition) { Page<Product> productPage = new Page<>(page, size); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(brand != null && !brand.trim().isEmpty(), Product::getBrand, brand) .eq(condition != null && !condition.trim().isEmpty(), Product::getCondition, condition) .eq(Product::getStatus, 1); // 只查上架中商品 Page<Product> result = productService.page(productPage, wrapper); return ResponseResult.success(result); } // POST /api/products @PostMapping public ResponseResult<Product> create(@Valid @RequestBody Product product) { // 设置默认值 product.setStatus(1); // 新发布即上架 product.setCreatedAt(LocalDateTime.now()); boolean saved = productService.save(product); return saved ? ResponseResult.success(product) : ResponseResult.fail("保存失败"); } // GET /api/products/{id} @GetMapping("/{id}") public ResponseResult<Product> detail(@PathVariable Long id) { Product product = productService.getById(id); return product != null ? ResponseResult.success(product) : ResponseResult.fail("商品不存在"); } }2.3.1 为什么用 MyBatis-Plus 而非 JPA?
- 学习成本低:
productService.save(product)直接插入,不用写@Entity+@Table+@Column一堆注解; - SQL 可控:复杂查询仍可用
LambdaQueryWrapper构建,比 JPA 的@Query注解更直观; - 毕业答辩友好:老师问“你怎么查某个品牌的商品?”,你可以指着
wrapper.eq(Product::getBrand, brand)说:“这就是动态拼 WHERE 条件”。
3. 学生身份与商品归属强绑定:用 JWT + Spring Security 实现细粒度权限控制
3.1 登录流程:Vue 提交账号密码 → Spring Boot 验证 → 返回 JWT Token → Vue 存入 localStorage
前端登录逻辑(src/views/Login.vue):
// 提交登录表单 const login = async () => { try { const res = await apiClient.post('/auth/login', { username: form.username, // 学号,如 2021001 password: form.password }) // 后端返回 { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } localStorage.setItem('user_token', res.data.token) localStorage.setItem('user_id', res.data.userId) router.push('/dashboard') } catch (err) { message.error('登录失败:' + (err.response?.data?.message || '未知错误')) } }Spring Boot 端配置 JWT 过滤器(关键代码节选):
// src/main/java/com/example/ecommerce/config/JwtAuthenticationFilter.java public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = getTokenFromRequest(request); if (token != null && jwtUtil.validateToken(token)) { String userId = jwtUtil.getUserIdFromToken(token); // 从数据库查用户角色(STUDENT / ADMIN) User user = userService.getById(Long.valueOf(userId)); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } private String getTokenFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }配置 Spring Security(SecurityConfig.java):
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(authz -> authz .requestMatchers("/auth/login", "/register", "/swagger-ui/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/products").authenticated() // 发布商品需登录 .requestMatchers(HttpMethod.DELETE, "/api/products/**").access("@securityService.canDeleteProduct(authentication, request)") // 自定义权限 .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }3.2 关键权限逻辑:学生只能删自己发布的商品
不能只靠前端隐藏“删除按钮”,必须后端校验。创建SecurityService实现@PreAuthorize表达式:
@Service public class SecurityService { @Autowired private ProductService productService; // @PreAuthorize("@securityService.canDeleteProduct(authentication, #id)") public boolean canDeleteProduct(Authentication auth, HttpServletRequest request) { String path = request.getRequestURI(); // /api/products/1024 String idStr = path.substring(path.lastIndexOf("/") + 1); Long productId; try { productId = Long.parseLong(idStr); } catch (NumberFormatException e) { return false; } // 获取当前登录用户 ID Long currentUserId = getCurrentUserId(auth); // 查询该商品的 publisherId 是否等于当前用户 Product product = productService.getById(productId); return product != null && product.getPublisherId().equals(currentUserId); } private Long getCurrentUserId(Authentication auth) { Object principal = auth.getPrincipal(); if (principal instanceof User) { return ((User) principal).getId(); } return null; } }然后在ProductController的删除方法上加注解:
@DeleteMapping("/{id}") @PreAuthorize("@securityService.canDeleteProduct(authentication, #id)") public ResponseResult<String> delete(@PathVariable Long id) { boolean removed = productService.removeById(id); return removed ? ResponseResult.success("删除成功") : ResponseResult.fail("删除失败"); }3.2.1 为什么这个方案比@PreAuthorize("principal.id == #productId.publisherId")更可靠?
- 后者需要在 SpEL 中访问
#productId的publisherId,但#id是路径变量(Long 类型),不是 Product 对象,SpEL 无法穿透查询; - 自定义
SecurityService方法可主动查库,逻辑清晰、可单元测试、报错信息明确(如“您无权删除他人商品”); - 毕业答辩时,老师问“如果学生 A 伪造请求删学生 B 的商品,你怎么拦?”,你可以直接展示这段代码和对应的单元测试用例。
4. 图片上传与存储:用本地磁盘 + Nginx 静态服务,避开云存储配置复杂度
4.1 Vue 端:用 el-upload 组件上传,限制格式与大小
<!-- ProductForm.vue 中的图片上传区 --> <el-upload class="avatar-uploader" action="/api/upload" :http-request="handleUpload" :show-file-list="false" :limit="3" :on-exceed="handleExceed" > <img v-if="imageUrl" :src="imageUrl" class="avatar" /> <i v-else class="el-icon-plus avatar-uploader-icon"></i> </el-upload>自定义上传方法(避免直接用action导致跨域):
// handleUpload 方法 handleUpload({ file }) { const formData = new FormData() formData.append('file', file) // 使用 apiClient(已配好 token) apiClient.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(res => { this.imageUrls.push(res.data.url) // 后端返回形如 /uploads/20240410/abc.jpg }).catch(err => { this.$message.error('上传失败:' + err.response?.data?.message) }) }4.2 Spring Boot 端:接收 MultipartFile,存入uploads/目录,并返回相对路径
// ProductController.java 新增方法 @PostMapping("/upload") public ResponseResult<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return ResponseResult.fail("文件不能为空"); } if (file.getSize() > 5 * 1024 * 1024) { // 5MB 限制 return ResponseResult.fail("文件不能超过 5MB"); } String contentType = file.getContentType(); if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType)) { return ResponseResult.fail("仅支持 JPG/PNG 格式"); } try { // 生成唯一文件名:时间戳 + 随机数 + 原后缀 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newFilename = System.currentTimeMillis() + "_" + RandomStringUtils.randomAlphanumeric(6) + ext; // 存入 uploads 目录(相对于 jar 包同级) String uploadDir = "uploads"; Path uploadPath = Paths.get(uploadDir); if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); } Path targetPath = uploadPath.resolve(newFilename); file.transferTo(targetPath); // 返回可被 Nginx 映射的路径(注意:不带盘符,是相对路径) String url = "/uploads/" + newFilename; return ResponseResult.success(url); } catch (Exception e) { return ResponseResult.fail("上传失败:" + e.getMessage()); } }4.3 Nginx 配置:将/uploads/请求映射到本地磁盘目录
在nginx.conf的http块内添加:
# 将 /uploads/ 开头的请求,指向项目根目录下的 uploads 文件夹 location /uploads/ { alias /path/to/your/project/uploads/; # 替换为你的绝对路径,如 /home/user/ecommerce/uploads/ expires 7d; add_header Cache-Control "public, immutable"; }注意:Spring Boot 默认不处理静态资源路径
/uploads/,所以必须用 Nginx 拦截并转发。若跳过 Nginx 直接用ResourceHandlerRegistry,则需额外配置addResourceLocations("file:uploads/"),但 Windows 路径写法易出错,Nginx 方案更稳定、更贴近生产环境。
5. 毕业论文可呈现的关键技术点与答辩话术:从代码到逻辑的闭环表达
5.1 如何在论文“系统实现”章节中,把技术选择讲出深度?
不要写:“本系统采用 Vue 框架,因为它是渐进式框架”。要写:
“选择 Vue 3 Composition API 而非 Options API,是因为其函数式组织方式更利于模块复用。例如商品列表页(ProductList.vue)与搜索页(Search.vue)共用
loadProducts()逻辑,只需将该函数抽离为useProductList()自定义 Hook,两页导入调用即可,避免复制粘贴导致后续修改不同步。这符合本科毕设‘小而精’的要求——不追求炫技,但体现工程化思维。”
对应代码示例(src/composables/useProductList.js):
import { ref, onMounted } from 'vue' import { productApi } from '@/api/product' export function useProductList(initialFilters = {}) { const products = ref([]) const pageInfo = ref({ current: 1, total: 0 }) const filters = ref({ ...initialFilters }) const load = async (page = 1) => { pageInfo.value.current = page const res = await productApi.list({ ...filters.value, page, size: 12 }) products.value = res.data.list || [] pageInfo.value.total = res.data.total || 0 } onMounted(() => load()) return { products, pageInfo, filters, load } }5.2 答辩高频问题预判与应答要点
| 问题 | 应答核心(30 秒内说完) | 论文可写位置 |
|---|---|---|
| “为什么用 MyBatis-Plus 而不用 JPA?” | “JPA 抽象层较厚,对本科毕设而言,MyBatis-Plus 提供了 XML 零配置的 CRUD,同时保留 SQL 可视化能力。比如商品搜索的多条件组合,用LambdaQueryWrapper动态构建 WHERE 子句,比 JPA 的 Criteria API 更直观,也便于我在论文中截图展示查询逻辑。” | 系统设计章节 → 持久层技术选型 |
| “JWT Token 怎么保证不被窃取?” | “Token 存于 localStorage 有 XSS 风险,因此我在登录成功后,立即清除登录表单中的密码字段,并在所有 API 请求头中使用Authorization: Bearer xxx,后端通过@PreAuthorize注解强制校验。此外,Token 设置了 2 小时过期,过期后前端自动跳转登录页,避免长期有效 Token 泄露。” | 安全性设计章节 → 认证机制 |
| “图片上传为什么不用阿里云 OSS?” | “OSS 需申请 AccessKey 并配置跨域策略,对毕设环境增加部署复杂度。我采用本地磁盘+Nginx 静态服务,既满足功能需求,又能在答辩时现场演示从上传、存储、到页面显示的完整链路,所有代码和配置均在 Git 仓库中可查。” | 系统实现章节 → 文件存储方案 |
5.3 一个能让老师眼前一亮的细节优化:商品列表页的防抖搜索
学生常把搜索框@input绑定loadProducts(),导致每敲一个字就发请求。改成防抖:
// ProductList.vue 中 import { ref, onUnmounted } from 'vue' const searchTimer = ref(null) const loadProducts = () => { // 清除上一次定时器 if (searchTimer.value) { clearTimeout(searchTimer.value) } // 设置新定时器(延迟 300ms 执行) searchTimer.value = setTimeout(() => { // 执行实际请求 fetchProducts() }, 300) } onUnmounted(() => { if (searchTimer.value) { clearTimeout(searchTimer.value) } })这个改动不到 10 行代码,却体现了对用户体验和服务器负载的双重考虑——答辩时老师看到搜索框不再疯狂刷新,会自然点头。
本文还有配套的精品资源,点击获取