校园商铺平台,简单说就是让每个同学在校园里都能开店卖点自己的东西——二手教材、自制手工艺品、零食代购、专业课资料,买家逛到店铺下单付款,卖家发货收货,后台还能管管商品和订单。这个系列我不想用网上那些现成的“课程项目”糊弄人,而是从最底层开始一行代码一个表结构地搭起来。第一期先把整个项目的地基打牢:实体类设计和数据库表的创建。
你可以把这个项目理解成一个微缩版电商系统,但比那种几十个微服务的电商项目实在得多。它覆盖了单表CRUD、多表联查、事务、状态流转、文件上传这些Java后端开发里最高频的能力。这一期不写Controller也不写Service,只做两件事:把所有核心业务表设计出来,把对应实体类写出来。表设计烂了,后面代码怎么写怎么别扭,这个坑我踩过太多次,所以第一期绕不过去。
这篇文章我会把用户、店铺、商品、分类、订单这几张核心表从字段到类型逐一拆开讲,给出完整的建表SQL,再把表怎么映射成实体类、MyBatis逆向工程怎么用、以及从SSM项目转Spring Boot后到底哪些配置文件没了,全部讲清楚。适合正在学SSM想往Spring Boot过渡的同学,也适合准备做课设、毕设或个人实战项目的朋友参考。
1. 项目定位与整体拆解
1.1 校园商铺平台到底在做什么
先别急着碰技术,得先把业务想明白。校园商铺平台里有三类角色:买家、卖家和平台管理员。买家可以浏览店铺、查看商品、下单购买、查看自己的订单;卖家可以申请开店、管理自己店里的商品和订单;管理员负责审核店铺、上下架违规商品。
核心业务流程是一条闭环:卖家注册账号申请开店、管理员审核通过、卖家发布商品;买家注册登录逛店、把商品加入订单、下单付款;卖家收到订单去发货,买家确认收货。整个过程涉及的所有数据,最后都要落到数据库表里。
选这个项目练手有一个很现实的原因:它既不是那种“只写一张用户表”的玩具项目,也不是那种复杂到你根本写不完的企业级系统。它正好处在一个中间位置——每个业务模块单独看不难,但是连起来就涉及非常典型的关联关系和数据一致性处理,比如一个订单包含多个商品,下单要扣库存,退款要回滚库存,这里面的坑足够你学到东西。
1.2 为什么从SSM起步又落到Spring Boot
SSM是Spring、SpringMVC、MyBatis三个框架各司其职的组合,在我读书那个年代,它几乎是Java后端课设的标配。SSM本身没有大问题,问题在于配置太折磨人。你要手工配web.xml,配Spring容器,配SpringMVC的DispatcherServlet,再配一份mybatis-config.xml,还要把数据源、事务管理器、扫描包一个个塞进XML里。建一个新项目光是让HelloWorld跑起来,可能就要折腾一上午。
Spring Boot做的事,简单说就是把那些重复的配置全部“自动完成”。内嵌一个Tomcat,你不再需要外部部署;环境准备、框架版本、常用Bean的创建全部通过starter自动搞定。你从重配置的时代一下子切换到重业务的时代。但这个项目我坚持从SSM的思路出发再落到Spring Boot,是因为这样转型会让你真正理解Spring Boot到底帮你做了什么。
实体类设计和数据库表设计在这两种技术栈下有一套公用的底层逻辑。一张用户表无论SSM还是Spring Boot,字段该是什么还是什么;一个实体类无论由XML装配还是通过注解扫描,映射关系也不会变。所以第一期内容在两种体系下都成立,后面几期我再慢慢把每层往里填。
1.3 表设计先行,实体类紧随其后
很多新手写项目喜欢一上来就建实体类,把类里的属性写满,然后让数据库去适配Java代码。我的习惯正好反过来:先建表,再写实体类。数据库表是业务的底层存储,你如果把一张表的字段定清楚了,实体类就是照葫芦画瓢;如果你连业务里有几张表、每张表存什么都不知道,那写出来的实体类一定乱七八糟。
表结构和实体类之间的关系用一个类比就好理解了:表是一栋房子的户型图,实体类是装修公司根据户型图画出来的效果图。效果图长什么样取决于户型图怎么画,你不能先画效果图再倒推承重墙。
在正式建表之前我还会做一个动作:先把公共字段一次性加到每张表里。id主键、create_time创建时间、update_time更新时间、deleted逻辑删除标记,这四个字段我会在每张核心业务表里都放一份。这样做的收益在后面写通用Mapper、做统一查询条件的时候会非常明显,但代价是建表SQL看起来有点重复。重复就重复,数据库设计不是代码,冗余通用字段比后面补字段返工要划算。
2. 开发环境与项目骨架
2.1 版本选择和技术栈基调
我这次用的是JDK 8、Spring Boot 2.7.x、MySQL 8.0、Maven 3.6。看到这个版本组合你可能会有疑问,现在Spring Boot 3都出来好久了,为什么不直接上新版?
原因是SSM项目绝大多数跑在JDK 8上,如果你直接从Spring Boot 3入手,会遇到一个非常麻烦的底层变化:Spring Boot 3要求JDK 17以上,而且把javax命名空间换成了jakarta。这意味着你原来在SSM里熟悉的import javax.servlet.,到了Boot 3要改成import jakarta.servlet.。新手在最开始就被这个细节卡住,会非常打击信心。Spring Boot 2.7.x既保留了完整SSM风格的兼容性,又能让你体验到自动配置的便利,作为过渡版本是最稳的。
MySQL用8.0是因为驱动类名发生了变化,老项目用com.mysql.jdbc.Driver,8.0以后官方推荐com.mysql.cj.jdbc.Driver,而且连接URL必须带时区和SSL相关参数,否则启动就报错。这些坑我会在后面的常见问题里展开。
2.2 pom.xml依赖怎么配
创建Spring Boot项目后,pom.xml里最关键的是替换成下面这些依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>看到没有,这里完全没有出现版本号,全靠spring-boot-starter-parent做统一版本管理。SSM时代你要是想用这几个框架,得自己记牢Spring版本、SpringMVC版本、MyBatis版本、MyBatis-Spring适配版本,版本号之间稍微不匹配就给你抛一堆奇怪的ClassNotFound,现在这份苦基本不用吃了。
注意mybatis-spring-boot-starter的groupId是org.mybatis.spring.boot,不是org.mybatis,这个写错就拉不到包。starter的版本也需要自己指定,因为parent只管理Spring官方家族的版本。
2.3 application.yml配置和SSM配置文件的差别
Spring Boot的核心配置文件就是src/main/resources/application.yml。我第一期版本里配置如下:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campus_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.campus.shop.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这段配置里有几个点要重点说。mybatis.mapper-locations指定了XML文件位置,如果你把mapper.xml放在了resources/mapper目录,但忘了这句配置,启动后项目会报Invalid bound statement异常,因为MyBatis根本扫描不到SQL语句。
map-underscore-to-camel-case必须补上true。数据库字段习惯用user_name这种下划线风格,Java实体类习惯用userName这种驼峰风格,开启这一项后MyBatis会自动帮你做映射转换,否则每个查询你都要手动resultMap,写法有多痛苦写过的都知道。
serverTimezone=Asia/Shanghai这个参数特别关键。MySQL 8的驱动要求客户端明确告知服务器的时区,如果你不写,有的环境会直接报错,有的环境不报错但时间莫名其妙差8个小时。这个坑在7.1里再展开。
SSM时代这些配置分别散落在web.xml、spring.xml、springmvc.xml、mybatis-config.xml四五个文件里,现在全部压缩成这一个文件。你体会一下,这就叫配置简化。
2.4 项目目录结构
第一期创建完项目后,目录结构是这样的:
campus-shop/ ├── pom.xml └── src ├── main │ ├── java │ │ └── com │ │ └── campus │ │ └── shop │ │ ├── CampusShopApplication.java │ │ ├── entity/ │ │ ├── mapper/ │ │ ├── service/ │ │ ├── controller/ │ │ └── vo/ │ └── resources │ ├── application.yml │ └── mapper/ └── test入口类CampusShopApplication上需要加一个注解@MapperScan("com.campus.shop.mapper"),没有这句,后面写的所有Mapper接口都不会被Spring管理,项目启动就直接失败。注意这里不同项目的包名不一样,你要改成自己实际的mapper包路径。
entity放数据库表对应的实体类,mapper放数据库访问接口和XML,service和controller是后几期往里填的,vo放联表查询时用的视图对象。第一期先把entity和mapper准备好即可。
3. 用户模块:最小的表,最大的细节
3.1 用户表字段逐一拆解
用户表是整个平台最基础的一张表,卖家要用、买家要用、管理员也要用,几乎所有表都跟它有关联。这张表的设计看起来最简单,但里面埋的细节最多。
CREATE TABLE tb_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(32) NOT NULL COMMENT '用户名', password VARCHAR(100) NOT NULL COMMENT '密码', nickname VARCHAR(32) DEFAULT NULL COMMENT '昵称', avatar VARCHAR(255) DEFAULT NULL COMMENT '头像地址', phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', email VARCHAR(64) DEFAULT NULL COMMENT '邮箱', user_type TINYINT NOT NULL DEFAULT 0 COMMENT '0-买家 1-卖家', status TINYINT NOT NULL DEFAULT 1 COMMENT '0-禁用 1-正常', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删 1-已删', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';主键字段我用BIGINT UNSIGNED AUTO_INCREMENT,没采用UUID。原因是自增主键在InnoDB的聚簇索引上有顺序写入优势,查询时也方便索引;UUID虽然看着像是"不暴露真实数量",但它是随机字符串,做主键会导致索引页频繁分裂,数据量大以后性能明显下降。校园商铺这种量级完全不需要为了隐藏数据量而牺牲性能和代码复杂度。
user_type用TINYINT而不是字符串,代表用户类型,0是买家,1是卖家。这里不用枚举存数据库,是因为枚举类型在Java里好写,但在数据库里修改枚举值需要执行ALTER TABLE,太麻烦了。用TINYINT加注释的方式又轻量又灵活。
username字段加了唯一索引UNIQUE KEY,因为用户名登录时要查询且不允许重复。但注意密码用的是VARCHAR(100),不是32,下面会单独解释。
create_time和update_time都给了默认值,特别是update_time配合ON UPDATE CURRENT_TIMESTAMP,这样你在代码里做UPDATE操作时就不用手动维护更新时间了,省心。你可能会觉得这些字段太琐碎,但实际开发里时间字段出问题是最多的。
3.2 User实体类如何对应表结构
表结构定完,实体类直接映射过来:
package com.campus.shop.entity; import lombok.Data; import java.time.LocalDateTime; @Data public class User { private Long id; private String username; private String password; private String nickname; private String avatar; private String phone; private String email; private Integer userType; private Integer status; private Integer deleted; private LocalDateTime createTime; private LocalDateTime updateTime; }这里我用了Lombok的@Data注解,自动生成getter和setter,实体类里就看不到一堆冗长的getter/setter代码了。SSM时代很多老项目里你看到实体类几百行全是getter/setter,那就是没引入Lombok造成的。如果你要手写,记得每个字段都要生成对应的get和set方法,漏一个后面赋值全是null。
注意类型选择。所有数字状态都用包装类型Integer,而不是int。原因非常简单:数据库字段如果允许NULL,实体类用int去映射,拿到null时拆箱会直接抛NullPointerException;用Integer就安全得多,能表达出"没有值"这个语义。价格用BigDecimal而不是double,时间字段用LocalDateTime而不是java.util.Date,这几个选择在后续章节都会展开。
还有一个小技巧:实体类字段名和数据库字段名不一致时,比如数据库是user_name而Java是userName,靠的是前面配置的map-underscore-to-camel-case完成自动映射。所以你建表时用下划线命名,Java类里用驼峰命名,两边风格各取所长,这是Java项目最常用的约定,不需要额外写映射注解。
3.3 密码、状态和逻辑删除的设计讲究
密码字段设成VARCHAR(100),刚开始我写的是32,因为传统MD5加密后是32位十六进制字符串。后来想明白了,如果用BCrypt这类加盐算法,加密后的字符串长度是60个字符,32完全不够。既然还不知道最终会选哪种算法,干脆用100,后面怎么换都装得下。
密码存储两个原则必须坚持:第一,绝对不允许明文存库,哪怕是在课程设计和个人项目里,明文存储一旦数据库泄露,所有用户密码裸奔,这是事故级别的问题;第二,加密必须加盐,不要直接用MD5裸算,用Spring Security里自带的BCryptPasswordEncoder或者自作聪明地拼一个固定盐都行,加盐和不加盐的防彩虹表能力差距是数量级的。
status字段用来控制账号启停,0禁用1正常。deleted字段做逻辑删除。每次查询用户列表时都要记得带上deleted = 0条件,这个条件写漏了,把逻辑删除当成物理删除玩,就会出现"删了还在"的诡异现象。我建议在Mapper的查询SQL里把deleted条件写进where,而不是依赖业务层提醒自己去加。
逻辑删除的好处是误删了还能恢复,数据不会被真正抹掉,这在电商场景里非常实用。有同学会问那为什么不直接物理删除,逻辑删除不是反而要在每个查询里加条件吗?存在即合理,逻辑删除是为数据安全和可追溯性服务的。比如你想分析"哪些商品是删除过的",没有deleted字段你永远不知道答案。
4. 店铺和商品:一对多关系的核心
4.1 店铺表设计
店铺表是平台上“卖家侧”的核心。一个用户开店,先创建一条店铺记录,店铺需要管理员审核后才能营业,这个审核状态必须在表里体现。
CREATE TABLE tb_shop ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', owner_id BIGINT NOT NULL COMMENT '店铺所有者用户ID', shop_name VARCHAR(64) NOT NULL COMMENT '店铺名称', shop_desc VARCHAR(255) DEFAULT NULL COMMENT '店铺描述', shop_address VARCHAR(255) DEFAULT NULL COMMENT '地址', shop_phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', logo_url VARCHAR(255) DEFAULT NULL COMMENT '店铺LOGO地址', shop_status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审核 1-正常 2-禁用', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner_id (owner_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店铺表';owner_id是外键逻辑,关联用户的id。我没有在数据库层面加FOREIGN KEY外键约束,很多生产环境里的表都这么做,理由是外键约束会影响插入性能,而且业务层已经保证了引用关系。新手可能很难接受“不建外键”,但你要明白外键的本质是数据完整性约束,在业务代码里通过事务和逻辑同样能保证这个完整性,代价是代码上更自觉一点。
店铺状态用了三个值:0待审核、1正常、2禁用。为什么不直接用0和1两个状态?因为现实业务里必须有审核环节。你如果直接给一个“可营业”和“不可营业”两个状态,无法区分“还没提交审核”和“提交了但没通过”,后面对管理员的审核列表就没法做。
shop_name没有加唯一索引,因为不同卖家理论上是可以取重名店铺名的,但如果你想限制重名,把UNIQUE KEY加在这上面也很容易,这个取决于产品规则,不是技术问题。
4.2 商品分类表设计
分类表很容易被忽略,但它是商品模块的骨架。没有分类表的话,商品表里只能硬编码一个category字段,查子分类会变得非常痛苦。
CREATE TABLE tb_product_category ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', category_name VARCHAR(32) NOT NULL COMMENT '分类名称', category_desc VARCHAR(255) DEFAULT NULL COMMENT '分类描述', parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示顶级分类', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表';parent_id是分类表独有的设计。顶级分类的parent_id记为0,二级分类的parent_id记录它所属顶级分类的id。这样设计的好处是分类可以无限级地嵌套下去,比如“数码”下面挂“手机”,“手机”下面挂“苹果手机”。
实体类对应起来也是很直白:
@Data public class ProductCategory { private Long id; private String categoryName; private String categoryDesc; private Long parentId; private LocalDateTime createTime; private LocalDateTime updateTime; }这里我同样没在实体类里写List children这个属性。原因下面专门展开说,这也是很多新手项目容易走偏的地方。
4.3 商品表设计
商品表是店铺和分类两棵树的连接点,也是订单数据的数据源。
CREATE TABLE tb_product ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', shop_id BIGINT NOT NULL COMMENT '所属店铺ID', category_id BIGINT NOT NULL COMMENT '分类ID', product_name VARCHAR(64) NOT NULL COMMENT '商品名称', product_desc VARCHAR(255) DEFAULT NULL COMMENT '商品描述', product_img VARCHAR(255) DEFAULT NULL COMMENT '商品图片', price DECIMAL(10,2) NOT NULL COMMENT '销售价格', stock INT NOT NULL DEFAULT 0 COMMENT '库存', sales INT NOT NULL DEFAULT 0 COMMENT '销量', product_status TINYINT NOT NULL DEFAULT 1 COMMENT '0-下架 1-上架', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_shop_id (shop_id), KEY idx_category_id (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';商品表的三个核心点是:第一个,price必须用DECIMAL(10,2)而不是double,十进制精确存储金额,二进制浮点存金额会出0.1加0.2不等于0.3的问题,这个在后面订单模块会再深入;第二个,stock库存是INT,卖出一个扣一个,但扣库存的SQL不能简单写成UPDATE tb_product SET stock = stock - 1,那样并发会超卖,需要在WHERE条件里加上stock > 0,这个细节等实现下单接口那期再写具体SQL;第三个,sales销量字段是个反范式设计,理论上可以从订单表统计,但每次统计订单表太慢,所以商品表里冗余一个累加字段,订单完成时加1。
shop_id和category_id都加了普通索引,不是为了规范好看,是因为后面商品列表经常按店铺查、按分类查,没有索引这些查询会走全表扫描,数据量稍微上来页面直接就卡了。
4.4 Java实体里的一对多怎么写,不怎么写
到了这里,你可能会觉得商品实体类里应该有一个List 属性,店铺实体里应该放一个List 的集合,这样看起来才"面向对象"。我建议第一期不要这么写。
POJO实体类最好只放数据库里那一行的字段映射,一对多关系应该放到VO(视图对象)里去组装。例如查询店铺详情页,你需要返回店铺信息和这个店铺下的商品列表,正确的做法是新建一个ShopVO,里面包含Shop的字段加上一个List ,然后通过一次查询把数据组装出来。
原因有三个:第一,如果实体类里直接放关联集合,你查一次用户可能会把他关联的所有订单、所有地址、所有购物车全部加载出来,查询性能极差,还会出现循环引用问题;第二,MyBatis在返回实体类时,如果你只是单表查询,集合属性永远是null,你还要花心思去处理它,非常尴尬;第三,实体类保持纯净,复用的时候不用考虑"这次查出来的User里有没有Orders”,逻辑清爽。
正确做法是:实体类只对应单表,联表查询和组合数据放到VO/DTO里,在Mapper中单独写方法、单独写resultMap。这个原则从第一期就要养成。
5. 订单模块与状态机
5.1 订单主表和订单明细表
订单模块是整个系统最值得花心思的部分,也是表设计里最能体现经验的地方。我用了两张表:订单主表和订单明细表。
CREATE TABLE tb_order ( id BIGINT UNSIGNED NOT NULL 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', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', order_status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待付款 1-已付款 2-已发货 3-已收货 4-已取消', receiver_name VARCHAR(32) NOT NULL COMMENT '收货人姓名', receiver_phone VARCHAR(20) NOT NULL COMMENT '收货人手机号', receiver_address VARCHAR(255) NOT NULL COMMENT '收货地址', remark VARCHAR(255) DEFAULT NULL COMMENT '买家备注', deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL COMMENT '付款时间', send_time DATETIME DEFAULT NULL COMMENT '发货时间', receive_time DATETIME DEFAULT NULL COMMENT '收货时间', cancel_time DATETIME DEFAULT NULL COMMENT '取消时间', PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_shop_id (shop_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';订单主表存的是一次订单的整体信息,包括下单人、收货信息、总金额、订单状态以及各个关键节点的时间。pay_time、send_time、receive_time、cancel_time这四个字段是时间戳字段,它们的作用是记录订单每个重要步骤的完成时间,方便后面做超时未支付自动关单、物流超时提醒这些功能。
CREATE TABLE tb_order_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', product_id BIGINT NOT NULL COMMENT '商品ID', product_name VARCHAR(64) NOT NULL COMMENT '商品名称快照', product_img VARCHAR(255) DEFAULT NULL COMMENT '商品图片快照', product_price DECIMAL(10,2) NOT NULL COMMENT '成交单价快照', quantity INT NOT NULL DEFAULT 1 COMMENT '购买数量', subtotal DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';注意订单明细表里为什么要冗余product_name、product_img、product_price这些商品字段。你可能会想,为什么不直接通过product_id去关联查询商品表?
因为商品是会变得。卖家改个商品名、调个价格、换张图片,如果你订单只存product_id,那历史订单显示的名称价格就会跟着商品表变,这就完全失真了。买家购买时的商品信息应该是一张“快照”,当时是什么样子就是什么样子。这是电商系统里非常经典的冗余设计,叫数据快照。以后你写订单导出、对账、开发票功能,会发现这些冗余字段能帮你省下无数麻烦。
5.2 订单状态机设计
订单状态字段用0到4五个数字分别代表:0待付款、1已付款、2已发货、3已收货、4已取消。这些数字如果用int散落在代码里,时间长了绝对会写错判断条件,所以我强烈建议在第一期就定义一个枚举类:
public enum OrderStatus { UNPAID(0, "待付款"), PAID(1, "已付款"), SHIPPED(2, "已发货"), RECEIVED(3, "已收货"), CANCELED(4, "已取消"); private final Integer code; private final String desc; OrderStatus(Integer code, String desc) { this.code = code; this.desc = desc; } public Integer getCode() { return code; } public String getDesc() { return desc; } }状态不是随便乱串的,它是一个状态机,几条转移路径必须先定死:
- 待付款可以变为已付款,可以变为已取消。
- 已付款可以变为已发货,不允许直接变为已取消。
- 已发货可以变为已收货。
- 已收货是终态。
- 已取消是终态。
这些规则在Service层做校验,在后端写订单状态流转方法时,不要用一堆if else裸写,而应该用一个统一的方法接收当前状态、目标状态,在方法内判断路径是否合法。这个设计逻辑现在你理解了,后几期写订单接口时会非常省力。
为什么我不推荐直接用数据库的枚举类型存状态?因为MySQL里的ENUM类型修改枚举值需要ALTER TABLE,而且与Java层的状态定义不一致会很难排查。TINYINT加注释,Java侧用枚举去映射,两边各自干自己擅长的事。
5.3 金额与库存的硬性约定
订单总金额total_amount和明细金额我全部用了DECIMAL(10,2),这是一个一开始就必须定死的约定。任何金额字段,不管是价格、总价、小计、退款金额还是优惠金额,都不能用float或double。
计算机里的二进制浮点数是没法精确表示大多数十进制的,你用double做两次运算,结果可能是9.999999999,打印出来你都能吓一跳。DECIMAL是真正的十进制精确类型,在MySQL里就是为金额这种需求准备的。长度选10,2代表整数部分8位小数2位,校园商铺这个量级够了。
另一个硬性约定是库存。我曾经在处理库存扣减时写过一个看起来没毛病的代码逻辑:先查询库存,判断库存大于0,再UPDATE stock = stock - 1。结果高并发下有两个请求同时读到库存是1,都认为可以扣,最后把库存扣成了负数。正确的方式是把判断条件放到UPDATE语句里:
UPDATE tb_product SET stock = stock - 1 WHERE id = #{productId} AND stock > 0;这条SQL返回的影响行数是0,就说明库存不足。这个设计在表结构层面很早就能确定下来,不需要等到写Service那天。
6. 建库建表SQL与逆向工程
6.1 完整的建库建表SQL
把本期设计的表汇总成一份完整SQL文件,命名为schema.sql,放在项目resources目录下。这里给出完整内容:
CREATE DATABASE IF NOT EXISTS campus_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE campus_shop; DROP TABLE IF EXISTS tb_order_item; DROP TABLE IF EXISTS tb_order; DROP TABLE IF EXISTS tb_product; DROP TABLE IF EXISTS tb_product_category; DROP TABLE IF EXISTS tb_shop; DROP TABLE IF EXISTS tb_user; CREATE TABLE tb_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(32) NOT NULL COMMENT '用户名', password VARCHAR(100) NOT NULL COMMENT '密码', nickname VARCHAR(32) DEFAULT NULL COMMENT '昵称', avatar VARCHAR(255) DEFAULT NULL COMMENT '头像地址', phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', email VARCHAR(64) DEFAULT NULL COMMENT '邮箱', user_type TINYINT NOT NULL DEFAULT 0 COMMENT '0-买家 1-卖家', status TINYINT NOT NULL DEFAULT 1 COMMENT '0-禁用 1-正常', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE tb_shop ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', owner_id BIGINT NOT NULL COMMENT '店主用户ID', shop_name VARCHAR(64) NOT NULL COMMENT '店铺名称', shop_desc VARCHAR(255) DEFAULT NULL COMMENT '店铺描述', shop_address VARCHAR(255) DEFAULT NULL COMMENT '店铺地址', shop_phone VARCHAR(20) DEFAULT NULL COMMENT '店铺电话', logo_url VARCHAR(255) DEFAULT NULL COMMENT '店铺LOGO', shop_status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审核 1-正常 2-禁用', deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner_id (owner_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='店铺表'; CREATE TABLE tb_product_category ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', category_name VARCHAR(32) NOT NULL COMMENT '分类名称', category_desc VARCHAR(255) DEFAULT NULL COMMENT '分类描述', parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父分类ID,0为顶级', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表'; CREATE TABLE tb_product ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', shop_id BIGINT NOT NULL COMMENT '店铺ID', category_id BIGINT NOT NULL COMMENT '分类ID', product_name VARCHAR(64) NOT NULL COMMENT '商品名称', product_desc VARCHAR(255) DEFAULT NULL COMMENT '商品描述', product_img VARCHAR(255) DEFAULT NULL COMMENT '商品图片', price DECIMAL(10,2) NOT NULL COMMENT '价格', stock INT NOT NULL DEFAULT 0 COMMENT '库存', sales INT NOT NULL DEFAULT 0 COMMENT '销量', product_status TINYINT NOT NULL DEFAULT 1 COMMENT '0-下架 1-上架', deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_shop_id (shop_id), KEY idx_category_id (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE tb_order ( id BIGINT UNSIGNED NOT NULL 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', total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额', order_status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待付款 1-已付款 2-已发货 3-已收货 4-已取消', receiver_name VARCHAR(32) NOT NULL COMMENT '收货人', receiver_phone VARCHAR(20) NOT NULL COMMENT '收货电话', receiver_address VARCHAR(255) NOT NULL COMMENT '收货地址', remark VARCHAR(255) DEFAULT NULL COMMENT '备注', deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, send_time DATETIME DEFAULT NULL, receive_time DATETIME DEFAULT NULL, cancel_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_shop_id (shop_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; CREATE TABLE tb_order_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', product_id BIGINT NOT NULL COMMENT '商品ID', product_name VARCHAR(64) NOT NULL COMMENT '商品名称快照', product_img VARCHAR(255) DEFAULT NULL COMMENT '商品图片快照', product_price DECIMAL(10,2) NOT NULL COMMENT '成交单价快照', quantity INT NOT NULL DEFAULT 1 COMMENT '数量', subtotal DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';执行这份SQL时有个顺序问题:带外键逻辑关系的表要先删子表再删父表,这里没有物理外键约束,但DROP TABLE的顺序依然按依赖关系从强到弱排列。tb_order_item依赖tb_order,tb_product依赖tb_shop,tb_shop依赖tb_user,所以倒序删除最安全。
数据库字符集一定要用utf8mb4而不是utf8。MySQL里的utf8最多3字节,存emoji表情会报错,utf8mb4是完整4字节对utf8的超集。现在不知道什么时候用户就会在昵称里放个表情,提前用utf8mb4能省一个以后的大坑。
6.2 用逆向工程生成实体类
建完表之后,MyBatis提供的一套逆向工程工具可以帮我们批量生成实体类、Mapper接口和XML文件。配置一个generatorConfig.xml放在resources下:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE generatorConfiguration PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN" "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd"> <generatorConfiguration> <context id="campusShopContext" targetRuntime="MyBatis3Simple"> <jdbcConnection driverClass="com.mysql.cj.jdbc.Driver" connectionURL="jdbc:mysql://localhost:3306/campus_shop?serverTimezone=Asia/Shanghai" userId="root" password="root"/> <javaModelGenerator targetPackage="com.campus.shop.entity" targetProject="src/main/java"/> <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources"/> <javaClientGenerator type="XMLMAPPER" targetPackage="com.campus.shop.mapper" targetProject="src/main/java"/> <table tableName="tb_user" domainObjectName="User"/> <table tableName="tb_shop" domainObjectName="Shop"/> <table tableName="tb_product_category" domainObjectName="ProductCategory"/> <table tableName="tb_product" domainObjectName="Product"/> <table tableName="tb_order" domainObjectName="Order"/> <table tableName="tb_order_item" domainObjectName="OrderItem"/> </context> </generatorConfiguration>然后在pom.xml里加插件:
<plugin> <groupId>org.mybatis.generator</groupId> <artifactId>mybatis-generator-maven-plugin</artifactId> <version>1.4.0</version> <configuration> <configurationFile>src/main/resources/generatorConfig.xml</configurationFile> <overwrite>true</overwrite> <verbose>true</verbose> </configuration> <dependencies> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> </dependencies> </plugin>执行mvn mybatis-generator:generate,它会自动为每张表生成一个对应实体类、一个Mapper接口和一个XML文件。这个工具的价值在于快速生成最基础的CRUD方法,比如selectByPrimaryKey、insert、updateByPrimaryKey、deleteByPrimaryKey。
但必须清醒认识到,逆向工程只帮你解决“表怎么映射成实体”的重复劳动,复杂联表查询、动态条件SQL、批量插入它一概不管。比如订单查询需要联表把商品快照带出来,还是得手写XML里的大段SQL。工具是加速器,不是替代品。
关于targetProject路径,用src/main/java这种相对路径时,如果你是用IDEA直接执行Maven命令,大概率没问题;如果在命令行执行且路径报错,改成绝对路径src/main/java或者直接指定targetProject="src/main/java"对应的完整文件路径即可,这个报错很常见,见到了别慌。
6.3 从SSM到Spring Boot的配置差异清单
这一期最核心的目标就是搞清楚SSM和Spring Boot的差异。实体类没有变,Mapper接口没有变,Service和Controller的写法也没有伤筋动骨,真正变掉的是装配方式。我把差异做成了一张表:
| 配置项 | SSM传统方式 | Spring Boot方式 |
|---|---|---|
| 依赖管理 | pom里手动维护各个框架版本 | starter统一管理版本,parent指定版本 |
| Web容器 | 外部Tomcat,配置web.xml | 内嵌Tomcat,无需web.xml |
| Spring配置 | applicationContext.xml加扫描 | 入口类@SpringBootApplication自动扫描 |
| SpringMVC配置 | springmvc.xml配视图解析器、拦截器 | 自动配置,少量自定义用WebMvcConfigurer |
| MyBatis配置 | mybatis-config.xml独立文件 | application.yml的mybatis节点 |
| 数据源 | 手动创建DataSourceBean | spring.datasource自动装配 |
| Mapper扫描 | XML里配 | @MapperScan一行注解 |
| SQL映射 | mapper XML路径在mybatis-config里配置 | mybatis.mapper-locations |
这张表基本概括了从SSM迁到Spring Boot要动的所有关键位置。你如果手头有一个SSM老项目,把配置按表格右边换一遍,业务代码大部分可以原样保留。
我在迁移时踩过一个关于MyBatis版本适配的坑:SSM老项目用的是mybatis 3.2,Spring Boot里用mybatis-spring-boot-starter 2.3.2,代码里直接用LocalDateTime时发现SQL执行后时间字段映射不上。后来查明白是MyBatis从3.4版本才内置了对Java 8日期类型的支持,老版要么加依赖要么升级,这种问题在迁移时一抓一个准。所以版本升级也要关注,不是Spring Boot本身解决一切。
7. 常见问题与排查技巧
7.1 时间差8小时的三个原因
时间字段是新手最容易踩的坑,而且踩完还懵。程序里查出来时间是北京时间,存到数据库少了8小时,或者反过来从库里查出来比本地时间晚了8小时。这通常有三个层面的原因叠加:
第一,JDBC连接URL里没有加serverTimezone=Asia/Shanghai。MySQL 8的驱动要求必须指定时区,加了这参数告诉驱动以北京时间解析。第二,Jackson序列化时没有设置时区,Spring Boot默认时区不是GMT+8,需要加spring.jackson.time-zone=GMT+8。第三,数据库服务器本身时区设置不对,可以通过SQL执行SELECT NOW()看看返回的时间是否正常,不正常需要改MySQL配置。
排查思路是先看MySQL返回的时间对不对,再看Java代码查询出来的时间对不对,逐层定位是哪一层出了问题。我建议从第一期建表开始就把这三处统一配好,后面时间相关的聚合查询就不会莫名奇妙地差出8小时。
7.2 下划线转驼峰映射失败
建表用的是user_name,实体类写的是userName,查询返回后userName为null,这种问题十有八九是map-underscore-to-camel-case没配置。
在SSM里,这个配置写在mybatis-config.xml的 节点;在Spring Boot里,写在application.yml的mybatis.configuration节点,注意节点的层级,写错位置也会默默失效。配置项一旦生效,MyBatis会在映射结果时自动把数据库列名user_name转成Java属性userName,不需要你手动写resultMap。
如果你不想依靠自动转换,而是想精确控制映射,可以手写resultMap并用column="user_name" property="userName"。但一张表几十个字段全手写resultMap,那个模板量真的很劝退,所以我强烈建议在项目初期就把自动驼峰转换打开。这也是表设计统一用下划线风格的前提,风格不一致会导致这个配置也救不了你。
7.3 表名和关键字冲突
表名命名的第一个原则是避开数据库保留字。order是一个MySQL保留字,你如果建一张order表,很多SQL语句里是SELECT * FROM order,直接报语法错误,必须写成order加反引号。我当时为了避免所有问题,统一给业务表加了tb_前缀,这纯粹是命名习惯,但能规避大量潜在冲突。
另外还有一个关于表名大小写的坑。MySQL在Windows上默认大小写不敏感,在Linux上是敏感的。你如果在Windows开发时建的表名是Tb_User,部署到Linux服务器上,SQL里写tb_user可能就找不到表。所以团队约定最省事的做法就是全小写命名,字段也是全小写加下划线,这样不管部署到什么系统都不会因为大小写问题炸掉。
7.4 其他容易踩的配置坑
逆向工程生成实体类时,如果targetProject的值在项目里不存在,工具会直接报错。解决方式很简单:先确保src/main/java目录存在,或者直接用绝对路径。这个报错信息可能不太直观,新手容易卡在那一步。
Lombok在编译时的注解处理也需要IDE支持。如果你用的是高版本IDEA,直接在设置里勾上Annotation Processing,否则就算依赖配好,实体类里的getter/setter也生成不出来,编译会报错。还有一点,如果你后来接手一个老项目,项目里实体类没有用Lombok而是手动写了getter/setter,不要贸然的把两者混用,一个类里既有@Data又手写某些getter,生成规则会乱。
密码字段的范围用VARCHAR(100)是我踩过坑得来的结论。最早用MD5时写的VARCHAR(32),后来切换BCrypt,原来所有密码字段都放不下,只能ALTER TABLE去扩宽。这种早期设计失误代价不大但很烦,一开始留够余量会省很多事。
最后分享一个关于建模节奏的体会
这一期虽然只完成了实体类和表结构的创建,但如果你真的照着把SQL执行一遍、把实体类写出来,会发现自己对项目的理解已经上了一个台阶。因为数据库表结构其实就是产品需求的浓缩版——业务有哪些角色、有哪些核心状态、哪些数据需要保存历史,全都在表里暴露无遗。
我个人在实际操作中的体会是,建表这件事是“前期多花一小时,后期省一天”的典型。刚开始写这个校园商铺项目时,我一直在纠结订单表需不需要快照字段,后来发现没有快照字段,订单详情页就永远无法展示买家下单那一刻的商品价格和名称。想明白这一层之后,我在设计其他表时都会下意识问自己一句:这个字段如果现在不加,以后要加会有多痛苦?
下一期我会顺着这几张表把Mapper层写起来,用逆向工程生成的代码作为底子,再补上手写的联表查询SQL,把商品列表、店铺详情、订单详情这些基础查询接口一步一步跑通。到时候你会真正体会到,地基打得稳,往上盖楼真的是一马平川。