作为一个从SSM时代一路走过来的Java后端开发者,我最近在做一个校园商铺平台的练手项目,正好赶上Spring Boot全面普及的节点,就想着把从零搭建的过程完整记录下来。这套系列文章不打算讲那些虚头巴脑的理论,直接落到实体类设计和数据库建表这些最实在的环节上。校园商铺和普通电商平台看着相似,实际做起来坑不少——用户角色多、店铺和商品的层级关系复杂、订单状态流转又涉及到线上线下结合,如果第一刀切不好,后面写业务逻辑的时候会非常痛苦。
先说清楚这套东西的定位。它本质上是一个校园场景下的O2O小平台,学生既可以是买家,也能申请成为店主,管理员则负责审核店铺入驻和商品上架。技术栈从传统的SSM(Spring + Spring MVC + MyBatis)过渡到Spring Boot,核心思路是复用MyBatis这套持久层方案,但把配置繁琐的部分全部交给Boot的自动装配去处理。这一篇先把地基打牢:搞清楚业务域里有哪些实体,它们之间是什么关系,然后设计出对应的数据库表结构。这块做扎实了,后面写Mapper接口、Service层的时候就能一路畅通。
1. 项目整体设计与技术选型思路
1.1 为什么从SSM转向Spring Boot
很多同学还在纠结要不要学SSM,我的建议很直接:SSM的框架组合可以不会手写配置,但你必须理解它,因为Spring Boot本质上就是把SSM时代那些繁琐的XML配置和注解配置封装成了自动化约定。
拿这个项目来说,如果是传统SSM,你得准备spring-context.xml、spring-mvc.xml、mybatis-config.xml、jdbc.properties、web.xml一堆配置文件,光是让一个Hello World跑起来就得折腾半小时。Spring Boot把这些东西全部简化了,一个application.yml搞定数据源、MyBatis映射路径、日志级别、服务端口。我实际对比过,同样一个项目,SSM的配置代码量大约是Spring Boot的三到四倍。
但是注意,简化不意味着你可以不关心底层机制。比如Spring Boot默认的数据源是HikariCP,而SSM时代我们常用的是DBCP或C3P0,如果你的MyBatis配置里写了不兼容的连接池参数,照样会启动报错。所以我的建议是:用Spring Boot的姿势,但带着SSM的知识去理解每一步在干什么。
1.2 领域模型梳理:校园商铺到底有哪些核心业务对象
建表之前必须先理清业务对象,这一步做不好,后面全乱。我把校园商铺平台拆成了五个核心域:用户域、店铺域、商品域、交易域、地址域。
用户域是最底层的,一个平台必然有用户系统。但校园场景有个特点:用户身份是动态变化的,今天你是消费者,明天申请了一份店铺入驻资料,审核通过后你就多了一个店主身份。所以不能像普通网站那样把用户和店铺写死成一对一,而要把身份信息拆开建模。
店铺域是校园O2O的核心特色。校园商铺大多是有实体位置的——比如食堂旁边的奶茶店、宿舍楼下的小超市、图书馆里的文印店。这意味着店铺表里必须包含位置相关信息,而位置又和学校的分区有联动,比如东区、西区、南门商业街这些概念。所以单独拆出一张区域表是合理的,店铺通过区域ID关联到具体位置。
商品域要理解校园店铺的另一个特点:商品种类少但更新频繁。奶茶店可能每天上架三到五种新品,文印店则常年售卖固定的打印卡和U盘。这就要求商品类别和商品本身分离,商品属于某个类别,类别属于某个店铺。这样设计的好处是,店主改类别名称的时候不需要改动商品表里的每一条记录。
交易域是整个平台最复杂的地方。校园交易的特点是金额小、量大、取货方式多样——有的是线下到店自取,有的是店里派人送到宿舍楼下。所以订单表不能只记录下单人、商品、金额,还要记录取货方式、取货时间、订单状态流转等多个字段。
地址域很好理解,学生下单后需要一个收货地址,但校园里这个地址往往不是详细的街道门牌,而是"某栋宿舍楼某间寝室"这种结构化描述,所以地址表要设计得足够灵活。
1.3 实体类与表结构的设计原则
实体类设计有一条铁律:先设计数据库表,再写实体类。很多新手喜欢反过来,先写代码然后让表适应代码,这会导致索引缺失、冗余字段爆炸、关联查询性能低下。
我这边遵循四个原则。第一个原则是"一表一实体",每张表对应一个Java POJO,字段一一映射,不做继承、不做接口抽象,保持简单直接。有人喜欢用通用Entity基类放主键和公共字段,我只建议放createTime和updateTime这种纯粹审计字段,业务字段不建议上提。
第二个原则是"业务查询字段并入主表"。比如商品表里需要经常展示商品原价和折扣价,有人会设计成两张表用外键关联,我建议直接把价格字段放在商品表里,最多加一个"是否促销"的标记位。校园商铺的并发量撑不起过度设计,单表解决的事情绝不拆表。
第三个原则是"用整数存状态值"。订单状态、支付状态、店铺审核状态这些一律用tinyint,不要用varchar存储中文描述。为什么?第一,数据库排序和索引对整数更友好;第二,实体类里用枚举或者常量类去映射,代码可读性依然很高。比如订单状态,0代表已下单未支付,1代表已支付,2代表已发货,3代表已完成,4代表已取消。
第四个原则是"冗余字段控制在合理范围"。比如店铺表里冗余一个商品数量字段,订单表里冗余下单时的店铺名称和商品快照,这都能大大减少联表查询。但是冗余必须建立在逻辑可控的前提下——我见过一个项目在订单表里冗余了十个字段,最后每次业务变更都要同步修改,那才是灾难。我的标准是:冗余字段必须是"从业务角度看几乎不会变化"的信息,比如店铺名称、商品名称这种,而不是单价、库存这种会变的数据。
2. 核心表结构设计详解
2.1 用户体系相关表:用户表与区域表
用户表我给它命名为tb_user,核心字段如下。主键user_id我选择用bigint自增,不搞UUID也绝不搞雪花ID——校园项目的并发量用自增主键完全够用,而且自增主键对InnoDB索引树的插入顺序非常友好,没有页分裂的性能损耗。
CREATE TABLE tb_user ( user_id BIGINT AUTO_INCREMENT COMMENT '用户ID', user_name VARCHAR(32) NOT NULL COMMENT '用户名(登录账号)', user_pwd VARCHAR(64) NOT NULL COMMENT '登录密码(BCrypt加密)', nick_name VARCHAR(32) DEFAULT NULL COMMENT '昵称', real_name VARCHAR(16) DEFAULT NULL COMMENT '真实姓名', email VARCHAR(64) DEFAULT NULL COMMENT '邮箱', mobile VARCHAR(16) DEFAULT NULL COMMENT '手机号', avatar VARCHAR(255) DEFAULT NULL COMMENT '头像URL', gender TINYINT DEFAULT 0 COMMENT '性别:0未知,1男,2女', user_type TINYINT DEFAULT 0 COMMENT '用户类型:0普通用户,1店主,2管理员', status TINYINT DEFAULT 0 COMMENT '状态:0禁用,1正常', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (user_id), UNIQUE KEY uk_username (user_name), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这里有个细节,密码字段我特意设置了64的长度。如果你用MD5加密,32位就够了,但那个方案强烈不推荐。我建议用BCrypt,它的哈希结果长度是60个字符,所以字段至少64。很多人在这上面踩过坑——存的时候好好的,查出来比对发现谁都不匹配,一查长度被截断了。
区域表tb_area的设计需要考虑校园的分区层级。有的学校是"校区-片区-具体位置"三级,有的只有"片区-具体位置"两级。我用parent_id的自关联方式来解决层级问题,同时冗余一个level字段标记层级深度,查询的时候不用递归就能快速知道节点深度。
CREATE TABLE tb_area ( area_id INT AUTO_INCREMENT COMMENT '区域ID', area_name VARCHAR(64) NOT NULL COMMENT '区域名称', parent_id INT DEFAULT NULL COMMENT '父区域ID,顶级为NULL', level TINYINT NOT NULL COMMENT '层级:0顶级,1次级,2次次级', sort_order INT DEFAULT 0 COMMENT '排序权重,越大越靠前', PRIMARY KEY (area_id), KEY idx_parent (parent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='区域表';为什么不把区域合并到店铺表里直接存一个区域名字?因为校园商铺的运营过程中,平台方经常需要按区域做统计——比如统计南区一共有多少家正在营业的店铺,或者按区域推送活动通知。如果区域信息散落在店铺表里,做聚合分析时会非常痛苦。
2.2 店铺与商品域设计:从区域到商品类别的层级关系
店铺表tb_shop是整个平台最核心的表之一,它连接了用户、区域和商品三个维度。店铺表的主要字段包括店铺ID、店主用户ID、店铺名称、店铺描述、所属区域ID、详细地址、店铺LOGO、店铺电话、营业状态、审核状态等。
CREATE TABLE tb_shop ( shop_id BIGINT AUTO_INCREMENT COMMENT '店铺ID', owner_id BIGINT NOT NULL COMMENT '店主用户ID,关联tb_user.user_id', area_id INT NOT NULL COMMENT '所属区域ID,关联tb_area.area_id', shop_name VARCHAR(64) NOT NULL COMMENT '店铺名称', shop_desc VARCHAR(255) DEFAULT NULL COMMENT '店铺描述', shop_logo VARCHAR(255) DEFAULT NULL COMMENT '店铺LOGO URL', shop_tel VARCHAR(16) DEFAULT NULL COMMENT '店铺联系电话', detail_addr VARCHAR(128) DEFAULT NULL COMMENT '详细地址', longitude DECIMAL(10,6) DEFAULT NULL COMMENT '经度', latitude DECIMAL(10,6) DEFAULT NULL COMMENT '纬度', shop_status TINYINT DEFAULT 0 COMMENT '营业状态:0休息,1营业', review_status TINYINT DEFAULT 0 COMMENT '审核状态:0待审核,1审核通过,2审核驳回', review_note VARCHAR(255) DEFAULT NULL COMMENT '审核备注', product_count INT DEFAULT 0 COMMENT '商品数量(冗余)', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (shop_id), KEY idx_owner (owner_id), KEY idx_area (area_id), KEY idx_review_status (review_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店铺表';经纬度字段我用DECIMAL(10,6),为什么不用FLOAT或DOUBLE?因为浮点数在数据库里的存储存在精度丢失问题,做范围查询时可能对不上。DECIMAL(10,6)可以精确存储坐标,10位总长度中6位用于小数部分,足够定位到米级精度,对校园这种小范围场景绰绰有余。
商品类别表tb_product_category承担着店铺内部商品分类的功能。注意,它是和店铺绑定的,不是全局共享的。我见过有人把商品类别表设计成全局一张表不分店铺,结果奶茶店和文印店的商品全混在一起,每次查询都要用shop_id加category_id两个条件过滤,索引效率极低。
CREATE TABLE tb_product_category ( category_id BIGINT AUTO_INCREMENT COMMENT '类别ID', shop_id BIGINT NOT NULL COMMENT '所属店铺ID', category_name VARCHAR(64) NOT NULL COMMENT '类别名称', sort_order INT DEFAULT 0 COMMENT '排序权重', PRIMARY KEY (category_id), KEY idx_shop (shop_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品类别表';商品表tb_product关联店铺和类别,加上商品自身的核心属性。校园商铺的商品有几个关键设计点:一是价格字段统一用DECIMAL(10,2),拒绝使用FLOAT;二是要有个商品主图URL字段,但推荐把多图拆到子表;三是库存字段要做成"可用库存"和"总库存"两个概念,后面做订单扣库存的时候逻辑会清晰很多。
CREATE TABLE tb_product ( product_id BIGINT AUTO_INCREMENT COMMENT '商品ID', shop_id BIGINT NOT NULL COMMENT '所属店铺ID', category_id BIGINT NOT NULL COMMENT '类别ID', product_name VARCHAR(128) NOT NULL COMMENT '商品名称', product_desc VARCHAR(512) DEFAULT NULL COMMENT '商品描述', product_main_img VARCHAR(255) DEFAULT NULL COMMENT '商品主图URL', product_price DECIMAL(10,2) NOT NULL COMMENT '商品原价', promo_price DECIMAL(10,2) DEFAULT NULL COMMENT '促销价,为空则不打折', total_stock INT NOT NULL COMMENT '总库存', available_stock INT NOT NULL COMMENT '可用库存', sales_count INT DEFAULT 0 COMMENT '销量(冗余)', status TINYINT DEFAULT 0 COMMENT '商品状态:0下架,1上架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (product_id), KEY idx_shop (shop_id), KEY idx_category (category_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';2.3 订单域设计:一主多从的订单结构
订单域是整个平台设计中最容易翻车的部分。很多初学者会把订单设计成一张大表,所有字段堆在一起,这是典型的反模式。校园商铺的订单必须拆成订单主表和订单明细表,原因有两个:第一,一个订单可能包含多种商品,每件商品的数量和金额都需要单独记录;第二,订单主表存的是收货人信息、总金额、订单状态这些公共信息,一旦字段过多,索引性能和可读性都会下降。
订单主表tb_order的核心字段包括订单号、用户ID、店铺ID、订单总金额、支付金额、支付方式、下单时间、支付时间、订单状态、取货方式等。订单号是业务层的唯一标识,虽然可以用订单主键ID直接展示给用户,但主键ID会暴露平台的订单量,我习惯额外生成一个业务订单号,格式类似时间戳加随机数。
CREATE TABLE tb_order ( order_id BIGINT AUTO_INCREMENT COMMENT '订单ID', order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', user_id BIGINT NOT NULL COMMENT '下单用户ID', shop_id BIGINT NOT NULL COMMENT '店铺ID', shop_name VARCHAR(64) DEFAULT NULL COMMENT '店铺名称(下单快照)', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', pay_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额', pay_method TINYINT DEFAULT 0 COMMENT '支付方式:0余额支付,1模拟微信,2模拟支付宝', order_status TINYINT DEFAULT 0 COMMENT '订单状态:0待支付,1已支付备货中,2已完成,3已取消', pickup_method TINYINT DEFAULT 0 COMMENT '取货方式:0到店自取,1送货上门', receiver_name VARCHAR(16) DEFAULT NULL COMMENT '收货人姓名', receiver_mobile VARCHAR(16) DEFAULT NULL COMMENT '收货人手机号', receiver_address VARCHAR(255) DEFAULT NULL COMMENT '收货地址(下单快照)', remark VARCHAR(255) DEFAULT NULL COMMENT '订单备注', pay_time DATETIME DEFAULT NULL COMMENT '支付时间', finish_time DATETIME DEFAULT NULL COMMENT '完成时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (order_id), UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_shop (shop_id), KEY idx_status (order_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';订单主表里的shop_name和receiver_address属于快照冗余。为什么一定要存快照?因为店铺可能会改名,用户也可能编辑自己的收货地址。如果订单详情页去做联表查询,查出来的是"现在"的店铺名称和地址,而不是"下单那一刻"的信息,这在电商系统里是不可以接受的逻辑错误。快照的本质是:订单是历史事实,它不能被后来的变更改写。
订单明细表tb_order_item则记录每一件商品的购买信息:关联订单主表、商品ID,冗余商品名称和价格快照,记录购买数量和小计金额。
CREATE TABLE tb_order_item ( item_id BIGINT AUTO_INCREMENT COMMENT '明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', product_id BIGINT NOT NULL COMMENT '商品ID', product_name VARCHAR(128) DEFAULT NULL COMMENT '商品名称(快照)', product_img VARCHAR(255) DEFAULT NULL COMMENT '商品主图(快照)', product_price DECIMAL(10,2) NOT NULL COMMENT '下单时单价(快照)', quantity INT NOT NULL COMMENT '购买数量', subtotal_amount DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (item_id), KEY idx_order (order_id), KEY idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';2.4 辅助表设计:地址表与商品图片表
收货地址表tb_user_address按用户维度管理,允许一个用户有多个地址,设置默认地址标志位。校园场景下地址字段不宜设计成varchar一大长串,而是拆成区域ID、详细描述和标签三部分。比如"东区"是区域表的area_id,详细描述是"3号宿舍楼512室",标签是"宿舍/教学楼/其他"。这样设计的好处是,平台方可以按区域分析学生的收货偏好,优化配送路线。
CREATE TABLE tb_user_address ( address_id BIGINT AUTO_INCREMENT COMMENT '地址ID', user_id BIGINT NOT NULL COMMENT '用户ID', area_id INT NOT NULL COMMENT '区域ID', detail_addr VARCHAR(128) NOT NULL COMMENT '详细地址描述', receiver_name VARCHAR(16) NOT NULL COMMENT '收货人姓名', receiver_mobile VARCHAR(16) NOT NULL COMMENT '收货人手机号', is_default TINYINT DEFAULT 0 COMMENT '是否默认地址:0否,1是', PRIMARY KEY (address_id), KEY idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货地址表';商品图片表tb_product_img单独建表,因为一个商品会有多张图片,主图存在商品表里方便列表页展示,详情页的多图则放在子表里,通过product_id关联,加上img_sort字段控制图片顺序。
CREATE TABLE tb_product_img ( img_id BIGINT AUTO_INCREMENT COMMENT '图片ID', product_id BIGINT NOT NULL COMMENT '商品ID', img_url VARCHAR(255) NOT NULL COMMENT '图片URL', img_sort INT DEFAULT 0 COMMENT '图片排序', PRIMARY KEY (img_id), KEY idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品图片表';3. 实体类设计的实操细节与踩坑记录
3.1 主键策略选择:自增主键与其他方案的对比
实体类里主键字段的生成策略,我直接给出结论:用数据库自增。MyBatis的insert语句里,通过useGeneratedKeys="true"和keyProperty="user_id"就能把自增主键回填到实体对象里,不需要额外的selectKey查询。对比一下其他方案的优劣。
UUID方案适合分布式场景,但主键是无序的字符串,InnoDB的聚簇索引在插入时会频繁触发页分裂,性能明显不如自增。而且UUID作为业务主键太长,做外键关联时会占用更多索引空间。雪花ID虽然解决了有序性问题,但需要维护雪花算法的机器ID分配方案,还要处理时钟回拨问题,对校园项目来说是杀鸡用牛刀。
我遇到过一个比较尴尬的场景:订单表用了自增ID,但是业务层对外展示的订单号却是"173500000001"这种格式。这不是主键,是单独生成的业务订单号,我用时间戳加三位随机数生成的。实体类里可以给这个字段加一个@JsonFormat注解,让后端返回给前端的时候序列化成字符串,防止前端JS精度丢失——这个问题很隐蔽,BigInt类型超过JS的安全整数范围后,显示出来最后几位全变成0,排查半天才意识到是序列化问题。
3.2 时间字段处理的坑:LocalDateTime 与 时区
实体类里所有时间字段统一使用LocalDateTime类型,不要用java.util.Date,也不要用java.sql.Timestamp。LocalDateTime配合MyBatis需要引入jsr310的依赖,但现在的Spring Boot 2.x版本都是默认内置支持的,不需要额外配置。
我踩过一个大坑:数据库里存的时间比实际时间少了8个小时。排查到最后发现是数据库连接串里没加serverTimezone参数,导致JDBC驱动用了默认的UTC时区去解析时间。Spring Boot的application.yml里,JDBC连接串一定要加上serverTimezone=Asia/Shanghai,同时实体类字段可以使用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")注解控制返回给前端的时间格式。
另外说一句,数据库表的create_time和update_time,我建议用数据库端的DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP来自动维护,而不是在Java代码里手动set。这样即使有人直接改了数据库数据,审计字段也能保持正确。实体类里只需要声明字段,插入数据时不要让它们干扰MyBatis的自动填充逻辑。
3.3 金额字段用Decimal还是Double
金额字段必须用BigDecimal,这是Java开发的基本常识,但很多人还是在实体类里用了Double。为什么不能用Double?因为浮点数的二进制存储导致0.1加0.2不等于0.3,在金额计算中会出现0.30000000000000004这种结果。虽然乘以100转成整数可以绕开,但代码可读性差,而且一旦遇到超大金额还有精度上限问题。
数据库层面,金额字段我用DECIMAL(10,2),10位总长度足够支撑最大99999999.99的金额,对校园商铺来说绰绰有余。在实体类里对应的Java类型就是BigDecimal。MyBatis对这种映射处理得很好,不需要额外的TypeHandler。要注意的坑是:BigDecimal做equals比较时,scale不同会导致false,比如2.0和2.00用equals比较是false,必须用compareTo。
还有一个习惯性问题:涉及金额的运算全部在Service层用BigDecimal的方法去完成,不要在前端页面里对金额做加减运算。前端只负责展示,所有计算都在后端做完然后返回结果。
3.4 关联关系的建模方式:外键到底建不建
实体类之间的关联关系,我的建议是:绝不使用数据库物理外键,而是在应用层维护逻辑关联。比如tb_shop表里的owner_id关联tb_user表的user_id,我不建立外键约束FOREIGN KEY,只在字段上建普通索引。
为什么?第一,物理外键会降低插入和更新性能,每次加外键约束都要做参照完整性检查。第二,校园商铺的业务经常要做联表查询,但在高并发场景下,外键会导致死锁的概率变大。第三,项目演进中可能会出现分库分表,一旦拆分数据库,物理外键就完全失效了。
实体类里的关联字段,比如Shop实体类里的ownerId,我的处理方式是:只在POJO中声明这个字段,不注入User对象,也不在实体类里加@ManyToOne这类ORM注解。MyBatis的关联查询通过resultMap的association和collection标签去控制在查询方法内部完成,这样更加灵活,不会绑定实体类的结构。
4. 建表SQL实战演示与常见问题速查
4.1 从实体类到建表SQL的转换过程
设计完实体类之后,建表SQL要跟实体类字段一一对应。我习惯先把实体类写出来,这样字段类型和约束条件就都明确了。以店铺实体类为例,它的字段和表字段的对应关系是这样的。
public class Shop { private Long shopId; // shop_id private Long ownerId; // owner_id private Integer areaId; // area_id private String shopName; // shop_name private String shopDesc; // shop_desc private String shopLogo; // shop_logo private String shopTel; // shop_tel private String detailAddr; // detail_addr private BigDecimal longitude; // longitude private BigDecimal latitude; // latitude private Integer shopStatus; // shop_status private Integer reviewStatus; // review_status private String reviewNote; // review_note private Integer productCount; // product_count private LocalDateTime createTime; // create_time private LocalDateTime updateTime; // update_time }注意这里有个细节:实体类字段的命名是驼峰式,数据库字段是下划线式。Spring Boot集成MyBatis时,可以在application.yml里配置map-underscore-to-camel-case: true,这样查询结果集就能自动从shop_id映射到shopId,完全不需要手写resultMap的每个字段。这是Spring Boot相比传统SSM的一个巨大优势——传统SSM时代必须在mybatis-config.xml里显式配置驼峰映射,很多人漏了这一项,结果查出来的对象所有字段全是null,排查了一下午才发现是这个配置问题。
4.2 一个完整的建表顺序参考
建表时要注意表的依赖顺序。因为存在逻辑关联关系,虽然我们不用物理外键,但建表时依然建议先建被依赖的表。我的建表顺序是:tb_area、tb_user,然后是tb_shop、tb_product_category、tb_product、tb_product_img、tb_user_address,最后是tb_order和tb_order_item。
为什么地址表放在订单表之前?因为用户的地址信息是独立维护的,下单的时候需要从地址表拉取信息填写到订单的快照字段中。而订单明细表放在最后是因为它依赖订单主表的order_id。
执行建表脚本时,建议开启SET NAMES utf8mb4;确保中文注释和中文默认值能正确存储。整个数据库的字符集统一使用utf8mb4,排序规则使用utf8mb4_general_ci。为什么不用utf8?因为utf8在MySQL里实际是utf8mb3,最多只能存3字节的字符,像emoji表情这种4字节字符会报错。虽然业务上不太可能在商品名称里加emoji,但用户昵称、店铺备注这些用户生成内容里真的会出现。
4.3 常见问题速查表:建表与实体类映射的坑
我把这几年做这类项目踩过的坑和同行反馈过的问题整理成一张速查表,大家在实操中可以直接对照排查。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 实体类查出来全是null | MyBatis没开启驼峰映射 | application.yml中设置map-underscore-to-camel-case: true |
| 时间字段少了8小时 | JDBC连接串缺少时区参数 | 连接串加serverTimezone=Asia/Shanghai |
| 中文数据变成问号 | 数据库或表字符集不是utf8mb4 | 统一使用utf8mb4字符集 |
| 商品价格变成59.999999 | 数据库用了FLOAT/DOUBLE存金额 | 改为DECIMAL(10,2) |
| 插入主键返回0 | 没配置useGeneratedKeys | insert标签加useGeneratedKeys="true" keyProperty="xxxId" |
| 自增ID回填不了 | keyProperty的写法与实体类属性名不一致 | keyProperty必须写实体类字段名而不是数据库列名 |
| 主键ID太大前端显示错误 | JS Number类型精度丢失 | ID序列化为字符串返回 |
| 外键约束导致死锁 | 物理外键在高并发下的锁竞争 | 取消外键,使用普通索引 |
4.4 设计评审时的几个自检点
在真正开始写业务代码之前,我建议把表结构拿去做一轮自检,每个表都过一遍下面这些问题。
第一,主键是否足够简洁,有没有业务字段被误当成主键。我见过有人把商品名称当主键,结果商品一改名,所有关联订单的数据全乱了。正确做法永远是独立的无业务含义自增ID作为主键。
第二,到底哪些字段需要加索引。索引不是越多越好,每个索引都会降低写入性能。我的习惯是:主键必加,唯一业务字段(如订单号、用户名)加唯一索引,频繁作为查询条件的字段(shop_id、user_id、status)加普通索引。但要注意,复合索引的字段顺序有讲究,比如订单表最常见的查询是status加上create_time做范围排序,那么复合索引 (status, create_time) 就比单独的两个索引更有用。
第三,状态字段需不需要注释。凡是TINYINT类型的状态字段,比如shop_status、order_status、review_status,我要求在建表SQL里必须写COMMENT,并且写清楚每个数字代表的含义。Java代码里对应的常量类也要写好注释。否则隔了两个月再回来看代码,根本想不起来2代表的是审核驳回还是审核通过。
第四,有没有考虑数据增长后的归档策略。校园商铺这种项目虽然数据量不会大到爆炸,但订单表会持续增长。我在设计的时候就预留了create_time字段用于后续按月分表,虽然第一期不做分表,但字段必须有,否则后面想优化只能做全表迁移。
5. 系列后续规划与个人经验补充
最后聊点我自己的实操体会。这套实体类和表结构是我在模拟项目X中反复调整了三轮才定下来的,第一版最大的问题就是订单表设计过于简单——没有快照字段,导致订单详情页每次都要联查店铺和用户表,不仅速度慢,而且一旦店铺信息变了,历史订单显示的内容也跟着变了。后来我补上了shop_name、receiver_name这些快照字段,问题才彻底解决。所以我的经验是:表结构设计的核心不是追求字段最少,而是追求"在查询时不需要回头找数据"。
另外关于Spring Boot和SSM的对比,我多说一句。很多人觉得SSM已经被淘汰了不用学,但你在Spring Boot里写MyBatis的Mapper、Service、Controller三层结构时,本质上还是SSM那套东西。Spring Boot只是帮你把装配过程自动化了,业务的骨架依然是你从SSM时代练出来的那套肌肉记忆。所以我这个系列虽然是基于Spring Boot来写的,但我会在涉及关键配置和原理的地方对照SSM的写法来说明,这样你既能跟上新技术的节奏,也不会丢掉对框架底层的理解。
这个系列的第一篇就到这里,核心内容是实体类设计和建表。下一篇会深入MyBatis的Mapper层设计,讲清楚如何基于这些表结构写出高效、可维护的CRUD和数据访问逻辑。欢迎在评论区留言交流你在建表和实体类建模过程中遇到的坑,也可以把你设计的表结构发出来讨论,我们一起优化。