news 2026/10/14 14:19:04

Spring Boot重构校园商铺:实体类设计与MySQL建表实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot重构校园商铺:实体类设计与MySQL建表实战

一年多以前,团队里那个跑了两年的校园交易项目还是标准的SSM三件套——Spring、SpringMVC、MyBatis。新成员入职第一周基本都耗在XML里找SQL,一个字段名拼错能折腾半天。后来我们决定用Spring Boot重做一版,名字沿用"校园商铺平台",功能范围也没变:用户注册登录、商家入驻、商品上下架、加购下单、订单管理、评价晒单。但这一次我们不想只是把XML换成注解,而是想把数据层彻底重新设计一遍,于是就有了这个系列。

系列第一篇,我先讲实体类设计与数据表创建。为什么不是先搭个Controller写个Hello World?因为实体类是所有接口、所有SQL、所有前端流转的共同底座。SSM时代大家习惯拎起工具类就开写,结果表结构改一次、Mapper改一次、Service跟着改一次,来回返工。这次我们换了思路:先把数据模型想清楚,再让代码往模型上靠。这篇文章走完,你会得到一套能直接执行的MySQL建表脚本,以及一批和SSM旧代码风格完全不同的Java实体类。

1. 开始前必须想清楚:这次重构到底在重构什么

1.1 从SSM到Spring Boot,不变的其实只有"数据模型"

SSM和Spring Boot并不是两种语言的差别,它们是同一套Java Web技术体系下的两种装配方式。业务还是那些业务,SQL还是那些SQL,真正变化的,是支撑业务的那套基础设施。

SSM时代,一个实体类往往要陪三样东西:实体本身、手写的getter/setter、对应的Mapper XML。数据库里加了字段,实体类要改,XML的resultMap要改,查询SQL也要改。Spring Boot + MyBatis-Plus这套组合出现后,大部分常规增删改查不再需要手写映射文件,实体类变成了真正意义上的"模型"——表结构是什么样,类就长什么样,字段对应关系靠注解声明即可。

我把这次迁移里实体层的变化拆成了三条,对照着看会很清楚:

对比项SSM旧方式Spring Boot新方式
时间字段java.util.DateLocalDateTime
样板代码手写getter/setter(或者找插件生成)Lombok注解搞定
主键策略数据库自增int/long应用层雪花算法生成Long
映射方式XML resultMap手写@TableName/@TableId注解
公共字段每个插入操作手动setMetaObjectHandler自动填充

这些变化的根因不是"新框架炫技",而是Spring Boot生态默认了更快、更少样板代码的配置方式,同时对Java 8时间API、不可变对象风格更加友好。换个说法,SSM时代实体类偏"数据载体",Spring Boot时代实体类更接近"领域模型",它可以直接承载逻辑删除、乐观锁、自动填充这些横切能力。

1.2 为什么第一期就锁定在实体层,而不是先搭框架

如果是个人练手项目,先搭Spring Boot工程再补表,问题不大。但校园商铺平台这种有用户、有订单、有支付状态流转的中小型业务系统,领域模型是骨架。骨架歪了,后续每一个功能开发都是在歪骨架上贴肉。

我们的顺序是:整理业务名词 → 画实体关系 → 建表 → 写实体类 → 再动Service和Controller。前两步看起来不产生代码,但真正决定整个项目质量的恰恰是这两步。比如订单和订单明细为什么必须拆两张表?购物车为什么要做用户和商品的联合唯一索引?这些决策在写代码之前就要定完,而不是等联调的时候被测试同学逼着改。

第一期把表和实体类全部落到仓库里,后面做用户登录、商品查询、下单事务时,基本不用回头动这一层。这是我最想通过这个系列传递的做法:先花时间把底座的砖铺平,后面砌墙的速度会快很多。

2. 校园商铺平台有哪些数据要管:九个核心实体拆解

2.1 一句话讲清楚业务闭环

校园商铺平台的核心链路可以压缩成一句话:学生注册登录后浏览分类和商品,把商品加入购物车,下单支付,商家在店铺后台发货,学生确认收货后评价。

这句话里每一个名词都是潜在的实体,每个动词都是潜在的服务方法。做领域建模时,先不要急着打开数据库工具建表,试着把这句话写成一张名词清单,再逐个检查这个名词是否需要单独建表、是否可以被字段替代。比如"评价晒单"这个动作,对应的是评论实体;"店铺后台"不是实体,是商铺实体的一个使用场景。

我见过不少初学者一上来就设计出二十多张表,最后一半表是空的,因为业务根本没有走到那一步。克制、够用、留好扩展点,比贪多更实际。校园商铺平台第一期我控制在九张核心表,下面逐一说明。

2.2 九个核心实体的职责与状态

这里我把实体的名字、业务含义、核心状态字段列出来。状态字段是实体设计中特别容易被忽略的部分,很多人把状态设计成字符串"正常""禁用"随便塞,后期统计时才发现没法过滤。

实体业务含义核心状态字段
用户User学生/商家/管理员三种身份user_type, status
商铺Shop商家入驻后创建的店铺status(待审核/正常/拒绝/关闭)
商品分类ProductCategory二级分类树,支撑前台导航本身无状态,用parent_id组织层级
商品Product店铺上架的商品status(下架/上架/审核中)
购物车Cart用户加购记录checked(勾选状态)
收货地址Address用户的收货信息is_default
订单OrderInfo一次下单的主记录status(待支付/待发货/已发货/已完成/已取消)
订单明细OrderItem订单内每个商品的快照无状态,随主订单
商品评论ProductComment用户对商品的评价和回复无状态,用parent_id支持回复

购物车在校园商铺场景里需要单独立表,因为用户可能多次加购不同商品、勾选部分结算。订单明细独立成表是订单设计的基本功,主表记金额和地址快照,从表记商品快照,这样订单生成后商品再改名、改价,历史订单依然可以看到下单时的那份快照数据。

2.3 实体关系怎么定:三条老规矩

实体间的关系,我只用了最简单的几条规则,没有引入复杂的中间实体:

  • 用户和商铺是一对多。一个用户可以开一家店铺(真实场景里可以扩展为多家,但第一期按一家处理),商铺通过userId关联用户。
  • 商铺和商品是一对多,分类和商品是多对一。商品表里存shopId、categoryId,查询时通过索引定位。
  • 订单和订单明细是一对多。订单明细里冗余了productId、商品名称、图片、成交价,这是电商业务的常见做法,目的就是"快照"两个字。

我不建议在数据库里强行加外键约束。外键在并发写入时会影响性能,而且一旦业务要做逻辑删除、分库分表,外键会成为绊脚石。关系靠代码层维护,配合索引保证查询性能,这是从SSM时代延续到现在的主流做法。

3. 建表实操:完整DDL与字段设计细节

3.1 建表之前的三个固定动作

每张表动手之前,我先做三件事,这三件事建议形成肌肉记忆。

第一,字符集统一utf8mb4。不要用utf8,utf8在MySQL里最多3个字节,存不了emoji和生僻字。校园项目里学生姓名、商品详情都可能有特殊字符,utf8mb4是最稳妥的选择。

第二,存储引擎统一InnoDB。事务、行锁、崩溃恢复都依赖它。MyISAM在SSM时代偶尔还有人用,现在没有理由选它。

第三,每个字段必须写COMMENT。数据库是团队资产,一张没有注释的表三个月后连创建者自己都看不懂。字段注释写清楚枚举值的含义,比如status字段直接写"0-正常 1-禁用",省去查代码的时间。

主键我统一用bigint,由应用层雪花算法生成。这一点后面实体类部分会再展开,建表阶段先记住:不要用自增ID,至少不要把它当作唯一方案。

3.2 用户、商铺、分类、商品的建表SQL

先放最核心的四张表。用户表比较好理解,重点看手机号唯一索引、用户类型的注释约定:

CREATE TABLE `t_user` ( `id` bigint NOT NULL COMMENT '主键ID(雪花算法)', `open_id` varchar(64) DEFAULT NULL COMMENT '预留登录标识,比如微信场景下的openId', `username` varchar(64) NOT NULL COMMENT '用户名', `mobile` varchar(20) NOT NULL COMMENT '手机号', `password` varchar(128) DEFAULT NULL COMMENT '密码(加盐后存储)', `nickname` varchar(64) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `user_type` tinyint DEFAULT '0' COMMENT '用户类型:0-普通用户 1-商家 2-管理员', `status` tinyint DEFAULT '0' COMMENT '状态:0-正常 1-禁用', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除:0-未删除 1-已删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_mobile` (`mobile`), KEY `idx_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';

商铺表里多了一个审核状态。商家申请入驻后,平台管理员要审核,所以我把status设计成0-待审核、1-正常、2-拒绝、3-关闭,同时预留audit_reason字段存拒绝原因,方便用户端展示:

CREATE TABLE `t_shop` ( `id` bigint NOT NULL COMMENT '主键ID', `user_id` bigint NOT NULL COMMENT '店主用户ID', `shop_name` varchar(128) NOT NULL COMMENT '商铺名称', `shop_logo` varchar(255) DEFAULT NULL COMMENT '商铺Logo', `shop_desc` varchar(512) DEFAULT NULL COMMENT '商铺简介', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `province` varchar(32) DEFAULT NULL COMMENT '省(行政区划编码)', `city` varchar(32) DEFAULT NULL COMMENT '市(行政区划编码)', `district` varchar(32) DEFAULT NULL COMMENT '区/县(行政区划编码)', `address_detail` varchar(255) DEFAULT NULL COMMENT '详细地址', `status` tinyint DEFAULT '0' COMMENT '审核状态:0-待审核 1-正常 2-拒绝 3-关闭', `audit_reason` varchar(255) DEFAULT NULL COMMENT '审核不通过原因', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_shop_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商铺表';

商品分类表用parent_id支持两级分类,这是最简单的树形结构。一级分类parent_id为0,二级分类指向一级分类的id。前台导航按parent_id查询即可:

CREATE TABLE `t_product_category` ( `id` bigint NOT NULL COMMENT '主键ID', `parent_id` bigint NOT NULL DEFAULT '0' COMMENT '父分类ID,0表示一级分类', `category_name` varchar(64) NOT NULL COMMENT '分类名称', `sort` int DEFAULT '0' COMMENT '排序权重,越小越靠前', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_parent_id` (`parent_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品分类表';

商品表是另一张核心表。价格用decimal(10,2)而不是float/double,库存用int,销量用int——这些都是老生常谈,但每次还是有人踩坑。图片字段我拆成了主图和详情图,详情图用text存JSON数组或逗号分隔的地址列表:

CREATE TABLE `t_product` ( `id` bigint NOT NULL COMMENT '主键ID', `shop_id` bigint NOT NULL COMMENT '所属商铺ID', `category_id` bigint NOT NULL COMMENT '分类ID', `product_name` varchar(128) NOT NULL COMMENT '商品名称', `subtitle` varchar(255) DEFAULT NULL COMMENT '副标题/卖点', `main_image` varchar(255) DEFAULT NULL COMMENT '主图地址', `detail_images` text COMMENT '详情图地址,JSON数组或逗号分隔', `price` decimal(10,2) NOT NULL COMMENT '售价(元)', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价(元),用于展示折扣', `stock` int NOT NULL DEFAULT '0' COMMENT '库存', `sales_count` int DEFAULT '0' COMMENT '销量', `status` tinyint DEFAULT '0' COMMENT '状态:0-下架 1-上架 2-审核中', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_shop_id` (`shop_id`), KEY `idx_category_id` (`category_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品表';

3.3 购物车、地址、订单、明细、评论的建表SQL

购物车表最关键的设计是uk_user_product联合唯一索引。同一个用户把同一个商品加购两次,底层直接冲突,应用层捕获异常后改成数量累加,比先select再insert更省一次查询:

CREATE TABLE `t_cart` ( `id` bigint NOT NULL COMMENT '主键ID', `user_id` bigint NOT NULL COMMENT '用户ID', `product_id` bigint NOT NULL COMMENT '商品ID', `quantity` int NOT NULL DEFAULT '1' COMMENT '数量', `checked` tinyint DEFAULT '1' COMMENT '是否勾选:0-未勾选 1-勾选', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_product` (`user_id`,`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='购物车表';

收货地址表里is_default标记默认地址,下单时如果没有主动选地址,默认取这一条。注意这里我没有把省市区存成数字编码,而是直接存行政区划编码字符串,前端做级联选择器时数据能对上:

CREATE TABLE `t_address` ( `id` bigint NOT NULL COMMENT '主键ID', `user_id` bigint NOT NULL COMMENT '用户ID', `receiver_name` varchar(32) NOT NULL COMMENT '收货人姓名', `receiver_mobile` varchar(20) NOT NULL COMMENT '收货人手机号', `province` varchar(32) DEFAULT NULL COMMENT '省(行政区划编码)', `city` varchar(32) DEFAULT NULL COMMENT '市(行政区划编码)', `district` varchar(32) DEFAULT NULL COMMENT '区/县(行政区划编码)', `detail_address` varchar(255) NOT NULL COMMENT '详细地址', `is_default` tinyint DEFAULT '0' COMMENT '是否默认地址:0-否 1-是', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货地址表';

订单表的名字我用了t_order_info而不是t_order,原因后面踩坑部分会细说。订单表里除了订单号、金额、支付方式这些基本字段,我还冗余了收货人姓名、手机号、地址快照。这是刻意为之:地址是用户地址簿里的数据,可能被修改或删除,但订单需要永远记住下单那一刻的地址。pay_time、deliver_time、finish_time、cancel_time四个时间字段对应订单状态流转的关键节点,方便后台做超时处理:

CREATE TABLE `t_order_info` ( `id` bigint NOT NULL COMMENT '主键ID', `order_no` varchar(64) NOT NULL COMMENT '订单号(业务唯一)', `user_id` bigint NOT NULL COMMENT '下单用户ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额(元)', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额(元)', `pay_type` tinyint DEFAULT '1' COMMENT '支付方式:1-微信 2-支付宝 3-余额', `status` tinyint DEFAULT '0' COMMENT '订单状态:0-待支付 1-待发货 2-已发货 3-已完成 4-已取消', `receiver_name` varchar(32) DEFAULT NULL COMMENT '收货人姓名快照', `receiver_mobile` varchar(20) DEFAULT NULL COMMENT '收货人手机号快照', `receiver_address` varchar(255) DEFAULT NULL COMMENT '收货地址快照', `remark` varchar(255) DEFAULT NULL COMMENT '订单备注', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `deliver_time` datetime DEFAULT NULL COMMENT '发货时间', `finish_time` datetime DEFAULT NULL COMMENT '完成时间', `cancel_time` datetime DEFAULT NULL COMMENT '取消时间', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表';

订单明细表把product_name、product_image、price都存了一份,这是商品快照。商品可以改价、改名、下架甚至删除,订单明细不受影响:

CREATE TABLE `t_order_item` ( `id` bigint NOT NULL COMMENT '主键ID', `order_id` bigint NOT NULL COMMENT '订单ID', `product_id` bigint NOT NULL COMMENT '商品ID', `product_name` varchar(128) NOT NULL COMMENT '商品名称快照', `product_image` varchar(255) DEFAULT NULL COMMENT '商品图片快照', `price` decimal(10,2) NOT NULL COMMENT '成交单价(元)', `quantity` int NOT NULL COMMENT '购买数量', `total_price` decimal(10,2) NOT NULL COMMENT '小计金额(元)', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`), KEY `idx_product_id` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单明细表';

评论表用parent_id支持回复,order_item_id用来校验"购买后才能评价",rating存1到5星。评价图片用varchar存逗号分隔的图片地址,一般三张就够:

CREATE TABLE `t_product_comment` ( `id` bigint NOT NULL COMMENT '主键ID', `product_id` bigint NOT NULL COMMENT '商品ID', `order_item_id` bigint DEFAULT NULL COMMENT '关联订单明细ID,证明购买过', `user_id` bigint NOT NULL COMMENT '评论用户ID', `content` varchar(500) DEFAULT NULL COMMENT '评价内容', `rating` tinyint DEFAULT '5' COMMENT '评分:1-5', `images` varchar(512) DEFAULT NULL COMMENT '晒单图片,逗号分隔', `parent_id` bigint DEFAULT '0' COMMENT '回复的评论ID,0表示顶级评论', `deleted` tinyint DEFAULT '0' COMMENT '逻辑删除', `version` int DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_product_id` (`product_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品评论表';

九张表到这里就齐了。可以看到每张表都带deleted、version、create_time、update_time这四个公共字段,这四个人人喊打的"套路字段"在后面的实体类设计里会大大减少重复代码。

3.4 索引、主键、外键的取舍

索引设计我遵循一个原则:只为高频查询路径建索引。用户表根据手机号登录,所以uk_mobile建唯一索引;订单表根据订单号查询,uk_order_no建唯一索引;购物车按用户查,订单明细按订单查,这些经常出现在where条件后面的字段都加普通索引。不会单独出现条件查询的字段,比如商品表的detail_images,坚决不建索引。

主键统一采取了"应用层雪花算法生成Long"的策略。自增主键在单机小项目里没问题,但校园商铺平台要考虑后续接入微信登录、未来可能的多实例部署。自增ID在批量插入时需要数据库往返获取自增值,且数据量大了以后迁移、合并都不方便。雪花ID在应用层生成,插入效率高,排序趋势递增,对InnoDB聚簇索引也友好。代价是实体类里id的生成逻辑要由MyBatis-Plus的IdType.ASSIGN_ID来接管,这个后面代码里会看到。

外键一张表都没加。外键约束是数据库层面的强一致保证,但在互联网业务里,逻辑删除、订单快照、分布式部署这些场景下,外键会严重限制灵活性。关系一致性由Service层事务保证,再加上必要的索引,这是当前主流Java后端项目的共识。

4. 实体类落地:从表结构到Java模型

4.1 先看一眼SSM时代的实体类,方便对比

SSM项目里的用户实体类大概长这样:

public class User { private Long id; private String openId; private String username; private String mobile; private String password; private String nickname; private String avatar; private Integer userType; private Integer status; private Date createTime; private Date updateTime; public Long getId() { return id; } public void setId(Long id) { this.id = id; } // 后面十几个getter/setter,大概两百行 }

这个类的最大特点就是纯数据的"哑对象"。createTime和updateTime要自己在代码里new Date()塞进去,删除是物理delete,根本没有deleted和version字段的概念。这种实体类本身没有错,但在业务逻辑稍微复杂一点的项目里,它把所有细节都推给业务代码,导致每个Service方法里都弥漫着样板代码。

4.2 Spring Boot + MyBatis-Plus的实体类基础组成

引入MyBatis-Plus后,实体类开始具备"自动填充、逻辑删除、乐观锁"这些能力,而且这些能力靠注解声明,不侵入业务逻辑。

先在pom.xml里把依赖配好。这里用的是Spring Boot 2.7.x配套的MyBatis-Plus版本,坐标如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

实体类的基本骨架由四部分构成:

  • Lombok的@Data注解替代getter/setter、toString、equals
  • @TableName声明实体对应的表名
  • @TableId声明主键字段和生成策略
  • @TableField声明字段填充策略、逻辑删除、版本号

这样写出来的实体类只有十来个字段,一眼就能看全。

4.3 核心实体类的具体代码

用户实体类:

@Data @TableName("t_user") public class User implements Serializable { private static final long serialVersionUID = 1L; @TableId(type = IdType.ASSIGN_ID) private Long id; private String openId; private String username; private String mobile; private String password; private String nickname; private String avatar; private Integer userType; private Integer status; @TableLogic @TableField(fill = FieldFill.INSERT) private Integer deleted; @Version @TableField(fill = FieldFill.INSERT) private Integer version; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }

注意id用Long而不是long,这样在新增未插入时id为null,MyBatis-Plus能识别出是insert而不是update。deleted字段标了@TableLogic,以后使用MyBatis-Plus内置的selectList、deleteById等方法时,框架会自动拼接deleted=0条件,物理删除自动变成逻辑删除。version字段标了@Version,之后更新时会自动带上"版本号+1"的乐观锁判断,防止并发修改互相覆盖。

商铺和商品实体类的结构几乎一致,区别只在建表SQL的字段。这里贴一下商品实体类,重点看价格字段类型decimal对应BigDecimal,以及detailImages这种text字段在Java里直接用String接收:

@Data @TableName("t_product") public class Product implements Serializable { private static final long serialVersionUID = 1L; @TableId(type = IdType.ASSIGN_ID) private Long id; private Long shopId; private Long categoryId; private String productName; private String subtitle; private String mainImage; private String detailImages; private BigDecimal price; private BigDecimal originalPrice; private Integer stock; private Integer salesCount; private Integer status; @TableLogic @TableField(fill = FieldFill.INSERT) private Integer deleted; @Version @TableField(fill = FieldFill.INSERT) private Integer version; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }

订单实体类相对特殊的是orderNo字段,它虽然是Long类型的主键之外另一个唯一标识,但必须由业务代码生成,不能用IdType.ASSIGN_ID。订单号的格式通常是"日期+随机数"或"日期+用户ID",方便人工识别:

@Data @TableName("t_order_info") public class OrderInfo implements Serializable { private static final long serialVersionUID = 1L; @TableId(type = IdType.ASSIGN_ID) private Long id; private String orderNo; private Long userId; private BigDecimal totalAmount; private BigDecimal payAmount; private Integer payType; private Integer status; private String receiverName; private String receiverMobile; private String receiverAddress; private String remark; private LocalDateTime payTime; private LocalDateTime deliverTime; private LocalDateTime finishTime; private LocalDateTime cancelTime; @TableLogic @TableField(fill = FieldFill.INSERT) private Integer deleted; @Version @TableField(fill = FieldFill.INSERT) private Integer version; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }

订单明细、购物车、地址、评论这些实体类风格完全一样,只是字段不同。大家照着这个模式写就行,我不打算把九个类全部贴出来,那样篇幅太长,反而不如自己动手敲一遍有感觉。

4.4 自动填充逻辑:createTime、deleted、version不用一个个set

实体类把字段都标注好了,还得有一个MetaObjectHandler来真正填充。MyBatis-Plus会在执行insert/update时回调这个处理器,我们在这里统一填入默认值:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "deleted", Integer.class, 0); this.strictInsertFill(metaObject, "version", Integer.class, 1); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }

为什么要用strictInsertFill而不是直接setFieldValByName?区别在于strictInsertFill只在实体属性为null时才填充,如果业务代码里显式设置了createTime,框架会尊重你的赋值。这样既保证了默认行为,又不堵死特殊场景。

这套机制最大的价值是删掉了Service层里"user.setCreateTime(new Date())"这种重复代码。SSM时代,几乎每个Service的新增方法都要手动补时间、补默认状态;现在实体类+填充器一次配好,后面开发的所有增删改查,公共字段全部自动管理。

5. 踩过的坑和留下的设计经验

5.1 表名撞上SQL关键字:order给我上了一课

第一次建订单表时,我图省事把表名直接命名为t_order,自认为是"加了前缀就和关键字区分开了"。结果写SQL的时候发现,MySQL里ORDER是保留字,t_order里的order也会参与语法解析,某些场景下必须加反引号才能跑通,比如在MyBatis的XML里写SELECT * FROM t_order WHERE user_id = ?时,工具自动校验会报错,手动加反引号虽然能过,但看着特别别扭。

后来我把表名统一改成了t_order_info。多写两个字母换来的是所有SQL、所有代码的顺畅。这个坑其实在SSM时代就有,但当时业务简单,用到的查询语句少,问题暴露不出来。项目复杂以后,订单表会被大量SQL引用,这种隐藏的地雷越早排掉越好。

延伸一下,任何跟数据库关键字重名的字段名、表名都应该避免。common例如desc、comment、rank这些词在字段命名时都要谨慎,尽量用description、remark、sort这类替代词。

5.2 逻辑删除与唯一索引的冲突:最隐蔽的暗坑

逻辑删除字段deleted加上唯一索引后,会形成一个经典的问题。商铺表里我加了uk_shop_name,商家A开了个店叫"校园小卖部",后来经营不下去,记录被逻辑删除(deleted=1)。过了一年新的商家B想注册同名店铺,insert时发现唯一索引还是冲突——因为那条deleted=1的老记录还占着uk_shop_name的位置。

这个坑在SSM时代不太常见,因为当时大家习惯物理删除,删了就是删了,唯一索引自然释放。现在Spring Boot + MyBatis-Plus把逻辑删除变成最佳实践,反而暴露了"逻辑删除记录占用唯一索引"的问题。

我采用的方案分两层。第一层是数据库层面,对需要逻辑删除又需要唯一约束的字段(如商铺名、手机号),不用普通唯一索引,而是把deleted和该字段做成联合唯一索引,逻辑删除时把deleted置为id值而不是1,这样每条已删除记录的联合索引值天然不同,不再互相冲突。第二层是应用层,新增前先查一次"是否存在未删除的同名记录",通过服务端校验兜底。两个方案配合使用,既保证并发下的唯一性,又让逻辑删除真正可用。

如果你不想用_id值覆盖deleted这种"小技巧",更简单的做法是彻底放弃数据库唯一约束,把唯一性校验放到Service层加分布式锁。校园商铺平台这种量级,用联合索引的方案就足够了。

5.3 金额和库存:类型用错,后面全是泪

金额字段用decimal(10,2)已经是标准答案,但实体类里对应的Java类型一定要用BigDecimal,这一点我还是要强调。double在金额计算里会因为浮点数精度问题导致0.1+0.2不等于0.3,下单时总额对不上,结算时少一分钱,这种BUG排查起来极其痛苦。

库存字段我用int,不带unsigned。有些教程推荐unsigned,但MySQL 8.0以后unsigned类型在Java侧映射时容易出问题,而且库存为负的场景应该靠代码判断而不是数据库约束。下单扣减库存时使用"UPDATE t_product SET stock = stock - ? WHERE id = ? AND stock >= ?"这种原子操作,配合乐观锁,并发条件下也能保证不会超卖。

5.4 状态字段要不要用枚举

实体类里status、userType、payType这些字段,我用的是Integer而不是Java枚举。很多新项目喜欢把状态定义成枚举类型,然后在实体类里直接用枚举字段,搭配MyBatis-Plus的EnumTypeHandler。这个思路没有错,但对于第一期希望快速跑通的项目,枚举会引入额外的序列化配置、前端传参转换等问题。

我的折中做法是:实体类字段保持Integer,同时在代码里建立常量类或者枚举来定义状态值,比如OrderStatusEnum.PENDING_PAY.getCode()。这样Service层判断状态时有语义,数据库和前端交互依然基于int,不用配置额外的类型处理器。团队后续如果觉得枚举更合适,再迁移的代价也不大。

状态字段注释一定要写清楚取值含义,这就回到建表阶段的COMMENT了。t_order_info表里status字段注释我写了整整一行:"订单状态:0-待支付 1-待发货 2-已发货 3-已完成 4-已取消"。一个注释,后期能省掉无数翻代码的时间。

如果后面这个系列往下走,用户登录、商品查询、下单事务、支付回调,这些功能都会集中在Service和Controller层,实体类和表结构基本能稳定不动。我个人的实际体会是:项目前期多花一天把实体层规划清楚,后面至少省出一周返工时间。第一期把这些基础打牢,下一期就可以放心地做接口设计和业务实现了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 14:18:38

堆叠沙漏网络SHN精读:姿态估计经典结构解析与复现指南

做姿态估计的人&#xff0c;应该都绕不开“堆叠沙漏网络”&#xff08;Stacked Hourglass Networks&#xff0c;SHN&#xff09;这一经典结构。它发表于2016年的欧洲计算机视觉会议&#xff08;ECCV&#xff09;&#xff0c;论文标题是Stacked Hourglass Networks for Human Po…

作者头像 李华
网站建设 2026/10/14 14:17:53

锂电池冷压机技术解析与应用实践

1. 锂电池冷压机的工业革命十年前我第一次接触锂电池生产车间时&#xff0c;工人还在用液压扳手手动锁紧压板。如今走进现代化产线&#xff0c;全自动冷压机正在以每分钟12个电芯的速度稳定输出&#xff0c;压力曲线在屏幕上划出完美的波浪线。这种将传统液压技术与智能控制结合…

作者头像 李华
网站建设 2026/10/14 14:16:54

Flutter for OpenHarmony:跨平台基础组件适配与实战指南

直接起手&#xff0c;不太喜欢做铺垫&#xff0c;先说结论&#xff1a;这个“Flutter for OpenHarmony”不是一个新框架&#xff0c;也不是又一个跨端方案&#xff0c;而是把Flutter引擎整体移植到OpenHarmony系统上&#xff0c;让原本写给Android、iOS的Flutter代码&#xff0…

作者头像 李华
网站建设 2026/10/14 14:16:38

本地免费AI换脸全攻略:从人脸检测到无缝视频融合

先把话说在前面&#xff1a;这个完全免费、免注册、本地离线跑、输出还没有水印的 AI FaceSwap 方案&#xff0c;我前前后后折腾了一周才彻底跑顺。今天不写那种照抄 README 的教程&#xff0c;直接把原理拆开、把命令行参数掰碎&#xff0c;再把踩过的坑全部倒出来。你只要能跟…

作者头像 李华
网站建设 2026/10/14 14:16:20

构网控制技术:新能源并网与动态无功支撑解析

1. 构网控制的核心价值与场景定位构网控制&#xff08;Grid-Forming Control&#xff09;是新能源发电系统并网运行的关键技术&#xff0c;其核心在于模拟同步发电机的机电暂态特性&#xff0c;为电力系统提供必要的惯量和电压支撑。在传统电力系统中&#xff0c;同步发电机通过…

作者头像 李华
网站建设 2026/10/14 14:15:39

AI Agent框架架构设计:模式、决策与常见陷阱

两年前要是有人跟我说&#xff0c;Agent 的核心瓶颈不是模型能力而是记忆&#xff0c;我会觉得他在夸大其词。但今年带着团队把一套基于大模型 Agent 的问答系统从云端 SaaS 迁到企业内网私有化部署之后&#xff0c;我的看法彻底变了——真正决定 Agent 能不能在企业里长期跑下…

作者头像 李华