JSP+SSM校园闲置物品交易平台毕设全解析:从数据库到核心代码
2026/9/7 18:38:27 网站建设 项目流程

每年到这个节点,总有一批人被毕业设计折腾得焦头烂额。如果你打开这篇内容,大概率也是拿到或者正准备选一个类似的课题:基于JSP和SSM的校园闲置物品交易平台。先给你吃颗定心丸——这个题一点都不“土”,它反而是我推荐技术基础一般、又想稳过答辩的同学优先考虑的方向。JSP负责页面展示,SSM(Spring + SpringMVC + MyBatis)负责业务逻辑和数据操作,整套组合解决的问题非常直接:学生之间怎么发布闲置、搜索商品、下单交易、管理个人信息,同时给管理员留出用户和商品管理的后台入口。

这篇文章不是给你贴一大段复制粘贴的代码,而是按照我当年做这个题的真实推进顺序来写:从“为什么选这个技术栈”到“数据库表怎么设计”,从“SSM配置文件怎么组织”到“发布商品、下单交易的核心代码链路”,最后再把我踩过的几个代表性坑——比如JSP改了不生效、图片视频路径错乱、两个人同时下单怎么保证数据一致性——一次性讲清楚。你按这个思路做完,不只是“能运行”,而是答辩时每个设计决策都能说出来龙去脉。

1. 为什么是“JSP + SSM”:选题价值与功能版图

1.1 老技术栈为什么反而是毕业设计的稳妥之选

我见过不少同学一上来就奔着前后端分离去,Spring Boot + Vue + Element UI,看起来很潮,结果光跨域、Token、路由守卫就折腾了两个星期,到最后论文里自己写的东西没多少,全是配置。毕业设计的评分逻辑和真实互联网项目的逻辑不一样,评审老师更在意的是:业务闭环是否完整、技术方案是否合理、你对整个系统是否真的理解。

JSP + SSM这套组合恰恰是“理解成本”最低的方案。JSP是服务端渲染页面,Java代码片段和JSTL标签可以直接嵌在HTML里,数据怎么来、怎么展示,一眼就能看明白,不需要额外学前端工程化那套东西。SSM三个成员的分工也很清晰:Spring管对象、SpringMVC管请求分发、MyBatis管数据库CRUD,正好对应一个Web项目从“用户浏览器发出请求”到“数据库返回结果”的完整链路。

而且这套技术栈和Spring Boot并没有断层。你搞清楚了Spring容器的IOC、AOP这些核心思想,以后写SpringBoot项目,只是把XML配置换成了自动装配注解,底层逻辑完全一致。转型成本很低,但作为本科毕业设计的考察点,它反而比“一键生成”的脚手架更能体现个人工作量。

1.2 交易场景的角色、故事线与产品取舍

先别急着打开IDE写代码,花半小时把“这个系统到底服务谁”想明白。校园闲置物品交易平台,核心场景就一句话:某位同学手里有闲置的教材、自行车、数码产品,挂到平台上,另一个同学看到后联系购买,双方线下完成交接。围绕这个场景,有三类角色:

  • 游客:可以浏览商品列表、搜索、查看商品详情。为什么游客要看详情?因为登录是个转化门槛,如果浏览都要登录,体验差,演示时也会显得系统很封闭。
  • 注册用户:可以发布闲置物品、编辑自己发布的商品、下架商品,可以购买别人的商品,可以收藏感兴趣的商品,还拥有个人中心管理自己的资料和订单。
  • 管理员:维护分类、管理用户、管理商品状态,还可以把订单数据导出成Excel做简单统计。

这个模糊的业务描述里藏着一个最重要的产品决策:校园闲置交易不需要做线上支付。因为交易双方大概率是同一所学校甚至同一栋宿舍楼的人,真实场景就是线下面交。你把支付系统接进来,反而引入了一堆安全和合规问题,对毕设来说属于明显的过度设计。所以我当时把订单状态设计成了“待确认 → 已完成 / 已取消”这种足够简单但逻辑完整的状态机,这样既能形成交易闭环,又不会把战线拉长。

1.3 功能清单与权限矩阵

下面这组功能清单是我最终落地到项目里的版本,你可以直接把它抄到开题报告或者论文的“功能需求分析”里。这里我用权限矩阵来展示,每一行代表一个功能入口,列代表能使用它的角色:

功能模块游客注册用户管理员说明
商品浏览与搜索按分类筛选、按关键词模糊搜索
商品详情查看展示图片、描述、卖家信息、浏览量
注册与登录注册后自动写入用户表,登录状态用Session维护
发布闲置商品需要登录,管理员也可以代发布
编辑/下架/删除商品只能操作自己发布的商品
下单购买一物一单,不能买自己发布的商品
确认完成交易买家确认后订单变为已完成
取消订单取消后商品自动恢复为“在售”
收藏商品收藏记录可查可删
个人中心展示个人信息、头像、统计数量
后台用户管理启用/禁用账号
后台商品管理下架违规商品
订单数据导出Excel把订单列表写入xls文件
分类管理增加、修改分类名称

这个表格直接展示了系统的全貌,同时也确定了数据库里必须有用户表、商品表、订单表、分类表、收藏表这五张基础表,至于要不要留言表,就看你是否打算做“商品咨询”这个功能,下一章会细讲。

1.4 工程目录:约定优于配置的落地

我当时建立工程目录时吃过一个亏——包名太乱,后面自己都找不到东西。推荐你按下面的方式组织,既符合Java Web惯例,也方便后续写论文画架构图:

src/main/java/com/campus/ ├── common/ # 通用类(Result返回体、常量、分页结果) ├── controller/ # SpringMVC控制层 ├── service/ # 业务接口 │ └── impl/ # 业务实现类 ├── mapper/ # MyBatis数据访问接口 ├── entity/ # 数据库实体类 ├── vo/ # 视图模型(联表查询的结果对象) └── util/ # 工具类(文件上传、Excel导出、加密)

JSP页面我建议统一放在src/main/webapp/WEB-INF/views/下,然后通过SpringMVC的视图解析器跳转。这样做有一个安全上的好处:WEB-INF目录下的资源无法被浏览器直接通过URL访问,用户必须经过Controller转发才能看到页面,可以挡住一些直接请求.jsp文件的投机行为。

2. 数据库是地基:六张核心表的设计思路

数据库设计决定了你后面写代码是顺风顺水还是反复返工。很多同学拿到题目就急着建表,结果做到订单模块发现少了一个字段,回头再改表,连带实体类、Mapper、页面全要动一遍,非常折磨。我按当时的建表顺序,把每一张表的设计意图讲给你。

2.1 用户表:除了账号密码还要存什么

用户表是整个系统的核心,因为商品、订单、收藏都通过外键关联到用户。除了常规的id、username、password之外,我建议加上这些字段:

字段名类型说明
nicknamevarchar(50)昵称,首页和商品详情页显示用
avatarvarchar(255)头像图片路径,不存二进制,只存路径
phonevarchar(20)联系方式,线下交易需要互换手机号
wechatvarchar(50)微信号,比手机号更常用
campusvarchar(50)所在校区或宿舍区,用于筛选同区域交易
roletinyint0表示普通用户,1表示管理员
statustinyint0正常,1禁用,被管理员封号后无法登录
create_timedatetime注册时间

密码不要明文存储,至少用MD5做一次散列。网上有现成的MD5工具类,几行代码的事。答辩时老师问“为什么密码不能明文存”,你要能回答出“数据库泄露后用户在其他平台可能用相同密码,也容易被脱库撞库”这类理由。

2.2 商品表:状态字段决定了交易流程的边界

商品表是信息展示的主体。除了常规的标题、描述、价格,我特别强调两个设计点:

第一,价格字段用decimal(10,2),不用float,更不用double。二进制浮点数在表示小数时存在精度误差,0.1 + 0.2 可能等于 0.30000000000000004,这在涉及钱的地方是不能接受的。Decimal是字符串存储的定点数,专门为金额这类场景设计。

第二,状态字段 status 是整个交易流程的开关。我采用的是下面这一套约定:

  • 0 = 在售
  • 1 = 已售出
  • 2 = 已下架
  • 3 = 待审核(如果你做了后台审核功能)

每一个状态都直接影响用户在前台能做什么操作。比如商品状态为1时,“立即购买”按钮必须禁用;状态为2时,只有卖家自己能看到“重新上架”入口。这台状态机看起来简单,但它在“发布商品 → 买家下单 → 卖家下架 → 交易完成”这条链路上起了核心作用。

商品表还需要 cover_image(封面图)和 images(多图,多个路径可以用逗号分隔存在一个字段里,也可以另建一张商品图片表)。对于毕设来说,逗号分隔存一个字段是性价比最高的方案,减少一张表,查询时按逗号split一下就能展示。

2.3 订单表:一物一单与价格快照思想

订单表是整个平台最关键的“事实表”。校园闲置交易有一个特点:每一件商品都是孤品,不存在库存数量大于1的情况,所以商品和订单是严格的一对一关系。这个认知很重要,它直接决定了订单表不需要订单明细、不需要购物车,因为买家不会同时对同一件商品买两件。

订单表核心字段如下:

字段名类型说明
order_novarchar(32)唯一订单号,展示给用户看的
product_idint关联商品表
buyer_idint买家ID
seller_idint卖家ID
pricedecimal(10,2)下单时的成交价格快照
statustinyint0待确认、1已完成、2已取消
remarkvarchar(255)订单备注
create_timedatetime下单时间
finish_timedatetime交易完成时间

这里有一个非常值得在论文和答辩中讲清楚的概念:price为什么要在订单表冗余一份,而不直接去查商品表?因为卖家在商品被下单后,依然可能修改商品价格,如果订单详情页每次去读商品表,买家看到的金额可能和下单时不一致。订单表保存一份“成交价格快照”,意思就是这笔交易一旦生成,价格就固定了,后续任何商品表改动都不影响历史订单。这是真实交易系统里非常常见的设计思路。

2.4 分类、收藏与留言:轻量化扩展设计

分类表最简单,只有id、name、sort_order三个字段,用于前台分类筛选和后台分类管理。收藏表两个业务字段:user_id 和 product_id,再加一个 create_time,并且一定要给(user_id, product_id)建联合唯一索引,防止用户对同一个商品收藏两次。

留言表是可选的。我当时做的时候没有独立做留言功能,而是把咨询逻辑简化成了“商品详情页展示卖家微信/手机号,线下沟通”。如果你想多一个功能亮点,可以做一张留言表:product_id、from_user_id、content、create_time,显示在商品详情页底部,实现类似“问一问”的效果。这个功能成本低,但对提升系统的交互感很有帮助,而且答辩时可以让老师看到你考虑了买卖双方的沟通需求。

2.5 核心建表SQL与字段类型细节

下面是用户表、商品表、订单表三张核心表的建表SQL,基本覆盖了前面讲的设计要点。直接复制到Navicat或者命令行执行都行:

CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT 'MD5散列后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像路径', `phone` varchar(20) DEFAULT NULL, `wechat` varchar(50) DEFAULT NULL, `campus` varchar(50) DEFAULT NULL COMMENT '校区/宿舍区', `role` tinyint NOT NULL DEFAULT '0' COMMENT '0普通用户 1管理员', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0正常 1禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `product` ( `id` int NOT NULL AUTO_INCREMENT, `seller_id` int NOT NULL COMMENT '发布者ID', `category_id` int DEFAULT NULL COMMENT '分类ID', `title` varchar(100) NOT NULL, `description` text COMMENT '商品描述', `price` decimal(10,2) NOT NULL COMMENT '出售价格', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价/参考价', `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图路径', `images` text COMMENT '多图路径,逗号分隔', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0在售 1已售 2下架 3待审核', `view_count` int NOT NULL DEFAULT '0' COMMENT '浏览量', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_seller` (`seller_id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲置商品表'; CREATE TABLE `orders` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `product_id` int NOT NULL, `buyer_id` int NOT NULL, `seller_id` int NOT NULL, `price` decimal(10,2) NOT NULL COMMENT '成交价格快照', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待确认 1已完成 2已取消', `remark` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `finish_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_product_id` (`product_id`), KEY `idx_buyer` (`buyer_id`), KEY `idx_seller` (`seller_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

这里有几个细节值得注意:所有表都用utf8mb4而不是utf8,因为utf8mb4才能完整支持Emoji和生僻字,防止用户头像昵称里带个特殊字符导致插入失败。订单表给product_id加了唯一索引,它的作用我在后面的并发章节会重点讲。InnoDB引擎支持事务和外键约束语义,是SSM项目里最合适的选择。

3. SSM整合与核心业务代码的落地

3.1 依赖清单与三份核心配置文件的协作关系

SSM整合的第一步是搞定Maven依赖。我在pom.xml里维护的依赖清单如下,已经去掉版本号,你可以按自己用的Spring版本补充:

<dependencies> <!-- Spring核心 --> <dependency>org.springframework:spring-webmvc</dependency> <dependency>org.springframework:spring-jdbc</dependency> <!-- MyBatis与整合包 --> <dependency>org.mybatis:mybatis</dependency> <dependency>org.mybatis:mybatis-spring</dependency> <!-- 数据库驱动与连接池 --> <dependency>mysql:mysql-connector-java</dependency> <dependency>com.alibaba:druid</dependency> <!-- JSP与JSTL --> <dependency>javax.servlet:javax.servlet-api</dependency> <dependency>javax.servlet:jstl</dependency> <!-- 文件上传 --> <dependency>commons-fileupload:commons-fileupload</dependency> <!-- JSON处理 --> <dependency>com.fasterxml.jackson.core:jackson-databind</dependency> <!-- 分页插件 --> <dependency>com.github.pagehelper:pagehelper</dependency> <!-- Excel导出 --> <dependency>org.apache.poi:poi</dependency> </dependencies>

配置文件的组织方式,我建议拆成三份,各司其职:

  • applicationContext.xml:Spring父容器,只扫描servicemapperentity等非Controller的Bean,配置数据源、事务管理器。
  • springmvc.xml:SpringMVC子容器,只扫描controller包,配置视图解析器、文件上传解析器、静态资源放行。
  • web.xml:负责把上面两份配置加载到Tomcat容器里。

为什么要分成父子容器,而不是一个Spring配置通吃所有?这是SSM整合最经典的一个坑点:如果两个容器都扫描了同一个Controller,会导致请求路径被两个Bean同时处理,经常出现“控制器映射冲突”或者事务失效的诡异bug。标准做法是父容器管Service和Mapper,子容器只管Controller,边界清晰。

web.xml里还有两个容易出错的地方。第一个是编码过滤器,一定要配置CharacterEncodingFilter,并且把forceEncoding设为true,否则POST提交的中文很容易乱码。第二个是DispatcherServlet的<url-pattern>,用/会接管所有请求,包括静态资源,所以必须在springmvc.xml里用<mvc:resources>放行/static/**/upload/**

SpringMVC的视图解析器这样配置:

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>

视图解析器的含义是:Controller返回"product/detail"字符串时,SpringMVC会自动拼接出/WEB-INF/views/product/detail.jsp这个物理路径。坚持返回逻辑视图名、不返回物理路径,是MVC设计模式的核心要求,也是答辩时能体现你理解分层架构的细节。

3.2 发布闲置:从表单到数据库的完整链路

发布闲置是整个平台第一个需要跨多个层次协作的功能。页面是一个表单,包含标题、分类、价格、原价、描述、封面图。表单提交时图片已经通过CommonsMultipartResolver上传到了服务器,所以Controller接收到的除了表单字段,还有MultipartFile

先看Controller层:

@Controller @RequestMapping("/product") public class ProductController { @Autowired private ProductService productService; @PostMapping("/add") @ResponseBody public Result add(@RequestParam("file") MultipartFile file, Product product, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { return Result.error("请先登录"); } // 保存图片,返回可访问的URL String imageUrl = FileUploadUtil.save(file); product.setCoverImage(imageUrl); product.setSellerId(loginUser.getId()); product.setStatus(0); // 默认在售 productService.addProduct(product); return Result.success("发布成功"); } }

这里用了统一返回体Result,它包装了code(状态码)、message(提示信息)、data(数据)三个字段。为什么要做这个包装?因为页面上很多操作是AJAX异步提交的,服务端返回统一结构的JSON,前端才能统一判断成功失败,而不是每个接口各回各的格式。这是一个从第一个接口开始就要养成的习惯。

FileUploadUtil.save()方法里有两个实操细节:图片保存路径不能写到IDE的target或out目录里,因为每次重新编译项目,这些目录会被清空,导致之前上传的图片全部丢失。我当时是把文件写到项目下的/upload/目录,并用UUID生成新文件名,防止用户上传的图片名重复或包含中文导致路径乱码:

public static String save(MultipartFile file) { String realPath = System.getProperty("user.dir") + "/upload/"; File dir = new File(realPath); if (!dir.exists()) { dir.mkdirs(); } String originalName = file.getOriginalFilename(); String ext = originalName.substring(originalName.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext; file.transferTo(new File(realPath + newName)); return "/upload/" + newName; }

Service层要注意这个场景是单表插入,事务显得不是很有必要,但我们要培养一个习惯:所有写操作Service方法都加@Transactional注解。这样做的好处是,以后某个业务方法里同时操作三张表时,你的事务意识已经在,而不会因为漏掉注解导致数据只写了一半。商品初始状态直接设为0(在售),这个状态值最好抽成一个常量类ProductStatusEnum,不要散落在代码里。

3.3 下单链路:校验、乐观锁与事务

下单是整个系统最核心、也最值得在答辩时展开讲的业务逻辑。用户点击“立即购买”后,后端要做的事情比表面上看起来多得多。我当时把下单方法写成下面这样:

@Override @Transactional(rollbackFor = Exception.class) public Result createOrder(Integer productId, Integer buyerId) { // 1. 查询商品 Product product = productMapper.selectByPrimaryKey(productId); if (product == null) { return Result.error("商品不存在或已被删除"); } // 2. 状态校验 if (product.getStatus() != 0) { return Result.error("商品已售出或已下架"); } // 3. 不能买自己的商品 if (product.getSellerId().equals(buyerId)) { return Result.error("不能购买自己发布的商品"); } // 4. 乐观锁更新:status=0 -> 1 int updated = productMapper.updateStatusConditionally(productId, 0, 1); if (updated != 1) { return Result.error("手慢了,商品刚刚被别人买走"); } // 5. 生成订单并落库 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setProductId(productId); order.setBuyerId(buyerId); order.setSellerId(product.getSellerId()); order.setPrice(product.getPrice()); order.setStatus(0); orderMapper.insertSelective(order); return Result.success(order); }

这个方法里,第4步是关键中的关键。updateStatusConditionally对应的SQL长这样:

<update id="updateStatusConditionally"> UPDATE product SET status = #{newStatus}, update_time = NOW() WHERE id = #{productId} AND status = #{oldStatus} </update>

注意WHERE条件里的status = #{oldStatus},它保证了“只有商品当前处于在售状态时才允许被改成已售出”。同时被两个人下单时,数据库的行锁会让第二次UPDATE的影响行数为0,于是第二个请求就会走进“手慢了”的分支,不会生成两张订单。

为什么不先查一下状态、再更新、再插入?因为查和更新之间有时间差,在这个间隙如果有另一个请求抢先更新了商品状态,你的校验就失效了。把状态判断放进UPDATE的WHERE条件里,是把“检查和修改”合并成了一个原子操作,这是并发控制里典型的乐观锁思路。围绕这几十行代码,你可以回答一连串答辩问题:什么是乐观锁、为什么要用事务、怎么保证不会超卖。

订单号的生成我用的是时间戳 + 三位随机数

private String generateOrderNo() { return System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000)); }

对校园场景来说已经足够,因为还配合了订单表里order_no的唯一索引,万一撞了,插入时数据库会直接报错,你可以在捕获异常后重新生成一个再插入。

3.4 订单状态流转与个人中心的数据视角

订单创建之后,状态流转是这样一个闭环:

  • 买家下单,订单状态 = 0(待确认),商品状态 = 1(已售出)
  • 双方线下完成交易,买家在自己的购买列表点击“确认完成”,订单状态 = 1(已完成)
  • 如果双方协商取消,买家或卖家取消订单,订单状态 = 2(已取消),同时商品状态要恢复为 0(在售)

这里有一个容易被忽视的坑:取消订单恢复商品状态时,也要使用条件更新。也就是说,不能直接UPDATE product SET status = 0 WHERE id = ?,而要先确认商品当前状态确实为1(已售出)。因为有可能出现这样的场景:买家A下单后商品变成了已售出,但是很长时间没有确认完成,卖家误以为交易黄了,手动把商品下架重新上架了。此时如果买家取消订单,后台再无条件把商品改成在售,就和卖家的本意冲突了。条件更新能够避免这种状态混乱。

个人中心的数据视角,其实就是围绕当前登录用户的ID做几张关联查询,不需要新增加页面就能完成:

  • “我发布的”列表:查询 product 表 where seller_id = 当前用户ID
  • “我卖出的”订单:查询 orders 表 where seller_id = 当前用户ID
  • “我买到的”订单:查询 orders 表 where buyer_id = 当前用户ID
  • “我的收藏”列表:查询 favorite 表 join product 表,条件是 favorite.user_id = 当前用户ID

3.5 个人信息展示与Excel导出

个人中心首页的信息展示页面,在热搜词里对应“jsp个人信息展示页面”。这个页面看起来简单,但要注意两个数据正确性问题。第一个是头像和昵称的刷新问题:用户修改资料后,session里保存的loginUser还是旧数据,如果直接从session里取,页面刷新后依然显示修改前的信息。正确做法是修改成功后重新查一遍用户表,把新对象set回session里。第二个是统计数字的SQL写法:比如“我的在售商品数”,一行SQL就能完成:

<select id="countBySellerIdAndStatus" resultType="int"> SELECT COUNT(*) FROM product WHERE seller_id = #{sellerId} AND status = #{status} </select>

Excel导出是热搜词里也出现过的功能,它是毕设一个很好的加分点。用Apache POI实现,核心代码如下:

public void exportOrders(List<OrderVO> orderList, HttpServletResponse response) throws Exception { HSSFWorkbook workbook = new HSSFWorkbook(); HSSFSheet sheet = workbook.createSheet("订单列表"); String[] headers = {"订单号", "商品名称", "买家", "卖家", "成交价格", "状态", "下单时间"}; HSSFRow headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); } int rowNum = 1; for (OrderVO order : orderList) { HSSFRow row = sheet.createRow(rowNum++); row.createCell(0).setCellValue(order.getOrderNo()); row.createCell(1).setCellValue(order.getProductTitle()); row.createCell(2).setCellValue(order.getBuyerName()); row.createCell(3).setCellValue(order.getSellerName()); row.createCell(4).setCellValue(order.getPrice().doubleValue()); row.createCell(5).setCellValue(order.getStatusDesc()); row.createCell(6).setCellValue(order.getCreateTime()); } response.setContentType("application/vnd.ms-excel;charset=utf-8"); response.setHeader("Content-Disposition", "attachment;filename=orders_" + System.currentTimeMillis() + ".xls"); workbook.write(response.getOutputStream()); }

这段代码有几个要点:Content-Type要设置成application/vnd.ms-excel,而不是默认的text/html,否则浏览器可能直接显示乱码;Content-Dispositionattachment表示作为附件下载而不是在浏览器内打开;文件名加时间戳是为了避免同名文件覆盖。这里选择的HSSFWorkbook生成的是.xls格式,毕设场景完全够用;如果以后需要处理大数据量,再考虑XSSF(.xlsx)或SXSSF(流式写)。学会POI的套路之后,遇到“导出用户列表”“导出商品列表”都只是换表头换数据源的问题。

4. 从“能跑”到“能答辩”:页面细节与实战排坑

4.1 JSP改了不生效?先查这三个地方

“JSP改了不生效”是热搜词里的高频问题,也是我折腾最久的一个坑。明明代码改对了,刷新浏览器还是旧页面,第一次遇到时差点以为是灵异事件。后来总结出三个最常见的排查层级:

第一,浏览器缓存。JSP最终是在服务器端渲染成HTML发给浏览器的,但浏览器可能会把渲染结果缓存下来。你先试Ctrl + F5强制刷新,或者打开开发者工具的Network面板勾选Disable cache再刷新。很多时候问题就这么解决了。

第二,Tomcat的Jasper缓存。JSP第一次被访问时,Tomcat会把它编译成Java源码再编译成class文件。你修改JSP文件后,如果重新部署时Tomcat没有清掉旧的编译结果,就会继续执行旧class。这个缓存通常存在于Tomcat的work目录。解决办法是在IDE里执行“Clean Project”或“Rebuild Project”,然后重新部署。IDEA的话,按一次Ctrl+F5还不够,大概率要Build -> Rebuild Project,再重启Tomcat。

第三,部署目录不对。JSP文件要拷贝到Tomcat实际运行的webapps目录下。IDEA开发时,你改的是源码目录下的JSP,但Tomcat跑的是target目录下的一份拷贝。如果IDE的项目没有自动同步(auto redeploy)配置好,你改的源码根本不会被复制到运行目录。这个时候去target/目录下看一眼JSP文件是否更新了,就能定位问题。

还有一个经验:把JSP放在WEB-INF/views下开发时,部分IDE在热部署方面确实不如放在webapp根目录下响应快。如果你遇到“改了必须重启才生效”的情况,可以先配置一下Tomcat的on frame deactivationUpdate classes and resources,这能明显改善开发体验。

4.2 图片上传与MP4视频播放的两个坑

图片上传和展示,最常见的坑就是“数据库里存的路径访问不到”。我刚开始做的时候,把图片写到了项目运行目录下的临时目录里,然后页面里用<img src="/upload/xxx.jpg">去访问,结果显示404。问题在于,Tomcat默认的静态资源处理器并不知道/upload/这个URL应该映射到磁盘的哪个物理目录。

解决方案有两种。第一种是在springmvc.xml里配置虚拟映射:

<mvc:resources mapping="/upload/**" location="file:E:/project/upload/"/>

第二种是推荐的做法:如果图片就在webapp/upload目录下,那么用<mvc:resources mapping="/upload/**" location="/upload/"/>放行即可。要注意location结尾的斜杠不能省,否则Spring匹配路径时会出问题。

另外,所有页面中涉及资源URL的地方,推荐统一使用${pageContext.request.contextPath}来拼接上下文路径。比如:

<img src="${pageContext.request.contextPath}${product.coverImage}" />

否则部署时项目访问名一旦不是根路径,图片全部会挂掉。

关于热搜词里“jsp实现mp4视频播放”,我是在做一个扩展功能时遇到的。如果商品详情页要支持展示介绍视频,常用的做法是HTML5的<video>标签:

<video width="640" height="360" controls src="${pageContext.request.contextPath}/upload/${product.videoUrl}"></video>

但是我把MP4文件放到upload目录后,浏览器并没有像预期那样播放,而是直接弹出了下载框。原因是Tomcat默认的web.xml里并没有注册.mp4对应的MIME类型,服务器返回的Content-Type不是video/mp4。解决方法是修改Tomcat的conf/web.xml,或者在项目的web.xml里添加:

<mime-mapping> <extension>mp4</extension> <mime-type>video/mp4</mime-type> </mime-mapping>

这个问题提醒我们一个通用的排查思路:浏览器能不能正确显示文件,取决于服务器响应头里的Content-Type;遇到文件在浏览器里“打开”与“下载”行为与预期不符时,第一反应应该是查MIME映射而不是调前端代码。

4.3 并发下单的一致性保护:乐观锁与唯一索引

前面讲下单逻辑时已经提到了乐观锁,这里再展开讲一个单独的场景:两个同学同时看中同一件商品,同时点了“立即购买”

如果代码只是先查商品状态是0,再执行更新,那么两个请求都可能查到status=0,然后都执行成功,生成两笔订单,一件商品被卖出两次。这是一个典型的数据一致性问题,真实交易系统绝对不能接受。

我的解决方案是双保险:

第一层保险是“条件更新”的乐观锁,也就是前面代码里的UPDATE product SET status = 1 WHERE id = ? AND status = 0。MySQL执行这条UPDATE时会自动对命中的行加锁,不管同时来了多少个请求,最终只有一个请求能拿到行锁并把status从0改成1,其他请求的UPDATE影响行数为0,从而在业务层判定失败。

第二层保险是数据库层面的唯一索引,也就是订单表里UNIQUE KEY uk_product_id (product_id)。这保证即使未来业务代码被改坏,出现了第一个保险失效的情况(比如某个新同事用一个不带条件状态的UPDATE把商品update了),数据库也会因为product_id重复而拒绝插入第二笔订单。表结构层面的约束是最后一道底线。

这个“业务层乐观锁 + 数据库层唯一索引”的纵深防御思想,在答辩时是非常亮眼的点。老师大概率会追问“你们怎么处理多人同时购买的问题”,你把这个双保险的来龙去脉讲清楚,基本上这个问题就稳了。

4.4 答辩前值得加的五个小细节

我要说一个残酷又现实的经验:很多功能不齐全但细节到位的项目,得分往往高于功能堆得多但粗糙的系统。如果你的时间是有限的,我强烈建议把以下五个细节优先级提前。

第一,分页。用PageHelper插件,三行代码就能让列表页从前台一次性全量加载变成真正的分页。这不只是性能问题,更是一种“项目规范感”的体现。

第二,登录拦截器。用SpringMVC的HandlerInterceptor实现,定义登录拦截器把/product/add/order/create/user/center等需要登录的请求拦截住,未登录时重定向到登录页。这是Web应用的基本安全能力。

第三,统一异常处理。用@ControllerAdvice@ExceptionHandler处理全局异常,捕获到的异常记录日志并返回友好提示,而不是让Tomcat抛出一整页500错误堆栈。完整异常体系是评判系统“成熟度”的重要维度。

第四,MyBatis的#{}参数绑定。写Mapper XML时严禁用${}拼接SQL,因为${}是直接字符串替换,存在SQL注入风险;#{}会被预编译成占位符,天然免疫大部分注入攻击。能把这个原理讲清楚,说明你对安全有基本认知。

第五,后台数据可视化。不需要做得花里胡哨,只要在管理后台首页展示几个统计数字:今日新增用户数、在售商品数、已完成订单数、成交总额。用一行COUNT聚合SQL就能完成,但视觉上的“系统感”却会强很多。

最后再说几句体己话

整个项目从零开始做到全部收尾,我最大的体会不是代码量有多大,而是“想清楚再做”这件事的价值。数据库字段为什么这样设计,状态为什么用数字不用字符串,订单里为什么要冗余成交价格,并发下单为什么必须用条件更新——这些看似微小的决策,恰恰构成了你在答辩时能够从容表达的核心素材。很多同学被答辩老师问住,往往不是因为代码功能不对,而是因为他从来没有自己问过自己“为什么这样写”。

另外,这块平台做完之后,别急着删。把闲置物品发布、下单、订单状态流转这条主线再走一遍,把Excel导出、视频展示、统计看板这些扩展功能单独整理成一篇文档,它们就是你简历上“项目经验”一栏最真实的内容。月后面如果再有人问我“JSP这么老,学它还有用吗”,我会告诉他:老只是相对技术迭代而言,你在SSM里练过的分层思想、事务边界、并发控制和数据库设计,换任何框架都不会过时。

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

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

立即咨询