在做电商后台或者商品管理系统的时候,最容易被低估的模块就是“商品信息维护”。表面上看只是对几个字段做增删改查,真正涉及 SKU、扩展属性、上下架、失效状态、逻辑删除之后,设计好坏直接决定后续迭代是不是顺畅。我最近在整理一套家居类商品的后台演示项目时,借着一款“可拆洗布艺沙发床”的完整商品信息,把商品档案管理的核心流程完整做了一遍。这篇文章就围绕这套实现展开,从需求拆解、数据模型、Spring Boot 后端,到 Thymeleaf 页面展示和状态流转,一步步还原一个带有真实业务感的商品管理系统,新手可以直接跟着做,正在做毕设或者公司小项目的朋友也能拿来参考。
1. 业务背景:商品状态管理为什么容易写乱
1.1 一条商品信息里到底藏了多少内容
我们以一款沙发床商品为例,它看起来只是一条商品记录,真正在后台建模的时候,会发现信息需要拆成两个层次。
第一层是商品的核心档案,包括商品名称、品牌、型号、分类、风格、尺寸、价格、库存、状态等。这一部分相对固定,是商品的主键信息,在列表中展示、在详情页回显,也都依赖这些字段。
第二层是商品的可变属性,也就是不同品类差异最大的部分。比如沙发床会有“是否可拆洗”、“面料成分”、“填充物”、“环保等级”、“甲醛含量”这些属性,而手机类商品则是“运行内存”、“存储容量”、“屏幕尺寸”。如果把这些属性全部做成主表字段,数据库每次加属性都要改表结构,非常被动。
所以我在商品主表之外增加了“扩展属性表”,用 key-value 的方式保存“材质=雪尼尔”、“环保等级=母婴A类”、“甲醛含量=0”这类信息。这样主表结构稳定,商品属性又足够灵活,等于为不同类目预留了扩展空间。
1.2 “已失效”不只是删除
标题中有一个词很关键:已失效。在真实电商后台里,商品往往不是直接被物理删除的,而是先进入“下架”、“失效”、“停售”等业务状态。已失效和删除的区别在于:
- 已失效是业务状态,商品数据还在,管理员可以查看历史记录、分析原因、重新上架或归档。
- 删除是数据操作,一旦物理删除,后续无法追溯。
因此本文的示例系统会把商品状态设计为三种:上架、下架、已失效。同时再增加一个逻辑删除标记,真正删除时只把deleted字段置为true,查询列表时统一过滤。这样既满足业务需要,也保留了追溯能力。
1.3 本项目的整体功能目标
完整功能拆分如下:
- 商品列表展示,支持按状态观察商品。
- 商品新增和编辑,包含核心字段填写和扩展属性维护。
- 商品详情页面,展示完整基础信息与属性列表。
- 上架、下架、标记失效三个状态操作按钮。
- 逻辑删除,数据不物理移除。
- 属性文本按行解析为结构化属性,便于在页面以表格展示。
2. 技术选型与环境准备
2.1 技术栈说明
本文示例采用 Spring Boot 作为后端基础框架,使用 Spring Data JPA 操作数据库,使用 Thymeleaf 作为服务端模板引擎。选择这套组合的原因很简单:
- Spring Boot 能快速搭建可运行的 Web 应用,适合示例项目。
- Spring Data JPA 可以通过方法名自动生成 SQL,减少样板代码。
- Thymeleaf 与 Spring Boot 集成好,页面可以直接展示后端数据。
- H2 数据库作为内存数据库,不需要额外安装环境,适合本地演示。
2.2 环境版本
示例代码以常见环境为例,不同版本可能会有细微差异,重点看设计思路:
- JDK:17
- Spring Boot:2.7.18
- 构建工具:Maven
- 数据库:H2 内存模式
- 模板引擎:Thymeleaf
- IDE:IntelliJ IDEA 或 Eclipse 均可
Spring Boot 2.7 对应的是javax.*命名空间,如果你使用 Spring Boot 3.x,需要将部分包名替换为jakarta.*,这个差异在引入代码时要特别注意。
2.3 项目结构规划
项目整体按常见的分层结构组织:
product-manage ├── pom.xml └── src/main ├── java/com/example/product │ ├── ProductApplication.java │ ├── entity │ │ ├── Product.java │ │ ├── ProductAttribute.java │ │ └── ProductStatus.java │ ├── repository │ │ └── ProductRepository.java │ ├── service │ │ └── ProductService.java │ └── controller │ └── ProductController.java └── resources ├── application.yml └── templates/product ├── list.html ├── form.html └── detail.htmlentity 放实体类,repository 放数据访问层,service 放业务逻辑,controller 放页面跳转和请求处理。模板放在templates/product目录下,由 Thymeleaf 自动解析。
3. 数据库与实体设计
3.1 表结构设计
数据库一共两张表。
商品主表product:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键自增 |
| name | varchar | 商品名称 |
| brand | varchar | 品牌 |
| model | varchar | 型号 |
| category | varchar | 分类 |
| style | varchar | 风格 |
| size | varchar | 尺寸 |
| price | decimal | 价格 |
| stock | int | 库存 |
| status | varchar | 商品状态,见枚举 |
| description | text | 商品描述 |
| deleted | boolean | 逻辑删除标记 |
| create_time | timestamp | 创建时间 |
| update_time | timestamp | 更新时间 |
商品扩展属性表product_attribute:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键自增 |
| product_id | bigint | 关联商品主表 |
| attr_name | varchar | 属性名称 |
| attr_value | varchar | 属性值 |
| sort | int | 排序号 |
一张主表配一张扩展属性表,是商品系统里很经典的设计方式。主表保证稳定的核心字段,扩展表支持不同类目的灵活属性。
3.2 状态枚举说明
状态字段使用枚举的字符串形式保存到数据库:
public enum ProductStatus { ON_SALE("上架"), OFF_SALE("下架"), INVALID("已失效"); private final String desc; ProductStatus(String desc) { this.desc = desc; } public String getDesc() { return desc; } }保存到数据库时使用EnumType.STRING,数据库中保存的是ON_SALE、OFF_SALE、INVALID。这样比保存枚举下标更安全,因为枚举顺序一旦调整,历史数据不会错乱。
3.3 商品主表实体类
// 文件路径:src/main/java/com/example/product/entity/Product.java package com.example.product.entity; import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import javax.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @Data @NoArgsConstructor @AllArgsConstructor @Entity @Table(name = "product") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; private String brand; private String model; private String category; private String style; private String size; private BigDecimal price; private Integer stock; @Enumerated(EnumType.STRING) private ProductStatus status; @Column(columnDefinition = "TEXT") private String description; private Boolean deleted = false; private LocalDateTime createTime; private LocalDateTime updateTime; @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("sort asc") private List<ProductAttribute> attributes = new ArrayList<>(); @PrePersist public void prePersist() { this.createTime = LocalDateTime.now(); this.updateTime = LocalDateTime.now(); if (this.deleted == null) { this.deleted = false; } } @PreUpdate public void preUpdate() { this.updateTime = LocalDateTime.now(); } }需要注意,@OneToMany中配置了cascade = CascadeType.ALL,表示保存或更新商品时,扩展属性会一起保存或更新。orphanRemoval = true表示当集合中某个属性被移除后,数据库中的对应记录会被删除。这是保证属性更新不残留脏数据的关键。
3.4 扩展属性实体类
// 文件路径:src/main/java/com/example/product/entity/ProductAttribute.java package com.example.product.entity; import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import javax.persistence.*; @Data @NoArgsConstructor @AllArgsConstructor @Entity @Table(name = "product_attribute") public class ProductAttribute { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String attrName; @Column(nullable = false) private String attrValue; private Integer sort; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id") private Product product; }属性表通过product_id外键关联商品主表。@ManyToOne使用懒加载,避免查询列表时把每个商品的整个属性列表都加载出来,减少不必要的性能开销。
4. 完整实战:从零搭建商品信息管理系统
4.1 创建 Maven 工程与 pom.xml
新建一个 Maven 项目,项目名可以设置为product-manage。pom.xml完整内容如下:
<?xml version="1.0" encoding="UTF-8"?> <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> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>product-manage</artifactId> <version>1.0.0</version> <name>product-manage</name> <description>商品信息管理示例项目</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>其中spring-boot-starter-web提供 Web 容器和 Spring MVC 能力,spring-boot-starter-data-jpa提供 JPA 操作能力,spring-boot-starter-thymeleaf提供模板页面渲染能力,h2是本地内存数据库。
4.2 编写 application.yml
在src/main/resources下创建application.yml:
server: port: 8080 spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver username: sa password: "" jpa: hibernate: ddl-auto: update show-sql: true open-in-view: false h2: console: enabled: true path: /h2-console thymeleaf: cache: false这里说明几个配置的作用:
ddl-auto: update表示启动时根据实体自动建表或更新表结构,适合学习和本地演示。show-sql: true会在控制台输出 JPA 执行的 SQL,方便排查问题。open-in-view: false是 Spring Boot 官方推荐的设置,避免在视图渲染阶段意外持有数据库连接。DB_CLOSE_DELAY=-1保证内存数据库在应用运行期间不会因为连接关闭而丢失数据。thymeleaf.cache: false让页面模板修改后立即生效,适合开发阶段。
4.3 编写启动类
// 文件路径:src/main/java/com/example/product/ProductApplication.java package com.example.product; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class ProductApplication { public static void main(String[] args) { SpringApplication.run(ProductApplication.class, args); } }4.4 编写 Repository
// 文件路径:src/main/java/com/example/product/repository/ProductRepository.java package com.example.product.repository; import com.example.product.entity.Product; import com.example.product.entity.ProductStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; public interface ProductRepository extends JpaRepository<Product, Long> { List<Product> findByDeletedFalseOrderByUpdateTimeDesc(); List<Product> findByStatusAndDeletedFalse(ProductStatus status); Optional<Product> findByIdAndDeletedFalse(Long id); }这里关键点是所有查询方法都带有DeletedFalse条件,保证逻辑删除的数据不会出现在业务查询结果中。findByIdAndDeletedFalse返回Optional,调用方可以明确判断商品是否存在。
4.5 编写 Service 层
Service 层承载核心业务逻辑,包括新增、修改、状态流转、逻辑删除、属性解析。
// 文件路径:src/main/java/com/example/product/service/ProductService.java package com.example.product.service; import com.example.product.entity.Product; import com.example.product.entity.ProductAttribute; import com.example.product.entity.ProductStatus; import com.example.product.repository.ProductRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; @Transactional(readOnly = true) public List<Product> listAvailable() { return productRepository.findByDeletedFalseOrderByUpdateTimeDesc(); } @Transactional(readOnly = true) public Product getDetail(Long id) { return productRepository.findByIdAndDeletedFalse(id) .orElseThrow(() -> new IllegalArgumentException("商品不存在或已删除")); } @Transactional public Product createProduct(Product product, String attributeText) { if (product.getStatus() == null) { product.setStatus(ProductStatus.OFF_SALE); } product.setDeleted(false); product.setAttributes(parseAttributes(attributeText, product)); return productRepository.save(product); } @Transactional public Product updateProduct(Long id, Product form, String attributeText) { Product product = getDetail(id); product.setName(form.getName()); product.setBrand(form.getBrand()); product.setModel(form.getModel()); product.setCategory(form.getCategory()); product.setStyle(form.getStyle()); product.setSize(form.getSize()); product.setPrice(form.getPrice()); product.setStock(form.getStock()); product.setDescription(form.getDescription()); product.setStatus(form.getStatus()); product.getAttributes().clear(); product.getAttributes().addAll(parseAttributes(attributeText, product)); return productRepository.save(product); } @Transactional public void changeStatus(Long id, ProductStatus status) { Product product = getDetail(id); product.setStatus(status); } @Transactional public void softDelete(Long id) { Product product = getDetail(id); product.setDeleted(true); product.setStatus(ProductStatus.INVALID); } private List<ProductAttribute> parseAttributes(String text, Product product) { List<ProductAttribute> list = new ArrayList<>(); if (text == null || text.trim().isEmpty()) { return list; } String[] pairs = text.split(";"); for (String pair : pairs) { if (!pair.contains("=")) { continue; } String[] kv = pair.split("=", 2); ProductAttribute attribute = new ProductAttribute(); attribute.setAttrName(kv[0].trim()); attribute.setAttrValue(kv[1].trim()); attribute.setSort(list.size()); attribute.setProduct(product); list.add(attribute); } return list; } }Service 层有两个地方值得重点关注。
第一,所有写操作都加了@Transactional,保证一个操作要么全部成功,要么全部回滚。比如更新商品时先更新主表再清理属性、插入属性,如果中途出错,事务会回滚到更新前状态,不会出现主表数据变了但属性没变的情况。
第二,parseAttributes方法把页面上传入的“属性=值;属性=值”文本解析成实体集合。新增时直接设置到商品实体,更新时先clear()再addAll(),配合前文实体类中的orphanRemoval = true,可以让旧属性先删除、新属性再插入,保持数据库干净。
4.6 编写 Controller
// 文件路径:src/main/java/com/example/product/controller/ProductController.java package com.example.product.controller; import com.example.product.entity.Product; import com.example.product.entity.ProductStatus; import com.example.product.service.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import java.util.stream.Collectors; @Controller @RequestMapping("/products") @RequiredArgsConstructor public class ProductController { private final ProductService productService; @GetMapping public String list(Model model) { model.addAttribute("products", productService.listAvailable()); return "product/list"; } @GetMapping("/{id}") public String detail(@PathVariable Long id, Model model) { model.addAttribute("product", productService.getDetail(id)); return "product/detail"; } @GetMapping("/new") public String createPage(Model model) { model.addAttribute("product", new Product()); model.addAttribute("statusList", ProductStatus.values()); model.addAttribute("attributeText", ""); return "product/form"; } @PostMapping public String create(@Valid @ModelAttribute Product product, @RequestParam String attributeText, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { model.addAttribute("statusList", ProductStatus.values()); model.addAttribute("attributeText", attributeText); return "product/form"; } Product saved = productService.createProduct(product, attributeText); return "redirect:/products/" + saved.getId(); } @GetMapping("/{id}/edit") public String editPage(@PathVariable Long id, Model model) { Product product = productService.getDetail(id); model.addAttribute("product", product); model.addAttribute("statusList", ProductStatus.values()); model.addAttribute("attributeText", buildAttributeText(product)); return "product/form"; } @PostMapping("/{id}/update") public String update(@PathVariable Long id, @Valid @ModelAttribute Product product, @RequestParam String attributeText, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { model.addAttribute("statusList", ProductStatus.values()); model.addAttribute("attributeText", attributeText); return "product/form"; } productService.updateProduct(id, product, attributeText); return "redirect:/products/" + id; } @PostMapping("/{id}/onSale") public String onSale(@PathVariable Long id) { productService.changeStatus(id, ProductStatus.ON_SALE); return "redirect:/products/" + id; } @PostMapping("/{id}/offSale") public String offSale(@PathVariable Long id) { productService.changeStatus(id, ProductStatus.OFF_SALE); return "redirect:/products/" + id; } @PostMapping("/{id}/invalidate") public String invalidate(@PathVariable Long id) { productService.changeStatus(id, ProductStatus.INVALID); return "redirect:/products/" + id; } @PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { productService.softDelete(id); return "redirect:/products"; } private String buildAttributeText(Product product) { if (product.getAttributes() == null) { return ""; } return product.getAttributes().stream() .map(a -> a.getAttrName() + "=" + a.getAttrValue()) .collect(Collectors.joining(";")); } }Controller 中把状态操作独立成具体的onSale、offSale、invalidate方法,而没有让页面直接传递任意状态值。这样等于把状态流转限制在业务允许的范围里,避免前端传一个不存在的状态或直接绕过业务规则。
4.7 商品列表页面
在src/main/resources/templates/product/list.html中编写列表页:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>商品列表</title> <style> body { font-family: "Microsoft YaHei", sans-serif; margin: 40px; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background: #f5f5f5; } .tag { background: #e8f5e9; color: #2e7d32; padding: 2px 8px; border-radius: 4px; font-size: 12px; } a { margin-right: 8px; } </style> </head> <body> <h2>商品列表</h2> <a th:href="@{/products/new}">新增商品</a> <table> <thead> <tr> <th>ID</th> <th>商品名称</th> <th>品牌</th> <th>分类</th> <th>风格</th> <th>价格</th> <th>库存</th> <th>状态</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="product : ${products}"> <td th:text="${product.id}">1</td> <td th:text="${product.name}">商品名称</td> <td th:text="${product.brand}">品牌</td> <td th:text="${product.category}">分类</td> <td th:text="${product.style}">风格</td> <td th:text="${product.price}">价格</td> <td th:text="${product.stock}">库存</td> <td> <span class="tag" th:text="${product.status.desc}">状态</span> </td> <td> <a th:href="@{/products/{id}(id=${product.id})}">详情</a> <a th:href="@{/products/{id}/edit(id=${product.id})}">编辑</a> <form th:if="${product.status.name() == 'OFF_SALE'}" th:action="@{/products/{id}/onSale(id=${product.id})}" method="post" style="display:inline"> <button type="submit">上架</button> </form> <form th:if="${product.status.name() == 'ON_SALE'}" th:action="@{/products/{id}/offSale(id=${product.id})}" method="post" style="display:inline"> <button type="submit">下架</button> </form> <form th:if="${product.status.name() != 'INVALID'}" th:action="@{/products/{id}/invalidate(id=${product.id})}" method="post" style="display:inline"> <button type="submit">标记失效</button> </form> <form th:action="@{/products/{id}/delete(id=${product.id})}" method="post" style="display:inline"> <button type="submit">删除</button> </form> </td> </tr> </tbody> </table> </body> </html>页面状态展示使用product.status.desc,也就是枚举中的中文描述。操作按钮根据当前状态做条件渲染,比如已上架时只显示下架按钮,下架时只显示上架按钮,已失效时不再显示上下架按钮,只保留编辑、删除和详情入口。
4.8 商品新增与编辑页面
新增和编辑可以共用同一个模板form.html,根据product.id是否为空判断是新增还是编辑:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>商品编辑</title> <style> body { font-family: "Microsoft YaHei", sans-serif; margin: 40px; } .form-item { margin-bottom: 12px; } label { display: inline-block; width: 100px; } input, textarea, select { width: 300px; padding: 6px; } textarea { height: 80px; vertical-align: top; } .error { color: red; } </style> </head> <body> <h2 th:text="${product.id == null} ? '新增商品' : '编辑商品'">商品编辑</h2> <form th:action="${product.id == null} ? @{/products} : @{/products/{id}/update(id=${product.id})}" th:object="${product}" method="post"> <div class="form-item"> <label>商品名称:</label> <input type="text" th:field="*{name}" placeholder="例如:可拆洗布艺沙发床"/> </div> <div class="form-item"> <label>品牌:</label> <input type="text" th:field="*{brand}" placeholder="例如:狄普"/> </div> <div class="form-item"> <label>型号:</label> <input type="text" th:field="*{model}" placeholder="例如:雾屿 2026新款"/> </div> <div class="form-item"> <label>分类:</label> <input type="text" th:field="*{category}" placeholder="例如:沙发床"/> </div> <div class="form-item"> <label>风格:</label> <input type="text" th:field="*{style}" placeholder="例如:奶油风"/> </div> <div class="form-item"> <label>尺寸:</label> <input type="text" th:field="*{size}" placeholder="例如:1.55米"/> </div> <div class="form-item"> <label>价格:</label> <input type="number" step="0.01" th:field="*{price}"/> </div> <div class="form-item"> <label>库存:</label> <input type="number" th:field="*{stock}"/> </div> <div class="form-item"> <label>状态:</label> <select th:field="*{status}"> <option th:each="status : ${statusList}" th:value="${status.name()}" th:text="${status.desc}">状态</option> </select> </div> <div class="form-item"> <label>扩展属性:</label> <textarea name="attributeText" th:text="${attributeText}" placeholder="格式:材质=雪尼尔;填充物=羽绒;环保等级=母婴A类"></textarea> </div> <div class="form-item"> <label>描述:</label> <textarea th:field="*{description}" placeholder="商品详细描述"></textarea> </div> <button type="submit">保存</button> <a th:href="@{/products}">返回列表</a> </form> </body> </html>扩展属性没有绑定到Product对象,而是以独立的文本参数传递,这样页面不需要维护复杂的动态列表组件。用户按“属性=值;属性=值”的格式填写,后端解析后自动转换为结构化数据。
4.9 商品详情页面
详情页主要用于回显完整信息和操作当前商品:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>商品详情</title> <style> body { font-family: "Microsoft YaHei", sans-serif; margin: 40px; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background: #f5f5f5; } </style> </head> <body> <h2 th:text="${product.name}">商品名称</h2> <table> <tr><th>品牌</th><td th:text="${product.brand}">品牌</td></tr> <tr><th>型号</th><td th:text="${product.model}">型号</td></tr> <tr><th>分类</th><td th:text="${product.category}">分类</td></tr> <tr><th>风格</th><td th:text="${product.style}">风格</td></tr> <tr><th>尺寸</th><td th:text="${product.size}">尺寸</td></tr> <tr><th>价格</th><td th:text="${product.price}">价格</td></tr> <tr><th>库存</th><td th:text="${product.stock}">库存</td></tr> <tr><th>状态</th><td th:text="${product.status.desc}">状态</td></tr> <tr><th>描述</th><td th:text="${product.description}">描述</td></tr> </table> <h3>扩展属性</h3> <table> <thead> <tr> <th>属性名称</th> <th>属性值</th> </tr> </thead> <tbody> <tr th:each="attr : ${product.attributes}"> <td th:text="${attr.attrName}">属性名</td> <td th:text="${attr.attrValue}">属性值</td> </tr> </tbody> </table> <p> <a th:href="@{/products/{id}/edit(id=${product.id})}">编辑</a> <a th:href="@{/products}">返回列表</a> </p> </body> </html>4.10 启动运行与验证
在项目根目录执行:
mvn spring-boot:run启动完成后访问:
http://localhost:8080/products页面会显示商品列表,此时列表为空,可以先点击“新增商品”录入一条数据。
建议录入以下演示数据:
- 商品名称:狄普雾屿 可拆洗布艺沙发床
- 品牌:狄普
- 型号:雾屿 2026新款
- 分类:沙发床
- 风格:奶油风
- 尺寸:1.55米
- 价格:3299
- 库存:50
- 状态:上架
- 扩展属性:风格=奶油风;尺寸=1.55米;面料=雪尼尔;填充物=羽绒;环保等级=母婴A类;甲醛含量=0;是否可拆洗=可拆洗
- 描述:采用雪尼尔布艺面料,羽绒填充,达到母婴级环保标准。
保存后会自动跳转到详情页,可以看到扩展属性已经被拆分并逐条展示。回到列表页,点击“下架”、“标记失效”可以观察状态变化。点击“删除”后,商品会从列表消失,但 H2 数据库中记录仍存在,只被标记为deleted=true。
如果想要查看数据库中的真实状态,可以访问 H2 控制台:
http://localhost:8080/h2-consoleJDBC URL 填写jdbc:h2:mem:testdb,用户名sa,密码留空,登录后执行:
SELECT id, name, status, deleted, create_time, update_time FROM product;可以直观看到业务删除和物理删除的区别。
5. 常见问题与排查思路
在实际开发中,把代码复制到本地后大概率会遇到几个问题,这里整理成一张排查表。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 启动报“Table already exists” | 之前使用ddl-auto: update创建过旧表,字段结构变化 | 开发环境可以删除 H2 内存库或切换数据库文件路径,重新启动 |
| 保存商品后属性列表为空 | 页面没有把attributeText提交到后端,或属性文本格式不对 | 检查表单中是否有name="attributeText",并按“属性=值;属性=值”填写 |
| 商品状态修改后不生效 | Service 方法没有加@Transactional,或前端请求没有正确匹配 | 检查方法和路径,确保@PostMapping地址正确 |
| 删除商品后列表仍然显示 | 删除接口没有执行,或查询方法没有加DeletedFalse条件 | 检查删除操作是否调用了softDelete,确认列表查询方法带过滤条件 |
| H2 控制台连接报“Database may be already in use” | 内存数据库被多个连接关闭导致数据丢失 | JDBC URL 加上DB_CLOSE_DELAY=-1,重启应用后再连接 |
| 页面中文显示乱码 | 文件编码 |