ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二手闲置交易系统JavaWeb课设源码拆解:架构、安全与部署

二手闲置交易系统JavaWeb课设源码拆解:架构、安全与部署

简介:一份面向JavaWeb期末大作业与课程设计的二手闲置物品交易系统源码,适合计算机及相关专业学生直接借鉴与二次开发。项目采用Spring Boot整合前端静态页面,围绕商品与用户等核心业务实现,涵盖列表展示、信息维护、异步交互等典型场景,代码结构规整,可满足课程设计答辩展示或实训报告撰写需求。压缩包共352个文件,以Java源码、XML配置、HTML/CSS/JS前端资源为主,另含图片、字体图标及项目配置文件,整体大小45.54MB,目录层级清晰,便于按Controller、Service、Dao等层次阅读。内容预览中可见GoodsController、UserController、UserServiceImpl等核心类,以及DruidConfig数据库连接池配置,能帮助理解请求路由、业务封装与数据持久化流程。目前已有4460人学习下载,适合希望快速搭建JavaWeb项目并打通前后端逻辑的同学。

1. 二手闲置物品交易系统不是玩具课设:拆源码前先想清楚这三个问题

「二手闲置物品交易系统」是 JavaWeb 课程设计里出现频率极高的一道题,但大多数同学交上去的版本只有「能跑」的水平:代码全部堆在一起,业务逻辑和页面跳转搅成一团,答辩被老师追问两句设计思路就露馅。我这次拆的这套源码,从类文件构成看,Spring Boot 启动类 Application、Controller/Service/Entity 三层结构、Druid 连接池显式配置、AjaxResult 统一返回封装、DateUtil 时间工具类全齐了,几乎把 JavaWeb 课设能考的要点都串在了一个交易系统里。

对正在做课设的人来说,这套源码真正的价值不在于「能跑」,而在于「能改」。你接手之后只需要回答三个问题:分层边界怎么划、数据库表怎么设计才能支撑买卖双方的数据流转、改哪些地方能在期末答辩时讲出设计感。这篇笔记就按这个顺序拆,从项目结构一路拆到部署踩坑,最后给你两个可以直接写进答辩词的改造点。

2. 项目骨架拆解:Spring Boot + Druid + 三层架构的分工与边界

2.1 从 class 文件反推项目结构:十个类之间的调用关系

拿到这套源码压缩包,先别急着启动。把 class 文件列表过一遍,项目长什么样基本就清楚了:Application 是 Spring Boot 的启动入口,GoodsController 和 UserController 是 HTTP 请求入口,GoodsServiceImpl 和 UserServiceImpl 是业务逻辑落点,Goods 和 User 是对应数据库表的实体类,DruidConfig 专门做数据源配置,AjaxResult 统一封装接口返回,DateUtil 负责时间格式化。这个构成说明作者没有把所有代码塞进一个类,而是按标准工程分包——这在课设评分里本身就是个隐性加分项。

源码目录在 IDEA 里通常长什么样?常见做法是分五个包:controller 放控制器,service 放接口和 impl 实现类,entity 放实体,config 放配置类,util 放工具类。如果用了 MyBatis,还会多一个 mapper 包放数据库访问接口。这套源码从类名推断大概率遵循这个结构,你导入后先在 pom.xml 里确认依赖,再逐个包扫一遍,十分钟内就能把代码地图建起来。

Controller 层的职责边界只有三条:接收参数、调用 Service、把结果包成 AjaxResult 返回。Service 层管业务规则——商品发布前校验登录态、商品下架前校验归属、状态流转是否合法,这些规则绝对不允许出现在 Controller 里。实体类只做数据库字段映射,不写业务方法。有些同学的实体类里塞了一堆 getter/setter 之外的业务方法,答辩时被问「你分层的依据是什么」,回答一旦含糊,前面代码写得再好也被打折扣。

// 典型的三层调用,Controller 尽量保持轻薄 @RestController @RequestMapping("/goods") public class GoodsController { private final GoodsService goodsService; public GoodsController(GoodsService goodsService) { this.goodsService = goodsService; } @PostMapping("/publish") public AjaxResult publish(@RequestBody Goods goods, HttpSession session) { Object userId = session.getAttribute("userId"); if (userId == null) { return AjaxResult.error("请先登录"); } return goodsService.publish(goods, (Integer) userId); } }

这段代码里有两个细节值得展开。第一个是构造器注入,通过构造方法把 GoodsService 传进来,而不是在字段上加 @Autowired,这样单元测试时可以直接 new 一个 mock 的 Service 传进去,依赖关系在编译期就被强制约束,字段注入做不到这一点。第二个是登录态处理:发布商品所需的 userId 从 Session 里取,而不是让前端把 userId 放在请求体里提交——如果前端能传 userId,任何人都能冒充卖家发布商品,这是典型的越权漏洞。

2.2 DruidConfig:为什么连接池配置是课设里的隐藏加分项

很多课设项目图省事,在配置文件里写三行 spring.datasource 就完事,根本不单独定义数据源。这套源码专门写了一个 DruidConfig,说明作者在这块是有意识加分的。Druid 是阿里巴巴开源的数据库连接池,相比 Spring Boot 默认的 HikariCP,它在课设场景里最实用的点是自带一个可视化监控页,能实时看到活跃连接数、执行耗时最长的 SQL、连接池占用情况。

答辩现场打开 localhost:8080/druid,指着监控页说出「我发现商品列表的查询耗时最高,给关键字列加了索引」,这个叙述比你空口说「我做了性能优化」有说服力得多。监控数据摆在那里,老师能看到系统是活的。

@Configuration public class DruidConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource") public DataSource druidDataSource() { return new DruidDataSource(); } @Bean public ServletRegistrationBean<StatViewServlet> statViewServlet() { ServletRegistrationBean<StatViewServlet> bean = new ServletRegistrationBean<>(new StatViewServlet(), "/druid/*"); bean.addInitParameter("loginUsername", "admin"); bean.addInitParameter("loginPassword", "123456"); return bean; } @Bean public FilterRegistrationBean<WebStatFilter> webStatFilter() { FilterRegistrationBean<WebStatFilter> bean = new FilterRegistrationBean<>(new WebStatFilter()); bean.addUrlPatterns("/*"); bean.addInitParameter("exclusions", "*.js,*.css,/druid/*"); return bean; } }

三个配置各有各的讲究。StatViewServlet 是监控页入口,URL 映射成 /druid/* 之后浏览器就能直接访问;loginUsername 和 loginPassword 是监控页登录凭证,建议在交付前改成自己的账号,不要留着默认的 admin。WebStatFilter 负责采集 SQL 统计,里面的 exclusions 参数是排除静态资源——如果忘了排除 js、css,监控页会被静态资源请求刷屏,真正有价值的 SQL 统计反而找不到。还有一个容易忽略的联动:如果项目里自己写了登录拦截器,要在拦截器注册时把 /druid/* 排除掉,否则两套过滤器规则冲突,监控页会白屏。

连接池参数方面,initialSize 和 minIdle 建议保持一致,都设 5;maxActive 设 20 对课设规模绰绰有余。不要迷信大数字,把 maxActive 调到 200 并不会让系统变快,反而会让连接池维护一堆空闲连接,启动变慢、数据库压力变大。

2.3 AjaxResult 统一返回格式:让前后端联调少一半口水

没有统一返回结构的项目,接口风格能乱到什么程度?有的接口返回对象,有的返回 String,出错时有的返回 null、有的直接抛异常堆栈。前端拿到这些,每个接口都要单独写解析逻辑,联调基本靠吼。AjaxResult 就是给所有接口套一个统一的壳,后端只要守住约定,前端一个拦截器就能处理所有响应。

public class AjaxResult extends HashMap<String, Object> { public static AjaxResult success(Object data) { AjaxResult result = new AjaxResult(); result.put("code", 200); result.put("msg", "操作成功"); result.put("data", data); return result; } public static AjaxResult error(String msg) { AjaxResult result = new AjaxResult(); result.put("code", 500); result.put("msg", msg); return result; } }

为什么让它继承 HashMap 而不是定义 code、msg、data 三个成员变量?因为继承 HashMap 后,想给返回包加一个字段直接 put 就行,不用改类定义,扩展成本最低。代价是没有类型约束,结构全靠约定。而约定的承载者就是前端 axios 拦截器:

// 前端统一处理:code 200 才走进成功回调 axios.interceptors.response.use( response => { if (response.data.code === 200) { return response.data.data; } alert(response.data.msg); return Promise.reject(response.data.msg); }, error => { console.error("请求异常:", error); } );

这段拦截器里藏着前后端协作的三个约定:code 为 200 才进入成功分支,非 200 统一弹提示;提示文案由后端维护在 msg 里,前端不写死任何业务提示语;后端想改提示内容,只动 AjaxResult.success 或 error 的 msg,全站生效。拿到这套源码后第一件事,就是检查是不是所有 Controller 方法都返回了 AjaxResult——漏掉一两个接口,前端就要为它们单独开特别处理分支,联调消耗立刻上来了。

3. 数据库设计先行:用户表、商品表与状态机的取舍

3.1 从实体类反推表结构:Goods 与 User 的核心字段

数据源配置好了,下一个问题是表怎么建。交易系统的核心是「一个人把闲置挂出来,另一个人看到后联系或购买」,数据结构落在两张主表上:用户表 user 和商品表 goods。之后如果要加订单、收藏、评论,每张新表都是围绕这两个主体扩展的。

用户表的核心字段包括:id 主键、username 登录名、password 加密后的密码、nickname 昵称、phone 联系电话——二手交易场景里买家需要联系卖家,这个字段必须要有、avatar 头像地址、create_time 注册时间。商品表的核心字段是:id、user_id 卖家外键、title 标题、description 描述、price 价格、images 图片列表、status 商品状态、view_count 浏览量、create_time 和 update_time。

CREATE TABLE `goods` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL COMMENT '卖家ID', `title` VARCHAR(100) NOT NULL COMMENT '标题', `description` TEXT COMMENT '商品描述', `price` DECIMAL(10,2) NOT NULL COMMENT '价格', `images` VARCHAR(500) DEFAULT '' COMMENT '逗号分隔的图片路径', `status` TINYINT DEFAULT 0 COMMENT '0在售 1已下架 2已售出', `view_count` INT DEFAULT 0 COMMENT '浏览量', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手商品表';

这张表有三个设计决策值得在答辩时展开讲。第一个是 price 用 DECIMAL(10,2) 而不是 FLOAT,因为浮点数在计算机里是近似存储,0.1 加 0.2 会得到一长串小数,涉及金额的字段必须用精确小数类型,这是行业规范。第二个是 status 用 TINYINT 而不是字符串,「在售」「已下架」「已售出」存成字符串不仅占空间,状态迁移还不好控制,下面一节细讲。第三个是 images 用逗号拼接存储多个图片路径,课设阶段比单独建商品图片表简单得多,等你有按图片维度做统计的需求时再拆表不迟。

导入数据库时有两点提醒:user 和 goods 不是 MySQL 保留字,但建表语句里最好加反引号,避免版本差异带来的意外问题;如果库里已有同名表,先 DROP 再执行,否则重复建表会报错。编码必须用 utf8mb4,只用 utf8 在存 emoji 表情时会报错,现在的商品描述里带 emoji 太常见了。

3.2 状态机设计:商品四态之间的合法流转

二手商品从发布到成交,会先后经历几个状态。简单系统通常定义三个:0 在售、1 已下架(卖家主动操作)、2 已售出。严谨一点的会多加一个 -1 表示管理员封禁。状态机设计的核心不是把状态定义出来,而是把「哪些跳转是合法的」在代码里写死。

// 合法的状态流转:0在售 -> 1下架/2售出;1下架 -> 0再次上架 // 已售出是终态,不能改回任何状态 private boolean canTransit(int from, int to) { if (from == 0) { return to == 1 || to == 2; } if (from == 1) { return to == 0; } return false; }

为什么要单独写这个方法?课设里最常见的翻车现场就是状态被乱改:SQL 直接写UPDATE goods SET status = 2 WHERE id = ?,任何一个知道接口的人都能把已售出的商品改回在售。把状态迁移规则收敛到 Service 层一个方法里,所有状态变更入口都先走它再落库,状态就乱不了。这个方法本身也适合在答辩时聊聊状态模式——不用真的引入二十几个类,至少你能说清楚「有限状态」这个概念。

状态校验之外,更隐蔽的是所有权校验。修改商品、下架商品、删除商品这三个接口,很容易只校验「商品存在」却漏掉「商品属于当前登录用户」。正确的接口调用链是:根据 goodsId 查出商品,再比对商品 user_id 与 Session 里的 userId,不一致直接返回「无权操作」。这个校验在 4.2 节的代码里有完整实现,到时候可以直接抄。

提示:状态机校验放在 Service 层,而不是依赖前端按钮显隐。前端隐藏按钮只是体验优化,后端校验才是安全底线。

3.3 DateUtil:时间字段在列表与详情里的处理

DateUtil 这个工具类看着不起眼,做列表页时你一定会用到它。数据库存的是 DATETIME,Java 里对应 LocalDateTime,浏览器要显示成「2024-12-20 14:30」或是「3 分钟前」,中间隔着两层转换。如果没有统一工具类,每个页面各自格式化一遍,格式迟早会乱。

public class DateUtil { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); public static String format(LocalDateTime time) { if (time == null) return ""; return FORMATTER.format(time); } public static LocalDateTime parse(String str) { if (str == null || str.isEmpty()) return null; return LocalDateTime.parse(str, FORMATTER); } public static String relativeTime(LocalDateTime time) { Duration d = Duration.between(time, LocalDateTime.now()); if (d.toMinutes() < 1) return "刚刚"; if (d.toMinutes() < 60) return d.toMinutes() + "分钟前"; if (d.toHours() < 24) return d.toHours() + "小时前"; return FORMATTER.format(time); } }

建议使用 JDK 8 的 java.time 包而不是 java.util.Date,前者类型安全、不可变,没有 Date 那一堆 deprecated 方法。format 负责标准格式输出,relativeTime 用于列表页展示「刚刚/分钟前/小时前」,让页面有真人使用的感觉,这个细节做到了,答辩观感立刻不一样。

建表时 create_time 直接给 DEFAULT CURRENT_TIMESTAMP,update_time 加上 ON UPDATE CURRENT_TIMESTAMP,应用层就不用手动维护这两个字段。这样插入、更新自动处理时间,也就不会出现「某个功能明显改过但 update_time 没变」的翻车现场。

4. 核心功能实战:从商品发布的完整链路看业务边界与安全校验

4.1 登录注册:Session 方案的选择与密码处理

二手交易系统里,商品的发布、下架、购买全部围绕「用户身份」展开,登录注册是第一道闸门。UserController 里通常实现 login、register、logout 三个接口。登录成功的本质是:服务端在 Session 里写入用户标识,客户端拿到 Session Cookie,后续每次请求都带着这个会话身份。

用户注册的安全细节集中在密码上。密码不能明文存数据库,这是 JavaWeb 课设老师最爱问的问题。常见的处理方向是存储前做不可逆哈希:MD5 加盐或者 BCrypt。MD5 有彩虹表风险,稳妥做法直接上 BCrypt,Spring Security 里自带 BCryptPasswordEncoder。不过课设为了轻量,很多人用 MD5 加固定盐也能被接受,关键在于能解释清楚「为什么服务端拿不到用户真实密码」——数据库泄露了,攻击者也无法反推出明文。

@PostMapping("/register") public AjaxResult register(String username, String password) { if (username == null || username.length() < 3) { return AjaxResult.error("用户名至少 3 位"); } if (password == null || password.length() < 6) { return AjaxResult.error("密码至少 6 位"); } User exist = userService.findByUsername(username); if (exist != null) { return AjaxResult.error("用户名已存在"); } User user = new User(); user.setUsername(username); user.setPassword(PasswordUtil.encode(password)); userService.register(user); return AjaxResult.success(null); }

这段注册代码体现三层约束:参数校验前端后端都要做,前端为了体验、后端为了安全——不少课设的思路是前端校验了后端就不管,这是大忌,因为接口是公开的,绕过前端直接发请求太容易了;唯一性检查放在插入之前,否则数据库里出现重名用户,登录逻辑就开始出乱子;密码在进入 Service 之前就完成加密,Controller 这一层永远接触不到明文密码,后面打日志也不会把密码带进日志文件。

登录接口的思路对称:取到用户名密码,查用户,比对哈希,写 Session,返回 AjaxResult。比对用的加密函数必须和注册时完全一致,否则每次登录都提示「密码错误」。这种「本地能跑、换个环境密码全部失效」的玄学问题,根源多半就是两套代码用了不同的加密方式,或者注册时每次随机盐导致哈希对不上。

4.2 商品发布与修改:参数校验和越权防护两手抓

商品是系统的核心资产,GoodsController 的接口设计直接决定系统的安全下限。发布一个二手商品的完整链路是:前端提交 title、description、price、images,后端从 Session 取当前用户,拼装 Goods 对象落库。这段逻辑的关键点只有一个——user_id 必须从 Session 取,前端传的 user_id 一律不认。

真正容易出问题的是修改接口。没有经验的实现长成这样:

// 反面教材:只校验参数,不校验归属 @PostMapping("/update") public AjaxResult update(Goods goods) { goodsService.update(goods); return AjaxResult.success(null); }

这段代码有什么问题?任何登录用户只要构造一个带id=3&price=0.01的请求,就能把别人的商品改成自己的价格。前端确实只在卖家页面显示编辑按钮,但接口是公开的,F12 打开控制台手动发一个请求就能绕过页面。正确实现必须加四道防御:

@PostMapping("/update") public AjaxResult update(@RequestBody Goods goods, HttpSession session) { Integer userId = (Integer) session.getAttribute("userId"); if (userId == null) { return AjaxResult.error("请先登录"); } Goods exist = goodsService.findById(goods.getId()); if (exist == null || !exist.getUserId().equals(userId)) { return AjaxResult.error("无权修改该商品"); } if (goods.getPrice() == null || goods.getPrice().compareTo(BigDecimal.ZERO) <= 0) { return AjaxResult.error("价格不合法"); } goods.setUserId(userId); goodsService.update(goods); return AjaxResult.success(null); }

四个动作缺一不可:先从 Session 取 userId 确认登录态,拿不到直接拒绝;再根据商品 id 查原商品,比对归属,不是自己的商品一律返回无权操作;价格做基础合法性校验,杜绝负数或零元商品;最后goods.setUserId(userId)一行强制覆盖——即使前端伪造了 userId 字段,到这里也会被覆盖成当前登录用户,越权路径彻底堵死。这套组合打完,「越权修改他人商品」这个课设高频漏洞基本没有缺口。

4.3 Service 层的事务与状态校验:三条写操作必须同生共死

Controller 层校验完参数,业务规则进入 Service 层。一个商品成交的完整链路往往涉及三张表的写操作:商品表更新状态为已售出、订单表插入一条订单记录、用户表给买家加交易计数。三条写操作必须以事务方式执行,要么全成功、要么全回滚,不然会出现「库存扣了但订单没生成」这类数据不一致。

@Transactional public void completeTrade(Integer goodsId, Integer buyerId) { Goods goods = goodsMapper.findById(goodsId); if (goods.getStatus() != 0) { throw new RuntimeException("商品当前不可购买"); } goodsMapper.updateStatus(goodsId, 2); orderMapper.insert(new Order(goodsId, buyerId, goods.getUserId(), goods.getPrice())); userMapper.increaseTradeCount(buyerId); }

这里的 @Transactional 注解由 Spring 拦截方法执行:进入前开启事务,方法正常结束提交,抛出 RuntimeException 自动回滚。这个注解有两个经典的大坑。一个是方法内部 try-catch 把异常吞掉,事务管理器看不到异常就不会回滚——正确做法是 catch 后重新抛出,或者干脆不 catch 让异常往外冒。第二个是同一个类里自调用带事务的方法,即 Controller 调 list 方法,list 方法里又调了 completeTrade,事务会静默失效,因为 Spring 事务依赖代理对象,自调用不经过代理。

状态校验放在 Service 层而不是直接写 SQL 条件,原因是「可读的报错信息」。SQL 里写UPDATE goods SET status=2 WHERE id=? AND status=0,状态不对时只会返回影响行数 0,前端拿到的反馈是「操作失败」,却不知道具体为什么。在 Service 层预先读一次商品、判断状态、抛出明确异常,前端就能把「商品已下架或已售出」直接展示给用户。如果系统里有多个入口都可能触发状态变更——发布、下架、购买、管理员操作——把这个状态机规则收敛到 Service 层一个方法里,是课设能稳定运行的底气。

5. 部署与避坑:IDEA 启动到 MySQL 配置的五个真实踩坑

从拿到源码到完整跑起来,花掉的时间大概率不是业务代码,而是这些不起眼的环境问题。下面五条是我拆这类 JavaWeb 课设时遇到最多的情况,按出现频率排序。

5.1 现象:启动时报数据库连接失败或 Access denied

启动 Spring Boot 项目,控制台最先报错的大多是数据库连接:Cannot connect to MySQL server on localhost:3306,或者Access denied for user 'root'@'localhost'。

原因基本锁定三个方向:本地 MySQL 没装或服务没启动;application.properties 里的连接配置和当前环境不匹配;MySQL 大版本和驱动版本不兼容,认证协议对不上。其中第三个最隐蔽,MySQL 8.0 默认使用 caching_sha2_password 认证,需要 mysql-connector-java 8.0 以上的驱动才支持,如果 pom 里依赖还停留在 5.1.x,账号密码全对也会报 Access denied。

解决路径是固定的:先在命令行敲mysql -u root -p,验证账号密码能连通;通了再看配置文件里的地址、端口、库名是否和本地一致;最后打开 IDEA 的 Maven 面板刷新依赖,确认驱动版本和 MySQL 大版本匹配。配置文件里的 url 建议补全三个参数:

spring.datasource.url=jdbc:mysql://localhost:3306/second_hand?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 spring.datasource.username=root spring.datasource.password=你的密码

useSSL=false 关闭 SSL 告警避免控制台刷屏,serverTimezone=Asia/Shanghai 解决时区导致的时间错乱,characterEncoding=utf8 保证中文不乱码。这三个参数值得写进你自己的标准配置模板,每次复制粘贴能少踩一半数据库连接坑。

5.2 现象:Druid 监控页 404 或白屏

Druid 依赖加了、DruidConfig 也写了,访问 localhost:8080/druid 还是 404,这种白屏问题的排查顺序很固定:确认 pom.xml 里 druid 相关依赖真的存在——有人从网上抄了配置类却忘了加依赖,这是最常见的低级翻车;确认 StatViewServlet 的 url-pattern 和访问地址一致,配的是 /druid/* 访问就得带分号后的斜杠;最后检查项目自定义拦截器有没有把 /druid/* 给拦了。

最后这条最隐蔽。Spring Boot 项目如果写了一个 WebMvcConfigurer 做登录拦截,通常会用addPathPatterns("/**")把所有路径都揽进来,忘了排除 /druid/*,监控页就永远被登录校验挡在外面。解决方式是在拦截器注册时显式追加.excludePathPatterns("/druid/*"),必要时连同静态资源一起排除,否则页面样式加载不出来,看着跟白屏一样。

提示:如果项目里没有自定义拦截器但页面仍然白屏,优先检查 druid-spring-boot-starter 的版本和 Spring Boot 版本兼容性,版本跨度太大的组合行为不可预期。

5.3 现象:登录成功但后续接口提示请先登录

页面上明明跳转到了登录后的首页,点「发布商品」却弹出「请先登录」。这个问题本质上是 Session 没有在第二次请求时被带回服务端。

先说最常见的前端原因:axios 默认不携带跨域 Cookie。如果你做的是前后端分离,前端跑在 5173 或 8081 这类端口,后端在 8080,请求属于跨域,浏览器不会自动附带 Session Cookie。解决方式是在 axios 配置里加withCredentials: true,同时后端开启 CORS 时设置allowCredentials(true),并且 allowOrigin 不能是*。

另一个真实场景是登录机制混用:前端把 token 存到了 localStorage,后端接口却在从 Session 取 userId;或者后端登录接口发了 Token,但没有把它放进 Session。两种机制互相打架,登录状态时好时坏。课设项目建议从一而终,要么全走 Session,要么全走 Token,不要一半一半,排查成本会直线上升。

5.4 现象:刷新页面后商品图片全没了

图片上传功能做完,第一次测试就发现刷新之后图片丢了。问题出在图片存储方式:数据库里存的是相对路径,图片实际被写进项目的 target/classes 目录,IDEA 每次 clean 构建都会清掉这个目录,图片随之灰飞烟灭。

另一种常见情况是上传路径写死成绝对路径,比如 D:/project/upload,数据库里也存绝对路径,换一台电脑部署,所有历史图片路径全部失效。解决思路:数据库只存相对路径,上传根目录指向操作系统上的独立目录,比如 /home/user/uploads;然后在 Spring Boot 配置项里定义一个file.upload-dir参数统一控制,这样换环境时只改一个配置就能把整个图片路径拉齐。

5.5 现象:换个浏览器或手机访问,页面样式乱掉

前后端一体的课设项目(JSP 或 Thymeleaf),静态资源路径用相对路径是排在第一位的样式问题。场景很典型:首页 index 下写<link href="css/style.css">能正常加载,但页面跳到 /goods/list 之后,浏览器解析相对路径的基准变成了 /goods/,于是请求 /goods/css/style.css,404,样式全丢。

解决方式统一改成根路径引用:JSP 用${pageContext.request.contextPath}拼接,Thymeleaf 用th:href="@{/css/style.css}"。改完静态资源路径,再检查后端有没有给静态资源放行——Spring Boot 的登录拦截器如果不排除 /css/、/js/、/images/**,会出现「登录后页面正常、未登录时样式全崩」的现象,这个坑在带校验拦截器的项目里几乎必踩一次。

6. 拿来改造成自己的课设:一个低成本高回报的页面与答辩清单

6.1 加一个「我的发布」页面,顺手点满三个考核点

原项目可能只有商品列表、详情、发布三个页面,缺一个「卖家管理自己商品」的入口。加一个 /goods/my 接口,按当前登录用户查询,前端复用列表页模板,只是请求参数带 userId——这个改动在答辩时够讲三分钟:数据范围隔离、登录态复用、页面复用。

@GetMapping("/my") public AjaxResult myGoods(HttpSession session) { Integer userId = (Integer) session.getAttribute("userId"); if (userId == null) { return AjaxResult.error("请先登录"); } return AjaxResult.success(goodsService.listByUserId(userId)); }

配套的 Mapper SQL 记得把 status 筛选条件留出来,下架的商品也要能查到,不然商品下架后你就再也无法操作它了,这是设计里最容易忽略的一个闭环。

6.2 答辩前的最后检查清单

拿到这套源码后,建议你自己先在空白环境里完整走一遍下面的流程:项目从零启动成功;数据库脚本在 README 里写明版本和导入顺序;所有接口返回统一走 AjaxResult;密码不以明文存在数据库;商品发布、编辑、下架三个接口都做归属校验;静态资源全部根路径引用;注册、登录、发布、下架完整走一遍确认 Session 不丢。如果答不上来「你为什么这么设计」,多从状态机和安全校验这两个点准备,它们最容易打开老师的话匣子。

每次答辩前我也会拿着这套源码重跑一遍环境——不是因为它多高深,而是连接池配置、图片目录、Session 传递这些坑,在每个项目里都会换个样子再出现一次。从那以后我每次部署课设项目,都强制走一遍上面的流程,答辩前一天重点核对数据库脚本和越权接口这两项。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进