我去年帮学校二手群做的一个闲置物品流转平台,前后从需求梳理到部署上线折腾了一个多月,用的就是最经典的 JavaWeb 技术栈——Spring + SpringMVC + MyBatis,数据库 MySQL,前端 JSP + Bootstrap,部署在腾讯云一台 2C4G 的轻量服务器上。这个项目做完之后,我觉得特别适合拿来当完整的 JavaWeb 实战案例,因为它踩遍了这类项目所有典型的坑:登录鉴权怎么做、文件上传怎么存、订单状态怎么流转、支付怎么接、上线之后怎么排查问题。这篇文章我尽量把设计和实现的全过程讲透,适合正在做课设、毕设,或者准备找 Java 后端实习想积累一个完整项目经验的同学当作参考。
1. 场景设定与需求拆解:先搞清楚这个平台要解决什么问题
1.1 一个真实的痛点场景
做项目最怕上来就写代码。我最初接手这个需求的时候,学生会的同学跟我描述的是“想要一个像闲鱼一样的网站”,这种需求描述等于没说。我花了两天时间蹲在二手群里观察大家怎么交易,才把真实场景摸清楚。
这群里的交易流程是这样的:有同学发了“出九成新自行车,原价 800,现价 300,有要的吗”,然后底下零零散散有人回复“还在吗”“能便宜点吗”“哪栋宿舍自提”。成交之后,买卖双方私下转账,交易记录全靠聊天记录撑。整个过程有两个明显的痛点:一是信息没有结构化,想找某个品类的东西得爬楼翻聊天记录;二是交易没有保障,钱货分离,出了问题没人管得清。
所以这个平台的定位就很清楚了,它要解决的核心问题不是“做一个高大上的电商”,而是把线下零散的闲置交易流程结构化、可追踪化。核心是物品信息发布、检索、下单、支付、交易状态跟踪这五件事。
1.2 功能模块拆解:用户、物品、订单、支付四张网
需求理清之后,我把它拆成了四个功能域,后面所有表结构和接口都是按这四个域去设计的:
| 功能域 | 核心功能 | 关键业务规则 |
|---|---|---|
| 用户域 | 注册、登录、个人资料、头像上传、我的发布/我的购买 | 手机号唯一、密码加密存储 |
| 物品域 | 发布物品、分类浏览、关键词搜索、物品详情、上下架 | 只有登录用户可发布,物品状态影响可操作性 |
| 订单域 | 买家下单、卖家确认、取消订单、确认收货 | 订单状态有严格流转方向,不可逆跳 |
| 支付域 | 生成支付单、对接支付宝、回调验签、订单状态同步 | 必须验签,必须处理重复回调 |
另外还有两个支撑功能——站内留言和收藏,它们不属于核心链路,但在提升用户黏性上作用很大。留言用来解决“能不能便宜点”这种议价场景,收藏用来解决“先看看,过两天再决定”这种犹豫场景。
这里我特别想提一个经验:功能拆解要围绕“状态”来做。每个功能域其实都是围绕某几个状态在转,用户有正常/禁用状态,物品有在售/锁定/已售/下架状态,订单有已创建/待支付/已确认/已完成/已取消状态。把这些状态流转画清楚,数据库表结构和代码逻辑都跟着清晰了。
1.3 需求优先级排序
四个功能域的业务价值不一样,开发成本也不一样。我做了一个简单的优先级排序,这个排序决定了之后的开发节奏:
- 用户注册登录——没有用户体系其他都免谈,最先做
- 物品发布与浏览——这是平台的内容基础,第二个做
- 下单与订单流转——交易闭环的核心,第三个做
- 支付对接——提升体验的关键,第四个做
- 留言、收藏、数据统计——锦上添花,最后做
实际开发中我也是按这个顺序推进的,每周交付一个功能域,整个项目大概四周完成主体功能,剩下的时间一直在调支付和部署上线。如果一开始就想把留言、收藏、支付、推荐全部做完,项目大概率会烂尾。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与工程搭建:为什么还是用 SSM 而不是全家桶
2.1 技术栈对比与取舍逻辑
现在很多同学一上来就用 Spring Boot,这本身没问题,但作为一个教学性质很强的 JavaWeb 项目,我最终还是选了 Spring + SpringMVC + MyBatis 这套经典 SSM 组合,配合 JSP + JSTL 做服务端渲染。
选择理由有三点。第一,SSM 是理解 JavaWeb 底层原理最好的中间态——你还能看到 web.xml 里 DispatcherServlet 是怎么配置的,能感知到 Spring 容器是如何在 Web 容器里启动的,这些东西在 Spring Boot 里全被自动配置藏起来了。第二,项目要跑在 Tomcat 上,SSM 的 WAR 包装到 Tomcat 的 webapps 目录就能跑,部署逻辑直观,排错也容易。第三,市面上大量课程设计、毕业设计项目就是 SSM 结构,遇到问题能搜到的资料最多。
Redis 我一开始就没打算加。这个平台的访问量级就是校园几百人,缓存带来的收益远小于它引入的复杂度(缓存一致性、穿透、雪崩),MySQL 加好索引完全扛得住。如果你以后做高并发的项目,再引入 Redis 也不迟,但在这个项目里属于过度设计。
2.2 用 VSCode 搭建 JavaWeb 工程的实际体验
现在很多同学习惯了 IDEA,但我这次特意用 VSCode 做了一遍开发,主要是因为团队里有个同学电脑跑不动 IDEA,VSCode 轻量,而且对 JavaWeb 项目的支持其实已经够用了。这里把关键配置列出来,给同样被 IDEA 卡到崩溃的同学一个选择。
VSCode 做 JavaWeb 开发需要装这么几个插件:
- Extension Pack for Java:这是基础包,包含了语言服务、调试器、Maven 支持、测试运行器
- Tomcat for Java:支持在 VSCode 里直接启动和调试 Tomcat
- SQLTools:连 MySQL 用,能直接执行 SQL 脚本
工程结构我用了标准的 Maven webapp 骨架,或者直接手动建目录也行:
bash复制src
├── main
│ ├── java
│ │ └── com/campus/market
│ │ ├── controller
│ │ ├── service
│ │ ├── dao
│ │ ├── entity
│ │ ├── common
│ │ └── util
│ ├── resources
│ │ ├── jdbc.properties
│ │ ├── spring-dao.xml
│ │ ├── spring-service.xml
│ │ ├── spring-mvc.xml
│ │ └── mybatis-config.xml
│ └── webapp
│ ├── static
│ ├── WEB-INF
│ │ ├── web.xml
│ │ └── views
│ └── test
│ └── java
一个小坑:VSCode 里配置 Tomcat 之后,默认部署路径是容器内路径 /ROOT,如果你不想让访问路径变成 http://localhost:8080/项目名/,可以在 Tomcat 插件配置里把 deployPath 改成 /。不然后面前后端联调的时候每个 URL 都要带项目名,很容易拼错。
2.3 Maven 依赖与配置文件关键点
pom.xml 里的核心依赖就这些,版本号我直接给出我当时验证过的组合:
xml复制<properties>
<spring.version>5.3.31</spring.version>
<mybatis.version>3.5.13</mybatis.version>
<mysql.version>8.0.33</mysql.version>
</properties>
Spring 5.3 系列在 JDK 8 和 Tomcat 9 下跑得最稳,MyBatis 3.5.13 配 mybatis-spring 2.0.7 也不会有兼容问题。MySQL 驱动务必要用 8.0.x,因为新版数据库的驱动类名是 com.mysql.cj.jdbc.Driver,老版的 com.mysql.jdbc.Driver 在 MySQL 8 里会警告甚至直接报错。
数据库连接配置里有一个很容易让人卡半天的点:serverTimezone。MySQL 8 默认时区是 UTC,如果不在 JDBC URL 里指定时区,你会发现数据库里存的时间比本地时间早了 8 个小时。
properties复制jdbc.driver=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/campus_market?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
jdbc.username=root
jdbc.password=yourpassword
characterEncoding=utf8 和 serverTimezone=Asia/Shanghai 这两个参数是中文不乱码和时间不出错的关键,任何一本正经教 JavaWeb 的书里都该把这两行加粗标红。
3. 数据库建模:围绕物品流转设计六张核心表
3.1 表关系总览
数据库设计是整个项目的灵魂。我见过太多人写 SSM 项目的思路是“有什么功能就建什么表”,结果表之间互相没有逻辑关联,数据冗余到飞起。我这次设计的核心思路是围绕物品从发布到售出的完整生命周期建模。
总共六张核心表:
sql复制CREATE TABLE `user` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` VARCHAR(32) NOT NULL UNIQUE COMMENT '用户名',
`phone` VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
`password` VARCHAR(64) NOT NULL COMMENT '密码(MD5加盐)',
`salt` VARCHAR(16) NOT NULL COMMENT '盐值',
`avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像URL',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`status` TINYINT DEFAULT 1 COMMENT '1正常 0禁用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `category` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(32) NOT NULL UNIQUE COMMENT '分类名',
`sort_order` INT DEFAULT 0 COMMENT '排序权重',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `item` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '物品ID',
`seller_id` INT NOT NULL COMMENT '卖家ID',
`category_id` INT NOT NULL COMMENT '分类ID',
`title` VARCHAR(64) NOT NULL COMMENT '标题',
`description` TEXT COMMENT '描述',
`price` DECIMAL(10,2) NOT NULL COMMENT '价格',
`original_price` DECIMAL(10,2) DEFAULT NULL COMMENT '原价',
`quality` TINYINT DEFAULT 1 COMMENT '成色 1全新 2几乎全新 3轻微使用痕迹 4明显使用痕迹',
`cover_image` VARCHAR(128) COMMENT '封面图URL',
`status` TINYINT DEFAULT 1 COMMENT '1在售 2已锁定 3已售出 0下架',
`view_count` INT 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`),
KEY `idx_status_time` (`status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `orders` (
`id` INT NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号',
`item_id` INT NOT NULL COMMENT '物品ID',
`buyer_id` INT NOT NULL COMMENT '买家ID',
`seller_id` INT NOT NULL COMMENT '卖家ID',
`amount` DECIMAL(10,2) NOT NULL COMMENT '成交金额',
`status` TINYINT DEFAULT 0 COMMENT '0待支付 1已支付待发货/待自提 2已确认完成 3已取消',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`pay_time` DATETIME DEFAULT NULL,
`finish_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_buyer` (`buyer_id`),
KEY `idx_seller` (`seller_id`),
KEY `idx_item` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `message` (
`id` INT NOT NULL AUTO_INCREMENT,
`item_id` INT NOT NULL COMMENT '物品ID',
`sender_id` INT NOT NULL COMMENT '发送者ID',
`content` VARCHAR(255) NOT NULL COMMENT '留言内容',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_item` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `favorite` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`item_id` INT NOT NULL COMMENT '物品ID',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_item` (`user_id`, `item_id`),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2 设计细节里的几个关键决策
订单号为什么要单独存一列,不直接用自增主键?
因为订单号要暴露给用户和支付平台,自增 ID 会泄露交易量,而且订单号的格式必须有业务含义。我的生成规则是:时间戳 + 用户ID后四位 + 四位随机数,保证全局唯一。比如 202406201534123456789012 这种,日志里一眼能看出这笔订单是什么时候创建的。
物品表为什么要冗余 buyer_id 的信息到订单表?
物品和订单是一对多的关系——同一件物品理论上可以被多个买家发起订单,只是最终只有一个订单能成交。所以订单表必须把买家、卖家、成交金额都存下来,作为交易快照。这个设计让订单表和物品表可以独立演进,也方便后续做“我的购买”“我的销售”查询。
物品表的 status 字段为什么要用 TinyInt 而不是字符串?
状态字段用数字枚举比字符串好维护得多。字符串是给人看的,“在售”“已锁定”“已售出”变起来很麻烦;数字枚举在代码里定义常量就行了,程序里比较状态用 item.getStatus() == ItemStatus.SOLD,语义清晰还能做类型检查。
搜索怎么做?
物品表的三个索引 idx_seller、idx_category、idx_status_time 覆盖了绝大多数查询场景:我的发布查卖家、分类页查分类、首页查在售最新。关键词搜索我先用 LIKE '%关键词%' 扫标题和描述,数据量上一万之后这个方案的性能问题会暴露,但校园项目几百条数据完全没压力。如果以后真要做全文搜索,再上 Elasticsearch 或者 MySQL 全文索引,都属于平滑迁移。
3.3 订单状态机:整个项目最需要画清楚的一张图
订单状态流转我单独拿出来说,因为这是大多数新手最容易写乱的地方。我在设计时定义了一个严格的状态机:
code复制0待支付 --(买家支付成功)--> 1已支付 --(卖家确认交付/买家确认收货)--> 2已完成
--(买家取消/超时未支付)--> 3已取消
代码层面,我把所有的状态流转写在一个方法里,不允许在业务代码里随便改订单状态:
java复制public void changeOrderStatus(Integer orderId, Integer fromStatus, Integer toStatus) {
Order order = orderDao.selectById(orderId);
if (order == null || !order.getStatus().equals(fromStatus)) {
throw new BizException("订单状态已发生变化,请刷新后重试");
}
// 使用条件更新,防止并发下的状态覆盖
int rows = orderDao.updateStatus(orderId, fromStatus, toStatus);
if (rows == 0) {
throw new BizException("更新失败,订单状态可能已变化");
}
}
这个方法看着简单,其实是整个交易系统的守门员。updateStatus 用的是 UPDATE orders SET status = #{toStatus} WHERE id = #{orderId} AND status = #{fromStatus},这种乐观锁思想能保证两个请求同时来的时候不会把状态改坏,比先查再改的写法安全得多。
4. 核心功能实现:从注册登录到下单购买
4.1 登录鉴权与密码存储
用户密码我用的方案是 MD5 + 盐值。虽然现在主流是 BCrypt,但 MD5 加盐在教学项目里仍然很常见,而且代码更简单直观。每个用户注册时生成一个随机盐值,存密码时是 MD5(password + salt)。
顺便说一下为什么不能直接存明文或只做 MD5:明文就不说了,出事直接社死;单次 MD5 的主要问题是彩虹表攻击——攻击者提前算好了常用密码的哈希值,拿到你的密码哈希一查表就能反推出明文。加盐之后,每个用户盐值不同,攻击者没法用一个彩虹表覆盖所有用户,成本大幅上升。
java复制public String encryptPassword(String password, String salt) {
String base = password + salt;
return DigestUtils.md5DigestAsHex(base.getBytes(StandardCharsets.UTF_8));
}
登录态我用的是 Session + Cookie 的组合。登录成功后把用户对象放到 Session 里,同时种一个 Cookie 标记登录状态。这里有个细节:HttpSession 默认过期时间是 30 分钟,如果你希望用户一周内不用重复登录,可以设置 Cookie 的 MaxAge:
java复制Cookie userCookie = new Cookie("IS_LOGIN", "1");
userCookie.setMaxAge(7 * 24 * 60 * 60);
response.addCookie(userCookie);
但记住,真正的登录信息不要全塞到 Cookie 里,Cookie 只放一个是否登录的标志,用户信息从 Session 取。否则用户改个 Cookie 就能伪造登录态,这是非常低级的安全漏洞。
4.2 物品发布与图片存储
物品发布是内容生产的核心入口。表单字段有标题、分类、成色、价格、原价、描述、封面图。这里我最想讲的是图片上传——很多新手在这一步被搞到怀疑人生。
我一开始图省事,把图片上传到项目本地目录,比如 upload/,存到数据库的路径是相对路径 /upload/xxx.jpg,页面用 <img src="/upload/xxx.jpg"> 引用。在本机跑得好好的,部署到服务器上之后图片全挂了,原因是 IDEA 或 VSCode 部署到 Tomcat 时,上传文件写进了 Web 应用的临时目录,重启 Tomcat 之后文件被清掉了。
后来我改成了独立文件存储目录的方案:在服务器上建了一个 /data/campus_market/upload 目录,Tomcat 配置里加了一个虚拟路径映射,把 /upload/** 映射到这个物理目录。这样图片和应用程序的生命周期分离开,重部署也不丢文件。
Tomcat 的 conf/server.xml 里加 Host 的 Context:
xml复制<Context path="/upload" docBase="/data/campus_market/upload" reloadable="false"/>
代码里文件上传的逻辑也简单:
java复制@PostMapping("/item/add")
public String addItem(ItemForm form,
@RequestParam("cover") MultipartFile file,
HttpSession session) {
if (file != null && !file.isEmpty()) {
String originalFilename = file.getOriginalFilename();
String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
String filename = UUID.randomUUID().toString().replace("-", "") + ext;
String savePath = "/data/campus_market/upload/" + filename;
file.transferTo(new File(savePath));
form.setCoverImage("/upload/" + filename);
}
itemService.addItem(form, currentUser(session));
return "redirect:/item/my";
}
注意文件名用了 UUID 重命名,一方面避免中文名在 URL 里被转义得很丑,另一方面避免用户上传同名文件互相覆盖。
4.3 列表页搜索与分页的落地写法
列表页是读多写少的典型场景。我的实现方案是 PageHelper + MyBatis,分页插件配置好之后,业务代码非常干净:
java复制public PageInfo<ItemVO> queryItems(ItemQuery query) {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
List<ItemVO> list = itemDao.queryByCondition(query);
return new PageInfo<>(list);
}
ItemQuery 是查询条件对象,包含关键词、分类ID、价格区间、成色、排序字段。MyBatis 的 XML 里用 <if> 标签动态拼接条件,这里贴一个核心片段:
xml复制<select id="queryByCondition" resultType="com.campus.market.entity.ItemVO">
SELECT i.*, c.name AS category_name, u.username AS seller_name
FROM item i
LEFT JOIN category c ON i.category_id = c.id
LEFT JOIN user u ON i.seller_id = u.id
<where>
i.status = 1
<if test="keyword != null and keyword != ''">
AND (i.title LIKE CONCAT('%', #{keyword}, '%')
OR i.description LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="categoryId != null">
AND i.category_id = #{categoryId}
</if>
<if test="minPrice != null">
AND i.price >= #{minPrice}
</if>
<if test="maxPrice != null">
AND i.price <= #{maxPrice}
</if>
</where>
<choose>
<when test="sortField == 'price'">
ORDER BY i.price ASC
</when>
<when test="sortField == 'time'">
ORDER BY i.create_time DESC
</when>
<otherwise>
ORDER BY i.create_time DESC
</otherwise>
</choose>
</select>
这里有一个容易踩的坑:<if> 标签里的数值类型判断要小心,categoryId 是 Integer 的时候,categoryId != null 能满足,但如果前端不传这个参数,后端接收到的就是一个 null,<if test="categoryId != null"> 能准确拦截。但你要是写成了 categoryId != '',Integer 和 String 比较会有问题,可能会抛异常。动态 SQL 里,字符串类型判空用 != null and != '',数值类型只用 != null,这个习惯要养成。
4.4 下单流程与库存锁定
下单是整个平台的交易起点。我在设计时遵循了一个原则:创建订单时不直接扣减库存,而是把物品状态从“在售”改成“已锁定”。
这个设计解决了两个问题。第一,买家发起订单只是“占坑”,不意味着一定会付款,锁定状态下其他买家不能再下单,但物品还在平台上展示,只是按钮变成“交易中”,相当于线下交易里“这个我先要了”的状态。第二,给后续支付环节留了缓冲,如果买家超时未支付,系统可以自动把状态改回“在售”。
下单的核心代码:
java复制@Transactional
public Order createOrder(Integer itemId, Integer buyerId) {
Item item = itemDao.selectByIdForUpdate(itemId); // 行级锁
if (item == null || item.getStatus() != ItemStatus.SELLING) {
throw new BizException("该物品已下架或已被锁定");
}
// 先锁定物品,防止并发重复下单
int rows = itemDao.lock(itemId, item.getSellerId());
if (rows == 0) {
throw new BizException("手慢了,物品已被其他买家锁定");
}
Order order = new Order();
order.setOrderNo(generateOrderNo());
order.setItemId(itemId);
order.setBuyerId(buyerId);
order.setSellerId(item.getSellerId());
order.setAmount(item.getPrice());
order.setStatus(OrderStatus.CREATED);
orderDao.insert(order);
return order;
}
注意 selectByIdForUpdate 这个方法,在 MyBatis 里对应的是:
xml复制<select id="selectByIdForUpdate" resultType="Item">
SELECT * FROM item WHERE id = #{id} FOR UPDATE
</select>
FOR UPDATE 是 MySQL 的行级排他锁,它保证同一时刻只有一个请求能查到这件物品并进入后续的锁定流程。不加这个锁,两个买家同时下单同一件物品时,都可能通过状态判断,然后都会去执行 UPDATE,把物品状态改掉,造成超卖。这种并发问题在单机 Tomcat + 单库 MySQL 的情况下就能发生,只要你的代码走了“先查再改”的老路,线上就会遇到脏读和覆盖。
@Transactional 注解加在方法上,保证 select ... FOR UPDATE 的锁在事务提交时才释放,如果中途抛出异常,整个操作回滚,物品状态不会被改坏。
5. 支付对接实录:支付宝沙箱环境从申请到验签
5.1 为什么建议先接沙箱
做 JavaWeb 项目,支付是很多同学很想加但不敢动的模块。我这次直接对接了支付宝电脑网站支付,而且强烈建议你先申请沙箱环境跑通全流程,再考虑上正式环境。
原因很简单:沙箱环境是支付宝提供的模拟支付环境,不需要真实商户资质,不需要真实资金流动,有一套完整的模拟账号和测试工具,非常适合开发调试。你在沙箱环境里把签名、下单、回调验签这套逻辑跑通了,迁移到正式环境只是换一下 APP_ID、密钥和网关地址的事。
申请沙箱的路径是:登录蚂蚁金服开放平台,进入“开发中心” -> “沙箱”,按照指引创建一个应用,拿到 APP_ID,同时配置 RSA2 密钥对。支付宝官方提供的工具会帮你生成一对应用私钥和应用公钥,你把公钥填到支付宝后台,私钥自己保存,后面代码里签名和验签都要用到。
5.2 集成步骤与核心代码
支付宝电脑网站支付的流程分成三段:后端生成支付表单、用户跳转支付宝付款、支付宝异步通知回调。
后端生成支付表单的核心逻辑:
java复制public String createPayForm(Order order) throws AlipayApiException {
AlipayClient alipayClient = new DefaultAlipayClient(
AlipayConfig.gatewayUrl, // 沙箱网关
AlipayConfig.appId, // APP_ID
AlipayConfig.merchantPrivateKey, // 应用私钥
"json", CharsetUtils.UTF_8,
AlipayConfig.alipayPublicKey, // 支付宝公钥
"RSA2"
);
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
request.setNotifyUrl(AlipayConfig.notifyUrl); // 异步回调地址
request.setReturnUrl(AlipayConfig.returnUrl); // 同步跳转地址
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", order.getOrderNo());
bizContent.put("total_amount", order.getAmount().toString());
bizContent.put("subject", "校园物品平台-订单" + order.getOrderNo());
bizContent.put("product_code", "QUICK_WAP_WAY");
request.setBizContent(bizContent.toJSONString());
return alipayClient.pageExecute(request).getBody();
}
这里有一个大家容易忽略的点:同步跳转地址 returnUrl 和异步通知地址 notifyUrl 的区别。returnUrl 是用户付完款之后浏览器从支付宝页面跳回你网站的地址,这个跳转用户可能随时关掉浏览器导致根本不触发;notifyUrl 是支付宝服务器主动向你的服务器发起的 POST 请求,这才是支付结果最终可靠的来源。所以,订单状态的更新必须写在异步通知的处理逻辑里,同步返回只能用来做页面提示,绝不能作为订单已支付的依据。
收到支付宝的回调通知之后,要做三件事:
第一,验签。用支付宝公钥对通知参数做签名验证,验证这个通知确实是支付宝发来的,而不是攻击者伪造的。
第二,校验业务参数。检查回调里的 out_trade_no 对应的订单是否存在、total_amount 是否和订单金额一致、app_id 是否匹配。
第三,处理重复通知。支付宝的通知机制是异步的,可能会发送多次,业务处理必须做幂等——同一个订单号即使收到十次通知,最终也只更新一次状态。
这是我的回调处理核心逻辑:
java复制@PostMapping("/notify/pay")
@ResponseBody
public String payNotify(HttpServletRequest request) throws Exception {
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (String name : requestParams.keySet()) {
params.put(name, request.getParameter(name));
}
// 1. 验签
boolean signVerified = AlipaySignature.rsaCheckV1(
params, AlipayConfig.alipayPublicKey, CharsetUtils.UTF_8, "RSA2");
if (!signVerified) {
return "failure";
}
String tradeStatus = params.get("trade_status");
String orderNo = params.get("out_trade_no");
String tradeNo = params.get("trade_no");
// 2. 业务校验
Order order = orderDao.selectByOrderNo(orderNo);
if (order == null) {
return "failure";
}
// 3. 幂等处理
if (order.getStatus() == OrderStatus.CREATED
&& "TRADE_SUCCESS".equals(tradeStatus)) {
orderDao.markPaid(orderNo, tradeNo);
itemDao.markSold(order.getItemId());
}
return "success";
}
注意最后返回值必须是 "success" 字符串,这是支付宝约定的响应:收到 success 就认为通知成功,停止继续推送;如果返回其他内容,支付宝会认为通知失败,过一段时间会重发通知,一直重试到成功为止。
5.3 接支付时踩过的几个坑
支付这块我踩的坑不少,列出来给后来人参考。
第一个坑:沙箱网关地址和正式网关地址不一样。申请沙箱应用后,官方文档给的沙箱网关是 https://openapi-sandbox.dl.alipaydev.com/gateway.do,正式环境是 https://openapi.alipay.com/gateway.do,这个地址配错了请求直接失败,而且报错信息不太友好,容易让人怀疑是签名问题。
第二个坑:私钥和公钥配反了。应用私钥是你自己生成的,放在后端代码里;应用的公钥要填到支付宝开放平台后台;支付宝公钥是从平台下载或复制过来的。三个文件放错位置,签名和验签都会失败。一个有效的自检方法是:先跑官方 SDK 自带的“查询订单”接口,如果查询能成功,说明签名配置正确。
第三个坑:沙箱环境的买家账号是虚拟的。支付时需要登录一个沙箱买家账号,这个账号可以在沙箱控制台里获取,密码通常是固定默认值(一般是 111111)。第一次接会在这个地方卡很久,以为是自己代码问题。
第四个坑:回调验签必须用支付宝公钥,不是应用公钥。我见过有人用应用私钥去验签,结果验签永远失败。应用私钥是你在签名时用的,验签要用支付宝公钥去解。
6. 部署上线与运维经验:从本机到服务器的最后一公里
6.1 打包部署的核心步骤
项目在本机跑通只是第一步,能部署到服务器稳定运行才算真正完成。我的部署环境是:腾讯云轻量服务器,Ubuntu 22.04,JDK 8,Tomcat 9,MySQL 8.0。这里有一个实际的取舍:为什么用 Tomcat 9 而不是 Tomcat 10?因为 Tomcat 10 把 javax.servlet 包改成了 jakarta.servlet,老版本 Spring 5 的 WAR 包部署上去会报 ClassNotFoundException,Spring 5.3 系列没有完全适配 Jakarta 命名空间。SSM 项目老老实实用 Tomcat 9,别折腾这些无谓的兼容性问题。
打包命令很简单:
bash复制mvn clean package -DskipTests
打出来的 WAR 包在 target/campus-market.war,直接丢到 Tomcat 的 webapps 目录下,启动 Tomcat 它就会自动解压部署:
bash复制cp target/campus-market.war /opt/tomcat/webapps/
/opt/tomcat/bin/startup.sh
6.2 数据库初始化和备份
数据库迁移我写了一个 init.sql 脚本,包含建库、建表、初始分类数据和测试数据。上线时执行一次,以后每次有表结构变更都在 migration 目录下加新的 V2__xxx.sql、V3__xxx.sql 之类的脚本,按顺序执行,这样能保证不同环境的数据库结构一致。
备份策略我当时用的是最简单的方案:每天凌晨用 crontab 执行一次 mysqldump,保留最近 7 天的备份文件。
bash复制0 3 * * * mysqldump -uroot -p'password' campus_market > /data/backup/campus_market_$(date +\%Y\%m\%d).sql && find /data/backup -name "*.sql" -mtime +7 -delete
这个方案虽然简陋,但对校园项目来说够用。等数据量大了、表复杂了,再考虑 binlog 增量备份或者专业备份工具。
6.3 上线后真实遇到的坑
上线后遇到最折磨人的一个问题:静态资源偶尔加载不出来,报 404。排查了半天,发现是 Tomcat 默认配置的 maxSwallowSize 太小,上传大文件时请求体没被完全消费,导致后续请求混乱。解决方案是在 Tomcat 的 server.xml 里给 Connector 增加 maxSwallowSize="-1" 让它不限制。
另一个问题:服务器内存不足导致 Tomcat 被系统杀掉。2C4G 的服务器跑 MySQL + Tomcat 其实够用,但如果 JVM 默认堆内存配置不合适,很容易把内存吃满。我后来在 catalina.sh 里设置了 JVM 参数:
bash复制JAVA_OPTS="-Xms256m -Xmx512m -XX:MaxMetaspaceSize=256m"
这个配置给 Tomcat 限定最大 512MB 堆内存,避免它跟 MySQL 抢内存导致整台机器 OOM。这里科普一个小知识:JVM 的 -Xmx 是堆内存上限,-XX:MaxMetaspaceSize 是元空间上限(存放类元数据),合理配置这两个值能让 Tomcat 在低配服务器上跑得很稳。
还有一个小事,但挺影响体验:部署后图片 404。这个问题在前面图片存储方案里已经提到了,就是绝对路径和部署路径的问题。上线后我专门把图片目录改到了 /data/campus_market/upload,并在 Tomcat 里配了虚拟路径,才算彻底解决。
6.4 可扩展方向
这个项目做完之后,我留了几个明确的扩展点:一是把订单超时未支付自动取消做成定时任务;二是加入基于标签的物品推荐;三是如果平台要自营物流,可以在订单表加物流单号和轨迹记录,甚至进一步做配送路径的轨迹分析,计算车辆在某段路线上的覆盖次数和里程,那就不是简单的增删改查了,要引入轨迹匹配和路网索引,属于后话。对于想拿这个项目去面试的同学,我的建议是先把核心链路讲清楚,然后挑一个扩展点说透——比如把支付回调的幂等设计讲明白,比罗列十个功能点更能打动面试官。
这个项目做完之后我最大的体会是:JavaWeb 项目真正考验人的不是某个框架有多熟,而是能不能把一条交易链路完整地跑起来——从用户注册、登录、发布物品、浏览搜索、下单、支付、状态回写、部署上线,每个环节都有它自己的坑。把这些坑一个个填平,你对整个 Web 开发的理解就不是停留在“会写接口”的层面了。如果你正在做类似的平台项目,我建议你别急着抄代码,先花两天时间把自己要解决的业务场景想清楚,把订单状态机画出来,再动手写代码,你会发现后面的路顺畅得多。
