
Java商城从零搭建:商品展示、购物车与订单功能实现
商品列表、购物车和订单是商城最常见的三条业务链路。它们看起来只是几个接口,真正需要仔细处理的却是商品价格、库存、用户身份和重复提交:购物车里的价格不能直接作为成交价,多个用户不能共用一个购物车,库存不能因并发下单变成负数。
本文使用 Java 17、Spring Boot 3 和 Maven,搭建一个可运行的 REST API 示例。示例包含商品查询、添加和删除购物车商品、提交订单、查询订单等功能。为了让代码集中易读,数据存放在内存中;这适合学习、接口联调和原型验证,不适合直接承载真实交易。文末会说明从示例走向生产系统时必须补齐的部分。
一、适用场景与实现边界
这套示例适用于学习 Spring Boot 的请求处理流程,或在没有数据库依赖的情况下快速验证前后端接口。启动后可以查询预置商品、修改购物车并提交订单。
它不适合直接作为线上商城后端,原因包括:
- 应用重启后,商品、购物车和订单都会丢失。
- 示例用请求参数传入 buyerId,仅用于模拟用户区分,不是身份认证。调用者可以伪造其他用户的 ID。
- 示例没有接入支付、地址管理、优惠券、退货、物流等业务。
- 进程内同步只能协调单个应用实例中的请求。多实例部署时,每个实例的内存和锁彼此独立。
实际商城通常使用关系数据库保存商品、库存和订单,使用登录态或令牌确定用户身份,并通过数据库事务、条件更新或锁处理库存竞争。支付也应由独立的支付流程完成,不能把“创建订单”当成“已付款”。
二、创建项目
安装 JDK 17 和 Maven,然后创建以下目录结构:
mall-demo/
├── pom.xml
└── src/
└── main/
└── java/
└── com/
└── example/
└── mall/
└── MallApplication.java
pom.xml 使用 Spring Boot Web Starter 提供 Web 服务及 JSON 序列化能力:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>mall-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
这里使用的 Spring Boot 版本是示例依赖版本,不意味着它是任何时点都最新的版本。实际项目应根据团队维护策略选择受支持的版本,并定期升级安全补丁。
三、实现商品、购物车与订单接口
下面将代码放在一个 Java 文件中,减少初学者在多个类之间切换的成本。真实项目可以再按 controller、service、repository、DTO 和 domain 拆分。示例商品只有两件,便于验证库存变化。
接口设计如下:
GET /api/products:展示商品及当前库存。GET /api/cart?buyerId=demo:查看指定示例用户的购物车。POST /api/cart/items:添加商品或增加已有商品数量。DELETE /api/cart/items/{sku}?buyerId=demo:移除购物车中的商品。POST /api/orders?buyerId=demo:按当前商品价格和库存创建订单。GET /api/orders/{orderId}?buyerId=demo:查看属于该用户的订单。
创建 src/main/java/com/example/mall/MallApplication.java,写入以下完整代码:
package com.example.mall;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
@SpringBootApplication
@RestController
@RequestMapping("/api")
public class MallApplication {
private final MallService mall = new MallService();
public static void main(String[] args) {
SpringApplication.run(MallApplication.class, args);
}
@GetMapping("/products")
public List<ProductView> products() {
return mall.listProducts();
}
@GetMapping("/cart")
public CartView cart(@RequestParam String buyerId) {
return mall.getCart(buyerId);
}
@PostMapping("/cart/items")
public CartView addCartItem(@RequestBody AddCartItem request) {
return mall.addToCart(request);
}
@DeleteMapping("/cart/items/{sku}")
public CartView removeCartItem(@RequestParam String buyerId,
@PathVariable String sku) {
return mall.removeFromCart(buyerId, sku);
}
@PostMapping("/orders")
public OrderView createOrder(@RequestParam String buyerId) {
return mall.createOrder(buyerId);
}
@GetMapping("/orders/{orderId}")
public OrderView getOrder(@RequestParam String buyerId,
@PathVariable String orderId) {
return mall.getOrder(buyerId, orderId);
}
record AddCartItem(String buyerId, String sku, Integer quantity) {}
record ProductView(String sku, String name, BigDecimal price, int stock) {}
record CartLine(String sku, String name, BigDecimal unitPrice,
int quantity, BigDecimal lineTotal) {}
record CartView(String buyerId, List<CartLine> items,
BigDecimal total) {}
record OrderLine(String sku, String name, BigDecimal unitPrice,
int quantity, BigDecimal lineTotal) {}
record OrderView(String orderId, String buyerId, String status,
Instant createdAt, List<OrderLine> items,
BigDecimal total) {}
static class Product {
final String sku;
final String name;
final BigDecimal price;
int stock;
Product(String sku, String name, BigDecimal price, int stock) {
this.sku = sku;
this.name = name;
this.price = price;
this.stock = stock;
}
}
static class MallService {
private final Map<String, Product> products = new LinkedHashMap<>();
private final Map<String, Map<String, Integer>> carts = new HashMap<>();
private final Map<String, OrderView> orders = new HashMap<>();
MallService() {
products.put("BOOK-001", new Product(
"BOOK-001", "Java 入门", new BigDecimal("59.90"), 20));
products.put("MUG-001", new Product(
"MUG-001", "陶瓷马克杯", new BigDecimal("29.50"), 10));
}
synchronized List<ProductView> listProducts() {
return products.values().stream()
.map(p -> new ProductView(p.sku, p.name, p.price, p.stock))
.toList();
}
synchronized CartView getCart(String buyerId) {
requireBuyerId(buyerId);
Map<String, Integer> cart =
carts.getOrDefault(buyerId, Map.of());
return toCartView(buyerId, cart);
}
synchronized CartView addToCart(AddCartItem request) {
if (request == null) {
throw badRequest("请求体不能为空");
}
requireBuyerId(request.buyerId());
requireSku(request.sku());
if (request.quantity() == null
|| request.quantity() < 1
|| request.quantity() > 99) {
throw badRequest("quantity 必须是 1 到 99 的整数");
}
Product product = findProduct(request.sku());
Map<String, Integer> cart =
carts.computeIfAbsent(request.buyerId(),
key -> new HashMap<>());
int oldQuantity = cart.getOrDefault(request.sku(), 0);
int newQuantity = oldQuantity + request.quantity();
if (newQuantity > 99) {
throw badRequest("同一商品在购物车中的数量不能超过 99");
}
cart.put(product.sku, newQuantity);
return toCartView(request.buyerId(), cart);
}
synchronized CartView removeFromCart(String buyerId, String sku) {
requireBuyerId(buyerId);
requireSku(sku);
Map<String, Integer> cart = carts.get(buyerId);
if (cart != null) {
cart.remove(sku);
}
return toCartView(buyerId,
cart == null ? Map.of() : cart);
}
synchronized OrderView createOrder(String buyerId) {
requireBuyerId(buyerId);
Map<String, Integer> cart = carts.get(buyerId);
if (cart == null || cart.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "购物车为空");
}
List<OrderLine> lines = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
// 先完整校验,避免校验到一半就扣减部分库存。
for (Map.Entry<String, Integer> entry : cart.entrySet()) {
Product product = findProduct(entry.getKey());
int quantity = entry.getValue();
if (quantity > product.stock) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"库存不足:" + product.sku);
}
BigDecimal lineTotal = product.price.multiply(
BigDecimal.valueOf(quantity));
lines.add(new OrderLine(product.sku, product.name,
product.price, quantity, lineTotal));
total = total.add(lineTotal);
}
// 示例中校验和扣库存在同一把进程内锁内执行。
for (Map.Entry<String, Integer> entry : cart.entrySet()) {
Product product = products.get(entry.getKey());
product.stock -= entry.getValue();
}
String orderId = UUID.randomUUID().toString();
OrderView order = new OrderView(orderId, buyerId,
"CREATED", Instant.now(), List.copyOf(lines), total);
orders.put(orderId, order);
carts.remove(buyerId);
return order;
}
synchronized OrderView getOrder(String buyerId, String orderId) {
requireBuyerId(buyerId);
if (orderId == null || orderId.isBlank()) {
throw badRequest("orderId 不能为空");
}
OrderView order = orders.get(orderId);
if (order == null || !order.buyerId().equals(buyerId)) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "订单不存在");
}
return order;
}
private CartView toCartView(String buyerId,
Map<String, Integer> cart) {
List<CartLine> lines = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
for (Map.Entry<String, Integer> entry : cart.entrySet()) {
Product product = findProduct(entry.getKey());
int quantity = entry.getValue();
BigDecimal lineTotal = product.price.multiply(
BigDecimal.valueOf(quantity));
lines.add(new CartLine(product.sku, product.name,
product.price, quantity, lineTotal));
total = total.add(lineTotal);
}
return new CartView(buyerId, List.copyOf(lines), total);
}
private Product findProduct(String sku) {
Product product = products.get(sku);
if (product == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "商品不存在");
}
return product;
}
private void requireBuyerId(String buyerId) {
if (buyerId == null || buyerId.isBlank()
|| buyerId.length() > 64) {
throw badRequest("buyerId 必须为 1 到 64 个字符");
}
}
private void requireSku(String sku) {
if (sku == null || sku.isBlank() || sku.length() > 40) {
throw badRequest("sku 必须为 1 到 40 个字符");
}
}
private ResponseStatusException badRequest(String message) {
return new ResponseStatusException(
HttpStatus.BAD_REQUEST, message);
}
}
}
四、运行并验证接口
在项目根目录执行:
mvn spring-boot:run
服务默认监听本机 8080 端口。先查询商品:
curl http://localhost:8080/api/products
返回内容是 JSON 数组,包含 SKU、名称、价格和库存。添加商品到购物车:
curl -X POST http://localhost:8080/api/cart/items \
-H "Content-Type: application/json" \
-d '{"buyerId":"demo","sku":"BOOK-001","quantity":2}'
请求体中的 quantity 代表本次增加的数量;如果购物车原来已有该商品,接口会累加。示例将单次添加数量限制为 1 到 99,并将同一用户购物车中单个 SKU 的累计数量限制为 99。这两个数值是本示例的业务限制,不是 Spring Boot 或 HTTP 协议的固有限制,也不代表通用商城标准。
查看购物车:
curl "http://localhost:8080/api/cart?buyerId=demo"
移除商品:
curl -X DELETE \
"http://localhost:8080/api/cart/items/BOOK-001?buyerId=demo"
若要下单,先添加商品,再调用:
curl -X POST \
"http://localhost:8080/api/orders?buyerId=demo"
订单中保存了商品名称、成交单价、数量和行金额的快照。成功后,该示例会扣减内存中的库存并清空该用户购物车。可以在响应中找到 orderId,再查询订单:
curl "http://localhost:8080/api/orders/替换为实际订单ID?buyerId=demo"
五、关键实现细节
1. 购物车价格不是订单价格承诺
购物车视图会根据当前商品价格重新计算金额。创建订单时也再次读取商品价格,并把单价、商品名称和数量复制到订单行中。这样,订单生成后即使商品名称或售价变更,历史订单仍能保留当时的快照。
在生产系统中,还要明确价格变更策略:下单时采用最新售价,还是向用户展示价格变化并要求重新确认。不能信任客户端提交的商品价格、行金额或订单总额;客户端传来的金额只能用于展示,服务端应从可信的商品和促销数据重新计算。
金额使用 BigDecimal,而不是 double。二进制浮点数不适合直接表达十进制货币金额。示例中的价格使用字符串构造 BigDecimal,避免先经过浮点数再转换。真实业务还需要规定币种、精度、舍入规则、税费以及优惠分摊方式。
2. 添加购物车不预留库存
把商品放入购物车通常不代表占有库存。示例只在创建订单时检查并扣减库存,因此可能发生“刚加入购物车时有货,下单时库存不足”。这是常见业务选择,不是程序错误。若业务要求购物车期间锁货,需要设计带过期时间的库存预占、释放任务和取消订单回滚逻辑,不能只在购物车接口里简单减库存。
3. 库存检查与扣减需要原子性
示例的 createOrder 方法使用 synchronized,使同一个 Java 进程中的下单请求串行执行,并先检查全部商品库存,再扣减库存。仅有“先查询库存、再单独扣减”而没有原子保护,两个请求可能同时看到有货并都完成下单,造成超卖。
但 Java 对象锁只在当前进程有效。部署多个服务实例、使用数据库或接入消息队列后,它不能保护跨进程的数据。数据库场景可以考虑事务内条件更新,例如“仅当库存大于等于购买数量时执行扣减”,并检查受影响行数;也可以根据业务需要使用行锁或其他库存服务方案。具体方案要结合吞吐量、库存模型和一致性要求验证。
4. 订单创建不是支付成功
示例订单状态为 CREATED,只表示订单已创建,不表示已经支付。真实支付流程通常需要支付单、支付渠道回调验签、幂等处理、支付超时、取消订单和库存释放。不能仅凭浏览器跳转、前端参数或用户重复提交把订单标记为已支付。
六、参数、安全与错误处理
请求参数限制的含义
buyerId 被限制为非空、最多 64 个字符,sku 被限制为非空、最多 40 个字符,购物车数量限制为 1 到 99。它们是示例代码主动执行的输入校验,用于拒绝明显无效的请求、限制业务数据范围,并减少异常输入带来的处理问题。它们不是完整的反滥用机制,也不能代替请求大小限制、频率限制或数据库约束。
如果用 JSON 把字符串字段传成数字、把 quantity 传成小数,Jackson 通常会在反序列化阶段返回请求错误;具体错误响应格式取决于 Spring Boot 和异常处理配置。示例对常见业务校验使用 400,对购物车为空和库存不足使用 409,对不存在的商品或订单使用 404。真实项目宜添加统一异常处理,将内部异常转换为稳定的错误码和可读消息,避免向客户端泄露堆栈或敏感信息。
buyerId 不是认证,也不是授权
示例允许调用者在 URL 或请求体中自由填写 buyerId。它只是演示“不同用户对应不同购物车和订单”的数据键,不能用于保护数据。攻击者可以把 buyerId 改成别人的值。生产环境应从经过验证的登录会话或访问令牌中取得当前用户身份,不应以客户端自行提交的用户编号作为授权依据。读取订单时还必须验证当前用户是否有权访问该订单;示例通过比较 buyerId 做了演示性检查,但其身份来源仍不可信。
只要接口面向浏览器或外部客户端,还应根据系统架构处理 HTTPS、会话安全、跨站请求伪造(适用于相应的 Cookie 会话场景)、速率限制、日志脱敏和权限校验。商品名称等文本展示到网页时,也应由前端按上下文进行安全转义,不能把数据库内容直接当作可信 HTML。
防重复下单需要幂等设计
本示例每次成功请求都会生成新订单。用户在网络超时后重试,可能创建重复订单;UUID 订单号只能降低编号冲突风险,不能防止同一业务请求被重复处理。真实接口可设计幂等键,由服务端记录幂等键与处理结果,并在数据库中设置唯一约束。幂等键需要绑定用户和业务请求,并定义有效期及重复请求的响应规则。
七、常见错误与改进方向
- 使用 double 保存金额。改用
BigDecimal,并统一规定精度和舍入规则。 - 相信客户端提交的价格。下单时由服务端读取商品和促销数据计算,并把成交信息写入订单快照。
- 把“购物车有货”当成“下单有货”。库存应在明确的下单或预占环节再次校验。
- 先扣一件库存,后发现另一件库存不足。数据库实现要放在事务中,并设计失败回滚;示例先校验全部商品再扣减。
- 认为 synchronized 能解决集群库存问题。它只协调同一个 JVM 中使用同一把锁的线程,不能替代跨实例的数据库或分布式一致性方案。
- 用 buyerId 参数保护订单。参数可被伪造。身份必须来自可靠认证信息,授权需要在服务端执行。
- 认为创建订单就代表付款。订单创建和支付成功是不同状态,应由支付回调等可信流程推动状态变化。
- 忽略应用重启和并发部署。内存 Map 不持久化,也不能在多实例之间共享。上线前需使用持久化存储并定义事务边界。
八、从示例升级到真实商城
下一步可以按业务边界逐步拆分,而不是一开始就堆入所有中间件:
- 接入关系数据库,将商品、购物车、订单及订单明细建模为表;订单明细保存商品名称、成交单价、数量等快照字段。
- 使用认证组件建立可信用户身份,从安全上下文中获取用户,而不是让客户端指定 buyerId。
- 为库存扣减、订单创建和购物车清理定义事务边界,并针对并发下单编写集成测试。
- 加入统一参数校验、错误响应、请求日志和必要的审计字段;日志中避免记录令牌、密码等敏感信息。
- 实现订单状态机,明确创建、待支付、已支付、已取消等状态之间允许的迁移,并处理支付回调幂等。
- 根据实际流量和业务要求,再考虑缓存、消息队列、库存预占或服务拆分。它们会增加一致性和运维复杂度,不应只为追求架构形式而引入。
从零实现商城的重点并非把接口数量做多,而是把数据的可信来源、状态变化和并发边界说清楚。本文的内存版代码可直接启动,用来观察商品展示、购物车计算和订单快照的基本流程;投入真实交易之前,必须替换演示身份与存储方式,并补足持久化、事务、幂等、支付和安全控制。








