简介:基于Java Spring Boot的二手交易平台完整源码,面向毕业设计、课程设计及需要快速上手全栈项目的初学者,解决从零搭建可运行交易系统的难题。围绕用户注册登录、商品发布与浏览、多条件搜索、购物车下单、订单管理等主要环节设计,覆盖了典型电商业务闭环。资源共809个文件,以Java后端源码、Vue前端组件、JavaScript脚本、HTML页面、CSS样式为主,同时包含SVG图标、GIF效果图、SQL数据库脚本和项目说明文档;压缩包整体16.84MB,目录按控制层、服务层、持久层与视图模块划分,结构清楚,便于按需研读或替换功能。目前已有41人学习或下载,属于可正常运行的课程设计/毕业设计方案,适合毕业设计或课设演示使用。项目自带MySQL建表与初始化脚本、启动/安装批处理命令以及可参考的备份文件,配置好开发环境后即可运行。读者既能从完整代码了解Spring Boot+Vue前后端联调思路,也能直接将其作为课程设计展示、论文支撑或二次开发基础。
1. 为什么二手交易平台是Java后端最稳的毕业设计选题
每年毕业设计选题季,总有一批人抱着“做个商城”的心态去找导师,结果被一句“满大街都是”怼回来。而二手交易平台不同——它看似也是买卖,但比普通商城多了一整条“人 → 物 → 钱 → 信任”的闭环。买家要注册登录、卖家要发布闲置、双方要讨价还价、下单后订单状态要一路流转、交易完成要留凭证。这些环节正好把Java后端开发里最常考的实体建模、关联查询、事务一致性、鉴权拦截、文件上传全部串起来,课程设计能交差,毕业设计能扩写成论文。更关键的是,二手平台的业务规则比标准商城更灵活:商品可下架重上架、价格可面议、订单可取消可退款。这些“非标流程”恰好是答辩时展示设计能力的素材。
这篇笔记按我实际做完这类项目的顺序来讲——先定技术选型,再建表建模,然后逐段落地核心代码,最后是花了两晚才解决的坑。读者如果正卡在“不知道从哪里开始”的阶段,可以直接照着章节顺序把工程搭起来,每一步都会解释为什么这么做,而不是只贴代码。
2. 技术选型与工程骨架:Spring Boot + MyBatis-Plus + MySQL 怎么搭最省事
2.1 为什么不用 SSM / SSH 而用 Spring Boot
很多学校课程还在教 SSM 整合,但到了做课程设计和毕业设计的节点,时间比什么都值钱。SSM 要手动配置 DataSource、SqlSessionFactory、MapperScanner、事务管理器,Spring MVC 还要配视图解析器和拦截器,这一套下来至少占掉两三天,而且配置错了报错信息又长又绕。Spring Boot 用自动配置和起步依赖把这些全干了,一个spring-boot-starter-web就内嵌 Tomcat 并注册好 DispatcherServlet,数据库连接池、事务管理器、MyBatis 的 Mapper 扫描都能通过注解或配置项打开。对于项目周期只有 8 到 12 周的课题来说,把时间省下来写业务代码、画 E-R 图、准备答辩,收益高得多。
另一个考量是 Spring Boot 的生态太成熟了。做二手交易平台要用的分页插件、代码生成器、参数校验框架,社区都有现成的 starter。遇到问题搜解决方案,十篇里有八篇是 Spring Boot 环境下的答案,这对新手非常重要。SSM 年代遇到一个NoMyBatisMapperException可能要查半天,Spring Boot 下往往是漏了@MapperScan或者依赖没引全,错误信息也更友好。
2.2 具体搭建步骤和依赖清单
创建工程我一般用 Spring Initializr,选 Java 8 或 11(对应学校环境最稳),依赖选Spring Web、MySQL Driver、MyBatis Framework。然后手动在pom.xml里补上 MyBatis-Plus 的依赖,因为很多课程设计环境里默认只有 MyBatis,而 MyBatis-Plus 能让单表 CRUD 连 SQL 都不用写:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency>这段依赖解决的是最繁琐的通用 Mapper 问题。MyBatis-Plus 提供BaseMapper<T>,里面预置了selectById、selectPage、updateById、deleteById等常用方法,二手交易平台里的用户表、商品表、订单表大部分操作都是单表增删改查,直接继承就能用,只有复杂统计查询才需要写 XML。版本号建议用 3.5.x,太老的 3.3.x 在 Spring Boot 3 下会冲突。
application.yml里最关键的几个配置项如下:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/second_hand?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true 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参数说明:serverTimezone=Asia/Shanghai必须加,MySQL 8.0 默认时区与驱动不一致时会报The server time zone value '�й���ʱ��' is unrecognized。useSSL=false是本地开发固定写法,避免 MySQL 8 默认开启 SSL 导致握手警告。multipart的两个参数分别控制单文件和单次请求的最大体积,二手交易的商品实拍图一般手机拍的都大于 1MB,这里给了 10MB 的冗余。map-underscore-to-camel-case让数据库的seller_id自动映射到实体的sellerId,能少写大量结果集映射。logic-delete-field打开逻辑删除后,调用deleteById实际执行的是UPDATE ... SET deleted = 1,对二手平台来说商品删除应该是隐藏而不是物理消失,这个配置后面会专门讲。
启动类就是标准的 Spring Boot 入口,加一个@MapperScan:
@SpringBootApplication @MapperScan("com.secondhand.mapper") public class SecondHandApplication { public static void main(String[] args) { SpringApplication.run(SecondHandApplication.class, args); } }@MapperScan的包路径必须和你的 mapper 接口所在包完全一致,否则启动时所有 mapper 都不会被注册,调用任何查询方法都会报Invalid bound statement。工程内部按controller、service、mapper、entity、common、config六个包组织,这不仅是整洁问题——答辩时老师通常会看包结构来快速判断你是否有分层意识。
2.3 为什么轻量级项目坚持用单体而不用微服务
一定要警惕把课设做成微服务的冲动。所有要部署多模块、引入 Nacos 和 OpenFeign 的方案在这个场景下都是自找麻烦。二手交易平台的用户量在答辩演示时可能就几百个,用单体架构连一百行并发配置都不用写,性能完全够。更重要的是,微服务的服务拆分、注册发现、配置中心概念,写进论文里如果自己讲不清楚,答辩老师三句话就能问穿。单体工程里体现服务层接口、控制层参数校验、事务控制,已经足够展示工程能力。
3. 数据库建模与交易核心:四张表如何支撑买卖闭环
3.1 用户表、商品表、订单表、交易流水表的设计
二手交易平台业务上可以拆成“人、物、单、账”四大部分,对应四张核心表。很多没做过交易系统的同学会漏掉交易流水表,只靠订单表里的状态字段打天下,做到后面发现退款记录、申诉凭证无处安放,只能临时加字段。我一般把表结构建为四个实体:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像访问路径', `credit_score` INT DEFAULT 100 COMMENT '信用分,默认100', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `goods` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品ID', `seller_id` BIGINT NOT NULL COMMENT '卖家ID,关联user表', `title` VARCHAR(100) NOT NULL COMMENT '标题', `description` TEXT COMMENT '描述', `category` VARCHAR(30) NOT NULL COMMENT '分类:手机数码/家具/书籍...', `price` DECIMAL(10,2) NOT NULL COMMENT '售价', `original_price` DECIMAL(10,2) DEFAULT NULL COMMENT '买入价,用于显示折扣', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '商品状态:0上架 1下架 2已售出', `view_count` INT DEFAULT 0 COMMENT '浏览次数', `cover_image` VARCHAR(255) COMMENT '封面图路径', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_seller_status` (`seller_id`, `status`), KEY `idx_category_created` (`category`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `goods_id` BIGINT NOT NULL COMMENT '商品ID', `seller_id` BIGINT NOT NULL COMMENT '卖家ID', `buyer_id` BIGINT NOT NULL COMMENT '买家ID', `amount` DECIMAL(10,2) NOT NULL COMMENT '成交金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待付款 1已付款 2已发货 3已完成 4已取消', `message` VARCHAR(255) DEFAULT NULL COMMENT '买家留言', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_buyer_status` (`buyer_id`, `status`), KEY `idx_seller_status` (`seller_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; CREATE TABLE `trade_flow` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL COMMENT '关联订单表主键', `flow_no` VARCHAR(32) NOT NULL COMMENT '流水号', `buyer_id` BIGINT NOT NULL, `seller_id` BIGINT NOT NULL, `amount` DECIMAL(10,2) NOT NULL COMMENT '金额', `direction` TINYINT NOT NULL COMMENT '方向:1付款 2退款 3平台佣金', `remark` VARCHAR(255) COMMENT '备注,如支付模拟、退款原因', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易流水表';表设计里有几个点答辩时一定要能讲清楚。用户密码字段设成VARCHAR(100)是因为 BCrypt 加密结果固定 60 字符,留出余量给未来升级算法。商品表里刻意区分了price和original_price,这对应二手交易里“几乎全新”的展示逻辑——用户看到买入价和卖价对比才有购买欲望。订单表单独设计order_no而不用自增主键给用户看,一是避免暴露平台真实订单量,二是业务单号以后接支付回调时必须用幂等键,自增 ID 不适合对外暴露。
索引方面,idx_seller_status解决卖家“我发布的商品”列表页——这是每个卖家的高频操作;idx_category_created对应首页分类浏览和时间排序。为什么不给description加全文索引?因为 MySQL 全文索引在中文分词上效果一般,而且课设规模用LIKE '%关键词%'糊一层已经够用,等答辩老师问“搜索怎么做的”再实事求是说这是改进方向。
3.2 为什么价格字段必须用 DECIMAL 不能用 Double
浮点数精度问题是 Java 开发里最经典的血泪经验。float和double在二进制存储中无法精确表示 0.1,两个浮点数相减会出现0.30000000000000004这样的结果。交易系统里金额一旦失之毫厘,订单金额和支付模拟金额对不上就会被老师当场发现。数据库层用DECIMAL(10,2),Java 实体里用BigDecimal,两层都精确表示十进制小数,加减乘除不会丢精度。还有一个细节:BigDecimal 构造时要用new BigDecimal("19.9")或者BigDecimal.valueOf(19.9),绝对不能new BigDecimal(19.9),后者是用 double 的二进制值转进去的,精度已经丢了。
3.3 逻辑删除与数据保留的取舍
二手交易平台的商品删除有特殊性。用户卖出一件闲置,如果删除后所有评论、交易记录都物理消失,后期产生纠纷就无从追溯。所以商品表、用户表都加了deleted字段,MyBatis-Plus 配置里打开了逻辑删除后,框架会在所有查询里自动追加AND deleted = 0。有个坑必须提前说:逻辑删除字段只对 MyBatis-Plus 自动生成的 SQL 生效,如果你手写了 XML 里的自定义查询,框架不会帮你改 SQL,必须要自己在<where>条件里显式加deleted = 0。我第一版上线就因为这个漏了很多条数据出来,排查方法很简单——打开log-impl的 SQL 输出,看执行的 SQL 里有没有带上逻辑删除条件。
4. 关键功能落地:订单状态机、防超卖与文件上传的代码实现
4.1 订单状态流转:用枚举和状态机把业务规则写死
订单状态如果只靠 if-else 判断,初期功能少看不出问题,一旦加上取消、退款、超时关闭,代码就变成一坨剪不断理还乱的判断。正确做法是定义订单状态枚举,把合法的状态流转路径集中管理:
public enum OrderStatus { PENDING_PAYMENT(0, "待付款"), PAID(1, "已付款"), SHIPPED(2, "已发货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); private final Integer code; private final String desc; OrderStatus(Integer code, String desc) { this.code = code; this.desc = desc; } public static OrderStatus fromCode(Integer code) { for (OrderStatus status : values()) { if (status.code.equals(code)) { return status; } } throw new IllegalArgumentException("未知订单状态: " + code); } public boolean canTransitTo(OrderStatus target) { return switch (this) { case PENDING_PAYMENT -> target == PAID || target == CANCELLED; case PAID -> target == SHIPPED || target == CANCELLED; case SHIPPED -> target == COMPLETED; default -> false; }; } }这段代码的意图很明确:状态流转规则全部集中到canTransitTo方法里。待付款订单能去已付款或已取消;已付款订单能去已发货或已取消;已发货只能去已完成。这样在 Service 层做订单更新时,不需要散落的 if 判断,调用方只需要执行:
OrderStatus current = OrderStatus.fromCode(order.getStatus()); if (!current.canTransitTo(target)) { throw new BizException("订单状态不允许从" + current.getDesc() + "流转到" + target.getDesc()); }状态机带来三个直接好处:第一,非法流转在业务入口就被拦截,不会污染数据库;第二,答辩时老师问“订单状态你怎么设计的”,可以直接把枚举类展示出来,比口述“我用数字 0 到 4 表示状态”强得多;第三,后续就算要加“退款中”状态,只需要在枚举里加一个值并修改一条流转规则,不影响其他代码。顺带说一句,这个枚举的switch语法已经是 Java 14 的增强版,大部分学校机房装的 JDK 8 跑不了,用的时候改成传统的if (this == PENDING_PAYMENT && target == PAID) return true;写法即可,别因为这个翻车。
4.2 秒杀式的商品扣减:防止两个人同时买到同一件闲置
二手平台虽然不像电商秒杀那样高并发,但答辩时老师一定会问“两个买家同时下单同一件商品怎么处理”。如果你用“先查询库存是否大于0,再更新库存”的方式,两个线程读到的库存都是 1,然后都执行减一,库存变成 -1,商品超卖。原因在于查询和更新之间有时间窗口,这不是玄学,是典型的并发竞态问题。
解决办法是条件更新,把判断和扣减合并到一条 SQL 里:
@Update("UPDATE goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0") int deductStock(Long goodsId);这条 SQL 的含义是“在确保库存大于 0 的前提下把库存减一”。MySQL 的行锁保证同一时刻只有一个线程能对同一行执行更新,第二个线程执行时stock > 0不成立,影响行数为 0。在 Service 层调用后判断返回值:
int rows = goodsMapper.deductStock(goods.getGoodsId()); if (rows == 0) { throw new BizException("商品已售罄"); } // 行数大于0才允许创建订单参数说明:stock字段在上一章的表结构里没有展示,这里补充一下,goods表还需要加stock INT NOT NULL DEFAULT 1,因为二手商品大部分只卖一件,但允许卖家发布多件。deductStock方法返回int是 MyBatis 的执行行数,这个返回值非常关键,它的语义不是“扣减了多少数量”,而是“更新影响了几行数据”。条件更新比乐观锁version字段的写法更简单——乐观锁要先查出 version,更新时对 version 做比对,失败还要重试,而条件更新一条 SQL 就完成了。它的局限在于只适用于“库存扣减”这种单字段原子操作的场景,如果复杂状态流转还是要配合乐观锁。
4.3 图片上传与访问路径设计:本地存储的完整方案
课设阶段不推荐引入 OSS 对象存储,因为涉及云厂商账号和计费问题,答辩环境依赖网络可能有风险。本地文件存储足够,但要注意路径设计的坑。我最初的做法是把图片放到项目static目录下,结果每次mvn clean后上传的图片全没了,因为target目录被清理重建。血泪经验得出的结论是:上传目录要放在项目外部,比如D:/secondhand-upload/,Linux 上就放/home/secondhand-upload/,再通过一个 WebMvc 配置把这个目录映射成 URL 访问。
上传接口的核心代码:
@PostMapping("/api/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String month = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; String relativeDir = month + "/"; File dir = new File(uploadDir + relativeDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return Result.success("/upload/" + relativeDir + filename); }代码逻辑按四步走:校验文件非空 → 提取扩展名(防止有些人传无后缀文件)→ 按月份建子目录并生成 UUID 文件名 → 保存文件到磁盘并返回访问路径。uploadDir是配置在application.yml里的绝对路径:
file: upload-dir: D:/secondhand-upload注意扩展名提取用的是lastIndexOf(".")而不是indexOf("."),因为文件名可能叫“毕业答辩.最终版.png”,点不止一个。UUID 文件名的作用是防止不同用户上传同名图片相互覆盖,同时避免中文文件名在 URL 传递时产生编码问题。返回的路径要把系统绝对路径藏起来,只暴露/upload/202511/xxx.jpg这样的相对路径,真正的磁盘映射由配置类完成:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler(uploadDir + "/"); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/api/login", "/api/register", "/upload/**", "/static/**", "/error"); } }这段代码同时处理了两件事:把/upload/**映射到磁盘目录、配置登录拦截器放行哪些地址。拦截器是课设项目另一个高频踩坑点。addPathPatterns("/**")会拦截一切路径,如果你不给/upload/**和/static/**放行,页面里的 CSS、JS、商品图片全部会被 302 重定向到登录页,浏览器控制台刷出一片红色报错。开发阶段排查这类问题,观察响应状态码是 200、302 还是 404,比你反复刷新页面有用得多。
5. 避坑记录:代码能跑但结果不对,问题多数出在这 5 处
5.1 登录状态突然失效:Session 过期时间没配
现象:用着用着页面就跳回登录页,尤其是上传图片或者提交订单后,操作被拦截器拦截。
原因:默认 Session 有效期是 30 分钟,但文件上传等耗时操作会拉长会话间隔,服务器端 Session 已经过期。另一个隐蔽原因是应用重启导致 Session 全部清空,因为 Tomcat 默认把 Session 存在内存里。
解决:在application.yml里显式配置会话超时:
server: servlet: session: timeout: 60mtimeout单位是分钟,60m表示 60 分钟。如果是前后端分离用 JWT,这种情况不会发生,但 JWT 有它自己的过期时间问题,需要前端在 401 时自动跳转登录页。课设项目用 Session + Cookie 最简单,不要自己造 Token 轮子。
5.2 订单金额变成 19.900000000000002
现象:数据库里金额明明是 19.90,Java 里计算完税费或者折扣后打印出来一长串浮点数。
原因:实体类里amount字段用了Double,虽然数据库是 DECIMAL,但 JDBC 驱动读出来转换成了 Double 精度丢失。
解决:实体类金额字段一律用BigDecimal,DTO 里的传输对象也用BigDecimal。前端传金额时用字符串而不是数字,因为 JSON 的19.9解析到 JS 的 Number 再传回后端,精度又丢一次。Controller 接收时用@RequestBody配合BigDecimal类型,参数字段写成"amount": "19.90"。这套链路非常关键:数据库 DECIMAL → 实体 BigDecimal → 前端字符串。
5.3 更新订单状态时把别人的修改覆盖了
现象:买家取消订单成功后,卖家那边看到的订单状态还是“待付款”,再点发货把已取消的订单改成了“已发货”。
原因:取消订单和发货操作同时发生时,两个请求都读取到了订单状态0(待付款),各自执行了自己的状态更新,后执行的把先执行的覆盖了。这就是典型的并发覆盖问题。
解决:状态更新 SQL 要带上当前状态条件:
@Update("UPDATE orders SET status = #{newStatus} WHERE id = #{id} AND status = #{oldStatus}") int updateStatusByIdAndOldStatus(Long id, Integer oldStatus, Integer newStatus);只有WHERE status = 旧状态成立才会更新,两个并发请求只有一个能成功。拿到返回行数为 0 时,说明状态已被别人改过,直接提示“订单状态已变化,请刷新后重试”。这个写法比在 Service 层加synchronized靠谱,因为课设项目经常部署在多实例环境,synchronized只对本机有效。
5.4 图片上传成功但页面打不开
现象:文件明明传到了磁盘,返回的地址http://localhost:8080/upload/202511/xxx.jpg却 404。
原因:没有配置资源映射。Spring Boot 默认只会把classpath:/static/下的文件当静态资源服务,你上传到的是D:/secondhand-upload或者 Linux 的/home/upload,它不是类路径里的目录,自然无法访问。
解决:用前面 4.3 节写的WebConfig,通过addResourceHandlers把/upload/**映射到磁盘目录。还有一个变体坑:如果忘了加file:前缀或者路径最后没加/,映射也会失败。Linux 路径正确写法是file:/home/secondhand-upload/,Windows 是file:D:/secondhand-upload/。
5.5 列表分页后前端拿不到数据
现象:selectPage查询后,接口返回的 JSON 里始终没有 records 里的内容,或者直接报ClassCastException。
原因:MyBatis-Plus 的selectPage(page, wrapper)返回的是IPage<T>,但很多人习惯当List用,前端拿到的对象里records是空的,而真正的数据其实在records字段里。另外一个常见原因是分页插件没配置,selectPage执行时 LIMIT 没拼上去,返回的是全量数据再在前端做假分页。
解决:分页插件只能在 MyBatis-Plus 里通过配置类注册:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }前端取数时用page.getRecords()而不是把page直接当列表。这个配置类只加一次,加完后selectPage才会生成真正的LIMIT ? OFFSET ?SQL。我见过有人把整个Page对象转成 JSON 返回给前端,然后前端怎么都解析不出数据,排查半小时发现前端代码没错,是后端少了分页拦截器。
6. 答辩前的最后一步:把单体工程演进成能讲出亮点的架构
做到这里,工程已经能完整跑通“注册 → 发布商品 → 浏览 → 下单 → 发货确认”这条主链路。但课设和毕设的评分差异往往体现在最后这一步——不是代码量多寡,而是你能否讲清“如果用户量大了怎么演进”。我一般会在论文里留出专门一节写架构演进方向,不需要全部实现,但至少要讲明白设计意图。这里给几条经过验证的切入点。
第一,引入 Redis 做首页热点商品缓存。现在的实现里每个用户打开首页都会查询数据库,用户量到上千后首页响应会明显变慢。用 Redis 把 top 20 的热门商品缓存起来,TTL 设 5 分钟,浏览计数先在 Redis 里累加,定时落库,能把数据库查询压力降一个量级。答辩时画出“请求先查缓存、未命中再查库”的流程图,比写一百行代码更有说明力。
第二,把商品搜索从LIKE '%关键词%'升级为 MySQL 全文索引或 Elasticsearch。课程设计用 LIKE 完全够,但论文里可以说这是系统下一步优化方向,因为 LIKE 前置通配符会放弃索引全表扫描,数据量过万后体验会断崖式下跌。如果愿意多花一天时间,可以引入ngram全文解析器,实现中文分词搜索——这算是一个稳妥的加分项。
第三,订单状态机的扩展性。当前的枚举虽然把流转规则收拢了,但“超时自动取消”“买家申请退款后卖家拒绝”这类逆向流程还没有完全覆盖。回答这类问题时可以用状态机的经典理论——每个状态定义合法事件,所有状态转移都在触发器里校验,跟 4.2 节一样。我再补一句血泪经验:我当年做的时候没在商品表加version字段,结果两个人同时编辑商品信息时后保存的人覆盖了先保存的人,答辩老师一眼就看出来了。如果不想在表里加版本号,至少应该在 Service 层做字段级更新,只更新真正变化的列。
回头看整个方案,技术难度都控制在课设范围内,但因为建模完整、并发处理有意识、状态设计可扩展,整体完成度会明显高于“纯 CRUD 系统”的平均水平。希望这篇笔记能帮你少走几个我当年绕过的弯路,也祝你的答辩一切顺利。
本文还有配套的精品资源,点击获取