Java商城从零搭建:商品展示、购物车与订单功能实现

科技资讯3小时前发布 jdkjadmin
0 00
Java商城从零搭建:商品展示、购物车与订单功能实现

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 不持久化,也不能在多实例之间共享。上线前需使用持久化存储并定义事务边界。

八、从示例升级到真实商城

下一步可以按业务边界逐步拆分,而不是一开始就堆入所有中间件:

  1. 接入关系数据库,将商品、购物车、订单及订单明细建模为表;订单明细保存商品名称、成交单价、数量等快照字段。
  2. 使用认证组件建立可信用户身份,从安全上下文中获取用户,而不是让客户端指定 buyerId。
  3. 为库存扣减、订单创建和购物车清理定义事务边界,并针对并发下单编写集成测试。
  4. 加入统一参数校验、错误响应、请求日志和必要的审计字段;日志中避免记录令牌、密码等敏感信息。
  5. 实现订单状态机,明确创建、待支付、已支付、已取消等状态之间允许的迁移,并处理支付回调幂等。
  6. 根据实际流量和业务要求,再考虑缓存、消息队列、库存预占或服务拆分。它们会增加一致性和运维复杂度,不应只为追求架构形式而引入。

从零实现商城的重点并非把接口数量做多,而是把数据的可信来源、状态变化和并发边界说清楚。本文的内存版代码可直接启动,用来观察商品展示、购物车计算和订单快照的基本流程;投入真实交易之前,必须替换演示身份与存储方式,并补足持久化、事务、幂等、支付和安全控制。

© 版权声明

相关文章