ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue实战:二手交易系统设计与全栈开发指南

SpringBoot+Vue实战:二手交易系统设计与全栈开发指南 一个二手物品交易系统对于做过Java课程设计或者毕业设计的同学来说绝对是可以直接封神的经典选题。它业务逻辑清晰、功能扩展性强而且技术栈组合非常成熟——SpringBoot负责后端接口Vue负责前端页面MySQL存数据源码加数据库加文档一套齐全拿来即用改改就能交。就算你不是为了应付答辩想把它当成一个正经的练手项目复盘一遍SpringBootVue全栈开发流程这套系统也能覆盖到绝大多数必踩的知识点用户认证、权限控制、商品发布、搜索分页、订单流转、文件上传……今天干脆把我整理这套系统的思路、核心表结构、关键代码和踩坑记录全部理一遍希望能给正在做同款项目的朋友一个靠谱参考。1. 项目整体设计与技术选型为什么偏偏是SpringBoot Vue1.1 技术栈选型的底层逻辑先说技术栈。市面上能做Web项目的组合非常多从最原始的JSP/Servlet到SSHStrutsSpringHibernate再到SSMSpringSpringMVCMyBatis再到我这次用的SpringBootVue前后端分离每一代都有各自的理由。二手交易系统是一个典型的“管理密集型”应用用户登录注册、商品信息维护、订单状态变更、评论留言……这些场景对开发效率的诉求远大于对极致性能的诉求。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)关联商品IDseller_idbigint(20)卖家IDbuyer_idbigint(20)买家IDpricedecimal(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共享问题。后来我把认证改成了JWTJSON Web Token方案。核心思路是用户登录成功后服务端把用户ID、用户名、角色等信息打包成一个Token用Base64编码加签名发给前端存储。前端每次请求在请求头里带上Authorization: token后端用拦截器校验签名是否有效有效就从Token里解析出用户信息。我选JWT的一个重要原因是它天然支持前后端分离Token是自包含的服务端不需要存Session状态天然适合水平扩展。但这也有个副作用——Token签发后无法主动让它失效除非等它自然过期。于是我给Token设置了24小时的过期时间也就是用户登录一天后需要重新登录对课设和毕设的场景来说是合理的。关键代码片段登录接口的核心逻辑// 用户登录 PostMapping(/login) public Result login(RequestBody LoginDTO loginDTO) { QueryWrapperUser 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()); MapString, 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-typepicture-card :http-requestuploadImage :limit4 :on-removehandleRemoveImage :file-listfileList i classel-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做表单再调用后端接口做增删改查。我写得比较顺手的套路是这样页面加载时调用getList()方法请求第一页数据点“新增”按钮弹窗表单清空提交后刷新列表点“编辑”按钮回显数据到表单提交后刷新列表点“删除”按钮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条件QueryWrapperGoods 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分钟拿到这套源码后如果你不想看文档光跟着报错一步步也能跑起来但我还是建议先把部署流程理一遍。我自己每次开新环境部署这个项目的固定步骤如下准备环境JDK 1.8 Maven 3.6 Node 12 MySQL 5.7以上。导入数据库在MySQL中创建second_hand数据库把项目根目录下second_hand.sql直接导入即可得到所有的表结构和初始化数据含测试账号。后端配置修改application.yml里的数据库地址、账号密码、文件上传路径然后运行DemoApplication.java。前端安装依赖在frontend目录下执行npm install接着执行npm run dev浏览器打开http://localhost:8080。联调测试使用自带的测试账号管理员账号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 QueryWrapperCart().eq(goods_id, goodsId)); collectMapper.delete(new QueryWrapperCollect().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数据库连接串也要加serverTimezoneAsia/Shanghai否则会报时区错误。换成MySQL 5.7环境时驱动类就不一样。如果直接把整个项目发给别人跑对方如果环境不一致这些配置就会变成第一道坎。7. 经验复盘一套项目源码背后的真正价值做完了这个二手物品交易系统我最大的感受是源码只是项目的骨架真正的价值在于你能否说清楚每一处设计“为什么这么来”。数据库表为什么这样建模、JWT为什么比Session适合前后端分离、订单状态为什么需要状态机、商品锁单为什么用乐观锁更新……这些问题的答案直接决定了答辩时老师点个头还是不断追问。我也建议所有拿到这套源码的朋友不要直接当成成品交作业。试着把项目跑起来然后从外到内改一个功能先改前端页面把一个商品卡片改成你喜欢的样式再加一个字段比如增加“校内自提点”字段把它加到发布表单、数据库表、列表展示三个环节最后改一个后端逻辑比如把商品下架逻辑加一个管理员审核前置流程。这样三轮改造下来整个系统的数据流、请求链路、表关系就会刻在脑子里了比任何文档都有效。项目本身只是一个起点它教给你的是“从0到1搭一套完整Web应用”的通用能力这份能力用到任何别的管理系统上你都能快速上手。最后顺手分享一个写代码时的微小习惯每次改完一个功能我习惯手动走一遍完整流程——注册账号、发布商品、换一个账号去买、完成订单、再去后台看数据。一套全链路跑通才算一次修改真正结束。很多时候自测流程太潦草答辩现场才要当众翻车狼狈的不是机器是人。
返回列表