简介:面向正在准备毕业设计或课程设计的Java学习者,这份以校园二手交易市场为业务场景的系统成品,涵盖用户注册登录、商品发布、商品浏览、订单管理等核心模块,能够帮助解决选题缺乏思路、系统实现不完整、缺少答辩演示素材等实际问题。压缩包共4个文件,包含2个mp4演示录像、1个zip源代码工程、1个sql数据库脚本,整体大小约73.27MB。其中演示视频分别从操作流程和典型交易链路录制,可快速了解系统功能全貌,同时也便于答辩前模拟讲解;源代码工程采用模块化分层结构,便于阅读、调试与二次开发;SQL脚本内置用户、商品、订单等基础数据,导入数据库后即可直接启动项目。目前已有116人学习下载。通过源码、录屏与数据库三者配合,既能直观观察运行效果,也能深入理解前后端交互逻辑与数据表设计,对Java Web入门和校园交易类项目快速落地均具参考价值。
1. 校园二手交易系统:先搞懂这套Java全栈项目在解决什么
临近毕业季,你会发现Java方向的课设和毕设里出现频率最高的题目就是校园二手交易市场。原因不复杂:它够典型——有用户体系、有商品展示、有购物车和订单这种事务操作,还有图片上传和会话管理这些Java Web里几乎必考的点,但业务规模又不会大到一个人做不完。这套系统表面上是“卖二手书和自行车”,实际上是把Servlet、JSP、SSM或Spring Boot、MySQL这些Java技术栈串成一条完整链路的试金石。
如果你拿到的就是这个题目的压缩包,里面带源代码、演示录像和数据库脚本,那这篇笔记会帮你理清三件事:这套系统最稳妥的技术选型是什么、核心代码应该怎么组织、以及那些只在真实部署时才会暴露的坑该怎么绕。全文基于我做过多个类似交易类项目的经验来写,按照“数据库先行、后端分层、前端联调、上线避坑”的顺序展开,你可以当一份可复现的实施方案用,也可以拿着它去检查你手上那套源码到底靠不靠谱。
2. 数据库设计先行:用户、商品、订单三张表决定系统上限
2.1 先定表结构:从交易链路倒推字段
二手交易系统的核心不是页面,是数据库里的三张表:用户表、商品表、订单表。我一般会从交易链路倒推设计。买家要能登录、浏览商品、下单,卖家要能发布商品、修改状态、确认成交。这条链路需要的字段其实非常固定。
用户表至少要有:id、username、password、phone、college或campus(用来做校园属性过滤)、create_time。商品表要有:id、title、description、price、original_price(可显示砍价空间)、images(多张图建议用逗号分隔或单独建图库表)、category_id、status(0在售/1已售/2下架)、seller_id、browse_count、create_time。订单表要有:id、order_no(必须,后面很多排查都靠它)、product_id、buyer_id、seller_id、price、status(0待付款/1待发货/2待收货/3已完成/4已取消)、create_time、pay_time。
其中最容易忽略的是商品表和订单表之间的状态一致性。很多系统的翻车现场是:订单生成后商品表status还是“在售”,结果同一个人又下一单,这就是典型的并发超卖。解决方式有两种,一种是下单时用UPDATE product SET status=1 WHERE id=? AND status=0这个带条件的更新语句,由数据库保证原子性;另一种是在Service层加synchronized或Redis锁。做毕设或小型课设,前一种写法够了。
CREATE TABLE `product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '商品标题', `description` text COMMENT '详细描述', `price` decimal(10,2) NOT NULL COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价/期望价', `images` varchar(500) DEFAULT NULL COMMENT '图片相对路径,多张用 | 分隔', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0在售 1已售 2下架', `seller_id` bigint(20) NOT NULL, `browse_count` int(11) NOT NULL DEFAULT '0', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手商品表';这段建表语句的关键点有三个:一是price用decimal而不是float,否则金额精度会在计算订单总额时出问题;二是status设了默认值0,插入时不用显式写;三是create_time用DEFAULT CURRENT_TIMESTAMP,可以少写一行代码。如果数据库本身是UTF-8,但表要显示emoji或某些特殊符号,就需要utf8mb4。字符集问题在Windows上的MySQL尤其常见,后面避坑章还会提。
2.2 MyBatis自动映射与增删改查的标准写法
有了表结构,接下来就是数据访问层。现在是2025年,我不建议再手写JDBC了。最常见的组合是Spring Boot + MyBatis-Plus + MySQL。MyBatis-Plus的好处是单表增删改查完全不用写SQL,直接用BaseMapper接口里现成的selectById、insert、updateById、deleteById就能跑通CRUD。如果有兴趣,还能用它的MybatisPlusGenerator根据Java实体类生成创建表的SQL语句,这个对快速搭项目非常顺手。
@Service public class ProductService { @Autowired private ProductMapper productMapper; public boolean createProduct(Product product) { // 发布商品时强制校验必填项 if (StringUtils.isBlank(product.getTitle()) || product.getPrice() == null) { throw new BizException("标题和价格不能为空"); } product.setStatus(0); // 默认在售 product.setBrowseCount(0); return productMapper.insert(product) > 0; } public Product getProductById(Long id) { return productMapper.selectById(id); } }这段Service代码强调了几个容易漏的点:createProduct里要主动给status和browseCount赋值,不然MyBatis-Plus如果配置了FieldStrategy.NOT_NULL,这两个字段会以NULL插入,数据库有默认值倒还好,没有默认值就直接报错。还有,insert(product)返回的是影响行数,不是主键。主键回填是发生在传入的product对象上,也就是product.getId()在你调用insert之后就有值了,新手经常会忘记这一点。
2.3 初始化数据脚本:演示录像能跑通的关键
完整的项目压缩包里一般会带一个init.sql或schema.sql。我建议你自己的版本里至少包含三类东西:建库建表语句、管理员账号(通常是admin/123456)、10~20条演示商品数据。演示数据非常关键,因为你录制演示录像时需要展示列表、详情、搜索、下单,如果数据库是空的,这些环节录出来会很难看。
INSERT INTO `user` (`id`, `username`, `password`, `role`, `phone`) VALUES (1, 'admin', 'e10adc3949ba59abbe56e057f20f883e', 'ADMIN', '13800000000'), (2, 'seller01', 'e10adc3949ba59abbe56e057f20f883e', 'USER', '13800000001'); INSERT INTO `product` (`id`, `title`, `description`, `price`, `seller_id`, `status`) VALUES (1, '九成新高数教材 带笔记', '考研用过,有少量划线,不影响阅读', 15.00, 2, 0), (2, '宜家台灯 宿舍用', '灯管瓦数小,适合晚上看书', 25.00, 2, 0);注意上面密码用的是MD5值“e10adc3949ba59abbe56e057f20f883e”,它对应的是明文“123456”。这只是演示方便,真实系统请至少加盐处理。数据脚本在项目启动时应该被Spring Boot自动加载,最简单的做法是把init.sql放在src/main/resources目录下,然后在application.yml里配置spring.sql.init.mode=always和schema、data的文件路径路径。这样能保证别人拿到源码后不需要手工导入数据库,直接启动就能看到和你演示录像一样的界面。
3. 后端分层落地:Spring Boot + MyBatis + JSP 的交易闭环
3.1 Maven工程结构与核心依赖
这个项目如果是基于Spring Boot搭建的,包结构一定要清晰。常见做法是com.campus.trade下面分controller、service、mapper、entity、config、common。Controller只做参数接收和结果返回,Service放业务逻辑,Mapper做数据访问。别把业务堆在Controller里,后面维护会崩溃。
pom.xml里的依赖选择要克制。必装的是spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok、jstl、tomcat-embed-jasper。注意最后两个,如果页面用JSP,哪怕Spring Boot内嵌了Tomcat,也需要额外引入tomcat-embed-jasper才能编译JSP,不引入的话一访问页面就报404,但日志里没有任何异常。这几乎是JSP版Spring Boot项目的头号坑。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>这些依赖的版本需要注意:MyBatis-Plus 3.5.x对应Spring Boot 2.x,如果用了Spring Boot 3.x要换成mybatis-plus-spring-boot3-starter。很多人拿旧源码配新Boot版本,一启动就抛ClassNotFoundException,就是因为这个版本对应关系没对上。
3.2 商品发布与图片上传的实现细节
商品发布是卖家功能里的核心交互。前端是一个表单,包含标题、描述、价格、图片。后端接收时要注意两点:一是表单必须设置enctype="multipart/form-data";二是图片不能直接存数据库里的二进制,应该保存到服务器的某个上传目录,数据库里只存相对路径。
@PostMapping("/product/add") public String addProduct(@RequestParam("title") String title, @RequestParam("price") BigDecimal price, @RequestParam("file") MultipartFile[] files, @SessionAttribute("loginUser") User user) { List<String> imgPaths = new ArrayList<>(); for (MultipartFile f : files) { if (f.isEmpty()) continue; String original = f.getOriginalFilename(); String ext = original.substring(original.lastIndexOf(".")); // 用UUID做文件名,避免中文名和重名 String fileName = UUID.randomUUID().toString().replace("-", "") + ext; String dateDir = new SimpleDateFormat("yyyyMMdd").format(new Date()); String relativatePath = "/upload/" + dateDir + "/" + fileName; File dir = new File(uploadDir + dateDir); if (!dir.exists()) dir.mkdirs(); f.transferTo(new File(uploadDir + dateDir + "/" + fileName)); imgPaths.add(relativatePath); } product.setImages(String.join("|", imgPaths)); productService.createProduct(product); return "redirect:/product/list?userId=" + user.getId(); }这段代码里我故意用了@SessionAttribute("loginUser")来拿当前登录用户,比用HttpSession直接get更干净。图片保存路径uploadDir是从配置项里注入的,这里要注意:Spring Boot打成jar包运行时,user.dir变量指向的目录会变,所以绝对路径最好配置成D:/campus_upload/这种明确的位置,而不是相对路径。
参数说明里容易踩坑的是transferTo(File)方法。如果目标文件已经存在会报错,所以UUID命名必须放前面。还有一个隐患是文件类型校验,如果只凭getOriginalFilename()后缀判断,被人传一个.jsp或.jspx到Tomcat的Web应用目录下,就可能被当作动态脚本解析。安全做法是额外检查f.getContentType(),并且把上传目录放到Web应用目录之外,或禁止该目录执行JSP。
3.3 订单状态机:从“待付款”到“已完成”的流转设计
订单是所有交易系统的核心,也是最容易写成一团乱麻的部分。我习惯先用一个状态机把状态定义清楚,再写代码。校园二手交易没有真实支付网关,通常是线下交易,所以状态比电商系统简单:待付款(可选)、待收货、已取消、已完成。如果做成线上支付(比如模拟支付按钮),那就要把支付回调作为一个独立事务考虑。
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long productId, Long buyerId) { // 1. 防止超卖:条件更新商品状态 int rows = productMapper.lockProduct(productId); if (rows == 0) { throw new BizException("商品已下架或已被购买"); } // 2. 创建订单 Product product = productService.getProductById(productId); Order order = new Order(); order.setOrderNo(generateOrderNo(productId, buyerId)); order.setProductId(productId); order.setBuyerId(buyerId); order.setSellerId(product.getSellerId()); order.setPrice(product.getPrice()); order.setStatus(0); orderMapper.insert(order); // 3. 修改商品状态为已售 productMapper.updateStatus(productId, 1); return order; }这段代码最关键的是lockProduct那条SQL,它本质上是UPDATE product SET status=1 WHERE id=#{id} AND status=0。这个条件更新天然是原子操作,并发情况下数据库只有一条记录会被成功更新。如果先查商品状态再更新,中间隔着一段Java逻辑,就一定会出现两个下单请求都读到“在售”的情况。
generateOrderNo我一般用时间戳加随机数,格式如“PO20250610153012345”加买家ID和商品ID的后四位。订单号不要用自增ID替代,因为订单号迟早要暴露给用户,并且会用来做对账。@Transactional必须加上,因为“锁商品”“插订单”“改状态”这三个动作要作为一个事务整体提交,中途任何一步失败都要回滚,否则会出现商品已售但订单没生成的脏数据。
3.3.1 取消订单的补偿逻辑
取消订单和创建订单同等重要。用户取消时,必须把商品状态从“已售”改回“在售”。如果漏了这一步,用户一点“取消”,商品就永久沉底了,卖家会以为系统吞了他的商品。我见过不止一个项目在演示录像里专门演示了这个bug:下单后取消,刷新商品详情,状态还是“已售”。
@Transactional(rollbackFor = Exception.class) public void cancelOrder(Long orderId, Long buyerId) { Order order = orderMapper.selectById(orderId); if (order == null || !buyerId.equals(order.getBuyerId())) { throw new BizException("订单不存在或无权操作"); } if (order.getStatus() != 0 && order.getStatus() != 1) { throw new BizException("当前订单状态不可取消"); } orderMapper.updateStatus(orderId, 4); // 4=已取消 productMapper.updateStatus(order.getProductId(), 0); // 恢复商品为在售 }这段逻辑我特意区分了“订单状态”和“商品状态”,在orderMapper.updateStatus和productMapper.updateStatus里维护两套状态。真实项目中这也是高发bug区:要么忘记恢复商品,要么把两个表的状态搞混。建议在Service层的注释里写清楚每一步操作的是哪张表,否则过两周自己回头都容易看错。
4. 前端页面与交互:JSP + Bootstrap 完成可演示的界面
4.1 导航栏与登录会话的前后端协作
校园二手系统的前端不需要追求花哨,但必须保证“演示流程完整”。登录、首页商品列表、发布商品、详情页、购物车、我的订单,这六个页面能串起来,答辩就成功了大半。技术栈上,JSP + Bootstrap + jQuery仍然是最省事的组合,原因很简单:JSP在服务端渲染数据,不需要写AJAX接口给Vue渲染,省掉了跨域和前后端联调的麻烦。
这里有一个关于会话管理的常见误用:把用户信息存到Application域里当缓存用。这是毕设里出现频率比较高的写法,但Application是全局的,A用户登录后,B用户如果把浏览器的同一个会话ID对应上,就能看到A的用户信息,这几乎等于裸奔。正确做法是存进HttpSession,然后通过@SessionAttribute注解取值。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>校园二手集市</title> <link rel="stylesheet" href="/css/bootstrap.min.css"> </head> <body> <nav class="navbar navbar-expand-lg navbar-light bg-light"> <a class="navbar-brand" href="/">校园二手市场</a> <c:choose> <c:when test="${sessionScope.loginUser != null}"> <span>你好,${sessionScope.loginUser.username}</span> <a class="btn btn-outline-primary btn-sm" href="/order/my">我的订单</a> <a class="btn btn-outline-danger btn-sm" href="/logout">退出</a> </c:when> <c:otherwise> <a class="btn btn-outline-primary btn-sm" href="/login">登录</a> <a class="btn btn-primary btn-sm" href="/register">注册</a> </c:otherwise> </c:choose> </nav> </body> </html>这里有个新手容易踩的点:JSTL判断表达式里写${sessionScope.loginUser != null},必须先把<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>引进来,而且项目里必须放了jstl.jar。很多人页面一报“根本无法显示此页面”,打开服务器日志发现是javax.servlet.jsp.tagext.TagLibraryValidatorException,就是缺jar包。
4.2 商品列表与分页:MyBatis-Plus分页插件的配置
商品列表是流量最大的页面,也是面试时会被追问最多的地方:怎么分页的?搜索条件怎么拼接的?排序怎么做?MyBatis-Plus提供了现成的分页插件,但我们容易忘记配置拦截器。不配置的话,调用selectPage会直接抛异常,或者返回全量数据——这取决于版本,我遇到的是后者,页面直接卡死。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); // 设置最大单页100条,防止别人恶意传10000页 pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }参数说明:DbType.MYSQL告诉插件要生成MySQL方言的分页SQL,也就是LIMIT ?。如果你用的是PostgreSQL或Oracle,这里就得换成对应的枚举。setMaxLimit是防御性设置,演示录像里往往被忽略,但答辩时老师如果问你“限制分页大小怎么处理”,你能答上这一点会加分。
搜索功能建议直接用LambdaQueryWrapper,比如按标题关键词和分类过滤:
public IPage<Product> searchProducts(long current, long size, String keyword, Long categoryId) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(keyword), Product::getTitle, keyword) .eq(categoryId != null, Product::getCategoryId, categoryId) .eq(Product::getStatus, 0) // 只查在售 .orderByDesc(Product::getCreateTime); Page<Product> page = new Page<>(current, size); return productMapper.selectPage(page, wrapper); }这段查询里,like(StringUtils.isNotBlank(keyword), Product::getTitle, keyword)的意思是:只有当keyword不为空时才拼接LIKE条件。这是MyBatis-Plus很重要的一个特性——条件构造器第一个参数是boolean,用它实现动态SQL,避免自己在字符串里拼“where 1=1”。这种做法也能防止SQL注入,因为所有参数都是#{}预编译绑定的。
4.3 购物车与下单流程的前端节奏
购物车有两种实现方式:基于数据库表和基于Session。校园二手交易的购物车,我建议用Session存List<CartItem>就够了。因为用户量不大、数据不重要,也不需要持久化,只用Session能省一张表和一个Service,演示的时候也更方便,关掉浏览器购物车就空了,符合轻量定位。
但下单流程必须走后端,而且下单前需要在前端做一次“二次确认”。我在演示时通常的动作是:点“加入购物车”之后,有购物车角标变化;点“结算”时弹一个确认框,显示商品名、价格、卖家、是否确认下单。这个确认框的作用不是防呆,而是让观看者看清订单数据从哪来、到哪去,它把“前端展示”和“后端写入”两个动作拆开了,便于答辩讲解。
<form action="/order/create" method="post"> <input type="hidden" name="productId" value="${product.id}"> <input type="hidden" name="buyerId" value="${sessionScope.loginUser.id}"> <button type="submit" class="btn btn-success" onclick="return confirm('确认购买「${product.title}」,价格${product.price}元?')">直接购买</button> </form>注意隐藏域里暴露了buyerId,这在真实系统里是不能接受的,后端必须从Session重新取用户ID,不能信任前端传值。我做项目时都会在后端Controller里加一个判断:如果Session里的用户ID和参数不一致,直接拒绝。这是那一节“数据可信边界”的要点——前端传的参数都是不可信的,必须以后端存储为准。
5. 避坑记:校园二手交易系统最常见的6个现场级故障
5.1 中文乱码:数据库连接参数少写了一行
现象:项目启动正常,页面也能打开,但是插入的商品标题在数据库里变成“???”或一片乱码,控制台偶尔还会报incorrect string value。
原因:大多数情况下是JDBC连接串里没设置characterEncoding=utf8,或表结构本身是charset=utf8,但数据库表里的中文字节是GBK格式。另一个重灾区是Tomcat的POST表单默认解码是ISO-8859-1,前端用UTF-8提交,Servlet层拿到的就是乱码。
解决:第一步,确保application.yml里连接串长这样:
jdbc:mysql://localhost:3306/campus_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false第二步,加一个Servlet Filter统一设置请求和响应编码:
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }这块我自己的血泪经验是:别只满世界找Filter配置,先检查数据库和表的charset是不是utf8mb4。如果是,连接串里的characterEncoding=utf8也必须写上,两个缺一个或写错一个,最后都是乱码。
5.2 图片上传后页面不显示:Tomcat虚拟路径没配
现象:图片上传成功,磁盘上文件也存在,通过浏览器访问http://localhost:8080/upload/20250610/xxxx.jpg却返回404。
原因:Spring Boot的JAR包内嵌Tomcat默认只把classpath:/static/作为静态资源根目录,/upload目录不在里面。如果直接用IDE启动,资源目录是src/main/resources/,你上传到磁盘的绝对路径和项目静态路径无关。
解决:配置资源映射,将磁盘目录映射到/upload/**虚拟路径:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:D:/campus_upload/"); } }这个file:前缀是必须的,不写它会被当成classpath路径。还有,目录末尾的斜杠不能丢,写成file:D:/campus_upload也会导致资源映射失效。这属于典型的“少一个斜杠,查半小时”问题。
5.3 重复下单和超卖:分布式锁太重,条件更新刚好
现象:点击“购买”按钮连续提交两次,数据库里生成了两条订单,商品状态变成已售,但买家实际只需要一件。
原因:第一次请求创建订单后,商品状态还没被改掉,第二个请求在Controller层读到的商品状态还是0,于是又走了一遍下单逻辑。你当然可以在前端禁用按钮,但前端的禁用对直接发POST请求的人完全无效。
解决:后端在数据库层面加约束。下单时先用UPDATE product SET status=1 WHERE id=? AND status=0获得影响行数,如果影响行数为0就抛异常。这比在Service层用synchronized或查了再改都可靠得多,而且不影响性能。如果项目真的需要防同一个用户重复提交相同商品,还可以在订单表加唯一索引(product_id, buyer_id),但注意这会导致用户取消订单后再次下单失败,所以取消订单时必须先删旧单或改状态。这个细节看你的业务取舍。
5.4 演示录像与代码版本对不上:交付时的“大坑”
现象:用户拿到压缩包后运行代码,发现界面和录像里面不一样,比如录像里有个“忘记密码”按钮,代码里根本没有;录像里能发布商品,代码里一提交就报500。
原因:通常是因为发布者拍摄录像后又改了代码,但没重新打包;或者数据库初始化脚本用的是旧表结构,新代码的SQL字段对不上。校园二手交易系统这种项目,数据库脚本一变,前后端代码都会跟着变,任何一个环节没同步,整个演示链路就会断。
解决:发布或提交前做一次“全新环境验证”。具体步骤是:删除本地数据库重新建库 →导入最新的init.sql→mvn clean package→ 启动项目 → 按录像里的操作走一遍 → 录一份新的演示录像。这步动作虽然耗时,但能根除交付不一致的问题。另外在工程里建议加一个README.txt,写上JDK版本(务必写明是1.8还是11)、MySQL版本、Tomcat版本、IDE是否是Eclipse或IDEA、数据库账号密码。这些信息每一条都可能让人卡住半小时以上。
5.5 JSTL与Servlet版本冲突:页面一行标签都不显示
现象:页面上写<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>,运行时整个代码变成文本直接显示在浏览器,没有任何报错。
原因:这是典型的JSP运行时依赖缺失。Servlet 4.0 / JSP 2.3之后,部分容器不再自动带上JSTL实现。如果项目里只有jstl.jar没有standard.jar,或者两个jar包的版本不匹配,JSTL标签就不会被解析。
解决:最简单的做法是统一用Maven管理:
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>taglibs</groupId> <artifactId>standard</artifactId> <version>1.1.2</version> </dependency>如果你用的是Tomcat 10+,注意它已经采用jakarta.*命名空间,那前面这些javax.*依赖全都不适用,要么换用jakarta.servlet.jsp.jstl-2.0.0,要么干脆降级到Spring Boot 2.x + 内嵌Tomcat 9。很多源码拿过来跑不起来的根因就在这一层。
5.6 日期字段显示为一串数字:JSON序列化格式没配
现象:商品列表接口(如果用的是REST风格返回JSON),createTime显示为1718325600000这样的时间戳,页面前端转得费劲。
原因:默认的Jackson序列化会把java.util.Date或LocalDateTime转为epoch毫秒数,除非设置了格式。这在前后端分离模式里很常见,JSP模式下因为后端直接渲染模板不会出现,但如果你在Controller方法上用了@ResponseBody就会遇到。
解决:在application.yml里全局配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8如果你用的是Java 8的LocalDateTime,上面配置对它是无效的,需要额外添加jackson-datatype-jsr310依赖,并写入:
spring: jackson: serialization: write-dates-as-timestamps: false然后在实体类的createTime字段上加@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="GMT+8")。这不是什么高深技术,但对演示录像里的页面展示效果影响非常大——别人看到一串数字会以为系统是坏的,而你配置好之后显示2025-06-10 14:30:25,观感完全不一样。
6. 进阶:用缓存和压测给系统换个“能答辩”的亮点
这套校园二手交易系统做到能跑通,满分100的话大概能拿75分。想往上冲到85分,最有效的两个投入是:加上Redis缓存、写一份简单的压测记录。理由很直接——答辩时老师问“系统性能怎么样”,你总不能说“我觉得挺快的”,但如果你递出一张JMeter压测表,上面写着“商品列表接口单机QPS 800,添加缓存后升到2200”,这就能用真实数据说话。
加缓存我一般选择只给首页商品列表和热门商品详情两个热点加。Redis并不需要装在独立的服务器上,本地Windows用WSL跑个redis-server,或者用Docker起一个容器都行。Spring Boot连接Redis只需要引入spring-boot-starter-data-redis,然后在Service层做一次“先查缓存,再查数据库”的逻辑:
public List<Product> getHotProducts() { String key = "product:hotlist"; List<Product> list = (List<Product>) redisTemplate.opsForValue().get(key); if (list == null) { list = productMapper.selectHotList(10); redisTemplate.opsForValue().set(key, list, 30, TimeUnit.MINUTES); } return list; }这段代码里我设置了30分钟过期,目的是商品价格变动后最长半个小时内首页能反映出来。用opsForValue()存取整个List有一个小缺陷——Redis会整体序列化/反序列化,数据量大时反而慢。但针对首页10条推荐位,这完全够用。注意redisTemplate的泛型别用错,常用的是RedisTemplate<String, Object>,需要配置对应的StringRedisSerializer和Jackson2JsonRedisSerializer,否则默认JDK序列化存进去的字符串在客户端和日志里看着是乱码,虽然Java代码自己读写没问题,但给老师演示数据可读性就差了点。
压测工具用JMeter最简单。你不需要写复杂的测试脚本,只需创建一个“线程组”,线程数设100,Ramp-Up Period设10秒,循环次数10次,添加一个HTTP请求指向/product/list?current=1&size=10,再添加“聚合报告”监听器。跑完看三列:Average(平均响应时间)、Throughput(吞吐量)、Error%。如果平均响应时间低于200ms、Error%为0,就说明系统的基本健康状况过关。一个隐藏的技巧是:先把Redis停了跑一遍,再开启Redis跑一遍,两张报告一对比,缓存的效果就非常直观了。这个对比比任何语言描述都有说服力。
做这个项目我最深的教训是:不要把时间花在堆功能上。这个系统80%的工程量都在“交易链路”这四个字——登录、发布、下单、订单流转。把这条链路的数据一致性打磨好,胜过加十个页面。我最初自己写的时候也想着把“消息通知”“后台权限管理”“数据统计分析”全部塞进去,结果每多一个功能就多几条坑,最后在纠错上花的时间反而不如一个干净的闭环版本。后来每次再搭类似项目,我都只围绕核心链路迭代。这条习惯希望也能帮你少走弯路,希望帮到你。
本文还有配套的精品资源,点击获取