☰
SpringBoot+Vue实战:二手交易系统设计与全栈开发指南
2026/9/30 14:58:47 网站建设 项目流程

一个"二手物品交易系统",对于做过Java课程设计或者毕业设计的同学来说,绝对是可以直接封神的经典选题。它业务逻辑清晰、功能扩展性强,而且技术栈组合非常成熟——SpringBoot负责后端接口,Vue负责前端页面,MySQL存数据,源码加数据库加文档一套齐全,拿来即用,改改就能交。就算你不是为了应付答辩,想把它当成一个正经的练手项目复盘一遍SpringBoot+Vue全栈开发流程,这套系统也能覆盖到绝大多数必踩的知识点:用户认证、权限控制、商品发布、搜索分页、订单流转、文件上传……今天干脆把我整理这套系统的思路、核心表结构、关键代码和踩坑记录全部理一遍,希望能给正在做同款项目的朋友一个靠谱参考。

1. 项目整体设计与技术选型:为什么偏偏是SpringBoot + Vue

1.1 技术栈选型的底层逻辑

先说技术栈。市面上能做Web项目的组合非常多,从最原始的JSP/Servlet,到SSH(Struts+Spring+Hibernate),再到SSM(Spring+SpringMVC+MyBatis),再到我这次用的SpringBoot+Vue前后端分离,每一代都有各自的理由。二手交易系统是一个典型的“管理密集型”应用:用户登录注册、商品信息维护、订单状态变更、评论留言……这些场景对开发效率的诉求远大于对极致性能的诉求。

SpringBoot的核心价值在于“约定大于配置”。我不用再像SSM时代那样写一堆XML配置文件,内嵌Tomcat也让部署变简单,mvn spring-boot:run就能直接起服务,这对做课设或毕设的节奏来说非常友好。而且SpringBoot的生态极其完善,Spring Security做登录鉴权、MyBatis-Plus操作数据库、Redis做缓存(虽然这个项目里我用得不多)都有现成起步依赖,能把注意力集中在业务本身而不是框架的配置地狱里。

Vue这边,我用的是Vue 2配合Element UI组件库。老实说Vue 3和Element Plus也挺成熟了,但考虑到很多学校机房教程和网络博客还停留在Vue 2,而且课程设计或者毕设答辩时,老师更关注的往往是“核心流程通不通、功能全不全”,而不是“你有没有用上最新的Composition API”,所以我最终选了稳定、资料多、遇到问题最容易搜到答案的Vue 2方案。如果你追求技术前沿,或者项目文档明确要求Vue 3,那代码结构其实也能平滑迁移,Vue 2和Vue 3在组件化、路由、状态管理这些思想层面是一脉相承的。

1.2 前后端分离到底分的是什么

这套系统最核心的架构决策就是“前后端分离”。怎么理解?传统JSP项目,前端页面和后端Java代码混在一起,浏览器拿到的是服务端渲染好的HTML。前后端分离之后,前端工程只负责页面展示和交互逻辑,后端工程只暴露JSON格式的数据接口,两者通过HTTP协议通信。

我画过一个非常直白的类比:把后端想象成一个餐厅后厨,前端就是前厅的菜单和点菜终端。顾客(用户)只看菜单,服务员(前端)把点单转化成“给后厨的命令”(HTTP请求),后厨做好菜再端出来(JSON响应)。菜单可以随时换排版,只要菜名不变(接口地址不变),后厨完全不需改动。这就是前后端各自独立开发、独立部署、独立演进的核心优势。

这套系统里的具体分工是:

  • 前端Vue工程:负责用户注册登录页、商品列表页、商品详情页、购物车、订单中心、后台管理页面;
  • 后端SpringBoot工程:负责用户校验、商品增删改查、文件上传(图片)、订单状态流转、数据统计;
  • MySQL数据库:负责所有持久化数据,用户表、商品表、订单表、收藏表、留言表等。

每一层各司其职,调试的时候也很爽。我在项目里遇到最多的问题往往就出在“接口约定不一致”,比如前端传的参数名是commodityId,后端实体类字段叫goodsId,一对接就报“参数缺失”。这也在情理之中——前后端分离架构下,接口文档的约束力直接决定了联调效率。

1.3 项目目录结构:代码“长什么样”最重要

拿到一个开源项目源码,第一件事不是跑起来,而是看目录结构。一个好的目录结构,能让你5分钟内找到想要的东西;一个混乱的目录,哪怕功能全,后续二次开发也是地狱。我参考了许多成熟项目的习惯,把后端分为以下几个层级:

com.shop.system ├── controller // 接口层,接收前端请求,返回JSON数据 ├── service // 业务逻辑层,核心逻辑都写在这里 │ └── impl // service接口的实现类 ├── mapper // 数据访问层,MyBatis的Mapper接口 ├── entity // 实体类,对应数据库表结构 ├── config // 配置类,比如跨域处理、静态资源映射 ├── common // 通用工具类、统一返回结果类 └── utils // 工具类,比如JWT令牌工具、文件上传工具

前端Vue工程目录则按照页面维度组织:

src ├── api // 所有请求后端接口的方法统一封装 ├── components // 公共组件,比如轮播图、商品卡片 ├── router // 路由配置,定义页面跳转规则 ├── store // Vuex状态管理,保存登录状态、购物车数据 ├── views // 页面组件,一个文件夹对应一个路由页面 ├── utils // 工具文件,比如axios实例封装 └── App.vue // 根组件,整个应用的入口

这可以说是很多企业级项目的标准分层了,照着这个结构去读源码,你不会迷路。我还特意在后端加了common包里一个Result类,所有接口统一返回{ code: 200, msg: "操作成功", data: {...} }这个格式,前端axios拦截器统一判断code字段就知道请求是否成功,省去大量重复的“搬运工”代码。

2. 数据库设计:二手交易系统的“地基”

2.1 核心表结构与字段设计思路

项目拿到手,如果你要改需求,很大概率要动数据库。二手交易系统的表设计,我一开始就按照“物、人、交易”三个维度去理:

  • 人:用户表(买家、卖家、管理员);
  • 物:商品表(描述商品是什么、几成新、价格多少、图片在哪);
  • 交易:由交易衍生出来的订单表、购物车表、收藏表、留言评论表。

下面是我这套系统中几张核心表的字段清单(已简化成最关键的列):

用户表(t_user)

字段名类型说明
idbigint(20)主键,自增
usernamevarchar(50)登录用户名,唯一
passwordvarchar(255)密码,MD5加密存储
nicknamevarchar(50)昵称,前端展示
phonevarchar(20)手机号,联系用
avatarvarchar(255)头像图片地址
roleint(11)角色:0管理员,1普通用户
create_timedatetime注册时间

商品表(t_goods)

字段名类型说明
idbigint(20)主键,自增
goods_namevarchar(100)商品名称
goods_desctext商品描述,支持较长文本
pricedecimal(10,2)售价,两位小数
original_pricedecimal(10,2)原价/买入价,突出性价比
degreevarchar(10)成色:全新、九成新、八成新、七成以下
imagevarchar(255)商品主图存储路径
imagestext多张图片,用逗号隔开
user_idbigint(20)发布者ID,外键关联用户表
statusint(11)商品状态:0在售,1已售出,2下架,3审核中
view_countint(11)浏览量
create_timedatetime发布时间

订单表(t_order)

字段名类型说明
idbigint(20)主键,自增
order_novarchar(32)订单编号,全局唯一
goods_idbigint(20)关联商品ID
seller_idbigint(20)卖家ID
buyer_idbigint(20)买家ID
pricedecimal(10,2)成交单价
statusint(11)0待付款,1待发货,2待收货,3已完成,4已取消
create_timedatetime下单时间

2.2 为什么商品表里要有“成色”和“状态”

我见过很多二手交易系统源码,有的直接照搬电商表结构,搞一堆SPU、SKU的复杂模型。说实话,二手场景和B2C电商有本质差异:电商卖的是标准品,同一件T恤有不同颜色尺码,需要SKU;二手交易卖的是个人闲置的孤品,一件东西就是一个SKU,不存在“规格”概念。所以商品表不需要单独拆SKU表,只需一个t_goods表配上图片字段就够了。

“成色”字段是二手平台特有的,我自己在技术上用了一个degree的字符串字段来存,前端下拉选择。虽然也可以用int类型加枚举注释做,但字符串在这个场景下更直观,拿到数据就能直接显示,不用再做一次字典映射。如果你更追求规范,可以在后端做枚举校验。

status字段的设计也要想清楚。商品状态不能只有“在售/下架”两种,还要考虑交易过程中“有人下单了但还没付款”“已经付款等发货”“交易成功后自动下架”。我曾经见过一份源码,卖家用一个is_sell的布尔值来表示是否出售,结果下单后商品直接消失,买家看不到商品信息,卖家也看不到历史订单里的商品快照——这是典型的状态机设计缺失。我的方案是:订单创建后,商品状态从“在售”变为“锁定”(可加一个lock_status),订单完成或取消后恢复在售或改为已售出,这样状态流转才完整。

2.3 外键到底建不建:这是个经典争议

在表设计里有个绕不开的坑:要不要用数据库物理外键?有些教程为了演示方便,到处加FOREIGN KEY约束,看起来数据严密,实际在业务复杂的系统里特别坑——你删一条商品记录,如果被订单引用,直接报外键约束错误,还得先去处理订单。我的习惯是:逻辑外键(在代码里维护关联关系)代替物理外键,只在实体类中创建userId、goodsId这种字段,通过SQL的JOIN查询来关联,这样删数据灵活,也不会破坏数据一致性。这个方案在团队开发时可能靠“代码自觉”,但对课设和毕设来说完全够用。

数据库设计这块,我额外做了几个“加分项”:所有表都带create_time字段,后续如果需要做“最近上架”“按时间排序”之类的功能可以直接用;t_order表里同时存了sellerId和buyerId,因为二手交易里同一个用户既可以是买家也可以是卖家,只存一个ID后面查订单列表就要做两次查询合并,非常麻烦。

3. 后端核心实现:SpringBoot让每个模块都清晰可控

3.1 用户认证机制:JWT还是Session?

用户登录认证,我在初版里用的是HttpSession,也就是传统单机Session方案。这个方案的缺陷很明显:用户每登录一次,服务端就要在内存里存一份Session数据,一旦重启服务,所有登录状态全部失效。而且如果同一台服务器部署了多个后端节点,Session没法共享,需要引入Redis来解决Session共享问题。

后来我把认证改成了JWT(JSON Web Token)方案。核心思路是:用户登录成功后,服务端把用户ID、用户名、角色等信息打包成一个Token(用Base64编码加签名),发给前端存储。前端每次请求在请求头里带上Authorization: token,后端用拦截器校验签名是否有效,有效就从Token里解析出用户信息。

我选JWT的一个重要原因是它天然支持前后端分离:Token是自包含的,服务端不需要存Session状态,天然适合水平扩展。但这也有个副作用——Token签发后无法主动让它失效,除非等它自然过期。于是我给Token设置了24小时的过期时间,也就是用户登录一天后需要重新登录,对课设和毕设的场景来说是合理的。

关键代码片段(登录接口的核心逻辑):

// 用户登录 @PostMapping("/login") public Result login(@RequestBody LoginDTO loginDTO) { QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("username", loginDTO.getUsername()); User user = userMapper.selectOne(wrapper); if (user == null) { return Result.error("用户名不存在"); } String md5Pwd = DigestUtils.md5DigestAsHex(loginDTO.getPassword().getBytes()); if (!md5Pwd.equals(user.getPassword())) { return Result.error("密码错误"); } // 生成JWT令牌 String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("userId", user.getId()); data.put("username", user.getUsername()); data.put("nickname", user.getNickname()); data.put("avatar", user.getAvatar()); data.put("role", user.getRole()); return Result.success(data); }

这里有个细节,密码存的是MD5加密串。MD5现在被证明存在彩虹表风险,真正企业项目一般会推荐BCrypt或者加盐哈希,但在这个系统里我只做了MD5,因为那样演示起来最直接——若需要更高安全性你可以在ArticleUtil里换一个加盐算法,接口不用改。

3.2 文件上传与图片处理:存本地还是存OSS?

商品发布的核心难点不在“商品表插入数据”,在于图片上传。二手物品交易,买家最关注的就是图片真实度,所以一个商品至少支持上多张图。前端我用的Element UI的el-upload组件,支持多图选择、图片预览、删除重选;后端接收MultipartFile文件流,保存到服务器的固定目录。

# application.yml 配置文件中的关键配置 spring: servlet: multipart: max-file-size: 5MB max-request-size: 50MB file: upload: path: /Users/xxx/uploads/ # 本地存储路径,按实际环境修改
@PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); // 生成新文件名,避免重名覆盖:时间戳 + 随机数 String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = System.currentTimeMillis() + "_" + (int)(Math.random() * 1000) + ext; File dest = new File(uploadPath, fileName); try { file.transferTo(dest); // 返回图片访问的相对路径 return Result.success("/images/" + fileName); } catch (IOException e) { e.printStackTrace(); return Result.error("文件上传失败"); } }

整体思路不算难,但有几个地方非常容易踩坑:

  • 配置文件里的上传路径要改成你电脑上的绝对路径,否则会报FileNotFoundException;
  • 上传后返回给前端的路径是相对路径,需要后端配置静态资源映射,把/images/**映射到本地的上传目录,否则前端拿到路径也刷新不出图片;
  • 上传的文件名我重新生成了,绝不直接用用户原文件名,否则中文文件名、重名碰撞、非法字符都能带来一系列问题。

3.3 订单流程:二手交易里的状态机怎么写

订单其实是一台状态机。状态机的核心思想是:系统里的数据状态只能沿着预定义的方向迁移,非法跃迁直接拒绝。

我在OrderService里专门做了一个updateOrderStatus方法,用switch判断当前状态和目标状态是否合法:

public Result updateStatus(Long orderId, Integer targetStatus, Long userId) { Order order = orderMapper.selectById(orderId); if (order == null) { return Result.error("订单不存在"); } int current = order.getStatus(); // 合法状态迁移:待付款(0) -> 待发货(1),待发货(1) -> 待收货(2),待收货(2) -> 已完成(3) boolean valid = (current == 0 && targetStatus == 1) || (current == 1 && targetStatus == 2) || (current == 2 && targetStatus == 3) || (current == 0 && targetStatus == 4); // 取消订单只允许在待付款阶段 if (!valid) { return Result.error("非法状态操作"); } // 校验操作人权限:买家和卖家只能操作跟自身相关的订单 if (!order.getBuyerId().equals(userId) && !order.getSellerId().equals(userId)) { return Result.error("无权操作该订单"); } order.setStatus(targetStatus); orderMapper.updateById(order); return Result.success(); }

这里有个很重要的细节:在服务端一定要做状态合法性校验,不能只靠前端按钮隐藏来控制。二手交易平台最怕的就是“超卖”和“重复下单”,如果多人同时打开同一个商品的详情页都点击“立即购买”,后端必须保证只有一个人能下单成功。我在创建订单的时候加了UPDATE语句做原子操作:

UPDATE t_goods SET status = 1 WHERE id = #{goodsId} AND status = 0

如果影响行数为1,说明商品抢占成功;如果影响行数为0,说明商品已经被别人买走了。这种方式在并发量不大的二手场景下已经完全够用,不需要引入Redis分布式锁,简单有效、好理解,答辩时也好解释。

4. 前端页面:Vue组件化的实战心法

4.1 页面架构:路由、Vuex和axios封装

Vue工程里的路由配置决定了用户能在哪些“页面”之间切换。我把整个系统粗略分为前台和后台两类路由:前台面向普通用户,包括首页、商品列表、商品详情、购物车、订单中心、个人中心;后台面向管理员,包括商品管理、用户管理、订单管理、数据统计。路由懒加载也加上了:

const routes = [ { path: '/', name: 'Home', component: () => import('../views/Home.vue'), }, { path: '/goodsList', name: 'GoodsList', component: () => import('../views/GoodsList.vue'), }, { path: '/goodsDetail/:id', name: 'GoodsDetail', component: () => import('../views/GoodsDetail.vue'), }, { path: '/cart', name: 'Cart', component: () => import('../views/Cart.vue'), }, ];

路由这儿有个细节,商品详情页用的是/goodsDetail/:id这种动态路由,也就是路由本身就带着商品ID。点击商品卡片跳转时,通过this.$router.push({ name: 'GoodsDetail', params: { id: goodsId } })实现;进入详情页后,再通过this.$route.params.id取出ID,请求后端接口。

Vuex用来解决跨组件共享数据的问题。最典型的场景就是购物车:不同页面可能都要展示“购物车里有几件商品”,而且商品加入购物车的操作发生在商品详情页,购物车角标的显示则可能在导航栏。这种跨页面的状态同步,用Vuex比用组件props传值或$emit好太多。我在store里设计了cartCount和cartList两个状态,用户每次增删购物车,组件里直接this.$store.dispatch('addToCart', goods),所有需要的地方都能自动响应。

axios请求的封装也很重要。我在utils/request.js里创建了一个axios实例,统一设置了baseURL和请求头拦截器:

import axios from 'axios'; const service = axios.create({ baseURL: '/api', timeout: 10000, }); // 请求拦截器:自动附带token service.interceptors.request.use( (config) => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = token; } return config; }, (error) => Promise.reject(error) ); // 响应拦截器:统一处理业务码 service.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 200) { // 如果是登录失效,直接跳到登录页 if (res.code === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } return Promise.reject(new Error(res.msg || '请求失败')); } return res; }, (error) => Promise.reject(error) ); export default service;

这样组件里调用接口就可以把业务逻辑写得很干净:

// 登录页里调用登录接口 login() { this.$refs.loginForm.validate((valid) => { if (valid) { login(this.loginForm).then((res) => { localStorage.setItem('token', res.data.token); localStorage.setItem('userInfo', JSON.stringify(res.data)); this.$message.success('登录成功'); this.$router.push('/'); }); } }); }

4.2 商品发布页:一个表单组件如何应对“复杂联动”

商品发布页面是前台功能的重头戏。它不是普通表单——包含图片上传、成色选择、价格填写、分类选择等内容,还要做实时校验。我用Element UI的表单校验功能实现了一套规则:商品名称为必填,价格必须大于0,图片至少上传一张,描述限制在500字以内。

这里有个经验:不要把校验逻辑写在data()里一大坨,而是把校验规则抽成一个对象,独立放在代码块里,这样当页面里面有多个表单(比如发布商品和修改资料是两个表单)时,规则可以复用,逻辑也清爽。

商品发布时的图片上传,我结合了Element UI的el-upload组件的http-request自定义上传方法:

<el-form-item label="商品图片" required> <el-upload action="#" list-type="picture-card" :http-request="uploadImage" :limit="4" :on-remove="handleRemoveImage" :file-list="fileList"> <i class="el-icon-plus"></i> </el-upload> </el-form-item>
// 自定义上传:调用后端接口 async uploadImage(option) { const formData = new FormData(); formData.append('file', option.file); const res = await uploadFile(formData); if (res.code === 200) { this.imageList.push(res.data); // 保存返回的图片路径 option.onSuccess(res.data); // 通知el-upload上传成功 } else { option.onError(new Error('上传失败')); } }

注意,图片上传成功之后,表单还没有真正提交。我要做的是把后端返回的图片路径收集到imageList数组里,等用户点“发布”时,再把这个数组和表单其他字段一起提交给后端。如果直接把el-upload的文件对象存进数据库,刷新页面后文件对象就失效了,图片也就显示不出来了。

4.3 后台管理端:Vue实现增删改查的标配套路

后台管理页面,说白了就是“表格+弹窗表单”。Element UI的el-table组件提供列展示、排序、分页功能,el-dialog做新增和编辑的弹窗,配合el-form做表单,再调用后端接口做增删改查。

我写得比较顺手的套路是这样:

  1. 页面加载时调用getList()方法,请求第一页数据;
  2. 点“新增”按钮,弹窗表单清空,提交后刷新列表;
  3. 点“编辑”按钮,回显数据到表单,提交后刷新列表;
  4. 点“删除”按钮,confirm确认后会调用后端删除接口,再刷新。

这个套路写多了就形成肌肉记忆了。核心在于所有的状态变更都需要“刷新列表”来同步最新数据,至于刷新是重新查询接口还是从当前列表里删掉一行,我后来都统一走“重新查询第一页”的方式,逻辑简单且一致性好。如果数据量大了,再把分页条件带上重新查询。

// 后台商品管理页面的核心逻辑 getList() { listGoods({ pageNum: this.pageNum, pageSize: this.pageSize, keyword: this.keyword }) .then((res) => { this.goodsList = res.data.records; this.total = res.data.total; }); } handleDelete(id) { this.$confirm('确认删除该商品吗?删除后不可恢复', '提示', { confirmButtonText: '确定删除', type: 'warning', }).then(() => { deleteGoods(id).then(() => { this.$message.success('删除成功'); this.getList(); }); }).catch(() => {}); }

表格里最实用的功能就是搜索。商品名、用户名、状态都可以作为搜索条件,后端用MyBatis-Plus的QueryWrapper动态拼接like条件:

QueryWrapper<Goods> wrapper = new QueryWrapper<>(); if (StringUtils.isNotBlank(keyword)) { wrapper.like("goods_name", keyword); } if (status != null) { wrapper.eq("status", status); } wrapper.orderByDesc("create_time");

这种写法在MyBatis-Plus里非常优雅,不用像原生MyBatis那样为每种组合写一堆动态SQL标签。

5. 项目部署与二次开发:源码拿到手该干什么

5.1 本地快速部署:从0到跑起来只要10分钟

拿到这套源码后,如果你不想看文档,光跟着报错一步步也能跑起来,但我还是建议先把部署流程理一遍。我自己每次开新环境部署这个项目的固定步骤如下:

  1. 准备环境:JDK 1.8 + Maven 3.6+ + Node 12+ + MySQL 5.7以上。
  2. 导入数据库:在MySQL中创建second_hand数据库,把项目根目录下second_hand.sql直接导入,即可得到所有的表结构和初始化数据(含测试账号)。
  3. 后端配置:修改application.yml里的数据库地址、账号密码、文件上传路径,然后运行DemoApplication.java。
  4. 前端安装依赖:在frontend目录下执行npm install,接着执行npm run dev,浏览器打开http://localhost:8080。
  5. 联调测试:使用自带的测试账号(管理员账号admin、普通用户张三),发布一个多余商品测试整个流程。

前后端分离开发时,由于端口不同(后端默认8080,前端默认8081或8082),会遇到跨域问题。我在后端加了一个CorsConfig配置类:

@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); } }

这个配置让前端可以跨域访问后端接口。注意allowedOriginPatterns("*")和allowCredentials(true)同时使用时的兼容性问题,在Spring Boot 2.6以上版本建议用allowedOriginPatterns而不是allowedOrigins("*"),否则会报“When allowCredentials is true, allowedOrigins cannot be "*"”的错误。

5.2 改造建议:基于这套系统还能快速加什么功能

有了基础版本之后,很多同学会想往里面加自己的东西,让项目显得更有亮点。我根据自己的经验列几个性价比高的扩展方向:

  • 引入Redis做缓存:把首页的热门商品、商品的浏览量统计数据丢进Redis,减少数据库压力,这个在答辩时很加分;
  • 接入阿里云OSS做图片存储:把本地文件上传改成OSS上传,解决服务器重启后图片丢失,同时可以讲出“分布式存储”的概念;
  • 增加聊天功能:二手交易是强信任场景,买家经常需要和卖家在线沟通。可以用WebSocket做一个简单实时的站内信聊天功能;
  • 增加商品回收/举报流程:普通用户可以对违规商品发起举报,管理员在后台处理,这是二手平台的安全必要功能。

我还见过一个特别有想法的同学,在这个基础上做了一个“学生校内交易”的版本,增加了“学号认证”和“宿舍区域”字段,感觉就是一个真能落地的校园创业项目了。做项目不能光做“轮子”,要想着它服务的真实场景,稍微加一点场景定制,就是很拿得出手的毕设亮点。

5.3 上线前必须做的小事:真金白银踩出来的教训

严格说,课设系统到本地运行就算完成,但如果真要部署到云服务器让别人访问,有几件事不做,后面会哭:

第一,MySQL数据库密码设置要够强,并且application.yml里的配置不能使用弱口令,否则扫到3306端口之后很容易被爆破。第二,文件上传目录如果是在Linux服务器上,必须给足权限,同时建议配置Nginx做静态资源访问,或把上传目录指到专人维护的目录下——我是遇到过“图片传上了但访问404”的坑,最后发现是Linux下目录权限不对。第三,后端服务不能裸奔,至少要用Nginx做反向代理,把接口域名配上HTTPS证书,浏览器里一看就是绿锁,安全感直接拉满。

事项状态说明
数据库初始化脚本已包含直接导入即可
测试账号已准备管理员/普通用户双角色
接口文档已包含配合源码更清晰
跨域配置已处理前后端分离必备

6. 常见问题与排查实录:平时最容易踩的12个坑

6.1 前后端联调阶段的高频报错

前后端联调是整个项目最让人头秃的阶段,80%的时间不是卡在业务逻辑,而是卡在“传参没对齐”和“环境配置不一致”。我把这半年来遇到的高频问题做一个速查表:

报错/现象原因解决方案
前端请求报404接口路径对不上检查Controller里的@RequestMapping路径
前端请求报500后端异常看后端控制台异常栈,多数是SQL或者空指针
图片上传后访问404静态资源映射没配加@Configuration静态资源映射,把本地目录映射到/images/**
商品列表一直为空数据库表名对不上检查@TableName注解与表名是否一致
日期数据格式不对后端返回时间戳在字段上添加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
前端Cookie跨域不携带withCredentials没设置axios加withCredentials: true

这里面“图片上传后访问404”是我第一次做这个项目时卡了特别久的问题。当时我以为上传接口成功了、文件也确实存进本地了,但浏览器访问图片地址就是打不开。后来才想起,SpringBoot默认只处理classpath:/static/下的静态资源,我在磁盘上的upload目录根本不在资源映射范围里。解决办法是加一个静态资源映射配置类:

@Configuration public class StaticResourceConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceLocations("file:" + uploadPath); } }

6.2 业务逻辑里的隐蔽坑

除了联调问题,业务逻辑上的坑更隐蔽,出了问题还不容易察觉。我说几个当时特别典型的:

第一个是“删除商品关联数据未处理”。如果直接删除一条商品记录,而购物车、订单、收藏表里还有引用它的记录,那么买家购物车里会显示一个“已失效的商品”,所有关联页面直接报错。正确的做法是在删除商品时,同步把购物车里和收藏里关联的数据也删掉,或者在商品表增加isDeleted逻辑删除字段。我在这个项目里用的是物理删除加级联清理,在GoodsService.deleteGoods()里做了事务控制:

@Transactional public void deleteGoods(Long goodsId) { goodsMapper.deleteById(goodsId); cartMapper.delete(new QueryWrapper<Cart>().eq("goods_id", goodsId)); collectMapper.delete(new QueryWrapper<Collect>().eq("goods_id", goodsId)); }

第二个是“库存概念缺失导致超卖”。二手交易的商品库存永远是1,买家下单时如果不控制并发,两个人同时提交订单,两个订单都创建成功,但这个商品其实只剩一件。我在前面提到的UPDATE t_goods SET status = 1 WHERE id = ? AND status = 0就是干这个事的。这条SQL本质就是一个行级锁的乐观锁实现,放在事务里执行,天然防超卖。

第三个是“搜索关键词为空时的SQL注入风险”。由于用了MyBatis-Plus的QueryWrapper,传进来的keyword会被当参数处理,不会发生SQL注入。但如果你是自己拼接SQL字符串,一定不能用"select * from goods where good_name = '" + keyword + "'"这种写法。MyBatis的#{}和${}要分清楚,${}是有注入风险的。

6.3 部署线下环境的注意事项

还有一批问题是在换环境部署时暴露的。在我自己测试机器上跑得好好的项目,当我把它搬到一台全新的Linux服务器上或者同学电脑上时,接连出事。

最典型的是“文件上传路径不存在”。我的代码里是File dest = new File(uploadPath, fileName);然后file.transferTo(dest);。如果uploadPath这个目录在系统里不存在,transferTo会抛IOException。要注意手动创建目录,Java的File.mkdirs()方法是最好的保证:

File dir = new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); }

另一个是“MySQL版本不一致”。我本机MySQL 8.0的数据库驱动依赖是com.mysql.cj.jdbc.Driver,数据库连接串也要加serverTimezone=Asia/Shanghai,否则会报时区错误。换成MySQL 5.7环境时,驱动类就不一样。如果直接把整个项目发给别人跑,对方如果环境不一致,这些配置就会变成第一道坎。

7. 经验复盘:一套项目源码背后的真正价值

做完了这个二手物品交易系统,我最大的感受是:源码只是项目的骨架,真正的价值在于你能否说清楚每一处设计“为什么这么来”。数据库表为什么这样建模、JWT为什么比Session适合前后端分离、订单状态为什么需要状态机、商品锁单为什么用乐观锁更新……这些问题的答案,直接决定了答辩时老师点个头还是不断追问。

我也建议所有拿到这套源码的朋友,不要直接当成成品交作业。试着把项目跑起来,然后从外到内改一个功能:

  1. 先改前端页面:把一个商品卡片改成你喜欢的样式;
  2. 再加一个字段:比如增加“校内自提点”字段,把它加到发布表单、数据库表、列表展示三个环节;
  3. 最后改一个后端逻辑:比如把商品下架逻辑加一个管理员审核前置流程。

这样三轮改造下来,整个系统的数据流、请求链路、表关系就会刻在脑子里了,比任何文档都有效。项目本身只是一个起点,它教给你的是“从0到1搭一套完整Web应用”的通用能力,这份能力用到任何别的管理系统上,你都能快速上手。

最后顺手分享一个写代码时的微小习惯:每次改完一个功能,我习惯手动走一遍完整流程——注册账号、发布商品、换一个账号去买、完成订单、再去后台看数据。一套全链路跑通,才算一次修改真正结束。很多时候自测流程太潦草,答辩现场才要当众翻车,狼狈的不是机器,是人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询