后端项目写来写去,最后还是会绕回商品管理这种最基础的场景:一张商品表、几个增删改查接口、再配一个分页查询列表。别小看这套东西,电商系统的商品模块、后台管理系统的基础能力、甚至很多人面试时候手写的题目,十有八九都是从它变体出来的。这篇文章就把我做过的这个简单商品管理小项目,从建表、接口设计、代码实现到踩坑排查的完整过程拆开讲一遍,内容适配想找一个能写进简历的小项目、或者刚接触后端开发想搞懂 CRUD 背后逻辑的同学。
1. 项目整体设计与技术选型
1.1 商品管理背后真正要解决的核心问题
一开始我以为商品管理就是把商品放进数据库再拿出来,写着写着才发现,真正要处理的是三件事:数据的持久化与一致性、业务规则的落地、查询效率的保障。持久化好理解,商品不能存在内存里,服务一重启就没了;业务规则就麻烦了,比如“商品下架之后不能被修改价格”“库存不能为负数”“删除商品后历史订单不能受影响”,这些逻辑如果不好好设计,越写到后面越疼。查询效率则是另一个层面,商品数量一旦上千,随意地SELECT *加内存过滤就会明显拖慢接口响应。
我当时给自己的约束是:不做花哨功能,但每个基础动作都必须有明确的设计依据。比如为什么新增接口返回 id,为什么删除要做成软删除,为什么列表要分页而不是一次性全量返回,这些都是在模拟真实项目的约束条件。做这个小项目的过程,本质上就是训练自己“先想清楚再写代码”的习惯,而这个习惯比项目本身值钱很多。
1.2 技术栈选型与理由
技术栈我挑了社区最常见的一套:Spring Boot + MyBatis-Plus + MySQL + Lombok。有人会问,为什么不用 Node 或者 Python?不是说它们不行,而是在“商品管理”这个话题下,Java 这套更适合还原国内后端的主流工作场景。Spring Boot 解决的是配置繁琐问题,约定优于配置,一个类就能把 Web 容器、数据源、事务管理器串起来;MyBatis-Plus 把单表 CRUD 简化到了极致,不用为insert into、select by id这种固定套路手写 SQL;MySQL 则是商品数据最稳妥的存储选择,事务和索引都支持得非常好。
我还见过用 Spring Data JPA 的做法,JPA 的思维是面向实体,写起来确实快,但遇到复杂查询时,要么拼@Query,要么写 Specification,上手门槛反而不低。MyBatis 原生的 SQL 更直观,加上 XML 里可以把动态条件写得很清楚,和绝大多数公司的技术栈也对得上。选 MyBatis-Plus 属于“自己多花一点学习成本,但后续项目里复用率高”的选择。
这里有个判断标准:小项目选技术栈,优先选“你下次做项目还能用得上”的,而不是“这个项目里写着最爽”的。MyBatis-Plus 在国内后端项目里的出现频率很高,把它的基础 CRUD、分页、逻辑删除、条件构造器摸透,后面去公司写业务代码基本是顺手的。
1.3 项目分层与包结构
包结构我按经典的三层模式来:controller、service、mapper,额外加了entity、dto、common三个包。entity对应数据库表,dto对应接口出入参,common放统一返回体Result、异常处理、分页对象等通用代码。有的初学者喜欢把一堆Product类放在同一个包里,反正都叫商品;等接口一多,ProductRequest、ProductResponse、ProductEntity、ProductQuery混在一起,改一个字段就要到处找,维护成本成倍增长。分层不单是代码美观,它是把“变化”隔离在特定区域:数据库字段变更只改entity,接口入参变更只改dto,两者互不污染。
2. 商品表结构设计与核心细节
2.1 商品表字段怎么定才“够用又不冗余”
先说结果,最终的表结构是这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| product_name | VARCHAR(100) | 商品名称 |
| category_id | BIGINT | 分类ID |
| price | DECIMAL(10,2) | 销售价格 |
| stock | INT | 库存数量 |
| image_url | VARCHAR(255) | 主图地址 |
| description | TEXT | 商品描述 |
| status | TINYINT | 0下架 1上架 |
| deleted | TINYINT | 0正常 1已删除,逻辑删除标记 |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
每个字段都不是随便加的。product_name限制 100 个字符,是因为名称通常不会太长,VARCHAR 过长既浪费存储也影响索引长度;price用DECIMAL(10,2)而不用浮点数,是为了避免金额精度丢失,这个坑后面单独讲;description用 TEXT 是因为描述文本可能很长,VARCHAR 有长度上限,而且它不需要走等值查询,用 TEXT 更合理。create_time和update_time是每个业务表都应该有的审计字段,排查问题的时候能救命。status用 TINYINT 而不是布尔值,是因为商品状态大概率不止两种——未来还有“待审核”“审核拒绝”之类的中间态,布尔值一开始用着简单,后面加状态就麻烦了。
2.2 软删除:为什么删商品要留一手
这个点是我特别想强调的。商品表删了一行数据,如果是物理删除,这一行就彻底没了。听起来很合理,但实际问题接踵而来:用户下单之后,订单关联的商品快照还在,如果商品被物理删除,历史订单里就查不到商品名了;用户误点了删除按钮,管理员想恢复数据,物理删除根本恢复不了;运营可能需要分析下架商品的销售数据,数据没了就什么都分析不了。所以我把删除设计成了软删除——也就是deleted字段置 1,所有查询条件里自动带上deleted = 0。
MyBatis-Plus 对这点的支持非常自然,在实体类的deleted字段上加@TableLogic注解,框架会自动把deleteById转成UPDATE ... SET deleted = 1,所有select也会自动追加deleted = 0条件。不用自己写一堆where deleted = 0,省心很多。当然,软删除不是万能的,它会占存储空间,唯一约束也不好加,这个取舍要看业务场景;对于商品这种需要留痕的数据,软删除几乎是标配。
2.3 常用查询字段的索引考虑
商品列表页最常见的筛选条件是:按商品名称模糊搜、按分类过滤、按上下架状态过滤。如果不加索引,数据量大了之后全表扫描会非常慢。我当时在category_id和status上都建了普通索引,因为它们是等值查询的高频字段;在product_name上建了普通索引,但是必须说清楚:LIKE '%关键词%'这种写法因为前置通配符,走不了索引,所以如果真要做搜索,要么用前缀匹配(LIKE '关键词%'),要么后续接搜索引擎或者全文索引。小项目里数据量不大,前缀匹配的体验就很好了。
索引不是越多越好,每个索引都要占空间、拖慢写入速度,所以只给真正会被查询条件用到的字段建索引。我见过有人把description也加上索引,结果查询从来没命中过,纯粹浪费。
3. 接口设计与核心实现
3.1 RESTful 接口规划与状态码约定
接口我按 REST 风格来规划:
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /api/products?page=1&size=10&keyword=... | 分页查询商品 |
| GET | /api/products/{id} | 查询单个商品 |
| POST | /api/products | 新增商品 |
| PUT | /api/products/{id} | 修改商品 |
| DELETE | /api/products/{id} | 删除商品 |
路径里的products是资源名,用复数;操作通过 HTTP 方法表达,同一个路径加不同方法就是不同动作。这样设计的好处是,接口语义一眼就能看懂,别人接手你的项目不需要看文档猜“这个接口到底是修改还是删除”。状态码约定上,新增成功返回 201,查询成功返回 200,参数校验失败返回 400,商品不存在返回 404,服务器内部错误返回 500。不只是状态码层面,我把统一返回体也做了:{ "code": 0, "message": "success", "data": ... },code 为 0 表示业务成功,这样前端不用根据 HTTP 状态码去硬猜业务结果。HTTP 状态码表达传输语义,业务码表达业务结果,两者各司其职。
3.2 参数的校验与错误信息
商品名称不能为空、价格不能为负数、库存不能为负数——这些约束如果在 Controller 里手写 if-else,一个接口三五个校验还算行,但每个接口都写一遍,代码会膨胀得很难看。我用了 Hibernate Validator 的注解式校验:在 DTO 字段上打@NotBlank、@NotNull、@DecimalMin,框架会在参数绑定后自动执行校验,不通过就抛MethodArgumentNotValidException。
配套的还有一个全局异常处理器,用@RestControllerAdvice拦截校验异常,把具体的错误信息整理成统一格式返回。比如前端没传商品名,返回的不是一串英文异常堆栈,而是“商品名称不能为空”这类明确提示。这个细节很影响协作体验,接口谁用谁知道,错误信息能不能看懂,直接决定前后端联调效率。
3.3 核心代码实现
代码层面我尽量精简。实体类大概长这样:
@Data @TableName("product") public class Product { @TableId(type = IdType.AUTO) private Long id; @NotBlank(message = "商品名称不能为空") private String productName; private Long categoryId; @DecimalMin(value = "0.0", message = "商品价格不能为负数") private BigDecimal price; private Integer stock; private String imageUrl; private String description; private Integer status; @TableLogic @TableField(select = false) private Integer deleted; private LocalDateTime createTime; private LocalDateTime updateTime; }这里有两个细节值得展开。第一,deleted字段加上了@TableField(select = false),因为正常业务根本不需要在查询结果里看到这个字段,省一次数据传输也好,少暴露内部标记也好,都值得加。第二,create_time和update_time我没有直接在代码里赋值,而是交给数据库的默认值来处理,建表时给它们设成DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP,这样代码里完全不用关心这两个字段,也不会出现漏赋值的情况。
Controller 层很薄,只是收参数、调 service、返回结果:
@RestController @RequestMapping("/api/products") public class ProductController { @Autowired private ProductService productService; @GetMapping("/{id}") public Result<Product> getProduct(@PathVariable Long id) { return Result.ok(productService.getProductById(id)); } @PostMapping public Result<Long> createProduct(@Valid @RequestBody ProductCreateRequest request) { return Result.ok(productService.createProduct(request)); } @PutMapping("/{id}") public Result<Void> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductUpdateRequest request) { productService.updateProduct(id, request); return Result.ok(); } @DeleteMapping("/{id}") public Result<Void> deleteProduct(@PathVariable Long id) { productService.deleteProduct(id); return Result.ok(); } }Service 层才是业务逻辑所在的层。一个典型的修改商品方法如下:
@Transactional public void updateProduct(Long id, ProductUpdateRequest request) { Product product = productMapper.selectById(id); if (product == null) { throw new BizException("商品不存在"); } if (product.getStatus() == 0) { throw new BizException("商品已下架,不能修改"); } Product target = new Product(); target.setId(id); target.setProductName(request.getProductName()); target.setPrice(request.getPrice()); target.setStock(request.getStock()); productMapper.updateById(target); }@Transactional保证更新过程中如果中途抛异常,数据不会停留在半更新状态。修改前先查一次商品,是为了校验存在性和当前状态,禁止对已下架商品做价格修改,这就是前面说的“业务规则落地”的体现。新增商品之后,直接返回新插入的记录 id,前端拿到 id 可以跳转详情页,非常实用。
3.4 分页接口和条件筛选
分页查询是小项目里最绕不开的环节。我直接用 MyBatis-Plus 自带的分页插件,在配置类里注册一下PaginationInnerInterceptor,查询时传入Page对象即可:
public PageResult<Product> listProducts(ProductQueryRequest query) { Page<Product> page = new Page<>(query.getPage(), query.getSize()); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getKeyword()), Product::getProductName, query.getKeyword()) .eq(query.getCategoryId() != null, Product::getCategoryId, query.getCategoryId()) .eq(query.getStatus() != null, Product::getStatus, query.getStatus()) .orderByDesc(Product::getCreateTime); productMapper.selectPage(page, wrapper); return PageResult.of(page); }LambdaQueryWrapper的好处是条件可以用 lambda 方法引用,字段名写错会直接编译报错,而不是等到运行期才发现 SQL 有问题。条件都是动态拼接的,keyword为空就不拼like,categoryId为 null 就不拼eq,这样同一个方法既能支持“只查上架商品”,也能支持“按关键词搜”,非常灵活。返回结果我包装成PageResult,里面带上total、page、size、list四个字段,前端拿到total才能算总页数、渲染分页组件;如果只返回 list,前端还得再发一个统计请求,体验就很差。
分页插件的原理也很简单:selectPage方法执行前,它会自动拦截生成的 SQL,拼上LIMIT并执行一条COUNT查询得到总数。想确认插件到底拼了什么 SQL,就把mybatis-plus.configuration.log-impl配置成输出到控制台,日志里能直接看到COUNT语句和带LIMIT的查询语句,排查分页问题非常有用。
4. 常见问题与排查技巧实录
4.1 字段名撞上 MySQL 关键字
这个坑我踩过一次。实体类有个字段叫order(排序权重),建表时没注意,写SELECT id, order FROM product直接报 SQL 语法错误。MySQL 里order是保留字,用来做排序,直接用会出问题。解决办法有两种:一是建表时用反引号把字段名包起来,但以后每条 SQL 都得写反引号,很烦;二是直接改字段名,比如sort_weight、idx,一劳永逸。我现在写表结构第一件事就是把字段名列一遍,凡是想到了order、desc、key、from这类保留字,直接换名字,从源头规避问题。
还有个类似的坑是字段乱命名。比如用拼音缩写spmc当商品名称,别人接手根本看不懂。字段名要么全英文,要么英文加下划线,语义明确最重要,这不是矫情,是减少团队沟通成本。
4.2 金额精度丢失
如果price用double或者float,存 9.9 元再读出来,很可能是 9.9000000000000004。很多初学者会困惑,为什么明明是小数,存出来却变了形。因为二进制只能用有限长度的 0 和 1 表示小数,10 进制里的 0.1 在二进制里是个无限循环小数,不可避免地会产生尾差;尾差平时看不出来,一旦做金额累加,几十次算下来就能差出几分钱,对账就对不上。所以金额字段必须用DECIMAL,Java 侧用BigDecimal接收参与运算。
另一个容易忽略的点是前端传金额时如果用了 JSON 的字符串格式,后端要注意类型转换;后端返回给前端时,BigDecimal默认会被序列化成数字,精度一般不会丢,但如果前端做浮点运算,建议后端直接返回字符串,让前端不要对金额做计算。
4.3 JSON 序列化时间格式
Spring Boot 默认把LocalDateTime序列化成数组,类似[2025, 1, 15, 10, 30, 0],前端看到这种数据基本是懵的。解决方式是在application.yml里配置全局时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8time-zone也必须一起配,否则如果服务器时区设置不对,入库时间和展示时间可能差 8 小时。数据库连接串里同样要加serverTimezone=Asia/Shanghai,三处一致了,时区问题基本就不会再出现。
4.4 分页查询的大偏移量问题
分页用的LIMIT offset, size,在小数据量下没问题,但offset很大的时候,MySQL 仍然要先把前面 offset 行都扫描并丢弃,才能返回需要的 size 行,性能会急剧下降。比如LIMIT 100000, 20,MySQL 可能扫描了 10 万行后只返回 20 行。对这个项目的体量来说问题不大,但养成这个意识很有价值。真到了大数据量分页,通常会用两种解法:一是“滚分页”,即记录上一次分页拿到的最小主键,下一次查询用WHERE id > #{lastId} LIMIT 20直接跳过去;二是“延迟关联”,先查出需要的 id 集合,再回表关联完整数据。这两种方案小项目里用不上,但面试和实际工作中很常见,提前知道不吃亏。
4.5 商品不存在时返回什么
一开始我返回null,前端拿到的 data 是 null,还需要自己判断“到底是没有还是接口异常”。后来我改成如果商品不存在,直接抛业务异常,全局异常处理器返回 code 为 40401、message 为“商品不存在”的结构,哪怕是 null 也能被业务语义明确表达出来。这里的一个小经验是:接口返回 null 本身没有错,但必须保证“为什么返回 null”是可解释的。如果仅仅因为查不到就返回 null,调用方很难区分是数据缺失、权限不足还是查询条件写错。不管是返回 null 还是抛异常,约定清楚都比含糊其辞强。
还有一个高频问题就是空指针。比如getProduct().getProductName(),如果getProduct()返回了 null,这行直接 NPE。写代码的时候,凡是外部传入的 id 查出来的对象,第一句就判断是否为空,不让 null 有机会扩散;凡是可能为 null 的字段,要么在数据库层面保证 NOT NULL,要么在代码层面做兜底默认值。好的代码风格不是用一堆Optional包装,而是从源头控制 null 的产生。
5. 项目还能从哪里延伸
5.1 从单一商品表走向真实业务形态
这个项目做完,我建议不要急着做下一个新项目,而是把它往真实业务方向上“长”。最典型的延伸是 SPU 和 SKU 的拆分:目前这张product表相当于把“商品”和“规格”揉在一起了,一件衣服有三个颜色、两个尺码,就相当于 6 个 SKU,再往下就是多规格组合、库存分仓、价格策略等一连串设计。另一个方向是接一个简单的库存扣减场景,在下单接口里对商品库存做UPDATE ... SET stock = stock - ? WHERE id = ? AND stock >= ?,用条件更新来防超卖。再往后还可以加一个操作日志表:谁在什么时间把商品价格从 99 改成了 89,这种审计能力在很多管理系统里都是刚需。
每个延伸都不难,但每加一个功能,你都会重新审视原来的表结构和接口设计,这种“重构式学习”比从零新写一个项目收获大得多。
5.2 让项目更像一个可交付产物
要是想让这个项目看起来更完整、更像能拿得出手的成果,我会补三块东西。第一是接口文档,集成一个轻量级的接口文档组件,给每个接口写清楚参数含义和返回示例,文档生成之后几乎不用维护,前端对接效率直接翻倍。第二是基础监控,把项目日志从System.out换成日志框架,将请求路径、耗时、关键参数打出来,出问题时不用靠猜。第三是单元测试,至少把 Service 层的“商品不存在时抛异常”“下架商品不能修改”这两个边界场景用测试覆盖住,面试聊项目的时候,“我有测试保护核心逻辑”这句话的杀伤力比“我写了十个接口”大很多。
做一个“简单商品管理”很容易,做一套“设计严谨、边界清晰、能持续扩展的商品管理”完全是两种难度。我个人的体会是,这类小项目从来不是教你怎么写 CRUD,而是教你怎么把一个需求从“能用”做到“好用”。如果你也在练手,不必追求接口数量,多花时间琢磨表结构为什么这么建、异常为什么要这样处理、哪些地方以后一定会改——把这三个问题想明白,这个小项目就真正值回票价了。