☰
Java网上商城系统设计与实现:从购物车到支付回调的完整实战
2026/10/4 17:01:05 网站建设 项目流程

简介:一份基于Java技术栈的网上商城网站设计与实现文档,面向Java Web方向毕业生、电商项目开发人员及需要参考完整设计流程的初学者。它以零食在线销售为业务场景,系统梳理了课题背景、可行性分析、需求分析、总体设计等章节,并围绕BS架构、JSP动态页面、MySQL数据库与MyEclipse工具链给出具体技术方案,可直接用于毕业设计论文撰写和系统开发起步。压缩包仅含1个docx文件,大小约1.23MB,内容以论文正文为主,结构清晰、便于按章节查阅和二次编辑。目前已有188人浏览学习,说明其具有一定参考价值。文档详细覆盖了用户管理、按分类/新品/特价等维度的商品检索、购物车与订单处理、第三方支付接口对接、物流追踪、在线客服等功能模块,还给出业务流程图、数据库设计思路和系统优势分析,并针对订单流程补充了库存检查、价格计算、优惠券应用等关键细节,可帮助读者快速复现同类商城项目,也能为毕业答辩和后续扩展提供扎实的论述素材。

1. 基于 Java 的网上商城不是增删改查,是从加购到发货的一条完整链路

拿到“基于java网上商城网站设计与实现”这类题目时,最常见的误区是把它当成三张表加几个页面的 CRUD:用户表、商品表、订单表,页面能跳就算做完。但真把商城当成品交付时,难点几乎都集中在购物车与库存、下单事务和支付回调这几段,而不是页面本身。这篇文章按 Java 技术栈,从技术选型、数据库建模、核心流程实现到部署避坑完整走一遍,适合正在做毕业设计、课程设计,或者想用第一个完整项目练手的 Java 学习者。你不需要预先会微服务,能跑通 Spring Boot 就够了。

2. 技术选型先想清楚:Spring Boot + MyBatis 为什么是商城最稳的底子

2.1 不用 SSH 也不用纯 JSP:理由集中在维护成本

很多教程还在讲 Struts2 + Spring + Hibernate,但这套组合在 2024 年已经不是新项目的首选。Struts2 早年出过多次远程代码执行漏洞,官方社区活跃度也低,新手照着老教程写完,光解决依赖冲突就要耗掉一整天。纯 JSP + Servlet 不是不能做,而是购物车、事务管理、登录拦截这些都要手写,等于把 Spring 容器帮你做好的事重新做一遍,工作量翻倍还不一定写得对。

Spring Boot + MyBatis 的组合胜在三个地方:一是起步依赖把 Tomcat、Jackson、参数校验这些常用件都带好了,写一个 Controller 就能跑起来;二是 MyBatis 保留了手写 SQL 的能力,商城这种要写多表 join、行级锁、条件更新的场景,SQL 在手上有明确的调优抓手;三是生态成熟,网上能搜到的“网上商城 用例图、源码、部署教程”绝大多数都是这个技术栈,遇到问题搜一搜就有答案。JPA 确实更省代码,但对新手像个黑匣子,复杂查询翻车以后反而不容易定位。

2.2 最小可运行骨架:pom.xml 与 application.yml 各配什么

建一个 Spring Boot 工程,第一步是确定依赖。下面这份 pom.xml 是我常用的最小集,去掉了无关插件:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这里只引了 web、MyBatis、MySQL 驱动和 Lombok。Lombok 不是必须的,但能省掉 getter/setter 的重复代码,对后期维护友好。版本 2.7.18 是 Spring Boot 2.x 后期比较稳的一个版本,JDK 8 和 JDK 11 都能跑,比直接上 3.x 少很多环境问题。MyBatis 的 starter 版本要和 Spring Boot 版本匹配,如果 boot 升到 3.x,mybatis starter 也要换对应版本。

再配 application.yml:

server: port: 8080 servlet: context-path: /shop spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.shop.entity configuration: map-underscore-to-camel-case: true

url 里必须带serverTimezone=Asia/Shanghai,否则 MySQL 8 会报时区错误。map-underscore-to-camel-case打开后,数据库字段create_time能直接映射到 Java 属性createTime,省掉大量 resultMap。context-path配成 /shop 后,所有接口都要通过http://localhost:8080/shop/...访问,这个前缀和后面部署有关系,提前想好能少踩一次 404 的坑。

2.3 controller/service/mapper 分包:一个能长期改动的工程结构

商城项目后期一定会加功能,比如优惠券、秒杀、多商户,所以包结构从第一天就按职责分好。常见做法是 controller、service、mapper、entity、config、common 六层:

com.example.shop ├── controller # 接收参数、返回结果 ├── service # 业务逻辑,事务边界在这里 ├── mapper # MyBatis 接口,只写数据库操作 ├── entity # 数据库表对应的实体 ├── config # 拦截器、跨域、静态资源配置 └── common # 统一返回体、异常、工具类

有个容易翻车的点是事务边界。事务注解@Transactional要放在 service 实现类上,而不是 controller 上。controller 只做参数接收和结果封装,一旦把事务放上去,多个请求同时进来时事务范围被拉长,连接池很快会被占满。service 里一个方法就是一次业务操作的完整事务,这个习惯从写第一个下单接口时就要养成。

3. 数据库设计先定边界:SPU/SKU、库存与订单明细

3.1 SPU 与 SKU:拆不拆,取决于商品要不要多规格

做商城数据库设计,先回答一个问题:商品要不要支持多规格。比如一件 T 恤有红色和蓝色两个 SKU,库存是各自独立的。如果只需要单规格,把商品和库存合成一张表可以少写很多 join,但后期加规格就得改表结构,相对麻烦。多商户跨境商城那种真实项目,几乎都会拆 SPU(标准产品单元)和 SKU(库存量单位)两张表,这是 Java 商城源码里最常见的建模方式。

我的建议是直接拆,成本并不高:

CREATE TABLE spu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_no VARCHAR(32) NOT NULL COMMENT '商品编号', title VARCHAR(128) NOT NULL COMMENT '商品标题', category_id BIGINT NOT NULL COMMENT '分类ID', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_id BIGINT NOT NULL, sku_no VARCHAR(64) NOT NULL, spec VARCHAR(64) NOT NULL COMMENT '规格描述,如 红色/X', price DECIMAL(10,2) NOT NULL COMMENT '售价', stock INT NOT NULL DEFAULT 0 COMMENT '库存', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意 sku 表里除了 stock,还加了一个 version 字段,这是为后面扣库存的乐观锁准备的。如果用UPDATE sku SET stock = stock - 1 WHERE id = ? AND stock > 0这种条件更新,version 不是必须的;但如果你想在更新时做更多校验,version 能给你一条退路。两张表通过 spu_id 关联,查询商品列表时再 join 分类表,前期不建外键,性能更好。

3.2 订单主表与订单明细:为什么必须冗余快照

订单表是商城最重要的表,设计上有一个新手常忽略的点:订单明细必须把下单那一刻的商品信息冗余进去,包括标题、图片、单价、规格。因为商品价格和标题随时会改,甚至可能下架,如果你下单之后去查商品表取名称和价格,历史订单就会跟着变,对账的时候数据就对不上。

订单主表一条记录对应一次下单,订单明细表多条记录对应这个订单里的多个商品。两张表用 order_no 关联。订单主表的关键字段如下:

字段类型说明
idBIGINT自增主键,不对外暴露
order_noVARCHAR(32)业务订单号,对外展示、支付回调都用它
user_idBIGINT下单用户
total_amountDECIMAL(10,2)订单总金额,单位为元
statusTINYINT10待支付 20已支付 30已发货 40已完成 50已取消
pay_timeDATETIME支付时间
create_timeDATETIME下单时间

订单明细表里要有sku_id、sku_spec(规格快照)、product_title(标题快照)、product_image(图片快照)、price(成交单价快照)、count(数量)。这些字段全部来自下单那一刻的 sku 和 spu 数据,之后商品怎么改都与历史订单无关。这一点做到位,后面做订单列表、统计报表都不会因为商品变更而翻车。

3.3 金额用 decimal,主键不暴露,外键不建:三个小而关键的约定

金额字段必须用DECIMAL(10,2),不要用 float 或 double。二进制浮点数在加减时会产生精度误差,订单金额差几分钱对不上账,这种问题最难排查。Java 侧对应的类型是BigDecimal,用new BigDecimal("19.99")的方式构造,不要直接传 double。

主键用自增BIGINT没问题,但对外永远不要暴露自增 id。用户 id 暴露还好,订单 id 一旦暴露,别人能从自增规律推测出你的订单量,这是安全风险。所以订单要有一个独立的order_no,用时间戳加随机数生成,比如yyyyMMddHHmmss加六位随机数,或者用雪花算法生成的字符串。对外展示、支付回调、查询都用 order_no。

外键约束建议不建。商城表之间关系复杂,外键会导致插入、删除时数据库做额外校验,高并发下容易锁竞争。表之间的关系完全由 service 层控制——先查再插、先校验再更新,这些逻辑写在 Java 代码里,比数据库外键更灵活。

4. 从加购到支付回调:购物车、下单事务与幂等怎么落地

4.1 购物车存 Redis 还是 session:两者都行,但取舍不同

购物车是商城第一个真正有状态的模块。最轻量的做法是存在 session 里,用户登录后把购物车对象序列化到 session,代码简单,不需要额外服务。但 session 方案有两个硬伤:一是用户换设备或者清理浏览器缓存,购物车就没了;二是 session 默认在内存里,用户量上来以后,内存占用会很难看。

真实项目一般用 Redis 存购物车,结构上是 hash:key 是用户 id,field 是 sku_id,value 是购买数量。Redis 的 hash 天然适合购物车这种按用户聚合的场景,增加数量、修改规格、清空都很方便。如果只是为了课程设计展示,session 方案也能跑通,但代码里最好留一个抽象接口,后面想切换 Redis 时不用改 controller。

@Service public class CartService { private final RedisTemplate<String, Object> redisTemplate; public void addToCart(Long userId, Long skuId, Integer count) { String key = "cart:" + userId; // hash 里每个 sku 存一个数量,重复加入时累加 redisTemplate.opsForHash().increment(key, skuId.toString(), count); } public Map<Object, Object> getCart(Long userId) { return redisTemplate.opsForHash().entries("cart:" + userId); } }

increment方法在 hash field 不存在时会从 0 开始加,所以重复加购同一个商品不会丢数量。注意购物车里的数量可能超过库存,下单时还要再做一次库存校验,不能只靠前端限制。Redis 这种方式需要额外部署 Redis 服务,教程类项目如果条件受限,可以先用 ConcurrentHashMap 模拟,或者直接用 session。

4.2 下单方法:事务里先锁库存,再写订单,异常一律回滚

下单是商城业务里事务性最强的一段。一个下单操作要做的事包括:校验购物车、扣减库存、写订单主表、写订单明细表。这四步必须在一个事务里,任何一个失败,所有已做的修改都要回滚,否则会出现库存扣了但订单没建成的数据不一致。

下面是一个典型的 service 实现:

@Override @Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, List<CheckoutItem> items) { // 1. 生成唯一订单号,不用自增id对外暴露 String orderNo = generateOrderNo(); BigDecimal totalAmount = BigDecimal.ZERO; // 2. 逐条扣减库存,条件带上 stock >= count,防止超卖 for (CheckoutItem item : items) { int updated = skuMapper.deductStock(item.getSkuId(), item.getCount()); if (updated == 0) { throw new StockNotEnoughException("库存不足,skuId: " + item.getSkuId()); } } // 3. 写订单主表 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setStatus(10); // 待支付 orderMapper.insert(order); // 4. 写订单明细,此时的价格、标题都是快照数据 for (CheckoutItem item : items) { Sku sku = skuMapper.selectById(item.getSkuId()); OrderItem detail = new OrderItem(); detail.setOrderNo(orderNo); detail.setSkuId(sku.getId()); detail.setProductTitle(sku.getTitle()); // 快照 detail.setPrice(sku.getPrice()); // 快照 detail.setCount(item.getCount()); orderItemMapper.insert(detail); totalAmount = totalAmount.add(sku.getPrice().multiply(BigDecimal.valueOf(item.getCount()))); } // 5. 回填总金额再更新一次 orderMapper.updateAmount(orderNo, totalAmount); return order.getId(); }

对应的扣库存 SQL 是关键,通常写在 mapper XML 里:

UPDATE sku SET stock = stock - #{count} WHERE id = #{skuId} AND stock >= #{count}

WHERE stock >= #{count}是防止超卖的核心。如果库存只剩 2 件,两个请求同时来买 2 件,数据库行锁会让第二个更新语句返回 0,这样就不会出现库存为负的情况。@Transactional(rollbackFor = Exception.class)必须写,因为 Spring 默认只对运行时异常回滚,如果你抛的是自定义的 checked 异常,不加这个配置事务不会回滚,库存照样被扣。

事务里的顺序也有讲究,先扣库存再写订单。如果先写订单再扣库存,库存不足时订单数据已经插入,虽然事务会回滚,但数据库的 undo log 会多干活,锁持有时间变长。先把库存锁住,能尽早暴露冲突。

4.3 支付回调:同一笔通知来三次,订单状态只能变一次

支付回调是商城最容易写错的一段。第三方支付平台在收到你的应答之前,会按一定间隔重发通知,可能是同一次支付发三次、五次。如果回调处理逻辑不做幂等,用户付一次钱,订单状态被重复更新,可能会触发两次发货,这就是资损级别的 bug。

幂等的标准做法是增加一张支付流水表,给订单号加唯一约束,回调先尝试插入流水,插入成功了才去更新订单状态。

public PayResult handlePayNotify(PayNotifyRequest notify) { // 1. 先查流水,处理过就直接返回成功,避免重复业务 PayLog existing = payLogMapper.selectByOrderNo(notify.getOrderNo()); if (existing != null) { return PayResult.success(); } // 2. 尝试插入支付流水,唯一索引会挡住并发重复请求 try { PayLog payLog = new PayLog(); payLog.setOrderNo(notify.getOrderNo()); payLog.setTradeNo(notify.getTradeNo()); payLog.setAmount(notify.getAmount()); payLogMapper.insert(payLog); } catch (DuplicateKeyException e) { // 并发下另一个请求已经插入成功,这里也按成功返回 return PayResult.success(); } // 3. 流水插入成功,才更新订单状态为已支付 orderMapper.updateStatus(notify.getOrderNo(), 20, new Date()); return PayResult.success(); }

这段代码的关键在于第 2 步的插入。订单号在支付流水表上有唯一索引,两个相同回调同时进来时,只有一个能插入成功,另一个会抛DuplicateKeyException。捕获这个异常后直接返回支付成功,因为业务已经被另一个线程处理过了。查了再插、插失败就返回,这个模式在任何涉及回调通知的地方都能复用。

5. 部署与避坑:war 包、JDK 版本和静态资源的三处翻车现场

5.1 打 war 还是打 jar:取决于你的服务器怎么跑

部署方式直接决定你后面会踩哪些坑。Spring Boot 默认打 jar 包,用java -jar shop.jar就能启动,适合服务器上只跑一个应用的场景。但很多课程设计和公司环境给的是 Windows 服务器,上面装好了 Tomcat 8 或者 9,要求你把项目打成 war 包丢进 webapps 目录。

先改pom.xml,把打包方式改成 war:

<packaging>war</packaging>

然后启动类继承SpringBootServletInitializer,覆盖configure方法。这个步骤漏掉的典型症状是:war 包能部署,但访问任何接口都是 404。因为 Spring Boot 的自动配置没有被 servlet 容器加载。

@SpringBootApplication public class ShopApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(ShopApplication.class); } }

打包命令统一用:

mvn clean package -DskipTests

打完的产物在target目录下,war 包复制到 Tomcat 的webapps目录,启动 Tomcat 后会自动解压部署。这里有一个要注意的点:如果 application.yml 里配了context-path: /shop,访问路径会是http://localhost:8080/shop/...,Tomcat 本身不管这个前缀,它只负责把请求转给 Spring Boot,所以别在 Tomcat 层再配置一遍 context,重复配置会导致路径变成/shop/shop。

5.2 本地是好的,服务器起不来:JDK、时区、数据库账号三连问

本地跑得好好的,复制到服务器上启动失败,十有八九是环境差异。第一检查 JDK 版本。Spring Boot 2.7 要求 JDK 8 以上,如果你的服务器上装的是 JDK 1.6,启动会直接报UnsupportedClassVersionError。用java -version命令确认,记住服务器上的 JDK 位数也要看,32 位 JDK 跑大应用容易内存不足。

第二检查 MySQL 时区。本地 MySQL 可能配置好了serverTimezone,但服务器上如果 MySQL 是默认配置,启动时 Spring Boot 初始化数据源会报:

The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized

这是时区乱码,不是编码问题。最快的解决方式是保持 application.yml 里的 URL 完整,包含serverTimezone=Asia/Shanghai,并且确认 MySQL 账号有远程访问权限。MySQL 8 默认的认证插件是caching_sha2_password,如果你的 Java 驱动版本太老,会报认证失败,换用 mysql-connector-java 8.0 以上的版本就能解决。

第三是端口冲突。服务器上可能已经跑了别的 Java 应用占着 8080,启动日志会显示Port already in use。这时候不要急着改代码,用netstat -ano | findstr 8080(Windows)或者lsof -i:8080(Linux)看看是谁占了端口,把配置文件里的server.port改掉,或者把冲突进程清掉。

5.3 避坑记录:五条真实踩坑的现象、原因与解决

现象一:登录后访问商品列表,页面能开,但点下单就 404。原因:下单接口路径写的是/orders/create,但项目配了context-path: /shop,controller 里用的是相对路径拼接,导致实际请求打到/orders/create而不是/shop/orders/create。 解决:前端请求统一用绝对路径,或者在application.yml里把 context-path 去掉,保持部署和本地方案一致。二选一,不要两个都留着。

现象二:图片上传成功,但页面上图片裂了。原因:文件保存到本地磁盘路径D:/uploads,但访问 URL 还是/files/xxx.jpg,Spring Boot 默认不映射磁盘物理路径,静态资源只在 classpath 下找。 解决:加一个 WebMvc 配置类,把/files/**映射到本地磁盘目录:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:D:/uploads/"); }

Linux 服务器上改成file:/usr/local/shop/uploads/,注意结尾必须带斜杠,否则映射不生效。

现象三:同时下单 10 件库存 5 的商品,居然下单成功了。原因:库存校验写成了select查库存再判断,两个线程同时查到库存 5,都通过了校验,然后一起扣成负数。 解决:把校验和扣减合并成一条 update 语句,用WHERE stock >= #{count}做条件更新,影响行数为 0 就抛异常。这是数据库层面的行锁,比 Java 代码里加 synchronized 靠谱得多。

现象四:支付成功后订单状态没变,但支付流水插进去了。原因:支付回调和订单状态更新写在了两个事务里,流水插入成功提交了,订单更新抛异常回滚,两边数据不一致。 解决:把订单更新和流水插入放在同一个事务方法里,或者先更新订单状态再插入流水,顺序调整后要么都成功要么都失败。支付流水表的作用是幂等,不是独立事务的起点。

现象五:服务器上日志全是问号,中文全乱。原因:Tomcat 的 URI 编码默认不是 UTF-8,请求参数里的中文经过 servlet 容器解析后已经变成乱码。 解决:在 Tomcat 的server.xml里给Connector加URIEncoding="UTF-8",同时确认代码里HttpServletResponse的 contentType 设了text/html;charset=UTF-8。这个问题在本地开发时很少出现,因为 IDE 里的 Tomcat 通常已经配好了。

6. 验证商城没写崩:并发压测、回调重放与日志定位

最后这一步很多人不做,但恰恰是区分“能跑”和“敢上线”的分界线。用压测工具先打一遍核心接口,再手动重放支付回调,确认关键状态逻辑没有问题,这比写多少单元测试都直观。

压测用 Apache ab 就够,不用上 JMeter。比如验证商品列表接口在 50 并发下能不能扛住:

ab -n 1000 -c 50 http://localhost:8080/shop/products

重点看两个指标:Failed requests是不是 0,Requests per second是多少。如果失败率超过 5%,优先检查是不是连接池配置太小,spring.datasource.hikari.maximum-pool-size默认 10,并发上来以后线程会等连接,表现为接口响应时间暴涨。

回调重放测试更简单:把支付成功的通知请求用 Postman 手动发两次,第二次返回的结果应该和第一次一样,而且订单状态在数据库里只变一次。我习惯在回调入口加一行日志,记录每次请求的 orderNo 和 tradeNo,重放时看日志就能确认幂等是否生效。

最后一招是看日志定位问题。部署到服务器上的项目,不要用System.out.println输出业务信息,统一用 logback,把滚动日志配置好:

logging: level: com.example.shop.mapper: debug

把 mapper 层日志级别调成 debug 后,MyBatis 会打印实际执行的 SQL 和参数。以后线上出现问题,先看日志里最后一条 SQL 执行到什么位置,基本能定位是数据库操作失败还是业务逻辑抛异常。我现在的习惯是压测不过不上线,回调重放不过不发版,这两条做到位,商城项目交付出去能少收到一半深夜求助消息。希望帮到你。

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

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

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

立即咨询