简介:这是一份基于 Java+MySQL 实现的校园二手交易市场 Web 项目,面向 Java Web 课程设计、期末实训或毕业设计参考,可用于理解从用户端到管理端的完整业务闭环。系统覆盖登录注册、管理员管理用户、卖家发布/下架/修改商品、买家搜索分类购物车与直接购买,并按“买家下单—卖家发货—买家确认收货”推进交易状态,另有密码加密、邮箱通知等拓展功能。资源包共 224 个文件,压缩后 9.89MB,包含 37 个 Java 源文件、24 个 HTML 页面、CSS/JS 前端资源、SQL 脚本、docx 设计说明及大量界面截图,可按需阅读源码、数据库脚本和说明文档。已有 120 人浏览学习,适合需要快速搭建同类型项目、准备课程设计答辩或进行二次开发的读者;拿到手可结合文档梳理数据库表结构、交易状态流转与后端接口设计。
1. 校园二手交易市场拆解:不是增删改查,是状态机
校园二手交易市场这类系统,刚拿到手时容易当成“用户表、商品表、订单表”三个增删改查来写。实际跑一遍流程会发现,真正的门槛在订单状态:买家下单后,卖家要能发货,买家要确认收货,管理员还要看得到每一笔买卖记录。基于 Java + MySQL 的 Web 课程设计(编号 100011131)把这块做成了完整可运行的项目,覆盖登录注册、管理员管理用户、卖家发布/下架商品、买家购物车/下单、密码加密与邮箱通知。这个范围很适合理解 Java Web 的会话机制和事务边界,也适合作为 java 面试题里“交易状态机怎么设计”的落地样本。下面按数据库、登录权限、买卖链路、进阶细节的顺序拆开讲。
2. MySQL 表结构与订单状态流转:三张核心表撑起整个交易闭环
2.1 用户/商品/订单/购物车四张表的字段设计
课程设计里最常见的坑是把所有状态都堆在一张表里,或者是订单表直接存“已下单/已发货/已完成”字符串。建议拆成四张表,并用TINYINT表达状态。订单表命名为orders,避免order成为 MySQL 关键字。
| 表名 | 核心字段 | 关键约束 | 用途 |
|---|---|---|---|
| user | username, password_hash, email, role, status | username 唯一 | 登录、个人信息、管理员 |
| goods | seller_id, title, category, price, stock, status | seller_id 外键 | 卖家发布商品 |
| orders | order_no, goods_id, buyer_id, seller_id, quantity, total_price, status | order_no 唯一 | 买卖记录与状态流转 |
| cart | user_id, goods_id, quantity | (user_id, goods_id) 唯一 | 购物车 |
建表时注意外键和索引的取舍。管理员账号按需求是内定的,无需注册,所以用户表里用role区分普通用户和管理员,status用来表示注销状态。密码字段不要命名为password,因为后面要存的是哈希值,用password_hash更明确。
CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, email VARCHAR(100) DEFAULT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT '0用户 1管理员', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0注销', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE goods ( id INT AUTO_INCREMENT PRIMARY KEY, seller_id INT NOT NULL, title VARCHAR(100) NOT NULL, category VARCHAR(20) DEFAULT '其他', price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 2下架 0删除', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_seller (seller_id), KEY idx_category (category), CONSTRAINT fk_goods_seller FOREIGN KEY (seller_id) REFERENCES user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里把seller_id和category单独建索引,是为了卖家管理“我的售卖商品”和买家按分类筛选时能走索引。status不建索引,因为大部分查询会带上其他条件,区分度不高。DECIMAL(10,2)用来存价格,避免FLOAT的精度问题,这是万国码表之外的另一个容易在 mysql 安装配置教程里被忽略的点。
订单表和购物车表继续按同样思路建。
CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, goods_id INT NOT NULL, buyer_id INT NOT NULL, seller_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1待发货 2待收货 3已完成 4已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, ship_time DATETIME DEFAULT NULL, confirm_time DATETIME DEFAULT NULL, KEY idx_buyer (buyer_id), KEY idx_seller (seller_id), CONSTRAINT fk_orders_goods FOREIGN KEY (goods_id) REFERENCES goods(id), CONSTRAINT fk_orders_buyer FOREIGN KEY (buyer_id) REFERENCES user(id), CONSTRAINT fk_orders_seller FOREIGN KEY (seller_id) REFERENCES user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE cart ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, goods_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_goods (user_id, goods_id), KEY idx_goods (goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表里冗余了seller_id,看起来从goods表能 join 到,但交易记录查询高频场景是“我买到的”和“我卖出的”,冗余可以少一次 join。ship_time和confirm_time记录关键动作时间,便于后期做超时未确认收货的补偿任务。
2.2 订单状态用什么字段表达:tinyint + 状态码字典
状态字段我建议用TINYINT,不用字符串。字符串会带来两个问题:一是数据库里容易写混淆,比如“待确认”和“待收货”意思接近;二是 Java 代码里到处散落魔法值,后期改状态名时不好维护。项目里状态码固定为四档。
| status | 含义 | 触发动作 |
|---|---|---|
| 1 | 待发货 | 买家下单 |
| 2 | 待收货 | 卖家发货 |
| 3 | 已完成 | 买家确认收货 |
| 4 | 已取消 | 买家或管理员取消订单 |
在 Java 里用一个枚举维护,前端下拉框和列表展示都引用它,而不是在 JSP 页面里写死数字。
public enum OrderStatus { WAIT_SHIP(1, "待发货"), WAIT_CONFIRM(2, "待收货"), DONE(3, "已完成"), CANCELED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static String descOf(int code) { for (OrderStatus s : values()) { if (s.code == code) { return s.desc; } } return "未知"; } }这个枚举的价值在跨层传递时体现:Servlet 拿到order.getStatus(),可以直接用OrderStatus.descOf()渲染页面;后端做状态变更时,判断当前状态是否是目标状态的前置状态,防止用户跳步骤。比如从待发货直接改成已完成就不合法,必须经过待收货。
2.3 买卖记录与商品分类统计的 SQL 写法
“查看买卖记录”是买家个人中心和卖家个人中心都要有的功能,但查询维度不同。买家看的是自己买到的订单,卖家看的是自己卖出的订单。
-- 买家:查看我买到的 SELECT o.order_no, g.title, o.quantity, o.total_price, o.status, o.create_time FROM orders o JOIN goods g ON o.goods_id = g.id WHERE o.buyer_id = ? ORDER BY o.create_time DESC; -- 卖家:查看我卖出的 SELECT o.order_no, u.username, g.title, o.quantity, o.total_price, o.status, o.ship_time FROM orders o JOIN user u ON o.buyer_id = u.id JOIN goods g ON o.goods_id = g.id WHERE o.seller_id = ? ORDER BY o.create_time DESC;参数?分别是当前登录用户的id,对应HttpSession里存的用户对象。查询结果直接封装成List<Map<String, Object>>或对应 VO 对象。注意两个查询都强制走了o.buyer_id和o.seller_id索引,订单量大了以后不会全表扫描。
商品分类统计用于首页“热门分类”或后台图表,属于典型的GROUP BY应用。
SELECT category, COUNT(*) AS cnt, AVG(price) AS avg_price FROM goods WHERE status = 1 GROUP BY category ORDER BY cnt DESC;status = 1只统计在售商品,下架商品保留在数据库里但不进入前台统计。这个查询能帮管理员快速看到哪个分类上架最多,也可以作为 java 基础能力面试题的实战场。
3. 用户登录注册与管理后台:Session、密码加密与默认密码重置
3.1 注册时的密码加密:不要用明文
需求里明确写了密码加密。课程设计最常用的是 MD5 加盐,虽然从现在的安全标准看 MD5 已经不够强,但在 Java Web 课设里是“够用且能讲清原理”的方案。如果直接存明文,数据库泄露就相当于所有账号泄露,面试官看到项目里出现明文密码会直接扣分。
| 方案 | 是否推荐 | 理由 |
|---|---|---|
| 明文 | 不推荐 | 一泄露全泄露 |
| MD5 摘要 | 不推荐 | 撞库容易,彩虹表可反查 |
| MD5 + 随机盐 | 课程设计推荐 | 实现简单,能解释盐的作用 |
| BCrypt | 生产推荐 | 自带盐和代价因子,但需要额外依赖 |
随机盐指的是每个用户生成不同的盐字符串,拼接密码后再做摘要,这样两个相同密码的用户得到的哈希值也不同。
public static String md5WithSalt(String password, String salt) { String src = password + "{" + salt + "}"; StringBuilder sb = new StringBuilder(); try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] bytes = md.digest(src.getBytes(StandardCharsets.UTF_8)); for (byte b : bytes) { sb.append(String.format("%02x", b)); } } catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5 algorithm not found", e); } return sb.toString(); }这个方法先拼出密码{盐}的字符串,然后取 MD5 摘要,最后转成 32 位十六进制字符串。String.format("%02x", b)把每个字节转成两位十六进制,保证生成的哈希长度一致。注册时生成一个随机盐,比如UUID.randomUUID().toString().substring(0, 8),把盐和最终的password_hash一起存到用户表里。
登录校验时,从数据库查出该用户的盐,再用相同方式计算哈希,和库里的password_hash比较。注意比较不要用==,要用MessageDigest.isEqual()或者equals(),防止时间侧信道攻击。
3.2 登录状态保持:HttpSession 的存与销
Web 项目登录后需要记住当前用户,最常见的是HttpSession。登录接口处理逻辑分三步:校验用户名密码、把用户对象塞进 session、跳转到首页。
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); User user = userDao.findByUsername(username); if (user == null || !user.getPasswordHash().equals(LoginUtil.md5WithSalt(password, user.getSalt()))) { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } HttpSession session = request.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); response.sendRedirect(request.getContextPath() + "/index"); }session.setMaxInactiveInterval(30 * 60)设的是 30 分钟无操作后 session 失效。登录失败时用 forward 留在登录页带错误提示,不要用 sendRedirect,否则错误信息会丢。注销接口则调用session.invalidate(),把整个会话作废。
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } response.sendRedirect(request.getContextPath() + "/login.jsp"); }request.getSession(false)表示如果没有 session 就返回 null,而不是新建一个。注销接口不应该创建新会话,这是很多初学者容易忽略的细节。登录后每个需要权限的页面都从 session 取loginUser,取不到就在过滤器里重定向到登录页。
3.3 管理员修改用户:密码只能重置不能改
需求里点名管理员可以更改用户信息,但密码只能重置、不可随意修改。这个设计很符合实际场景:管理员不应该知道用户的原始密码,能重置成默认密码就够了。
重置密码的 SQL 如下:
UPDATE user SET password_hash = ?, salt = ?, status = 1 WHERE id = ? AND role = 0;?依次是默认密码的哈希、新生成的盐、目标用户 id。role = 0是防止管理员把另一个管理员账号重置掉。默认密码通常是123456,重置成功后把用户邮箱传过来,通知对方“你的密码已被重置为默认密码,请尽快登录修改”。
管理员其他操作还包括新增用户和注销用户。新增用户时密码也走默认密码,和重置逻辑复用同一个加密方法。注销用户不是物理删除,而是把status改成0,这样历史订单里的用户仍然能被关联查询到。
public void resetPassword(int userId) { String salt = UUID.randomUUID().toString().substring(0, 8); String defaultPwd = LoginUtil.md5WithSalt("123456", salt); userDao.updatePasswordHashAndSalt(userId, defaultPwd, salt); userDao.findEmail(userId).ifPresent(email -> mailService.sendAsync(email, "密码已重置", "新密码为 123456")); }这里把“重置”和“通知”放在同一个方法里,但通知是异步的,不会因为邮箱服务超时而导致重置接口变慢。下一章会看到邮箱通知的具体实现。
4. 卖家发布与买家购买:商品上下架、购物车与下单链路
4.1 发布商品、修改商品与下架保留
卖家发布商品是典型的表单提交:标题、分类、价格、库存、图片地址。新增和修改商品可以共用一个 JSP 页面,通过隐藏字段goodsId区分。
| 操作 | SQL | 说明 |
|---|---|---|
| 新增商品 | INSERT INTO goods (...) | seller_id 从 session 取 |
| 修改商品 | UPDATE goods SET ... WHERE id=? AND seller_id=? | 防止卖家改别人商品 |
| 下架商品 | UPDATE goods SET status=2, stock=0 WHERE id=? AND seller_id=? | 保留商品记录 |
下架的关键是stock=0,而不是删除记录。需求里明确要求“将商品数量置为 0,保留在数据库中”,这样历史订单关联商品时不会出现外键悬空。前端页面用status=2过滤,不再展示在主页。
修改商品信息时,要注意权限校验。卖家只能改自己发布的商品,所以 WHERE 条件必须带seller_id,否则越权改别人商品。
int rows = goodsDao.updateBySeller(goodsId, title, category, price, stock, sellerId); if (rows == 0) { throw new BusinessException("商品不存在或无权修改"); }updateBySeller生成的 SQL 是UPDATE goods SET title=?, category=?, price=?, stock=? WHERE id=? AND seller_id=?。rows == 0可能是商品 ID 不存在,也可能是这商品不属于当前卖家,两种情况都直接拒绝,避免猜测商品 ID 遍历修改。
4.2 购物车加购、改数量、删除与下单的事务处理
购物车表用(user_id, goods_id)唯一键约束,加购可以用一条 SQL 完成“存在则数量加一,不存在则插入”:
INSERT INTO cart (user_id, goods_id, quantity) VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE quantity = quantity + 1;ON DUPLICATE KEY UPDATE依赖唯一键uk_user_goods,第一次加购插入记录,第二次加购把数量加一。改数量和删除商品就更直接:
UPDATE cart SET quantity = ? WHERE user_id = ? AND goods_id = ?; DELETE FROM cart WHERE user_id = ? AND goods_id = ?;购物完成后下单是整条链路里事务边界最清晰的部分:从购物车生成订单,同时扣减商品库存,清空对应购物车项。这三个动作要么全部成功,要么全部失败。
Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 查询购物车项,同时锁住对应商品行 List<CartItem> items = cartDao.findCartItems(conn, buyerId); for (CartItem item : items) { int rows = goodsDao.decreaseStock(conn, item.getGoodsId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("库存不足: " + item.getTitle()); } double total = item.getPrice() * item.getQuantity(); orderDao.insert(conn, buyerId, item.getSellerId(), item.getGoodsId(), item.getQuantity(), total); cartDao.delete(conn, buyerId, item.getGoodsId()); } conn.commit(); } catch (Exception e) { conn.rollback(); throw new ServletException(e); } finally { conn.setAutoCommit(true); conn.close(); }decreaseStock的建议 SQL 是UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ?,这个 SQL 在数据库层面防止了超卖,影响行数为 0 就说明库存不足。这里把buyerId作为共享参数传入每个 DAO 方法,是为了保证整个链路用同一个数据库连接,事务才能生效。
4.3 直接购买与库存扣减的并发处理
直接购买是跳过购物车,买家在商品详情页点“立即购买”。业务上与购物车下单差别不大,只是没有删除购物车这一步。但直接购买更容易遇到并发问题:两个买家同时买同一个商品,库存只剩一件。
上面的decreaseStock写法是安全的,因为UPDATE ... WHERE stock >= ?是原子操作,数据库行锁会串行化同一行的更新。需要注意的是一次下单多件商品时,锁的粒度是每个商品行,如果购物车里有多个商品,锁定和扣减顺序应该按goods_id排序,避免死锁。
UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ? AND status = 1;这里的status = 1是顺手检查商品是否还在售。如果商品已经下架,即使库存充足也不能购买。下架员工在后台把商品置为下架时,并发购买请求会因为status不满足条件而更新失败,返回“商品已下架”。
5. 进阶细节:邮箱通知、超时未确认收货与重复下单排查
5.1 邮箱通知用线程池异步发送
需求里要求邮箱通知用户注销或重置密码。同步发送邮件最直接的坏处是下单或重置操作会卡在 SMTP 握手阶段,网络抖动时接口超时。常见做法是启动一个只有两个线程的ExecutorService专门发信。
private static final ExecutorService EMAIL_POOL = Executors.newFixedThreadPool(2); public void sendAsync(String to, String subject, String content) { EMAIL_POOL.submit(() -> { try { sendMail(to, subject, content); } catch (Exception e) { log.error("send mail failed, to={}", to, e); } }); }线程池大小固定为 2,因为校园二手交易系统的邮件量不会大,两个线程足够。投递失败只记录日志,不重试,因为这是课设不是生产邮件系统。如果邮件发送接口偶发超时,可以在sendMail里设置 SMTP 连接超时和读取超时,防止线程被长时间占用。
5.2 超时未确认收货的状态补偿
交易流程是买家下单 -> 卖家发货 -> 买家确认收货。但实际使用中买家可能发完货不确认,状态一直停在待收货。这时需要一个定时任务做状态补偿。常见做法是扫描超过 15 天未确认的订单,自动置为已完成。
UPDATE orders SET status = 3, confirm_time = NOW() WHERE status = 2 AND ship_time IS NOT NULL AND ship_time < DATE_SUB(NOW(), INTERVAL 15 DAY);这个 SQL 用DATE_SUB算出 15 天前的时刻,把超时订单批量更新。执行定时任务可以放在一个后台线程里,每天凌晨跑一次。注意不要扫描全部订单,status = 2过滤掉其他状态,避免全表更新。如果后续加了自动取消订单的规则,可以沿用同一套定时逻辑。
5.3 重复下单与库存超卖的排查
排查重复下单时,先看订单表order_no有没有唯一索引。如果两台服务器同时处理同一请求,INSERT时会触发唯一键冲突,此时程序抛出 DuplicateKeyException,业务层捕获后返回“订单已提交”,而不是再插一条。库存超卖则优先检查decreaseStock是否带stock >= ?条件,不带这个条件的扣库存 SQL 在高并发下一定出问题。
真正难排查的是“下架和购买并发”的情况:卖家下架商品的同时买家正在下单。我一般会在下单前SELECT ... FOR UPDATE锁商品行,再检查status和stock,这也是为什么事务内查询商品信息要传同一个连接的原因。如果只是分两次独立请求做查询和更新,中间状态就可能错乱。
本文还有配套的精品资源,点击获取