一年多以前,团队里那个跑了两年的校园交易项目还是标准的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.Date | LocalDateTime |
| 样板代码 | 手写getter/setter(或者找插件生成) | Lombok注解搞定 |
| 主键策略 | 数据库自增int/long | 应用层雪花算法生成Long |
| 映射方式 | XML resultMap手写 | @TableName/@TableId注解 |
| 公共字段 | 每个插入操作手动set | MetaObjectHandler自动填充 |
这些变化的根因不是"新框架炫技",而是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层,实体类和表结构基本能稳定不动。我个人的实际体会是:项目前期多花一天把实体层规划清楚,后面至少省出一周返工时间。第一期把这些基础打牢,下一期就可以放心地做接口设计和业务实现了。