收到这类 Spring Boot 农副产品线上商场系统的需求,第一反应是“商品展示、购物车、下单、后台管理”那一套,网上现成模板确实不少。但真正把项目从源码跑到服务器部署,我才发现坑全藏在库存扣减、订单状态、开发环境版本这些地方。这篇内容我会按一个可运行的农商城系统来拆,从数据库表设计到后端核心流程,再讲到调试部署和问题排查,适合正在做课程设计、毕业设计,或者第一次接手 Spring Boot 商城项目的人。
1. 想清楚业务边界再动手:农副产品商城到底在做什么
1.1 用户角色与功能范围
农副产品线上商城表面看是“电商”,但拆开之后至少要覆盖三类角色:
| 角色 | 核心功能 | 说明 |
|---|---|---|
| 普通用户 | 浏览商品、搜索、加购物车、下单、查看订单、管理收货地址 | 核心是流程顺畅,下单不能出 bug |
| 商家/运营 | 维护商品、上下架、调整库存、处理订单 | 商品资料和库存信息要实时准确 |
| 管理员 | 用户管理、类目管理、数据统计、基础配置 | 一般挂在后台管理系统里 |
不要一上来就写代码。我建议先画一张“页面 + 接口 + 表”的映射清单,比如每个页面需要哪几个接口,每个接口要读写哪些表,这样后续做起来不会东一榔头西一棒子。
农副产品本身还有一个特点:商品会有产地、季节性、规格(比如一箱、一斤、一份),所以商品表的扩展字段必须留好,比如origin(产地)、unit(单位)、spec(规格说明)。这些字段在真正上线后比想象中更重要。
1.2 为什么选 Spring Boot 而不是 SSM 或其他技术栈
现在做这类系统,Spring Boot 几乎成了默认选项。它不是比 SSM 多了什么高深能力,而是把 Spring 全家桶的配置简化了,内嵌 Tomcat,用 Maven 引 starter 就能跑起来,部署也方便。对课程设计、毕业设计来说,技术栈“稳”比“炫”更重要。
有一点必须提醒:Spring Boot 版本不要盲目追新。现在最高版本的 Spring Boot 3.x 已经强制要求 JDK17,并把原来的 javax.servlet 包改成了 jakarta.servlet,很多老源码拿到手直接编译不过。我的建议是使用 Spring Boot 2.7.x,配合 JDK8 或者 JDK11,生态最成熟,网上能找到的依赖版本也几乎不用改。标题里强调“开发环境”和“调试部署”,很多时候问题就出在版本不一致。
1.3 项目包结构规划
一个能长期维护的商城项目,包结构必须清晰。我常用的结构是这样的:
code复制com.example.farmmall
├── config
│ ├── MybatisPlusConfig.java
│ ├── WebMvcConfig.java
│ └── RedisConfig.java
├── controller
│ ├── AuthController.java
│ ├── ProductController.java
│ ├── CartController.java
│ └── OrderController.java
├── service
│ ├── UserService.java
│ ├── ProductService.java
│ └── OrderService.java
├── mapper
│ ├── UserMapper.java
│ ├── ProductMapper.java
│ ├── OrderMapper.java
│ └── OrderItemMapper.java
├── entity
│ ├── User.java
│ ├── Product.java
│ ├── Orders.java
│ └── OrderItem.java
├── dto
│ ├── LoginDTO.java
│ └── OrderCreateDTO.java
├── vo
│ └── ProductVO.java
├── common
│ ├── Result.java
│ └── BusinessException.java
└── FarmMallApplication.java
大原则就一句话:Controller 只做参数接收和响应封装,Service 写业务逻辑,Mapper 管数据访问。别把 SQL 到处粘,也别在 Controller 里写一堆业务判断,后面调试会想哭。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把表结构建对,比写代码更重要
2.1 核心表结构与关系
商城系统的表不会有太多花活,核心就是用户、商品、订单三类。我按实际项目习惯拆成了这些表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 用户/管理员 | username、password、role、phone |
| category | 商品分类 | name、parent_id、sort |
| product | 商品表 | category_id、name、price、stock、status |
| orders | 订单主表 | order_no、user_id、total_price、status |
| order_item | 订单明细表 | order_no、product_id、product_name、price、quantity |
| cart | 购物车表 | user_id、product_id、quantity、checked |
| user_address | 收货地址 | user_id、receiver_name、receiver_phone、detail |
商品表的核心 DDL 可以这样建:
sql复制CREATE TABLE `product` (
`id` bigint NOT NULL AUTO_INCREMENT,
`category_id` bigint DEFAULT NULL,
`name` varchar(100) NOT NULL,
`subtitle` varchar(255) DEFAULT NULL,
`main_image` varchar(255) DEFAULT NULL,
`price` decimal(10,2) NOT NULL,
`stock` int NOT NULL DEFAULT 0,
`sales` int NOT NULL DEFAULT 0,
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架',
`origin` varchar(50) DEFAULT NULL COMMENT '产地',
`unit` varchar(10) DEFAULT NULL COMMENT '单位:斤/箱/份',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单主表要特别留意order_no,它不能直接用数据库自增 id,而是要在业务层生成一个唯一字符串,比如“时间戳 + 随机数”的方式:
sql复制CREATE TABLE `orders` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`user_id` bigint NOT NULL,
`total_price` decimal(10,2) NOT NULL,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消',
`receiver_name` varchar(30) DEFAULT NULL,
`receiver_phone` varchar(20) DEFAULT NULL,
`receiver_address` varchar(255) DEFAULT NULL,
`pay_time` datetime DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 字段设计里容易被忽略的细节
价格字段一定用 decimal(10,2),不要用 float 和 double。农副产品经常有折扣和满减,浮点数算着算着就会出现 0.1+0.2 不等于 0.3 这种问题。数据库字符集用 utf8mb4,因为你不知道用户会在备注里填什么生僻字,utf8mb4 能直接避免一部分乱码问题。
订单状态不要用字符串,建议用 tinyint 加注释,枚举值统一写在代码里。我见过很多项目把状态写成“待支付”“已支付”,后来要加状态就非常痛苦。数字状态加上注释,代码里用常量或枚举对应,扩展起来才轻松。
2.3 索引不是越多越好
很多同学喜欢给所有字段都加索引,完全不必要。商城系统查询最频繁的是这几个场景:
- 商品列表按照分类和上下架状态筛选,给
category_id、status建索引 - 订单列表按用户查,给
user_id建索引 - 订单号要唯一,给
order_no建唯一索引 - 购物车按用户查,给
user_id建索引
表数据量不大的时候,过多的索引反而拖慢插入和更新速度。合理的稀疏索引设计,比乱加一堆索引要实用。
3. 后端核心流程:从登录鉴权到下单扣库存
3.1 统一返回体、异常拦截和 JWT 登录
既然是前后端分离的开发方式,后端接口要先定好统一的返回格式。我习惯用一个泛型类:
java复制@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.setCode(200);
r.setMessage("success");
r.setData(data);
return r;
}
public static <T> Result<T> error(String message) {
Result<T> r = new Result<>();
r.setCode(500);
r.setMessage(message);
return r;
}
}
登录用 JWT 是比较通用的做法。好处是后端不需要维护 session,服务器集群部署时也不会有 session 同步问题。我一般会单独写一个 JwtUtil,登录成功之后生成 token,前端每次请求放在 header 里:
java复制@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Resource
private UserService userService;
@Resource
private JwtUtil jwtUtil;
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO dto) {
User user = userService.login(dto.getUsername(), dto.getPassword());
String token = jwtUtil.generateToken(user.getId(), user.getRole());
return Result.success(token);
}
}
密码存储一定不要明文,用 BCrypt 加密。很多人调试时为了方便直接明文存数据库,答辩的时候老师一眼就能看出来,这是基本安全素养问题。
3.2 商品列表:缓存与查询性能
商品列表是读多写少的部分,非常值得加 Redis 缓存。我的做法是先把商品查询结果序列化成 JSON 放到 Redis 里,设置了 5 到 10 分钟的过期时间。后台如果改动了商品,再主动删除对应缓存,而不是等它自然过期。
java复制public IPage<ProductVO> pageProducts(int page, int size, Long categoryId) {
String key = "product:page:" + page + ":" + size + ":" + categoryId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, new TypeReference<IPage<ProductVO>>(){});
}
IPage<ProductVO> result = productMapper.selectPageVO(new Page<>(page, size), categoryId);
redisTemplate.opsForValue().set(key, JSON.toJSONString(result), 10, TimeUnit.MINUTES);
return result;
}
这里要注意缓存 key 的设计,不要只用商品 id,要包含查询条件。不然不同筛选条件之间缓存互相覆盖,用户会看到错误的数据。
3.3 下单扣库存:事务和乐观锁缺一不可
商城系统里最容易被问倒的就是超卖问题。很多人写的逻辑是先查库存,再判断库存够不够,够就 update,这在并发量高的情况下会出问题:两个请求同时查出库存是 1,结果都执行 update,库存就变成负数了。
正确的做法是把扣库存判断放到 SQL 里,一次 update 完成“判断 + 扣减”:
java复制@Update("UPDATE product SET stock = stock - #{count}, sales = sales + #{count} " +
"WHERE id = #{productId} AND stock >= #{count}")
int deductStock(@Param("productId") Long productId, @Param("count") Integer count);
如果返回的行数是 0,说明库存不足,业务层直接抛异常。整个下单流程一定要放在同一个事务里:
java复制@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderCreateDTO dto, Long userId) {
String orderNo = generateOrderNo();
List<OrderItem> items = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
for (CartItemDTO cart : dto.getItems()) {
int rows = productMapper.deductStock(cart.getProductId(), cart.getQuantity());
if (rows == 0) {
throw new BusinessException("商品库存不足或已下架");
}
Product product = productMapper.selectById(cart.getProductId());
OrderItem item = new OrderItem();
item.setOrderNo(orderNo);
item.setProductId(product.getId());
item.setProductName(product.getName());
item.setProductImage(product.getMainImage());
item.setPrice(product.getPrice());
item.setQuantity(cart.getQuantity());
items.add(item);
total = total.add(product.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity())));
}
Orders order = new Orders();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setTotalPrice(total);
order.setStatus(0);
order.setReceiver(dto.getReceiver());
orderMapper.insert(order);
orderItemMapper.batchInsert(items);
return order.getId();
}
这段代码看起来简单,但解决了两个核心问题:第一,扣库存和生成订单是一个原子操作;第二,通过 stock >= #{count} 条件天然避免了并发超卖。使用 @Transactional 时要注意,默认只有 RuntimeException 会回滚,所以 rollbackFor = Exception.class 一定要写。
3.4 订单状态机要提前设计
订单状态不能靠 if-else 乱跳。最基础的链路是:
- 待支付 → 已支付 → 已发货 → 已完成
- 待支付 → 已取消
用户只能先取消“待支付”订单;已支付订单如果要取消,必须走退款流程。接口层要做状态校验,比如更新订单状态时 SQL 里带上当前状态条件:
sql复制UPDATE orders SET status = 1, pay_time = NOW()
WHERE order_no = #{orderNo} AND user_id = #{userId} AND status = 0
这样即使多个请求更新同一订单,也只有状态匹配的那个能成功。这段逻辑我在实际项目中踩过不少次,不加状态条件的 update,很可能把“已完成”的订单又改回“已支付”。
4. 开发环境、调试和部署:最容易劝退的一步
4.1 一套稳妥的开发环境组合
标题里强调“开发环境”“调试部署”,我把它单独拿出来讲。很多源码拿到本地跑不起来,真不是代码问题,而是本机环境不一样。我推荐这个组合:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| JDK | 8 或 11 | 配 Spring Boot 2.7 最稳 |
| Maven | 3.6.3 以上 | 不要用太老的 3.2 |
| MySQL | 5.7 或 8.0 | 两个版本都行,连接 URL 有区别 |
| Redis | 5.x 以上 | Windows 可以用 Redis for Windows 或者 WSL 方式 |
| 开发工具 | IDEA | 社区版也能用 |
| 前端 | Node 16/18 | 如果项目是 Vue 前后端分离 |
如果你拿到的是老项目,而本机装了 JDK17,一定要先看 pom.xml。Spring Boot 2.x 用 JDK17 虽然能编译,但部分老依赖会有兼容问题;Spring Boot 3.x 用 JDK8 则直接启动不了。先把版本统一,再谈跑通。
4.2 application.yml 配置与注意事项
数据库连接配置是新手最容易翻车的地方。我给出一个能同时兼容 MySQL 5.7 和 8.0 的配置:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/farm_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
database: 0
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
serverTimezone=Asia/Shanghai 一定要加,不然数据库和服务器时区不一致,时间字段会差 8 小时。MySQL 8.0 还要注意allowPublicKeyRetrieval=true,否则某些连接工具会报“Public Key Retrieval is not allowed”。
4.3 打包部署:从 java -jar 到 Linux nohup
本地跑通之后,部署到 Linux 服务器也不复杂。先把后端打包成 jar 包:
bash复制mvn clean package -DskipTests
然后把 target/farm-mall.jar 上传到服务器,用 nohup 方式后台运行:
bash复制nohup java -jar farm-mall.jar --spring.profiles.active=prod > mall.log 2>&1 &
启动之后要看日志来判断是否成功:
bash复制tail -f mall.log
看到 Started FarmMallApplication 就是成功了。如果端口被占用,先用 netstat -ntlp 查,然后决定改配置还是杀进程。前端如果也是需要的,打包后把静态文件交给 Nginx 托管,后端接口地址配成服务器 IP 或者域名。
4.4 拿到现成源码后的启动顺序
拿到一个不熟悉的商城源码,不要直接双击启动类。我按照这套顺序来,基本不会乱:
- 创建数据库,导入
farm_mall.sql - 修改
application.yml里的数据库账号密码、Redis 地址 - 检查
pom.xml中 Spring Boot 版本和 JDK 是否匹配 - 先启动后端,确认接口能访问
- 再启动前端,登录测试核心流程
- 测试商品浏览、加购物车、下单、后台管理
很多报错是因为跳过了第2步和第3步,直接启动,然后疯狂查代码。
5. 常见问题与实战排查记录
5.1 编译与运行层面
| 问题表现 | 原因 | 解决办法 |
|---|---|---|
| 找不到主类或无法加载主类 | 项目没编译成功,或者 module 设置不对 | IDEA 里执行 mvn clean install,重新导入 |
Access denied for user 'root'@'localhost' |
数据库账号密码不对 | 检查 application.yml,确保密码没写错 |
Unknown database 'farm_mall' |
数据库没有创建 | 先执行 CREATE DATABASE farm_mall 再导入 SQL |
Port 8080 was already in use |
端口被占用 | `netstat -ano |
java.lang.UnsupportedClassVersionError |
JDK 版本不匹配 | 把 IDEA 的 project structure 和 Maven compiler 版本统一 |
5.2 业务逻辑层面
| 问题表现 | 原因 | 解决办法 |
|---|---|---|
| 并发下单库存变成负数 | 代码里“先查再更新” | 改成 UPDATE ... WHERE stock >= count |
| 下单成功但订单列表看不到 | 订单关联 user_id 没传,或者 token 里没取用户 id | 检查 JWT 拦截器,统一从 token 解析用户 id |
| 商品修改后列表数据不变 | Redis 缓存没有清理 | 商品变更时删除对应缓存 key |
| 数据库有中文乱码 | 数据库连接没指定 UTF-8 | URL 加 characterEncoding=utf8,表用 utf8mb4 |
| 返回给前端的值为 null | 实体字段和数据库下划线字段没映射 | 开启 MyBatis-Plus 的 map-underscore-to-camel-case 或用 @TableField |
5.3 部署层面
| 问题表现 | 原因 | 解决办法 |
|---|---|---|
| 页面图片 404 | 本地文件路径和服务器路径不一致 | 上传路径改为绝对路径,配置静态资源映射 |
| 跨域报错 | 前后端分离,后端没放开 CORS | 写一个 WebMvcConfigurer,配置允许跨域 |
| 服务器时间差 8 小时 | JVM 默认时区和数据库时区不一致 | 启动参数加 -Duser.timezone=Asia/Shanghai |
NoSuchMethodError |
依赖版本冲突 | 用 mvn dependency:tree 排查依赖,排除冲突版本 |
| 前端接口连不上 | 前端 baseURL 写成了 localhost 或端口不对 |
改成后端实际访问地址 |
6. 最后再分享一点实操心得
我发现很多同学拿到一套能跑的源码,第一件事就是改标题和作者,然后直接拿去交作业。但这个项目一旦被问到“你讲一下下单流程”或者“你的库存怎么防超卖”,答不上来反而会出问题。最好的方式是把表结构、接口流程、事务边界自己动手画一遍,哪怕只改一个模块,也比全盘照抄更有收获。
如果是项目还要配一篇 1 万字以上的论文文档,我建议就按“需求分析、数据库设计、系统详细设计、系统实现、系统测试”这个框架来写。数据库设计章节可以放 ER 图和数据字典,系统实现章节重点写核心接口的设计思路,不一定要贴大段代码,但要把“为什么这么设计”讲清楚。老师看重的不是字数,而是逻辑闭环。
农副产品商城这个项目虽然经典,但把它做完整、做扎实,仍然能锻炼到事务、缓存、权限控制、部署这些在学校里不那么容易接触到的技能。把环境问题解决掉,把下单流程跑通一遍,你会发现自己对 Spring Boot 的理解会上一个台阶。
