ARTICLE DETAIL

资讯详情

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

二手手机交易平台开发实战:Spring Boot毕设状态机与并发扣库存设计

二手手机交易平台开发实战:Spring Boot毕设状态机与并发扣库存设计 做毕业设计那会儿我把题目定为“基于Spring Boot与JavaEE的二手手机交易平台”当时以为这不就是一个普通的增删改查管理系统真正动手之后才发现交易类系统的复杂程度远远超过预期。二手手机这个品类又比普通商城多了一层“非标品”的特殊性成色怎么定义、图片怎么展示、订单状态怎么流转每一个细节都牵一发动全身。这篇文章把我从需求梳理、技术选型、数据库建模到主链路代码和上线前的坑完整过一遍适合正在准备同类方向的Spring Boot毕设、或者想补一个完整交易类项目经验的同学。你不需要照着抄但至少能少走我走过的那些弯路。1. 项目背景二手手机交易平台到底在解决什么问题1.1 二手手机交易的痛点与平台价值二手手机交易和全新的B2C商城有一个本质区别单价高、非标、买家对质量极度不放心。一台iPhone全新机与99新的二手机差价可能超过两千块但买家顾虑的事情很多——屏幕是不是换过、有没有隐藏ID锁、电池效率实际多少、卖家描述的成色是不是靠谱。卖家也有自己的担心挂出去会不会被无理砍价、线下面对面交易会不会被掉包、买家收货后会不会故意挑毛病退货。平台夹在中间核心价值其实就是两件事第一把信息展示规范化让手机的成色、外观、维修情况尽量透明第二把交易流程从“线下私下聊”拉到线上用订单状态把买卖双方的权责固定下来。所以在写代码之前我给自己定了一个基调这个平台做的不单纯是商城而是一个“信任中介”。这句话直接影响了我后面所有数据库设计和订单状态机的取舍。1.2 角色划分与核心业务闭环这个平台我划分了三种角色买家、卖家、管理员。一个用户既可以买也可以卖所以用户表不需要拆成两张只需要用字段标识身份。卖家需要登录验证、发布商品、编辑商品、管理自己的订单买家负责检索商品、收藏、下单、模拟支付、确认收货、评价管理员负责商品审核、用户管理、订单纠纷处理、平台公告维护。核心业务闭环我用文字描述是卖家发布手机信息平台审核通过后变为“在售”买家通过品牌、价格区间、成色等级筛选商品选好后下单平台生成待支付订单模拟支付成功变成待发货卖家发货变成待收货买家确认收货变成已完成最后双方互相评价。这里每一步都对应订单表的一个状态任何一个环节处理错了后面排查起来都很痛苦所以我把状态机的设计放到了整个项目的最前面而不是等代码写完了再补。2. 技术选型Spring Boot和JavaEE的相容性2.1 一段被误解的关系史很多同学一看到题目里“基于JavaEE”就开始慌以为要回去用EJB、Servlet那一套老古董。其实JavaEE规范早就不等于“繁琐的J2EE”它更像一个标准集合Servlet规范、JSP、JPA、JMS、Bean Validation等都属于它的范畴。Spring Boot虽然自己做了大量封装但底层Web应用依然跑在Servlet容器里它只是把配置过程简化到了极致并且大量依赖JavaEE定义的标准接口。用一个不严谨但好记的类比JavaEE规定的是“如何盖一栋符合标准的房子”Spring Boot则是直接给你一套装修好的精装房你拎包入住、改改风格就行。所以毕设题目写成“基于JavaEE的Spring Boot项目”是完全说得通的。我当时项目里就用了spring-boot-starter-validation做参数校验走的就是Bean Validation规范这些都可以在答辩时作为“基于JavaEE”的支撑点。2.2 我最终选定的技术栈和为什么我的技术栈最终确定为Spring Boot 2.7.x MyBatis-Plus MySQL 8.0 Redis MinIO JWT Lombok。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.14/version /parent dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.2.1/version /dependency dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.2/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency为什么不用JPA而选MyBatis我自己的习惯是涉及复杂的动态查询比如二手手机按价格区间、品牌、成色、关键字组合筛选时MyBatis的XML写SQL更直观后期做SQL优化也方便。MyBatis-Plus自带分页插件单表操作几乎不需要写CRUD代码开发效率很高。Spring Boot的自动装配机制帮我把数据源、事务、Redis连接的初始化都处理好了配置集中在application.yml里维护起来比传统SSH省事太多。2.3 中间件的边界何时引入Redis、对象存储、消息队列有人会问为什么不用微服务为什么不用RocketMQ我的回答是这类项目的核心是交易流程正确性数据量级别一般在几十万商品一台4C8G的服务器已经完全够用。中间件只加Redis用来做三件事缓存热门商品列表、保存验证码、配合实现简单的接口频率限制。消息队列在订单超时关闭场景确实更优雅但到了单体项目的规模基于定时任务加表字段扫描的做法副作用更少也更容易向答辩老师解释清楚。你少学一个MQ不会影响毕业设计评分但你把状态机搞错、把并发超卖处理错了一定会被问倒。技术选型的核心原则是“够用、能讲清楚、出了问题能快速定位”而不是越复杂越显得厉害。3. 数据库设计把状态机建模想清楚再动工3.1 核心表清单与职责划分我总共设计了11张核心表它们的职责划分如下表名职责关键字段user买家/卖家共用账号id, username, password, phone, avatar, statusadmin管理员账号id, username, password, rolephone_goods手机商品信息id, title, brand_id, condition_level, price, images, statusbrand手机品牌id, name, logocategory商品分类id, name, pidorders订单主表order_no, buyer_id, seller_id, goods_id, statusorder_item订单快照明细id, order_id, goods_title, goods_image, pricecart购物车id, user_id, goods_id, quantityfavorite收藏id, user_id, goods_idcomment评价id, user_id, order_id, goods_id, content, ratenotice平台公告id, title, content, create_time品牌和分类我没有拆太细。二手手机品牌比较固定主要就是苹果、华为、小米、OPPO、vivo、三星、荣耀这些分类可以用来表示价格档位或者机型类型比如“旗舰机”“千元机”“老年机”。品牌单独建表付出的成本很低但给后台管理端的下拉框和商品筛选项带来了很大便利。3.2 商品表成色、图片、审核状态都藏在这里商品表是整个系统的信息中心也是所有展示页面和数据统计的数据源。我给出一个简化但完整的建表语句CREATE TABLE phone_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(64) NOT NULL COMMENT 商品标题, subtitle VARCHAR(255) COMMENT 卖点描述, brand_id BIGINT NOT NULL COMMENT 品牌ID, category_id BIGINT COMMENT 分类ID, condition_level TINYINT NOT NULL DEFAULT 1 COMMENT 成色等级1-5, price DECIMAL(10,2) NOT NULL COMMENT 售价, original_price DECIMAL(10,2) COMMENT 原价参考, images VARCHAR(2000) COMMENT 图片地址JSON数组, detail TEXT COMMENT 详细描述, user_id BIGINT NOT NULL COMMENT 卖家ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1在售 2已下架 3已售, audit_reason VARCHAR(255) COMMENT 审核不通过原因, view_count INT DEFAULT 0, create_time DATETIME, update_time DATETIME );这里有两个字段值得多说几句。第一个是condition_level二手手机不能简单用“全新”和“二手”两个词我划分了99新、95新、9成新、8成新、有明显修复痕迹五个等级搜索时按等级筛选展示时用前端标签。第二个是audit_reason管理员审核不通过时填写的理由哪怕你不做站内信这个字段也能让卖家知道商品为什么被打回。images字段我一开始存的是逗号分隔的地址后来改成了JSON数组字符串因为一部手机通常要展示正面、背面、屏幕细节、包装盒等多张图逗号分隔在文件名里如果碰巧带了逗号就会解析错。3.3 订单表交易正确性的基石订单表是交易流程的核心我的orders表结构大致如下CREATE TABLE orders ( order_no VARCHAR(32) PRIMARY KEY, buyer_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, goods_title VARCHAR(64), goods_image VARCHAR(255), price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1待发货 2待收货 3已完成 4已取消 5退款中 6退款完成, pay_time DATETIME, ship_time DATETIME, receive_time DATETIME, create_time DATETIME );为什么订单号要自己生成而不是直接用自增ID因为自增ID会暴露业务量而且订单号在很多业务场景里要向用户展示太长太规律都不合适。我用的是yyyyMMddHHmmss 随机数 用户ID后四位并发够用看起来也专业。状态之间的流转必须走合法路径待支付不能直接变已完成只能走“待支付→已取消”或者“待支付→待发货支付成功”。我在代码里写了一个OrderStatus枚举把所有允许的流转路径都定义好Controller里禁止随意order.setStatus(3)这种写法所有状态变更必须经过统一的服务层方法。3.4 索引设计与事务边界划分索引设计上我并没有把所有字段都加索引那样只会让写入变慢。实际执行最多的查询是商品搜索和订单查询所以重点加了这几组phone_goods表status create_time、brand_id status、seller_idorders表buyer_id、seller_id、status create_time超时扫描订单、order_no唯一索引搜索场景的SQL基本是where status 1 and brand_id ? and price between ? and ?一条联合索引idx_status_brand_price(status, brand_id, price)就能覆盖大部分情况。事务边界划分也是我特别重视的。下单时要同时操作订单表和商品表状态必须放在同一个事务里。但事务别开太大我见过有人把生成缩略图、发送通知都塞进下单事务压测时数据库连接被长期占用吞吐量直接掉了三分之一。我的原则是只把对数据库有写入操作的步骤放进事务外部调用和耗时操作全部放在事务提交之后。4. 主链路开发从注册登录到确认收货4.1 登录认证JWT加拦截器的组合拳权限控制我用的是JWT加自定义拦截器没有引入Spring Security。对这类项目来说Spring Security的学习成本明显高于收益拦截器加注解做角色控制完全够用。核心思路是在配置类里注册拦截器Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/goods/list, /api/goods/detail); } }JWT的载荷里只放userId和role两个必要字段不放多余信息。每个请求从Header中取Token拦截器解析成功后把用户信息塞进ThreadLocal后续的Controller通过UserContext.getUserId()直接拿省去在方法参数里反复传用户ID的麻烦。这里要补充一个细节JWT本身是无状态的用户退出登录后Token在过期前仍然有效所以我额外维护了一个Redis黑名单退出时把Token加入黑名单过期时间与Token一致两套机制配合起来才算是完整的登录方案。密码存储方面我强烈建议不要用MD5至少用BCrypt。BCrypt是自动加盐的同一个密码每次加密结果都不同存储安全性和对比安全性都有保障。Spring Security的加密模块可以单独引入也可以用spring-security-crypto这个轻量依赖注册时加密存库登录时比对即可。4.2 商品发布与图片上传从白名单到MinIO商品发布拆成两步先上传图片拿到URL再提交表单数据。图片上传这一块我踩过一些坑重点在文件类型和大小校验上。String ext FilenameUtils.getExtension(file.getOriginalFilename()); if (!Arrays.asList(jpg, jpeg, png, webp, gif).contains(ext.toLowerCase())) { throw new BizException(仅支持图片格式); } if (file.getSize() 5 * 1024 * 1024) { throw new BizException(单张图片不能超过5MB); }只判断扩展名是不够的网上很多教程让你看一眼扩展名就放行实战中很容易被伪造。我后来在服务层里加了文件头魔数校验判断文件真正的前几个字节判断它真的是JPEG还是PNG这样能挡住大部分伪装成图片的可执行文件。存储目录按日期分生成规则是yyyyMMdd/加UUID文件名再把真实的扩展名拼到后面避免同一个目录文件数量膨胀也避免文件名冲突。存储引擎上我把上传逻辑封装成了一个StorageService接口开发环境用本地磁盘实现部署到服务器后用MinIO实现。MinIO作为开源对象存储对这类项目特别合适配置也不复杂几十行代码就能接入。如果直接把图片存在本地目录Docker重建容器时文件很容易丢所以生产环境我强烈建议用MinIO或者云对象存储。4.3 搜索筛选一条SQL的进化史搜索是二手手机交易平台使用频率最高的接口。二手手机搜索的基本筛选条件有关键字、品牌、价格区间、成色等级排序方式有最新发布、价格从低到高、价格从高到低。这类动态查询用MyBatis的XML最合适QueryWrapper虽然方便但条件多了阅读性很差select idsearchGoods resultTypeGoodsVO SELECT * FROM phone_goods where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR subtitle LIKE CONCAT(%, #{keyword}, %)) /if if testbrandId ! nullAND brand_id #{brandId}/if if testminPrice ! nullAND price gt; #{minPrice}/if if testmaxPrice ! nullAND price lt; #{maxPrice}/if if testconditionLevel ! nullAND condition_level #{conditionLevel}/if AND status 1 /where ORDER BY create_time DESC /select这个SQL有个小细节关键字搜索用的是title LIKE和subtitle LIKE因为二手手机的标题描述通常比较短没必要上全文索引。商品量到10万级之前这条SQL加上联合索引完全可以顶住不需要上Elasticsearch。等未来规模大了再改也只是替换检索层的实现不影响整体架构。热门品牌的第一页搜索结果我用Redis做了缓存每次商品上下架时清一下对应品牌的缓存命中率很高接口响应也能控制在几十毫秒级别。4.4 下单扣库存用数据库行锁替代用户级锁这是整个项目最容易出问题的地方也直接决定了你能不能扛住答辩老师的追问。问题场景很典型一个手机只有一台库存两个买家同时点下单如果代码先select判断状态再update必然出现超卖。正确的做法是用条件更新一步到位Transactional public void createOrder(Long goodsId, Long buyerId) { Goods goods goodsService.getById(goodsId); if (goods null || goods.getStatus() ! 1) { throw new BizException(商品已下架); } int rows goodsService.update( new LambdaUpdateWrapperGoods() .eq(Goods::getId, goodsId) .eq(Goods::getStatus, 1) .set(Goods::getStatus, 3) ); if (rows 0) { throw new BizException(手慢了商品已被拍下); } Order order buildOrder(goods, buyerId); orderMapper.insert(order); }这段代码的精髓在于update ... where status 1。数据库的行锁会保证同一时刻只有一个事务能把商品状态从1改成3第二个事务执行更新时匹配不到记录rows为0直接抛出“手慢了”的异常。这里不需要在Java层加synchronized或者分布式锁数据库的行锁天然就是最可靠的并发控制手段。还有两个细节需要提醒第一Transactional必须加上这个事务的隔离级别用默认的即可第二buildOrder方法里不要再重新查询商品直接在步骤1拿到的对象上构造订单避免多一次无谓的数据库查询。4.5 模拟支付与订单超时关闭真实对接支付宝、微信支付需要商户资质毕设阶段通常做模拟支付。我的流程是下单成功后跳转到模拟支付页用户点“确认支付”后端更新支付时间和订单状态从待支付变成待发货。这个接口本身逻辑很简单但一定要在支付成功回调里校验订单状态——如果订单已经超时取消了就不能再支付成功否则会造成“订单取消但钱付了”的尴尬局面。订单超时关闭我也用了最简单可靠的定时任务方案Component public class OrderTimeoutTask { Scheduled(cron 0 */1 * * * ?) public void closeTimeoutOrders() { // 每1分钟扫描一次关闭创建时间超过15分钟且仍为待支付的订单 ListOrder list orderMapper.selectTimeoutOrders(15); for (Order order : list) { orderService.closeTimeoutOrder(order); } } }这里有一个很多人会犯的错扫描到超时订单后直接批量把订单状态更新为取消顺手也把商品状态统一改回在售。如果那个商品已经被其他用户买走这个批量更新就会把已售商品改成在售造成数据错乱。正确做法是逐个处理并且只对还处于待支付状态的订单执行关闭操作商品状态只有在它还是“已售”且对应订单待支付时才允许回滚。5. 后台管理管理员要的不是花架子5.1 后台功能清单后台管理端我拆成了六个模块用户管理、商品审核、品牌分类维护、订单管理、评论管理、公告管理。用户管理页面需要支持拉黑用户拉黑后该用户发布的新商品自动进入待审核状态登录接口也要返回明确的提示这是平台内容安全的第一道防线。商品审核是后台最核心的功能管理员在审核页看到每件待审核商品的完整信息、图片列表和卖家备注点击通过就变成“在售”点击不通过需要填写原因商品状态变为“下架”并附带audit_reason。订单管理页面只做查看和纠纷处理管理员不直接改订单金额但可以强制取消违规订单并回滚商品状态。评论管理主要是删除违规评价品牌分类维护则是给前端下拉框提供数据源。5.2 首页统计与数据报表统计功能不需要写得很重每天成交订单数、每周新注册用户数、热销品牌排行这些直接用SQL聚合就可以完成SELECT COUNT(*) FROM orders WHERE status 3 AND pay_time BETWEEN ? AND ?但如果数据量大每次都实时聚合会拖慢接口。我建议做一张统计汇总表定时任务每半小时跑一次后台首页只查汇总表的数据图表用ECharts渲染前后端定义一个统计VO传递数据即可。还有一个实用的小功能热销品牌排行可以用group by brand_id配合count(*)按订单量降序排列展示成柱状图这个数据在答辩演示时特别直观。6. 上线前必须补的课并发、安全、事务6.1 并发抢购的实测与修复对比我知道很多人看到“并发”两个字就觉得离自己很远但交易类系统必须面对这个场景。我自己写了一个模拟并发脚本用100个线程同时下单同一台手机修复之前大约有7个线程都返回了下单成功但商品只有一台这就是典型的超卖。改成update where status 1之后再跑一次100并发结果只有1个成功其余全部收到“手慢了商品已被拍下”数据完全正确。修复前后的对比是场景修复前修复后100线程并发抢一台手机约7个线程下单成功仅1个成功订单与商品状态一致性不一致部分订单无货完全一致是否需要引入额外中间件否否靠数据库行锁解决这里的关键结论是不要靠Java层的锁去解决并发问题而要让数据库的行锁成为最后一道防线。条件更新的写法不仅解决超卖还能保持代码简单事务边界清晰。6.2 文件上传的安全边界文件上传是Web项目被攻击的高发点我在这里加了四道防线扩展名白名单、文件头魔数校验、文件大小限制、上传目录禁止执行脚本。前三个在4.2节已经讲过第四点容易被忽略——如果你把上传目录放在Tomcat的应用目录里又允许执行JSP或者动态脚本攻击者上传一个恶意的JSP文件就可能直接拿到服务器权限。所以上传目录一定要放在应用目录之外并且通过映射方式访问这不仅是配置问题更是安全意识问题。另外还有一个很常见的坑多文件上传时中文文件名或者表单其他字段出现乱码多半是编码过滤器没有设置好在Spring Boot中配置一个CharacterEncodingFilter把编码强制为UTF-8就能解决。6.3 接口防刷与请求校验登录接口一定加验证码Redis存验证码有效期5分钟验证码图片可以用开源库生成没必要自己造轮子。下单接口要限制频率比如同一个用户一秒钟最多请求一次可以用Redis的INCR计数实现。对于所有写接口参数校验统一用Validated加DTO注解例如public class OrderCreateDTO { NotNull(message 商品ID不能为空) private Long goodsId; }这样Controller里就不需要写一堆if判断参数错误会由全局异常处理器统一拦截返回代码干净很多。XSS和SQL注入的基础防御也要做——MyBatis的#{}天然防止SQL注入但${}拼串时一定要警惕XSS方面对商品标题、评论内容做前端转义后端在输出时再做一次白名单校验。6.4 事务失效的四个经典场景事务失效是我在答辩前复习时重点整理的知识点也是面试官最喜欢问的问题。第一个经典场景是同类内部调用this.createOrder()不会走Spring的AOP代理事务注解直接失效必须注入自身代理或者把事务方法拆分到另一个Bean里。第二个是异常被自己catch掉了Spring的事务默认只在RuntimeException和Error时回滚你捕获了异常业务照样提交。第三个是方法不是publicSpring的Transactional是通过代理实现的非public方法无法被代理拦截。第四个是MySQL表用了MyISAM引擎MyISAM不支持事务建表时一定要确认是InnoDB。还有一个容易被忽略的知识点Spring Boot 2.x默认使用CGLIB代理因为它不能直接用JDK动态代理这个背景知识在解释事务为什么失效时特别有用。理解代理机制之后很多Spring框架里的“玄学问题”都能想通了。7. 部署与交付从jar包到服务器7.1 多环境配置与打包项目开发环境和生产环境的数据库地址、Redis地址、文件存储路径都不一样我用application-dev.yml和application-prod.yml做区分主配置文件里只留公共配置。打包用mvn clean package -DskipTests会在target目录生成可执行jar包部署时直接java -jar phone-trade.jar --spring.profiles.activeprod即可。这里可以提一个开发期的小乐趣Spring Boot的Banner是可以在线生成的折腾一个自己的启动Banner放到banner.txt里启动时能看到专属标识对项目仪式感的提升很有帮助。我补充一句关于“jar反编译成项目”的热搜话题——如果你不小心把源码搞丢了别指望反编译能还原出完整的工程结构class文件反编译得到的代码缺失注释和资源文件可读性很差。所以版本管理真的很重要哪怕是单人开发也应该把代码提交到Git仓库这是我从这个项目养成的习惯。7.2 Docker部署与Nginx反向代理如果服务器上装了Docker部署会简单很多。一个基本的Dockerfile长这样FROM openjdk:8-jre-alpine RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime COPY target/phone-trade.jar /app/app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app/app.jar, --spring.profiles.activeprod]Nginx在项目里做两层事情一是把/api请求反向代理到Spring Boot的8080端口二是代理静态资源目录比如上传的图片。前后端如果分离还需要把前端打包后的dist目录交给Nginx托管。有一个特别容易被忽略的细节Docker容器内默认时区是UTC比北京时间早8个小时订单超时、时间统计这类逻辑会出错所以Dockerfile里一定要加时区设置这一点我排查了很久才定位到。最后说一句走了很多弯路才得出的体会。交易平台类的毕设代码谁都能敲差别往往在状态和边界这些看不见的地方。我建议你在动手前先把订单状态机、商品状态、并发扣库存这几条主线理清楚等你真的把“两个用户同时抢同一台手机”这个场景压测通过时你对Spring Boot事务、数据库行锁、条件更新这些知识点的理解会比看十篇教程都有用。少问怎么做多问边界在哪里做完你就会发现二手手机交易平台这个题目比那些空泛的管理系统值得做多了。
返回列表