1. 项目概述

本文将带你实现一个轻量级电商系统。后端采用 Spring Boot 构建 RESTful 接口,前端使用 Angular 消费这些接口。功能聚焦于核心电商流程:

✅ 用户浏览商品
✅ 添加/移除购物车商品
✅ 提交订单

整个项目结构清晰,适合快速理解前后端协作模式。最终还会演示如何将前后端打包为单一应用部署。


2. 后端实现

使用 Spring Boot 3.1.5 + JPA + H2 内存数据库快速搭建服务端。H2 方便本地开发调试,无需额外安装数据库。

📌 提示:若想深入学习 Spring Boot 或 REST API 设计,可参考官方文档或社区经典教程。

2.1 Maven 依赖配置

核心依赖如下,通过 Spring Initializr 快速生成:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>3.1.5</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.1.5</version>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.1.214</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.9.6</version>
</dependency>

⚠️ 注意:jackson-datatype-jsr310 用于支持 Java 8 时间类型(如 LocalDate)的 JSON 序列化。


2.2 数据库配置

开启 H2 控制台便于调试,并启用 SQL 日志输出:

spring.datasource.name=ecommercedb
spring.jpa.show-sql=true

# H2 控制台配置
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

启动后访问 http://localhost:8080/h2-console,使用以下参数登录:

  • JDBC URL: jdbc:h2:mem:ecommercedb
  • Username: sa
  • Password: 留空

2.3 项目结构

标准 Maven + Spring Boot 分层结构:

├── pom.xml            
├── src
│   ├── main
│   │   ├── frontend          # Angular 源码
│   │   ├── java
│   │   │   └── com.baeldung.ecommerce
│   │   │       │   EcommerceApplication.java
│   │   │       ├── controller 
│   │   │       ├── dto  
│   │   │       ├── exception
│   │   │       ├── model
│   │   │       ├── repository
│   │   │       └── service
│   │   └── resources
│   │       │   application.properties
│   │       ├── static        # Angular 构建产物存放目录
│   │       └── templates
│   └── test
│       └── java
│           └── com.baeldung.ecommerce
│               └── EcommerceApplicationIntegrationTest.java

✅ 所有 Repository 接口均继承自 CrudRepository,省略展示。


2.4 全局异常处理

统一处理常见异常,提升 API 友好性:

@RestControllerAdvice
public class ApiExceptionHandler {

    @SuppressWarnings("rawtypes")
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handle(ConstraintViolationException e) {
        ErrorResponse errors = new ErrorResponse();
        for (ConstraintViolation violation : e.getConstraintViolations()) {
            ErrorItem error = new ErrorItem();
            error.setCode(violation.getMessageTemplate());
            error.setMessage(violation.getMessage());
            errors.addError(error);
        }
        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }

    @SuppressWarnings("rawtypes")
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorItem> handle(ResourceNotFoundException e) {
        ErrorItem error = new ErrorItem();
        error.setMessage(e.getMessage());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }
}

✅ 捕获 JSR-303 参数校验异常
✅ 处理资源未找到(如商品 ID 不存在)


2.5 商品模块(Products)

商品仅支持查询,通过 CommandLineRunner 在启动时预置数据。

实体类

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull(message = "Product name is required.")
    @Basic(optional = false)
    private String name;

    private Double price;

    private String pictureUrl;

    // 全参构造、getter/setter 省略
}

服务层

@Service
@Transactional
public class ProductServiceImpl implements ProductService {

    private final ProductRepository productRepository;

    public ProductServiceImpl(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public Iterable<Product> getAllProducts() {
        return productRepository.findAll();
    }

    @Override
    public Product getProduct(long id) {
        return productRepository
          .findById(id)
          .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
    }

    @Override
    public Product save(Product product) {
        return productRepository.save(product);
    }
}

控制器

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping(value = { "", "/" })
    public @NotNull Iterable<Product> getProducts() {
        return productService.getAllProducts();
    }
}

初始化数据

@Bean
CommandLineRunner runner(ProductService productService) {
    return args -> {
        productService.save(new Product("Laptop", 999.99, "/images/laptop.jpg"));
        productService.save(new Product("Mouse", 25.50, "/images/mouse.jpg"));
        productService.save(new Product("Keyboard", 75.00, "/images/keyboard.jpg"));
    };
}

启动后访问 http://localhost:8080/api/products 即可获取商品列表。


2.6 订单模块(Orders)

支持创建订单,包含多个商品及数量。

核心实体设计

⚠️ 踩坑提醒:Order 是 SQL 保留字,必须显式指定表名!

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @JsonFormat(pattern = "dd/MM/yyyy")
    private LocalDate dateCreated;

    private String status;

    @JsonManagedReference
    @OneToMany(mappedBy = "pk.order")
    @Valid
    private List<OrderProduct> orderProducts = new ArrayList<>();

    @Transient
    public Double getTotalOrderPrice() {
        return orderProducts.stream()
            .mapToDouble(OrderProduct::getTotalPrice)
            .sum();
    }

    @Transient
    public int getNumberOfProducts() {
        return this.orderProducts.size();
    }

    // getter/setter 省略
}

订单项(OrderProduct)与复合主键

使用 @EmbeddedId 实现多对多关联:

@Entity
public class OrderProduct {

    @EmbeddedId
    @JsonIgnore
    private OrderProductPK pk;

    @Column(nullable = false)
    private Integer quantity;

    public OrderProduct(Order order, Product product, Integer quantity) {
        pk = new OrderProductPK();
        pk.setOrder(order);
        pk.setProduct(product);
        this.quantity = quantity;
    }

    @Transient
    public Product getProduct() {
        return this.pk.getProduct();
    }

    @Transient
    public Double getTotalPrice() {
        return getProduct().getPrice() * getQuantity();
    }

    // getter/setter、equals/hashCode 省略
}
@Embeddable
public class OrderProductPK implements Serializable {

    @JsonBackReference
    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    // getter/setter、equals/hashCode 省略
}

@JsonIgnore 避免序列化循环引用
@Transient 方法用于计算总价,不持久化

服务与控制器

@Service
@Transactional
public class OrderServiceImpl implements OrderService {

    private final OrderRepository orderRepository;

    public OrderServiceImpl(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public Iterable<Order> getAllOrders() {
        return orderRepository.findAll();
    }
    
    @Override
    public Order create(Order order) {
        order.setDateCreated(LocalDate.now());
        return orderRepository.save(order);
    }

    @Override
    public void update(Order order) {
        orderRepository.save(order);
    }
}
@PostMapping
public ResponseEntity<Order> create(@RequestBody OrderForm form) {
    List<OrderProductDto> formDtos = form.getProductOrders();
    validateProductsExistence(formDtos);

    Order order = new Order();
    order.setStatus("NEW");
    
    List<OrderProduct> orderProducts = new ArrayList<>();
    for (OrderProductDto dto : formDtos) {
        Product product = productService.getProduct(dto.getProductId());
        orderProducts.add(new OrderProduct(order, product, dto.getQuantity()));
    }
    order.setOrderProducts(orderProducts);
    orderService.update(order);

    String uri = ServletUriComponentsBuilder
      .fromCurrentServletMapping()
      .path("/orders/{id}")
      .buildAndExpand(order.getId())
      .toString();
    
    HttpHeaders headers = new HttpHeaders();
    headers.add("Location", uri);

    return new ResponseEntity<>(order, headers, HttpStatus.CREATED);
}

✅ 接收 OrderForm DTO
✅ 校验商品存在性
✅ 返回 201 Created + Location


3. 前端实现(Angular)

使用 Angular CLI 快速搭建前端工程,版本要求 ≥ 4.3(因使用 HttpClient)。

3.1 初始化项目

cd src/main
ng new frontend

默认启动端口:http://localhost:4200


3.2 集成 Bootstrap

提升 UI 视觉效果:

npm install --save bootstrap

修改 angular.json,在 styles 中添加:

"node_modules/bootstrap/dist/css/bootstrap.min.css"

3.3 组件与模型设计

系统界面预览:

ecommerce

创建组件

ng g c ecommerce
ng g c ecommerce/products
ng g c ecommerce/orders
ng g c ecommerce/shopping-cart

数据模型

export class Product {
    id: number;
    name: string;
    price: number;
    pictureUrl: string;

    constructor(id: number, name: string, price: number, pictureUrl: string) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.pictureUrl = pictureUrl;
    }
}
export class ProductOrder {
    product: Product;
    quantity: number;

    constructor(product: Product, quantity: number) {
        this.product = product;
        this.quantity = quantity;
    }
}
export class ProductOrders {
    productOrders: ProductOrder[] = [];
}

3.4 主组件(EcommerceComponent)

包含导航栏与子组件容器:

<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
    <div class="container">
        <a class="navbar-brand" href="#">Baeldung Ecommerce</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" 
          data-target="#navbarResponsive" aria-controls="navbarResponsive" 
          aria-expanded="false" aria-label="Toggle navigation" 
          (click)="toggleCollapsed()">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div id="navbarResponsive" 
            [ngClass]="{'collapse': collapsed, 'navbar-collapse': true}">
            <ul class="navbar-nav ml-auto">
                <li class="nav-item active">
                    <a class="nav-link" href="#" (click)="reset()">Home
                        <span class="sr-only">(current)</span>
                    </a>
                </li>
            </ul>
        </div>
    </div>
</nav>

<div class="row">
    <div class="col-md-9">
        <app-products #productsC [hidden]="orderFinished"></app-products>
    </div>
    <div class="col-md-3">
        <app-shopping-cart (onOrderFinished)="finishOrder($event)" #shoppingCartC 
          [hidden]="orderFinished"></app-shopping-cart>
    </div>
    <div class="col-md-6 offset-3">
        <app-orders #ordersC [hidden]="!orderFinished"></app-orders>
    </div>
</div>

CSS 补丁(避免 navbar 遮挡内容):

.container {
    padding-top: 65px;
}

TS 逻辑(父组件控制子组件状态):

export class EcommerceComponent implements OnInit {
    private collapsed = true;
    orderFinished = false;

    @ViewChild('productsC') productsC: ProductsComponent;
    @ViewChild('shoppingCartC') shoppingCartC: ShoppingCartComponent;
    @ViewChild('ordersC') ordersC: OrdersComponent;

    toggleCollapsed(): void {
        this.collapsed = !this.collapsed;
    }

    finishOrder(orderFinished: boolean) {
        this.orderFinished = orderFinished;
    }

    reset() {
        this.orderFinished = false;
        this.productsC.reset();
        this.shoppingCartC.reset();
        this.ordersC.paid = false;
    }
}

3.5 服务层(EcommerceService)

核心通信与状态管理:

@Injectable()
export class EcommerceService {
    private productsUrl = "/api/products";
    private ordersUrl = "/api/orders";

    private productOrder: ProductOrder;
    private orders: ProductOrders = new ProductOrders();
    private total: number;

    private productOrderSubject = new Subject();
    private ordersSubject = new Subject();
    private totalSubject = new Subject();

    ProductOrderChanged = this.productOrderSubject.asObservable();
    OrdersChanged = this.ordersSubject.asObservable();
    TotalChanged = this.totalSubject.asObservable();

    constructor(private http: HttpClient) {}

    getAllProducts() {
        return this.http.get(this.productsUrl);
    }

    saveOrder(order: ProductOrders) {
        return this.http.post(this.ordersUrl, order);
    }

    // getter/setter 省略
}

跨域代理配置

避免硬编码后端地址,创建 proxy-conf.json

{
    "/api": {
        "target": "http://localhost:8080",
        "secure": false
    }
}

修改 package.json

"scripts": {
    "start": "ng serve --proxy-config proxy-conf.json",
    "build": "ng build"
}

✅ 启动命令:npm start(而非 ng serve


3.6 商品组件(ProductsComponent)

加载商品并支持加入购物车:

export class ProductsComponent implements OnInit {
    productOrders: ProductOrder[] = [];
    products: Product[] = [];
    selectedProductOrder: ProductOrder;
    private shoppingCartOrders: ProductOrders;
    sub: Subscription;
    productSelected: boolean = false;

    constructor(private ecommerceService: EcommerceService) {}

    ngOnInit() {
        this.loadProducts();
        this.loadOrders();
    }

    loadProducts() {
        this.ecommerceService.getAllProducts()
            .subscribe((products: any[]) => {
                this.products = products;
                this.products.forEach(product => {
                    this.productOrders.push(new ProductOrder(product, 0));
                });
            });
    }

    loadOrders() {
        this.sub = this.ecommerceService.OrdersChanged.subscribe(() => {
            this.shoppingCartOrders = this.ecommerceService.ProductOrders;
        });
    }

    addToCart(order: ProductOrder) {
        this.ecommerceService.SelectedProductOrder = order;
        this.selectedProductOrder = this.ecommerceService.SelectedProductOrder;
        this.productSelected = true;
    }

    removeFromCart(productOrder: ProductOrder) {
        const index = this.getProductIndex(productOrder.product);
        if (index > -1) {
            this.shoppingCartOrders.productOrders.splice(index, 1);
        }
        this.ecommerceService.ProductOrders = this.shoppingCartOrders;
        this.productSelected = false;
    }

    reset() {
        this.productOrders = [];
        this.loadProducts();
        this.ecommerceService.ProductOrders.productOrders = [];
        this.loadOrders();
        this.productSelected = false;
    }
}

3.7 购物车组件(ShoppingCartComponent)

监听商品变更,实时计算总价:

export class ShoppingCartComponent implements OnInit, OnDestroy {
    orderFinished: boolean;
    orders: ProductOrders;
    total: number;
    sub: Subscription;

    @Output() onOrderFinished = new EventEmitter<boolean>();

    constructor(private ecommerceService: EcommerceService) {
        this.total = 0;
        this.orderFinished = false;
    }

    ngOnInit() {
        this.orders = new ProductOrders();
        this.loadCart();
        this.loadTotal();
    }

    loadTotal() {
        this.sub = this.ecommerceService.OrdersChanged.subscribe(() => {
            this.total = this.calculateTotal(this.orders.productOrders);
        });
    }

    loadCart() {
        this.sub = this.ecommerceService.ProductOrderChanged.subscribe(() => {
            const productOrder = this.ecommerceService.SelectedProductOrder;
            if (productOrder && productOrder.quantity > 0) {
                this.orders.productOrders.push(new ProductOrder(
                    productOrder.product, productOrder.quantity));
            }
            this.ecommerceService.ProductOrders = this.orders;
            this.total = this.calculateTotal(this.orders.productOrders);
        });
    }

    ngOnDestroy() {
        this.sub.unsubscribe();
    }

    finishOrder() {
        this.orderFinished = true;
        this.ecommerceService.Total = this.total;
        this.onOrderFinished.emit(this.orderFinished);
    }

    reset() {
        this.orderFinished = false;
        this.orders = new ProductOrders();
        this.total = 0;
    }
}

3.8 订单组件(OrdersComponent)

模拟支付流程,提交订单至后端:

export class OrdersComponent implements OnInit {
    orders: ProductOrders;
    total: number;
    paid: boolean = false;
    sub: Subscription;

    constructor(private ecommerceService: EcommerceService) {
        this.orders = this.ecommerceService.ProductOrders;
        this.total = this.ecommerceService.Total;
    }

    ngOnInit() {
        this.sub = this.ecommerceService.OrdersChanged.subscribe(() => {
            this.orders = this.ecommerceService.ProductOrders;
            this.total = this.ecommerceService.Total;
        });
    }

    pay() {
        this.paid = true;
        this.ecommerceService.saveOrder(this.orders).subscribe();
    }
}

模板展示订单详情与支付按钮:

<h2 class="text-center">ORDER</h2>
<ul>
    <li *ngFor="let order of orders.productOrders">
        {{ order.product.name }} - ${{ order.product.price }} x {{ order.quantity}} pcs.
    </li>
</ul>
<h3 class="text-right">Total amount: ${{ total }}</h3>

<button class="btn btn-primary btn-block" (click)="pay()" *ngIf="!paid">Pay</button>
<div class="alert alert-success" role="alert" *ngIf="paid">
    <strong>Congratulations!</strong> You successfully made the order.
</div>

4. 前后端合并部署

开发阶段前后端分离更高效,但生产环境建议打包为单一应用。

自动化构建脚本

修改 package.json,添加构建后处理脚本:

"scripts": {
    "build": "ng build",
    "postbuild": "npm run deploy",
    "predeploy": "rimraf ../resources/static/ && mkdirp ../resources/static",
    "deploy": "copyfiles -f dist/frontend/** ../resources/static"
}

安装所需工具:

npm install --save-dev rimraf mkdirp copyfiles

打包流程

  1. 执行 npm run build(Angular 构建 + 自动拷贝到 static
  2. 启动 Spring Boot 应用(mvn spring-boot:run
  3. 访问 http://localhost:8080 即可使用完整应用

✅ 静态资源由 Spring Boot 托管
✅ 前后端共用同一端口,部署简化


5. 总结

本文实现了一个功能完整、结构清晰的简易电商系统:

  • ✅ 后端:Spring Boot + JPA + H2,RESTful 接口设计
  • ✅ 前端:Angular 组件通信、状态管理、HTTP 交互
  • ✅ 工程实践:前后端分离开发 + 一体化部署

项目代码已开源,可作为学习模板快速上手:

👉 GitHub 源码地址