







本项目不涉及到微服务架构,所有模块的数据表都统一放在一个数据库中。
因此只需创建一个数据库,命名为 reggie 。
CREATE DATABASE IF NOT EXISTS reggie CHARACTER SET 'utf8';
根据功能划分为不同模块,我根据模块的不同创建不同的数据表。
| 数据表 | 描述 | 难点 |
|---|---|---|
| 后台系统用户表 | 该数据库的root用户就是管理员 | |
| C 端用户表 | ||
| 订单表 | ||
| 菜品表 | ||
| 地址表 | ||
| 套餐表 |
很不幸,我想破脑袋也只能想到划分为 6 张表。但老师对该项目却划分出 11 张表。
导入老师设计好的数据表:


老师设计数据表的思路:
| 数据表 | 描述 |
|---|---|
| address_book | 地址簿表 |
| category | 菜品和套餐的分类表 (荤菜、素菜、周一套餐 ) |
| dish | 菜品表 |
| dish_flavor | 菜品口味关系表 |
| employee | 后台系统普通员工表 |
| order_detail | 订单明细表 |
| orders | 订单表 |
| setmeal | 套餐表 |
| setmeal_dish | 套餐菜品关系表 |
| shopping_cart | 购物车表 (感觉增删会很频繁) |
| user | C 端用户表 |
创建前首先确保自己的 Maven 软件和 Maven 仓库已经与 IDEA 关联好:


按下图设置:

导入依赖,有些依赖如 MyBatis-Plus 和 Druid 依赖没有被官方收录,我们先导入下图的三个:

打开项目的 pom.xml 文件,加入以下依赖:
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.4.3version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.76version>
dependency>
<dependency>
<groupId>commons-langgroupId>
<artifactId>commons-langartifactId>
<version>2.6version>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>druidartifactId>
<version>1.2.12version>
dependency>
牢记每次修改完 pom.xml 配置文件都必须按 Shift + Ctrl + O 来刷新 Maven,Maven 才能帮你下载并导入依赖。

把 Spring Boot 默认的配置文件 application.properties 修改为 application.yml :

在 application.yml 中添加如下配置:
# 配置服务器端口
server:
port: 8080
# 配置Druid数据库连接池
spring:
application:
# 应用名称 (可选)
name: reggie_take_out
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 你的数据库密码
# 配置Mybatis-Plus
mybatis-plus:
configuration:
# 在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
# 例如:属性名映射 user_name --> userName
# 例如:类名映射 address_book --> AddressBook
map-underscore-to-camel-case: true
# 开启MP运行日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: ASSIGN_ID
打开 src/main/java/edu/ouc/ReggieTakeOutApplication.java ,添加 Lombok 提供的 @Slf4j 注解,方便输出日志来调试:
@Slf4j // 日志
@SpringBootApplication // Spring Boot启动类
public class ReggieTakeOutApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieTakeOutApplication.class, args);
// 打印Slf4j日志
log.info("项目启动成功");
}
}
运行 ReggieTakeOutApplication.java :

看到如图所示的输出,就说明你的 Spring Boot 开发环境已经搭建好了。
本项目以后端开发为主要学习目标,前端代码已经提供好,直接加载即可。

粘贴到 src/main/resources/static 目录下:

重启项目,在浏览器中输入 http://localhost:8080/backend/index.html :
看到此页面说明前端代码已成功导入。
先来查看后台登录的前端页面,浏览器地址栏输入 http://localhost:8080/backend/page/login/login.html :

按 F12 打开浏览器的控制台,点击 “登录” 按钮,查看浏览器是以何种方式向服务器发送请求的:

可以看到请求方式是 POST ,请求 URL 是 http://localhost:8080/employee/login 。由于我们还没写对应的 Controller ,报 404 是很正常的。
按照 Spring Boot 的开发思路,我们应该按照:数据层 (Mapper) –> 服务层 (Service) –> 表现层 (Controller) 三步走来开发。

后台登录功能对应的数据表为 employee ,其表结构如下:
DESC employee;

对数据表中的字段进行逐一分析:
| 字段 | 作用 |
|---|---|
| id | 员工编号,主键,为什么不用自增?可能使用了MyBatis-Plus的雪花算法 |
| name | 员工姓名 |
| username | 登录账号,加了唯一约束,登录账号不允许重复 |
| password | 登录密码 |
| phone | 手机号码 |
| sex | 性别 |
| id_number | 身份证号码 |
| status | 员工状态 (禁用/可用) |
| create_time | 创建时间 |
| update_time | 修改时间 |
| create_user | 创建人是谁,以员工ID记录 |
| update_user | 修改人是谁,以员工ID记录 |
目前数据表 employee 仅有一条记录,就是后台管理员的记录:

打开 src/main/resources/static/backend/page/login/login.html ,这是后台管理系统的登录页面。
其中,我们后端工程师需要关心的最核心的东西就是:前后端数据交换的统一格式 (也称前后端协议) 。具体来说,是后端响应给前端的数据格式。就是下面这段代码:
methods: {
async handleLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (valid) {
this.loading = true
let res = await loginApi(this.loginForm)
if (String(res.code) === '1') { // code:状态,1表示登录成功
localStorage.setItem('userInfo', JSON.stringify(res.data)) // data:数据,这里指的是账号和密码
window.location.href = '/backend/index.html'
} else { //登录失败
this.$message.error(res.msg) // msg:登录失败提示信息
this.loading = false
}
}
})
}
}
从上面这段登录代码中可以看出来,这里和前端工程师约定好的前后端数据交换的统一格式应当包含 3 部分:
| 协议 | 描述 |
|---|---|
| code | 状态,1 表示成功 |
| data | 传递的数据 |
| msg | 操作失败/成功的提示信息 |
json 格式如下所示:
res{
"code":1,
"data":{
"username":"admin",
"password":"123456"
},
"msg":"登录成功/登录失败"
}
创建数据表 employee 的实体类 Employee.java 。并用 Lombok 快速生成 Getter 、Setter 、toString() 、equals() 等。
其中的属性与数据表 employee 的字段一一对应。
创建 src/main/java/edu/ouc/entity/Employee.java :
@Data
public class Employee {
private static final Long serialVersionUID = 1L;// 序列化ID
private Long id;
private String name;
private String username;
private String password;
private String phone;
private String sex;
private String idNumber;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT)
private Long updateUser;
}
登录只涉及到对数据库的查询操作。即,根据账户名 username 查询密码 password 。
创建数据层、服务层和表现层对应的文件夹。

创建 src/main/java/edu/ouc/mapper/EmployeeMapper.java 。
继承 MyBatis-Plus 的 BaseMapper 泛型接口,添加 @Mapper 注解。就能获取父类 BaseMapper 中已经写好的增删改查方法。
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}
创建 src/main/java/edu/ouc/service/IEmployeeService.java 。
我们可以让服务层接口继承 MyBatis-Plus 的 IService 泛型接口来进行快速开发。
public interface IEmployeeService extends IService<Employee> {
}
继承 IService 后能获取很多通用的增删改查方法:

src/main/java/edu/ouc/service/impl/IEmployeeServiceImpl.java 。ServiceImpl ,再实现 IEmployeeService.java 。其中, 中的 M 指的是对应的 DAO 接口,T 指的是实体类。这样,我们就无需实现 IEmployeeService.java 中全部的方法,而是根据需要,既可以使用提供的基础 CRUD 方法,也可以自定义新的方法。创建 src/main/java/edu/ouc/common/R.java 。
@Data
public class R<T> {
private Integer code;
private T data;
private String msg;
private Map map = new HashMap();// 动态数据
public R() {
}
// 适用于成功
public static <T> R<T> success(T object) {
R<T> r = new R<>();
r.code = 1;
r.data = object;
return r;
}
// 适用于失败
public static <T> R<T> error(String msg) {
R<T> r = new R<>();
r.code = 0;
r.msg = msg;
return r;
}
// 操作动态数据的
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
在表现层所有方法的返回值类型都为 R ,这是与前端约定好的响应格式。
| 协议 | 描述 |
|---|---|
| code | 状态,1 表示成功,0和其他数字为失败 |
| data | 传递的数据 |
| msg | 操作失败的提示信息 |
编写代码前,一定要把逻辑理顺了再开始敲代码。
我的逻辑:
usernamr 和密码 password 封装成一个 Employee 对象。login() 方法。老师的逻辑:

创建 src/main/java/edu/ouc/controller/EmployeeController.java 。
由于前端页面登录时,向后端请求的 URL 地址是 http://localhost:8080/employee/login :

因此,表现层 EmployeeController 的类上面要添加请求映射注解 @RequestMapping("/employee") ,登录的方法上添加 POST 请求映射注解 @PostMapping("/login") 。
EmployeeController.java :
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {
// 注入业务层
@Autowired
private IEmployeeService empService;
// 后台员工登录登录方法
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
String username = employee.getUsername();
String password = employee.getPassword();
// 1.对前端传入的密码进行md5加密
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 2.根据前端传入的账号去数据库中查询
// 2.1 创建查询条件对象
LambdaQueryWrapper<Employee> lqw = new LambdaQueryWrapper<>();
// 2.2 使用MP的等值查询eq
lqw.eq(Strings.isNotEmpty(username), Employee::getUsername, username);
// 2.3 根据lqw的条件进行等值查询
Employee emp = employeeService.getOne(lqw);
// 3.判断查询到的员工是否为空
if (emp == null) {
return R.error("用户不存在,登录失败");
}
// 4.密码比对
if (!password.equals(emp.getPassword())) {
return R.error("密码错误,登录失败");
}
// 5.查看员工状态是否禁用
if (emp.getStatus() != 1) {
return R.error("该账号已禁用");
}
// 6.将员工ID存放在Session保存作用域中
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
}
由于我们经常需要 Debug ,把因此要把前端请求超时时间设置得大一些,以方便我们调试。
打开 src/main/resources/static/backend/js/request.js 。第 8 行,单位是毫秒,默认值 10000 ,即 10s 。修改为 1000000 :
// 超时
timeout: 1000000
启动服务,浏览器访问 http://localhost:8080/backend/page/login/login.html :


登录成功。
我们在登录方法 login() 中将查询到的员工 ID 保存在Session保存作用域中。我们可以按 F12 来查看保存作用域中的内容:

登录成功后,进入后台管理的主页,当点击右上角的图标后,就会退出登录:

按 F12 打开浏览器的控制台,点击退出图标,查看浏览器是以何种方式向服务器发送请求的:

可以看到请求方式是 POST ,请求 URL 是 http://localhost:8080/employee/logout 。由于我们还没写对应的 Controller 中的退出登录方法,报 404 是很正常的。
可是,退出登录不需要提交任何表单啊,为什么用 POST 请求方式呢?
打开 src/main/resources/static/backend/index.html ,这是后台管理系统的主页。
logout() {
logoutApi().then((res)=>{
if(res.code === 1){ // 当后端返回code=1时退出登录成功
localStorage.removeItem('userInfo') // 删除保存作用域中的数据
window.location.href = '/backend/page/login/login.html' // 跳转回登录页面
}
})
}
因此,我们因该在表现层的 logout() 方法中返回一个属性 code = 1 的 R 。
由于针对 Employee 的数据层、业务层和表现层都已经在上一节写好了。因此直接在表现层 EmployeeController 写退出登录的方法即可。
EmployeeController.java :
// 后台员工退出登录方法
@PostMapping("/logout")
public R<String> logout(HttpServletRequest request) {
// 1.清除Session保存作用域中保存的数据
request.getSession().removeAttribute("employee");
// 2.返回结果
return R.success("退出成功");
}
重启服务,浏览器访问 http://localhost:8080/backend/index.html :

点击右上角的图标,退出登录:

自动跳转回登录页,且 Session 保存作用域中的数据被删除:

http://localhost:8080/backend/index.html 也能直接访问后台管理的主页。这是极其不合理的。LoginCheckFilter ;@ServletComponentScan ;我们先不实现过滤器的具体处理逻辑,先把上述前 2 个步骤先搭建起来。
创建 src/main/java/edu/ouc/filter/LoginCheckFilter.java 。
// 过滤器注解
// filterName:过滤器名称,可以随便起
// urlPatterns:想要拦截的URL地址,/*表示拦截所有请求
@WebFilter(filterName = "LoginCheckFilter", urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
// 强转
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 打印日志
log.info("拦截到请求:{}", request.getRequestURI());
// 放行
filterChain.doFilter(request, response);
}
}
然后在启动类 ReggieTakeOutApplication.java 中加入 Servlet 组件扫描注解 @ServletComponentScan :
@Slf4j // 日志
@SpringBootApplication // Spring Boot启动类
@ServletComponentScan // Servlet组件扫描,扫描过滤器
public class ReggieTakeOutApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieTakeOutApplication.class, args);
// 打印Slf4j日志
log.info("项目启动成功");
}
}
重启服务,浏览器访问 http://localhost:8080/backend/index.html ,IDEA 控制台上就能看到所有拦截到的请求:


每个 HTML 页面都引入了 src/main/resources/static/backend/js/request.js 这个 JS 文件。里面定义了一个前端的拦截器:
// 响应拦截器
service.interceptors.response.use(res => {
if (res.data.code === 0 && res.data.msg === 'NOTLOGIN') {// 返回登录页面
console.log('---/backend/page/login/login.html---')
localStorage.removeItem('userInfo')
window.top.location.href = '/backend/page/login/login.html'
} else {
return res.data
}
},
error => {...}
)
当这个前端拦截器接收到从后端发来的 R 格式数据,如果 R 的属性 code 等于 0 且 msg 为 “NOTLOGIN” 时,就会跳转至登录页面。
打开 src/main/java/edu/ouc/filter/LoginCheckFilter.java :
// 过滤器注解
// filterName:过滤器名称,可以随便起
// urlPatterns:想要拦截的URL地址,/*表示拦截所有请求
@WebFilter(filterName = "LoginCheckFilter", urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter {
// Spring框架提供的路径匹配器,支持通配符
public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
// 强转
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 1.获取本次请求的URL
String requestURI = request.getRequestURI(); // /backend/index.html
// 2.定义不需要拦截的URL地址数组
String[] urls = new String[]{
"/employee/login", // 登录页面
"/employee/logout", // 退出登录
"/backend/**", // 后台页面的页面的静态资源
"/front/**" // 移动端页面的静态资源
};
// 3.判断本次请求URL是否需要拦截
Boolean check = check(urls, requestURI);
// 4.如果check为true则不需要处理,直接放行
if (check) {
filterChain.doFilter(request, response);
return;
}
// 5.如果需要处理,则判断登录状态
if (request.getSession().getAttribute("employee") != null) {
// 能进入说明已经登录,直接放行
filterChain.doFilter(request, response);
return;
}
// 6.走到这里就是没登录
// 向浏览器响应一个流,让前端读到R里面的数据
response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
}
// 核查请求URL是否在放行URL数组中,检查本次请求是否需要放行
private Boolean check(String[] urls, String requestURI) {
for (String url : urls) {
boolean match = PATH_MATCHER.match(url, requestURI);
if (match) {
return true;
}
}
// 循环完了都匹配不上就返回false
return false;
}
}
略。
在后台主页,右上方有 ”+添加员工“ 的按钮,点击直接进入添加员工的页面:

随便填点数据,点击 ”保存“ 查看前端是以何种请求方式请求后端,并查看是请求后端哪一个 URL 。


可以看到请求方式是 POST 请求,请求URL 是 /employee 。
向后端传递的数据如下所示:

需要向数据库插入一条记录。

其中,字段 id 由 MyBatis-Plus 的雪花算法自动生成,不需要我们设置;字段 username 有唯一约束不能重复,在后端代码中注意返回用户名已存在的提示;字段 status 默认值为 1 不用设置。
前端传送来的数据只有字段 name 、username 、phone 、sex 、id_number 。因此,剩下没有涉及到的字段都需要我们在表现层的 EmployeeController.java 的添加员工方法中设置。
其中,字段 password 我使用默认密码为身份证后 6 位为初始密码,这也非常常用。
添加员工的页面在 src/main/resources/static/backend/page/member/add.html 。
submitForm(formName, st) {
this.$refs[formName].validate((valid) => {
if (valid) {
if (this.actionType === 'add') {
const params = {
...this.ruleForm,
sex: this.ruleForm.sex === '女' ? '0' : '1'
}
addEmployee(params).then(res => {
if (res.code === 1) {
this.$message.success('员工添加成功!')
if (!st) {
this.goBack()
} else {
this.ruleForm = {...}
}
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
...
当后端返回 code = 1 时,才向用户表示添加成功。
EmployeeController.java 。我自己写的第一版代码:
// 新增员工功能
@PostMapping
public R<String> save(HttpServletRequest request, @RequestBody Employee employee) {
// 1.检验账号username是否已存在
String username = employee.getUsername();
LambdaQueryWrapper<Employee> lqw = new LambdaQueryWrapper<>();
lqw.eq(Employee::getUsername, username);
Employee emp = empService.getOne(lqw);
if (emp != null) {
return R.error("账号已存在,添加失败");
}
// 2.设置创建人ID
employee.setCreateUser((Long) request.getSession().getAttribute("employee"));
// 3.设置最后修改人ID
employee.setUpdateUser((Long) request.getSession().getAttribute("employee"));
// 4.设置初始密码为身份证后6位,并经过MD5加密
String idNumber = employee.getIdNumber();
String password = DigestUtils.md5DigestAsHex(idNumber.substring(idNumber.length() - 6).getBytes());
employee.setPassword(password);
// 5.设置创建时间
employee.setCreateTime(LocalDateTime.now());
// 6.设置创建时间
employee.setUpdateTime(LocalDateTime.now());
// 7.调用业务层保存到数据库中
empService.save(employee);
return R.success("添加成功");
}
但是老师是使用异常处理器来进行全局异常捕获。
对于字段 username 唯一索引,我的代码是直接在代码中判断数据库中是否已经有相同 suername 的记录。
但是这样会增多一次向数据库查询的次数,其实效率是很低的。
因此,老师在这里使用的是异常处理器进行全局异常捕获。
全局异常处理器底层是基于代理,来代理 Controller ,通过 AOP 把里面如 save() 这些方法拦截到,其中的异常统一汇总到全局异常处理器。
创建 src/main/java/edu/ouc/common/GlobalExceptionHandler.java 。
// Spring提供的注解,拦截所有注解是@RestController和@Controller的Controller
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody // 最终要返回json数据
@Slf4j
public class GlobalExceptionHandler {
// 异常处理器注解,括号里是该方法要处理的异常类型
@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex) {
log.info(ex.getMessage()); // Duplicate entry '111' for key 'employee.idx_username'
// 1.判断异常信息是否包含违反唯一约束的前面两个字
if (ex.getMessage().contains("Duplicate entry")) {
// 2.按空格把异常信息分割成String数组
String[] split = ex.getMessage().split(" ");
// 3.取数组索引为2的字符串,即重复的username
String msg = split[2] + "账号已存在,添加失败";
// 4.返回错误提示信息
return R.error(msg);
}
// 5.如果不是则返回未知错误
return R.error("未知错误");
}
}
目前 employee 数据表中记录情况如图:

输入相同的账号 lindaiyu :

点击 ”保存“ ,提示账号已存在:

修改一下账号,再点击保存:

保存成功:

只要登录了后台管理的主页,就会向后端发送 GET 请求:

请求的 URL 是 /employee/page ,并把当前页码 page=1 和 一页显示几行 pageSize=10 也一并提交到后端。
打开 src/main/resources/static/backend/page/member/list.html 。
await getMemberList(params).then(res => {
if (String(res.code) === '1') {
this.tableData = res.data.records || []
this.counts = res.data.total
}
})
可以看到前端代码会自动读取 Page 对象里的各种属性和信息。
MP 需要我们手动设置拦截器,往预编译 SQL 语句中最后追加 LIMIT 分页语句。
创建 src/main/java/edu/ouc/config/MPConfig.java ,定义用于分页功能的 MP 拦截器:
@Configuration // 声明为配置类,以便被Spring扫描到,读取配置
public class MPConfig {
@Bean // /Spring第三方Bean注解,以便被Spring管理
public MybatisPlusInterceptor mybatisPlusInterceptor() {
// 1.创建MP的拦截器容器
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 2.往MP拦截器容器中添加分页拦截器
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
// 3.返回拦截器容器
return interceptor;
}
}
在确定分页查询的入参时,员工页面中还有一个按员工姓名来查询的搜索框,我们来查看这个搜索框向后端发送的请求与 URL 是怎么样的:


可以看到,按员工姓名进行条件查询的请求 URL 与分页查询相比仅多了一个参数 name 。于是我们干脆也把该参数写在 Controller 分页查询的入参中。
打开 src/main/java/edu/ouc/service/IEmployeeService.java :
public interface IEmployeeService extends IService<Employee> {
// 分页查询+条件查询
Page<Employee> getPage(Long currentPage, Long pageSize, String name);
}
然后实现业务层接口 src/main/java/edu/ouc/service/impl/IEmployeeServiceImpl.java :
@Service
public class IEmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements IEmployeeService {
// 自动注入数据层
@Autowired
private EmployeeMapper employeeDAO;
// 分页查询+条件查询
@Override
public Page<Employee> getPage(Long currentPage, Long pageSize, String name) {
// 1.创建新的条件查询构造器,用于根据员工姓名搜索
LambdaQueryWrapper<Employee> lqw = new LambdaQueryWrapper<>();
// 2.添加过滤条件:根据员工姓名查询
lqw.like(Strings.isNotEmpty(name), Employee::getName, name);
// 3.添加排序条件:按最后修改时间降序排序
lqw.orderByDesc(Employee::getUpdateTime);
// 4.分页构造器
Page<Employee> page = new Page<>(currentPage, pageSize);
// 5.把分页和条件查询包装器传入数据层的selectPage()方法
return employeeDAO.selectPage(page, lqw);
}
}
打开 src/main/java/edu/ouc/controller/EmployeeController.java :
// 分页查询+根据员工姓名查询功能
@GetMapping("/page")
public R<Page<Employee>> getPage(Long page, Long pageSize, String name) {
return R.success(empService.getPage(page, pageSize, name));
}
我们数据量有限,可以在 src/main/resources/static/backend/page/member/list.html 修改成每页展示 5 条数据:
<el-pagination
class="pageList"
:page-sizes="[5, 10, 20, 30, 40]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="counts"
:current-page.sync="page"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>el-pagination>
data() {
return {
input: '',
counts: 0,
page: 1,
pageSize: 5, // 每页展示 5 条数据
tableData: [],
id: '',
status: '',
}
}



点击 “禁用” ,查看前端发送的请求方式和 URL 地址:




采用 PUT 请求,URL 是 /employee 。405 是没有 PUT 请求的 Servlet 。
此外,可以看见点击 “禁用” 按钮的同时,前端会发送要修改的员工 ID (这个 ID 会有问题,后面细说) 和要修改成的状态码 ( 0 表示要禁用) 给后端。在写后端相应接口时要接收这两个入参。
需要注意,只有管理员 (admin用户) 可以对其他普通用户进行添加员工、启用、禁用操作,所以普通用户登录系统后,添加员工、启用、禁用按钮不显示。在 src/main/resources/static/backend/page/member/list.html 实现的方式:
思路就是 Vue 初始化的时候,从 Session 保存作用域中获取当前登录用户的账号:
created() {
this.init()
this.user = JSON.parse(localStorage.getItem('userInfo')).username
}
然后判断当前用户的账号是否为 “admin” ,如果是的话才显示员工启用/禁用按钮。当 status 为 1 (启用状态) 时显示 “禁用” 按钮,反之显示 “启用” 按钮。
<el-button
type="text"
size="small"
class="delBut non"
@click="statusHandle(scope.row)"
v-if="user === 'admin'"
>
{{ scope.row.status == '1' ? '禁用' : '启用' }}
el-button>
但是我觉得老师的不合理,普通员工绝对不允许添加员工、编辑和操作其他员工的信息。因此我增加了一条是否是管理员的判断,普通员工登录后台时,添加员工、和整个 “操作” 一列都不会显示。
src/main/resources/static/backend/page/member/list.html :
<el-table-column
{ scope.row.status == '1' ? '禁用' : '启用' }}
el-button>
template>
el-table-column>
<el-button
v-if="user === 'admin'"
type="primary"
@click="addMemberHandle('add')"
>
+ 添加员工
el-button>
非管理员员工登录页面效果:

status ;修改状态失败的根本原因是:js 对 Long 型数据处理时丢失末位 2 位的精度,导致传输给后端的 ID 的最后两位全部变成 “00” ,与数据库中对于的 ID 不一致。
解决方案一:全局配置在服务端给前端响应 JSON 数据时进行处理,将 Long 型数据统一转为 String 字符串 (使用消息转换器) 。
@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter jackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter();
ObjectMapper objectMapper = new ObjectMapper();
// 全局配置序列化返回 JSON 处理
SimpleModule simpleModule = new SimpleModule();
// JSON Long ==> String 将所有的 Long类型 转换成 String类型 返回
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
objectMapper.registerModule(simpleModule);
jackson2HttpMessageConverter.setObjectMapper(objectMapper);
converters.add(jackson2HttpMessageConverter);
}
}
解决方案二 (推荐) :在要转换成 String 字符串的实体类的 Long 型 ID 属性上添加注解 @JsonSerialize(using = ToStringSerializer.class) 。可以帮助我们实现字段值的序列化和反序列化。
src/main/java/edu/ouc/entity/Employee.java :
@Data
public class Employee {
private static final Long serialVersionUID = 1L;// 序列化ID
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
...
}
// 修改员工信息
Boolean update(HttpServletRequest request, Employee employee);
// 修改员工信息
@Override
public Boolean update(HttpServletRequest request, Employee employee) {
// 1.设置修改人
employee.setUpdateUser((Long) request.getSession().getAttribute("employee"));
// 2.设置修改时间
employee.setUpdateTime(LocalDateTime.now());
// 3.调用数据层的更新操作
return updateById(employee);
}
// 修改员工信息
@PutMapping
public R<String> update(HttpServletRequest request, @RequestBody Employee employee) {
if (empService.update(request, employee)) {
return R.success("修改成功");
}
return R.error("修改失败");
}
略。
编辑功能是先查后修改。先把要编辑的员工的信息查询出来回显到编辑页面,用户修改完成后,点击 “保存” 再调用修改操作方法。
点击 “编辑” 按钮:

打开浏览器控制台查看请求方式与请求 URL :

可见,编辑按钮采用 GET 请求,请求 URL 是 /employee/待编辑员工ID 。
注意这个 URL 的 ID 是随待编辑的员工不同而动态变化的。这就需要在表现层 Controller 的 GET 请求映射注解中用花括号把 ID 括起来,即 @GetMapping("/{id}") 。此外,方法的入参还需要用 @PathVariable 注释修饰。
然后是用户修改完,点击 “保存” 时的请求方式与 URL :

由于上一节已经编写完通用的修改方法,因此点击保存直接就成功保存了。无需再额外实现修改方法:

update() 方法。上面说到,编辑按钮的请求 URL 是 /employee/待编辑员工ID 。
注意这个 URL 的 ID 是随待编辑的员工不同而动态变化的。这就需要在表现层 Controller 的 GET 请求映射注解中用花括号把 ID 括起来,即 @GetMapping("/{id}") 。此外,方法的入参还需要用 @PathVariable 注释修饰。
EmployeeController.java :
// 根据ID查询员工信息
@GetMapping("/{id}")
public R<Employee> getById(@PathVariable Long id) {
Employee employee = empService.getById(id);
// 当查询结果不为空时才返回employee
if (employee != null) {
return R.success(employee);
}
return R.error("查询员工不存在");
}
用户修改完,点击 “保存” 时的请求方式与 URL :

由于上一节已经编写完通用的修改方法,因此点击保存直接就成功保存了。无需再额外实现修改方法:

前面员工表 emoloyee 中有 4 个字段属于公共字段:

这 4 个字段不仅在员工表中存在,在很多其他表中也存在。为了避免对每一张数据表都要重复维护这些公共字段,MP 框架提供的公共字段自动填充功能,能帮助我们简化开发。
@TableField 注解,指定自动填充的策略;MetaObjectHandler 接口。src/main/java/edu/ouc/entity/Employee.java :
@Data
public class Employee {
private static final Long serialVersionUID = 1L;// 序列化ID
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String name;
private String username;
private String password;
private String phone;
private String sex;
private String idNumber;
private Integer status;
@TableField(fill = FieldFill.INSERT) // 插入时填充字段
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时填充字段
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT) // 插入时填充字段
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时填充字段
private Long updateUser;
}
创建 src/main/java/edu/ouc/common/MyMetaObjectHandler.java 。
@Component // 让Spring管理
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
// 当执行插入数据时自动填充
@Override
public void insertFill(MetaObject metaObject) {
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime", LocalDateTime.now());
metaObject.setValue("createUser", new Long(1));
metaObject.setValue("updateUser", new Long(1));
}
// 当执行更新数据时自动填充
@Override
public void updateFill(MetaObject metaObject) {
metaObject.setValue("updateUser", new Long(1));
metaObject.setValue("updateTime", LocalDateTime.now());
}
}
这个类写好以后,不光是员工表 employee ,所有含有这 4 个字段的数据表都可以自动填充。
有了自动填充后,新增员工的方法中就再也不需要手动设置这 4 个公共字段了。
src/main/java/edu/ouc/controller/EmployeeController.java :
// 新增员工功能
@PostMapping
public R<String> save(HttpServletRequest request, @RequestBody Employee employee) {
// 1.设置初始密码为身份证后6位,并经过MD5加密
String idNumber = employee.getIdNumber();
String password = DigestUtils.md5DigestAsHex(idNumber.substring(idNumber.length() - 6).getBytes());
employee.setPassword(password);
// 2.调用业务层保存到数据库中
empService.save(employee);
return R.success("添加成功");
}
有了自动填充后,修改员工的方法中就再也不需要手动设置这 2 个公共字段了。业务层的修改方法全删掉,直接在表现层写即可。
src/main/java/edu/ouc/controller/EmployeeController.java :
// 修改员工信息
@PutMapping
public R<String> update(@RequestBody Employee employee) {
if (empService.updateById(employee)) {
return R.success("修改成功");
}
return R.error("修改失败");
}
略。
createUser 和 updateUser 时设置的用户 id 是固定值。id 。MyMetaObjectHandler 类中是不能获得 Http Session 对象的,不能从 Session 保存作用域中获取当前登录用户的 ID 。所以我们需要通过其他方式来获取登录用户 id 。ThreadLocal 来解决此问题,它是 JDK 中提供的一个类。ThreadLocal 之前,我们需要先确认一个事情:就是客户端发送的每次 http 请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中,涉及到下面类中的方法都属于相同的一个线程:LoginCheckFilter.java 的 doFilter() 方法;EmployeeController.java 的 update() 方法;MyMetaObjectHandler.java 的 updateFill() 方法。可以在上面的三个方法中,分别加入下面代码来获取当前线程 id 。以此证明它们同属一个线程:
// 查看当前线程的ID
long id = Thread.currentThread().getId();
log.info("线程ID为:{}", id);
输出:
INFO 15412 --- [nio-8080-exec-3] edu.ouc.filter.LoginCheckFilter : 线程ID为:34
INFO 15412 --- [nio-8080-exec-3] edu.ouc.controller.EmployeeController : 线程ID为:34
INFO 15412 --- [nio-8080-exec-3] edu.ouc.common.MyMetaObjectHandler : 线程ID为:34
可以看到,证明了这三个方法确实同在一个线程内。
ThreadLocal 并不是一个 Thread ,而是 Thread 的一个局部变量。ThreadLocal 维护变量时,ThreadLocal 为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。ThreadLocal 为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。ThreadLocal 来存储当前登录用户的 ID 。| 方法 | 作用 |
|---|---|
| public void set(T value) | 设置当前线程的线程局部变量的值 |
| public T get() | 获取当前线程对应的线程局部变量的值 |
LoginCheckFilter.java 的 doFilter() 方法中获取当前登录用户的 id ,并调用 ThreadLocal 的 set() 方法来保存当前线程的线程局部变量的值 (用户 id ) ;MyMetaObjectHandler.java 的 updateFill() 方法中调用ThreadLocal 的 get() 方法来获取当前线程所对应的线程局部变量的值 (用户 id ) 。BaseContext 工具类,基于 ThreadLocal 封装的工具类;创建 src/main/java/edu/ouc/common/BaseContext.java :
public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
// 保存当前登录用户的ID
public static void setCurrentUserId(Long id) {
threadLocal.set(id);
}
// 获取当前登录用户的ID
public static Long getCurrentUserId() {
return threadLocal.get();
}
}
LoginCheckFilter.java 的 doFilter() 方法中调用 BaseContext 来设置当前登录用户的 id ;打开 src/main/java/edu/ouc/filter/LoginCheckFilter.java 第68行:
// 5.如果需要处理,则判断登录状态
if (request.getSession().getAttribute("employee") != null) {
// 能进入说明已经登录,直接放行
Long id = (Long) request.getSession().getAttribute("employee");
log.info("用户{}已登录", id);
// 把当前登录用户的ID保存到ThreadLocal中
BaseContext.setCurrentUserId(id);
filterChain.doFilter(request, response);
return;
}
MyMetaObjectHandler.java 的方法中调用 BaseContext 获取登录用户的 id 。打开 src/main/java/edu/ouc/common/MyMetaObjectHandler.java :
@Component // 让Spring管理
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
// 当执行插入数据时自动填充
@Override
public void insertFill(MetaObject metaObject) {
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime", LocalDateTime.now());
// 获取当前线程的登录用户的ID
Long id = BaseContext.getCurrentUserId();
metaObject.setValue("createUser", id);
metaObject.setValue("updateUser", id);
}
// 当执行更新数据时自动填充
@Override
public void updateFill(MetaObject metaObject) {
// 获取当前线程的登录用户的ID
Long id = BaseContext.getCurrentUserId();
metaObject.setValue("updateUser", id);
metaObject.setValue("updateTime", LocalDateTime.now());
}
}
至此,以后所有数据表中的公共字段都可以自动填充了,无需重复手动设置。
分类管理页面同时管理两种分类,一种是菜品分类 (粤菜、川菜、鲁菜) ;另一种则是套餐分类 (情人节限时优惠套餐、超值午餐精选) 。



菜品分类和套餐分类会在 “菜品管理” 和 “套餐管理” 上分别充当属性:


点击 ”确定“ 时,查看浏览器控制台:



可以看到,新增分类时,前端以 POST 方式向后端发起请求,请求 URL 是 /category ,请求负载把分类名称 name 、分类的类别 type 和展示顺序 sort 都发送到后端。
分类管理对应的数据表就是 category :

相应的字段介绍:
| 字段 | 描述 |
|---|---|
| id | 分类ID |
| type | 1菜品分类 or 2套餐分类 |
| name | 分类名称 (唯一约束) |
| sort | 展示顺序 (移动端展示的顺序) |
| create_time | 创建日期时间 |
| update_time | 修改日期时间 |
| create_user | 创建人ID |
| update_user | 修改人ID |
创建 src/main/java/edu/ouc/entity/Category.java :
@Data
public class Category {
private static final Long serialVersionUID = 123L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private Integer type;
private String name;
private Integer sort;
@TableField(fill = FieldFill.INSERT) // 插入时填充字段
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时填充字段
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT) // 插入时填充字段
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时填充字段
private Long updateUser;
}
创建 src/main/java/edu/ouc/mapper/CategoryMapper.java :
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
}
创建业务层接口 src/main/java/edu/ouc/service/ICategoryService.java :
public interface ICategoryService extends IService<Category> {
}
创建业务层实现类 src/main/java/edu/ouc/service/impl/CategoryServiceImpl.java :
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements ICategoryService {
@Autowired
private CategoryMapper categoryDAO;
}
创建 src/main/java/edu/ouc/controller/CategoryController.java :
@Slf4j
@RestController
@RequestMapping("/category")
public class CategoryController {
@Autowired
private ICategoryService categoryService;
// 新增分类
@PostMapping
public R<String> save(@RequestBody Category category) {
if (categoryService.save(category)) {
return R.success("添加成功");
}
return R.error("添加失败");
}
}
点击 ”新增菜品分类“ :

查看数据库:

点击 ”新增套餐分类“ :

查看数据库:

分页查询以 GET 请求方式向后端请求,请求 URL 为 /category/page 。同时携带两个参数:当前页码 page 和 每页记录数 pageSize 。

打开业务层接口 src/main/java/edu/ouc/service/ICategoryService.java :
public interface ICategoryService extends IService<Category> {
// 分页查询
Page<Category> getPage(Long currentPage, Long pageSize);
}
打开业务层接口的实现类 src/main/java/edu/ouc/service/impl/IEmployeeServiceImpl.java :
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements ICategoryService {
@Autowired
private CategoryMapper categoryDAO;
// 分页查询
@Override
public Page<Category> getPage(Long currentPage, Long pageSize) {
// 1.创建条件查询构造器,用于按展示顺序排序
LambdaQueryWrapper<Category> lqw = new LambdaQueryWrapper<>();
// 2.根据展示顺序排序
lqw.orderByAsc(Category::getSort);
// 3.创建分页构造器
Page<Category> page = new Page<>(currentPage, pageSize);
// 4.调用数据层的分页查询
return page(page, lqw);
}
}
打开表现层 src/main/java/edu/ouc/controller/CategoryController.java :
// 分页查询
@GetMapping("/page")
public R<Page<Category>> getPage(Long page, Long pageSize) {
return R.success(categoryService.getPage(page, pageSize));
}
点击分类管理页面:

”删除“ 按钮以 DELETE 请求方式向后端请求,请求 URL 为 /category 。同时携带要删除的分类的 ID 。

打开表现层 src/main/java/edu/ouc/controller/CategoryController.java :
// 删除分类
@DeleteMapping
public R<String> deleteById(Long ids) {
if (categoryService.removeById(ids)) {
return R.success("删除成功");
}
return R.error("删除失败");
}



前面说到删除不能直接删,要确保该分类下没有关联任何菜品和套餐才能删。
这就需要调用菜品和套餐的业务层,根据要删除的分类 ID 进行查询,如果查询结果不为空,则删除失败,如果查询结果为空,则可以删除。
首先需要把菜品和套餐的实体类、数据层和业务层全部搭起来。我提前在第七和第八章已经搭好了。

在业务层接口中自定义分类删除抽象方法。
ICategoryService.java :
public interface ICategoryService extends IService<Category> {
// 分页查询
Page<Category> getPage(Long currentPage, Long pageSize);
// 完善的删除分类操作
Boolean remove(Long id);
}
在业务层接口的实现类中实现具体的删除功能。
CategoryServiceImpl.java :
@Service
@Slf4j
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements ICategoryService {
// 注入菜品业务层
@Autowired
private DishServiceImpl dishService;
// 注入套餐业务层
@Autowired
private SetmealServiceImpl setmealService;
// 分页查询
@Override
public Page<Category> getPage(Long currentPage, Long pageSize) {
// 1.创建条件查询构造器,用于按展示顺序排序
LambdaQueryWrapper<Category> lqw = new LambdaQueryWrapper<>();
// 2.根据日期时间排序
lqw.orderByAsc(Category::getSort);
// 3.创建分页构造器
Page<Category> page = new Page<>(currentPage, pageSize);
// 4.调用数据层的分页查询
return page(page, lqw);
}
// 完善的删除分类操作
// 根据id删除分类,删除之前判断分类下是否有关联的菜品或套餐
@Override
public Boolean remove(Long id) {
// 1.查询当前分类是否关联了菜品,如果已经关联,则抛出一个业务异常
// 1.1 创建条件构造器
LambdaQueryWrapper<Dish> dishLqw = new LambdaQueryWrapper<>();
// 1.2 添加查询条件,按照分类ID进行查询
dishLqw.eq(Dish::getCategoryId, id);
// 1.3 返回查询结果的总数
int dishCount = dishService.count(dishLqw);
// 1.4 如果查询结果大于0,说明该分类已经关联菜品,抛出一个自定义的业务异常
if (dishCount > 0) {
throw new CustomException("该分类有关联菜品,无法删除");
}
// 2.查询当前分类是否关联了套餐,如果已经关联,则抛出一个业务异常
// 2.1 创建条件构造器
LambdaQueryWrapper<Setmeal> setmealLqw = new LambdaQueryWrapper<>();
// 2.2 添加查询条件,按照分类ID进行查询
setmealLqw.eq(Setmeal::getCategoryId, id);
// 2.3 返回查询结果的总数
int setmealCount = setmealService.count(setmealLqw);
// 2.4 如果查询结果大于0,说明该分类已经关联套餐,抛出一个自定义的业务异常
if (setmealCount > 0) {
throw new CustomException("该分类有关联套餐,无法删除");
}
// 3.如果都没有关联,就可以正常删除
return removeById(id);
}
}
创建 src/main/java/edu/ouc/common/CustomException.java :
public class CustomException extends RuntimeException {
static final Long serialVersionUID = 32424L;
public CustomException() {
}
public CustomException(String message) {
super(message);
}
}
打开 src/main/java/edu/ouc/common/GlobalExceptionHandler.java :
// 捕获删除分类失败的业务异常
@ExceptionHandler(CustomException.class)
public R<String> customerExceptionHandler(CustomException ce) {
return R.error(ce.getMessage());
}
打开 CategoryController.java :
// 完善的删除分类操作
// 根据id删除分类,删除之前判断分类下是否有关联的菜品或套餐
@DeleteMapping
public R<String> deleteById(Long ids) {
if (categoryService.remove(ids)) {
return R.success("删除成功");
}
return R.error("删除失败");
}
“川菜” 这一类别已经关联了菜品,我们尝试来删除它。


删除一个没有关联菜品和套餐的分类:


”修改“ 按钮以 PUT 请求方式向后端请求,请求 URL 为 /category 。请求负载与新增分类的表单一致。


打开表现层 src/main/java/edu/ouc/controller/CategoryController.java :
// 删除分类
@DeleteMapping
public R<String> deleteById(Long ids) {
if (categoryService.removeById(ids)) {
return R.success("删除成功");
}
return R.error("删除失败");
}


| 代码 | 描述 |
|---|---|
| method=“post” | 采用 POST方式提交数据 |
| enctype=“multipart/form-data” | 采用 multipart 格式上传文件 |
| type=“file” | 使用 input 的file控件上传 |
略。
在配置文件中统一管理文件保存路径。
打开 application.yml :
# 文件保存路径
reggie:
path: G:\img\
创建 src/main/java/edu/ouc/common/CommonController.java :
在下面下载功能实现完再一并展示。
需要把用户刚上传的图片回显到浏览器上。如果不回显,浏览器上就会是这种:

请求方式是 GET ,请求 URL 是 /common/download 。

打开 src/main/java/edu/ouc/common/CommonController.java :
@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {
// 获取配置文件中的存储路径
@Value("${reggie.path}")
private String basePath;
// 文件上传
@PostMapping("/upload")
public R<String> upload(MultipartFile file) { // 入参名必须是file
// file是一个临时文件,需要转存到指定位置,否则本次请求完成后临时文件会删除
// 获取原始文件名
String originalFilename = file.getOriginalFilename(); // 原始名称.jpg
// 截取原始名称的后缀
String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); //.jpg
// 使用UUID重新生成文件名,防止文件名称重复造成文件覆盖
String fileName = UUID.randomUUID().toString() + suffix; // sdfaiourei.jpg
// 创建一个目录对象
File dir = new File(basePath);
// 判断当前目录是否存在
if (!dir.exists()) {
// 目录不存在需要创建
dir.mkdir();
}
try {
// 转存
file.transferTo(new File(basePath + fileName));
} catch (IOException e) {
e.printStackTrace();
}
// 返回文件名称即可
return R.success(fileName);
}
// 文件下载
@GetMapping("/download")
public void download(String name, HttpServletResponse response) { // 入参名字必须是name
FileInputStream fis = null;
ServletOutputStream os = null;
try {
// 输入流,通过输入流读取文件内容
fis = new FileInputStream(new File(basePath + name));
// 输出流,通过输出流将文件写回浏览器,在浏览器展示图片
os = response.getOutputStream();
// 下面都是JavaSE中IO流的内容
byte[] buffer = new byte[1024];
int len = 0;
while ((len = fis.read(buffer)) != -1) {
os.write(buffer, 0, len);
os.flush();
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
// 关闭流资源
try {
if (fis != null)
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (os != null)
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
菜品管理页面:

菜品分类会在 “菜品管理” 新建菜品时充当属性:

首先要获取菜品分类,这要去 CategoryController.java 中补充相关方法。


点击 ”保存“ 时,查看浏览器控制台:



可以看到,新增菜品时,前端以 POST 方式向后端发起请求,请求 URL 是 /dish ,请求负载把菜品相关的属性都发送到后端。
菜品管理对应的数据表就是 dish :

相应的字段介绍:
| 字段 | 描述 |
|---|---|
| id | 菜品ID |
| name | 菜品名称 (唯一约束) |
| category_id | 分类ID |
| price | 价格 |
| code | 商品码 |
| image | 菜品图片 |
| description | 菜品描述 |
| status | 售卖状态:0停售;1起售 |
| sort | 展示顺序 (移动端展示的顺序) |
| create_time | 创建日期时间 |
| update_time | 修改日期时间 |
| create_user | 创建人ID |
| update_user | 修改人ID |
| is_deleted | 是否已删除 |
还涉及到菜品口味表 dish_flavor 。

相应的字段介绍:
| 字段 | 描述 |
|---|---|
| id | 菜品口味ID |
| dish_id | 菜品ID |
| name | 口味名称 |
| value | 对应口味的可选值 |
| create_time | 创建日期时间 |
| update_time | 修改日期时间 |
| create_user | 创建人ID |
| update_user | 修改人ID |
| is_deleted | 是否已删除 |
创建 src/main/java/edu/ouc/entity/Dish.java 。这里有关价格很关键,MySQL 中为了精度无丢失采用了 DECIMAL 定点数据类型,那么实体类对应的 Java 实体类的属性中应该用 BigDecimal 数据类型:
@Data
public class Dish {
private static final Long serialVersionUID = 329847832957L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String name;
@JsonSerialize(using = ToStringSerializer.class)
private Long categoryId;
private BigDecimal price;
private String code;
private String image;
private String description;
private Integer status;
private Integer sort;
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
创建 src/main/java/edu/ouc/entity/DishFlavor.java :
@Data
public class DishFlavor {
private static final Long serialVersionUID = 313L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@JsonSerialize(using = ToStringSerializer.class)
private Long dishId;
private String name;
private String value;
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
创建 src/main/java/edu/ouc/mapper/DishMapper.java :
@Mapper
public interface DishMapper extends BaseMapper<Dish> {
}
创建 src/main/java/edu/ouc/mapper/DishFlavorMapper.java :
@Mapper
public interface DishFlavorMapper extends BaseMapper<DishFlavor> {
}
创建菜品口味业务层接口 src/main/java/edu/ouc/service/IDishFlavorService.java :
public interface IDishFlavorService extends IService<DishFlavor> {
}
创建菜品口味业务层实现类 src/main/java/edu/ouc/service/impl/DishFlavorServiceImpl.java :
@Slf4j
@Service
public class DishFlavorServiceImpl extends ServiceImpl<DishFlavorMapper, DishFlavor> implements IDishFlavorService {
}
创建菜品业务层接口 src/main/java/edu/ouc/service/IDishService.java :
public interface IDishService extends IService<Dish> {
// 新增菜品,同时插入菜品对应的口味数据
Boolean saveWithFlavor(DishDto dishDto);
}
创建菜品业务层实现类 src/main/java/edu/ouc/service/impl/DishServiceImpl.java :
@Slf4j
@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements IDishService {
@Autowired
private DishFlavorServiceImpl dishFlavorService;
// 新增菜品,同时插入菜品对应的口味数据
@Override
@Transactional // 涉及到多张表操作,开启事务
public Boolean saveWithFlavor(DishDto dishDto) {
// 1.保存菜品的基本信息
this.save(dishDto);
// 2.获取菜品的ID
Long dishId = dishDto.getId();
// 3.遍历菜品口味并逐一赋上菜品ID值,然后保存
List<DishFlavor> flavors = dishDto.getFlavors();
// 3.1 使用Stream处理集合,结果赋回给它自己
// peek()是stream的中间操作,是对象的时候才能修改
flavors = flavors.stream().peek(flavor -> flavor.setDishId(dishId)).collect(Collectors.toList());
// 4.调用批量保存
return dishFlavorService.saveBatch(flavors);
}
}
涉及到多表操作,要在启动类中开启 Spring 事务注解管理。
src/main/java/edu/ouc/ReggieTakeOutApplication.java :
@Slf4j // 日志
@SpringBootApplication // Spring Boot启动类
@ServletComponentScan // Servlet组件扫描,扫描过滤器
@EnableTransactionManagement // 开启Spring事务注解管理
public class ReggieTakeOutApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieTakeOutApplication.class, args);
// 打印Slf4j日志
log.info("项目启动成功");
}
}
打开 CategoryController.java ,补充获取菜品分类的方法:
// 添加菜品或套餐时获取分类信息
@GetMapping("/list")
public R<List<Category>> list(Category category) {
LambdaQueryWrapper<Category> lqw = new LambdaQueryWrapper<>();
lqw.eq(category.getType() != null, Category::getType, category.getType());
// 双重排序条件
lqw.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);
return R.success(categoryService.list(lqw));
}
创建 src/main/java/edu/ouc/controller/DishController.java 。菜品口味相关操作也一并放在 DishController.java 下。
注意:前端提交的表单中既有菜品 dish ,又有菜品口味 dishFlavor ,我们需要保存这 2 个不同的实体类对象。
解决方法:创建 DTO (Data Transfer Object) ,即数据传输对象,一般用于表现层与服务层之间的数据传输。
创建 DTO src/main/java/edu/ouc/dto/DishDto.java :
@Data
public class DishDto extends Dish {// 继承Dish,就拥有了Dish的全部属性和方法
// 封装菜品口味
private List<DishFlavor> flavors = new ArrayList<>();
// 菜品分类名称
private String categoryName;
// 副本
private Integer copies;
}
打开 DishController.java :
@Slf4j
@RestController
@RequestMapping("/dish")
public class DishController {
@Autowired
private DishServiceImpl dishService;
@Autowired
private DishFlavorServiceImpl dishFlavorService;
// 添加菜品,同时插入菜品对应的口味数据
@PostMapping
public R<String> save(@RequestBody DishDto dishDto) {
if (dishService.saveWithFlavor(dishDto)) {
return R.success("保存成功");
}
return R.error("保存失败");
}
}
添加一道鲁菜 ”九转大肠“ 。

数据表 dish 中保存成功:

菜品口味表中保存成功:

分页查询以 GET 请求方式向后端请求,请求 URL 为 /dish/page 。同时携带两个参数:当前页码 page 和 每页记录数 pageSize 。


菜品的分页查询还是挺复杂的。
需要展示菜品图片,这意味着需要调用下载方法 download() 显示到浏览器上。
此外,还需要显示 ”菜品分类“ 的名称,但实体类 Dish 中只记录了菜品分类的 ID categoryId ,因此,需要去 DishDto 中获取菜品分类名称 categoryName 。

DishDto :
@Data
public class DishDto extends Dish {// 继承Dish,就拥有了Dish的全部属性和方法
// 封装菜品口味
private List<DishFlavor> flavors = new ArrayList<>();
// 菜品分类名称
private String categoryName;
// 副本
private Integer copies;
}
DTO 显示出菜品的分类名称。在菜品业务层接口 IDishService.java 中添加分页+按菜品名称查询方法:
public interface IDishService extends IService<Dish> {
// 新增菜品,同时插入菜品对应的口味数据
Boolean saveWithFlavor(DishDto dishDto);
// 分页查询+按菜品名称查询
List<DishDto> getPage(Long page, Long pageSize, String name);
}
菜品业务层接口的实现类 DishServiceImpl.java :
@Slf4j
@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements IDishService {
@Autowired
private DishFlavorServiceImpl dishFlavorService;
@Autowired
private CategoryServiceImpl categoryService;
// 新增菜品,同时插入菜品对应的口味数据
@Transactional // 涉及到多张表操作,开启事务
public Boolean saveWithFlavor(DishDto dishDto) {
// 1.保存菜品的基本信息
this.save(dishDto);
// 2.获取菜品的ID
Long dishId = dishDto.getId();
// 3.遍历菜品口味并逐一赋上菜品ID值,然后保存
List<DishFlavor> flavors = dishDto.getFlavors();
// 3.1 使用Stream处理集合,结果赋回给它自己
// peek()是stream的中间操作,是对象的时候才能修改
flavors = flavors.stream().peek(flavor -> flavor.setDishId(dishId)).collect(Collectors.toList());
// 4.调用批量保存
return dishFlavorService.saveBatch(flavors);
}
// 分页查询+按菜品名称查询
@Override
public Page<DishDto> getPage(Long page, Long pageSize, String name) {
// 1.创建分页构造器
Page<Dish> dishPage = new Page<>(page, pageSize);
// 1.1 用于解决浏览器显示菜品分类名称,但需要给分页构造器赋值,采用复制dishPage的方法
Page<DishDto> dishDtoPage = new Page<>();
// 2.创建查询条件构造器
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
// 3.添加过滤添加,按菜品名称查询
lqw.like(Strings.isNotEmpty(name), Dish::getName, name);
// 4.添加排序条件,按菜品更新时间降序排列
lqw.orderByDesc(Dish::getUpdateTime);
// 5.调用数据层的分页查询方法,此时dishPage中已经有值了
page(dishPage, lqw);
// 6.对象拷贝
// 6.1 把dishPage中的属性值复制到dishDtoPage中,但要忽略dishPage中的records
// 这是因为records中的数据是真正展示到浏览器上的,我们要处理一下records中的数据
BeanUtils.copyProperties(dishPage, dishDtoPage, "records");
// 7.使用stream处理dishRecords集合,目的是处理成dishDtoRecords的集合
// 7.1 获取dishPage的records
List<Dish> dishRecords = dishPage.getRecords();
// 7.2 通过stream流处理dishRecords,目的是要根据分类ID查询分类表,最终获得分类名称
List<DishDto> dishDtoRecords = dishRecords.stream().map(dish -> { // 遍历dishRecords集合中的每个dish,进行如下操作
// 7.2.1 获取每个Dish的分类id
Long categoryId = dish.getCategoryId();
// 7.2.2 根据分类ID查询分类表,最终获得分类名称
String categoryName = categoryService.getById(categoryId).getName();
// 7.2.3 创建DishDto对象
DishDto dishDto = new DishDto();
// 7.2.4 先把Dish对象的所有属性拷贝到DishDto对象,然后再设置刚刚获得的分类名称
BeanUtils.copyProperties(dish, dishDto);
// 7.2.5 设置分类名称
dishDto.setCategoryName(categoryName);
// 7.2.6 返回全部赋值完成的dishDto
return dishDto;
}).collect(Collectors.toList()); // stream流的终止操作:返回一个List集合
// 8.把处理好的dishDtoRecords赋回dishDtoPage中
dishDtoPage.setRecords(dishDtoRecords);
return dishDtoPage;
}
}
打开表现层 src/main/java/edu/ouc/controller/CategoryController.java :
// 分页查询+按菜品名称查询
@GetMapping("/page")
public R<Page<DishDto>> getPage(Long page, Long pageSize, String name) {
return R.success(dishService.getPage(page, pageSize, name));
}
categoryServiceImpl 中依赖了 dishServiceImpl ,而 dishServiceImpl 又依赖了 categoryServiceImpl ,这种相互依赖称为依赖循环。categoryServiceImpl –> dishServiceImpl –> categoryServiceImpl …The dependencies of some of the beans in the application context form a cycle:
categoryController (field private edu.ouc.service.ICategoryService edu.ouc.controller.CategoryController.categoryService)
┌─────┐
| categoryServiceImpl (field private edu.ouc.service.impl.DishServiceImpl edu.ouc.service.impl.CategoryServiceImpl.dishService)
↑ ↓
| dishServiceImpl (field private edu.ouc.service.impl.CategoryServiceImpl edu.ouc.service.impl.DishServiceImpl.categoryService)
└─────┘
解决办法 (治标不治本) :在相互依赖的两个 Bean 上分别加上 @Lazy 注解即可。
DishServiceImpl :
// 注入分类业务层依赖
@Autowired
@Lazy
private CategoryServiceImpl categoryService;
CategoryServiceImpl :
// 注入菜品业务层
@Autowired
@Lazy
private DishServiceImpl dishService;
真正的治本的方法是重新设计依赖。
能正常显示出菜品的分类名称:

首先是待修改菜品信息回显,”修改“ 按钮以 GET 请求方式向后端请求,请求 URL 为 /dish/菜品ID 。


然后是把修改好的菜品信息以 PUT 请求方式,把修改后的信息传输到后端:



业务层接口 IDishService.java :
public interface IDishService extends IService<Dish> {
// 新增菜品,同时插入菜品对应的口味数据
Boolean saveWithFlavor(DishDto dishDto);
// 分页查询+按菜品名称查询
Page<DishDto> getPage(Long page, Long pageSize, String name);
// 根据菜品ID查询菜品信息和对应的口味信息
DishDto getByIdWithFlavor(Long id);
// 修改菜品,同时修改菜品对应的口味数据
Boolean updateWithFlavor(DishDto dishDto);
}
业务层接口的实现类:
// 根据菜品ID查询菜品信息和对应的口味信息
@Override
public DishDto getByIdWithFlavor(Long id) {
// 1.根据ID查询菜品基本
Dish dish = getById(id);
// 2.查询菜品口味
LambdaQueryWrapper<DishFlavor> lqw = new LambdaQueryWrapper<>();
lqw.eq(DishFlavor::getDishId, id);
List<DishFlavor> flavors = dishFlavorService.list(lqw);
// 3.创建要返回的DishDto对象
DishDto dishDto = new DishDto();
// 4.把dish复制到dishDto中
BeanUtils.copyProperties(dish, dishDto);
// 5.把菜品口味封装到DishDto中
dishDto.setFlavors(flavors);
return dishDto;
}
// 修改菜品,同时修改菜品对应的口味数据
@Override
@Transactional // 涉及到多张表操作,开启事务
public Boolean updateWithFlavor(DishDto dishDto) {
// 1.删除菜品口味表中对应菜品的口味
LambdaQueryWrapper<DishFlavor> lqw = new LambdaQueryWrapper<>();
lqw.eq(DishFlavor::getDishId, dishDto.getId());
dishFlavorService.remove(lqw);
// 2.截取dishDto中的菜品口味信息
List<DishFlavor> flavors = dishDto.getFlavors();
// 3.使用stream流操作为菜品口味集合设置对应的菜品ID
flavors = flavors.stream().peek(flavor -> flavor.setDishId(dishDto.getId())).collect(Collectors.toList());
// 4.批量添加修改后的菜品口味到菜品口味表中
dishFlavorService.saveBatch(flavors);
// 5.更新菜品基本信息
return this.updateById(dishDto);
}
【注意】凡是涉及到多表联查,都要使用 DTO 的实体类。
打开表现层 src/main/java/edu/ouc/controller/DishController.java :
// 根据菜品ID查询菜品信息和对应的口味信息
@GetMapping("/{id}")
public R<DishDto> getById(@PathVariable Long id) {
return R.success(dishService.getByIdWithFlavor(id));
}
// 修改菜品,同时修改菜品对应的口味数据
@PutMapping
public R<String> update(@RequestBody DishDto dishDto) {
if (dishService.updateWithFlavor(dishDto)) {
return R.success("修改成功");
}
return R.error("修改失败");
}


单个菜品停售与批量停售并没有什么区别,只是携带的菜品 ID 是一个还是多个而已。因此就把单个菜品与多个菜品的停售/启售都合并到一个方法里。
按 “停售” ,修改当前菜品的售卖状态。

注意:已经是启售状态的菜品,再点击 “批量启售” 要不要给予相应的提示呢?
setmeal 也要停售。setmeal_dish 表中查询该菜品是否已经关联套餐。如果关联了套餐,则无法停售,抛出业务异常,提示用户必须先停售关联套餐,才能停售当前菜品。”停售“ 按钮以 POST 请求方式向后端请求,请求 URL 为 /dish/status/0 。同时携带要停售的菜品的 ID 。
单个菜品停售与批量停售并没有什么区别,只是携带的菜品 ID 是一个还是多个而已。


”启售“ 按钮以 POST 请求方式向后端请求,请求 URL 为 /dish/status/1 。同时携带要启售的菜品的 ID 。


【业务逻辑】如果要停售则必须检查关联套餐是否停售。
为了实现这一点,先去 Setmeal 的业务层添加一个功能:根据菜品IDs集合,查询对应套餐Ids集合。
Setmeal 的业务层接口 ISetmealService.java :
// 根据菜品IDs集合,查询对应套餐Ids集合
Set<Long> getIdsByDishId(List<Long> dishIds);
Setmeal 的业务层接口实现类 SetmealServiceImpl.java :
// 根据菜品IDs集合,查询对应套餐Ids集合
@Override
public Set<Long> getIdsByDishId(List<Long> dishIds) {
// 1.创建setmeal_dish的过滤条件封装器
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
// 2.添加过滤条件:IN()
lqw.in(SetmealDish::getDishId, dishIds);
// 3.查询对应的SetmealDish实体类集合
List<SetmealDish> setmealDishes = setmealDishService.list(lqw);
// 4.获取其setmealId的去重集合:Set集合是无序不可重复的
return setmealDishes.stream().map(SetmealDish::getSetmealId).collect(Collectors.toSet());
}
菜品 Dish 业务层接口 IDishService.java :
// (批量)停售/启售菜品
Boolean updateStatus(Integer status, List<Long> ids);
菜品 Dish 业务层接口实现类 DishServiceImpl.java :
// (批量)停售/启售菜品
@Override
public Boolean updateStatus(Integer status, List<Long> ids) {
// 业务逻辑:如果要停售则必须检查关联套餐是否停售
if (status == 0) {
// 根据菜品IDs获取其关联的套餐IDs
Set<Long> setmealIds = setmealService.getIdsByDishId(ids);
// 如果该菜品关联的套餐IDs不为空,才继续进行下一步
if (!setmealIds.isEmpty()) {
// 创建setmeal的过滤条件封装器
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
// 添加过滤条件:IN()套餐IDs
lqw.in(Setmeal::getId, setmealIds);
// 添加过滤条件:在售套餐
lqw.eq(Setmeal::getStatus, 1);
// 如果满足的setmeal记录数大于0,则说明关联套餐在售,抛出业务异常
if (setmealService.count(lqw) > 0) {
throw new CustomException("停售失败,菜品所关联套餐仍在售,请停售相关套餐");
}
}
}
// 目的:尽量减少与MySQL通信的次数
// 1.根据菜品ID集合批量查询菜品
List<Dish> dishes = this.listByIds(ids);
// 2.使用集合的stream流修改售卖状态
dishes = dishes.stream().peek(dish -> {
dish.setStatus(status);
}).collect(Collectors.toList());
// 3.批量update
return this.updateBatchById(dishes);
}
打开表现层 src/main/java/edu/ouc/controller/DishController.java :
// (批量)停售/启售菜品
@PostMapping("/status/{status}")
public R<String> updateStatus(@PathVariable Integer status, @RequestParam List<Long> ids) {
if (dishService.updateStatus(status, ids)) {
return R.success("修改状态成功");
}
return R.error("修改状态失败");
}


菜品删除功能涉及到两张数据表,分别是 dish 和 dish_flavor 。用户点击 “删除” 按钮后,后端首先根据菜品 ID 删除 dish 中的关联菜品,再删除 dish_flavor 中对应的口味记录。

dish 数据表中的记录,然后删除 dish_flavor 中的记录。”删除“ 按钮以 DELETE请求方式向后端请求,请求 URL 为 /dish 。同时携带要删除的菜品的 ID 。
单个菜品删除与批量删除并没有什么区别,只是携带的菜品 ID 是一个还是多个而已。


删除菜品业务层需要依赖套餐管理的业务层 SetmealServiceImpl.java 。
在套餐业务层接口 ISetmealService.java 添加根据菜品IDs集合,返回对应套餐 IDs 集合的功能。
// 根据菜品IDs集合,查询对应套餐Ids集合
Set<Long> getIdsByDishId(List<Long> dishIds);
套餐业务层接口实现类 SetmealServiceImpl.java :
// 根据菜品IDs集合,查询对应套餐Ids集合
@Override
public Set<Long> getIdsByDishId(List<Long> dishIds) {
// 1.创建setmeal_dish的过滤条件封装器
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
// 2.添加过滤条件:IN()
lqw.in(SetmealDish::getDishId, dishIds);
// 3.查询对应的SetmealDish实体类集合
List<SetmealDish> setmealDishes = setmealDishService.list(lqw);
// 4.获取其setmealId的去重集合:Set集合是无序不可重复的
return setmealDishes.stream().map(SetmealDish::getSetmealId).collect(Collectors.toSet());
}
菜品业务层接口 IDishService.java :
// 删除(批量删除)菜品
Boolean removeWithFlavor(List<Long> ids);
菜品业务层接口实现类 DishServiceImpl.java :
// 删除(批量删除)菜品
@Override
public Boolean removeWithFlavor(List<Long> ids) {
// 1.判断待删除菜品是否正在售卖
// 1.1 创建dish的条件封装器
LambdaQueryWrapper<Dish> dishLqw = new LambdaQueryWrapper<>();
// 1.2 添加过滤条件:IN()-菜品ID要在入参ids中
dishLqw.in(Dish::getId, ids);
// 1.3 添加过滤条件:等值-菜品在售
dishLqw.eq(Dish::getStatus, 1);
// 1.4 调用数据层,根据条件封装器dishLqw的条件查询dish构成的集合
List<Dish> dishes = this.list(dishLqw);
// 1.5 如果dish集合不为空,说明待删除菜品中有在售菜品,抛出业务异常
if (!dishes.isEmpty()) {
// 1.6 获取在售菜品名称,方便用户停售对应菜品
List<String> dishNames = dishes.stream().map(Dish::getName).collect(Collectors.toList());
// 1.7 把在售菜品名称也返回到前端页面,方便用户停售对应菜品
throw new CustomException(dishNames + "正在售卖,删除失败,请停售后重试");
}
// 2.判断待删除菜品关联套餐是否在售
// 2.1 根据菜品IDs获取所关联的所有套餐IDs
Set<Long> setmealIds = setmealService.getIdsByDishId(ids);
// 2.2 创建setmeal的条件封装器
LambdaQueryWrapper<Setmeal> setmealLqw = new LambdaQueryWrapper<>();
// 2.3 添加过滤条件:IN()-套餐ID要在setmealIds中
setmealLqw.in(Setmeal::getId, setmealIds);
// 2.4 调用数据层,根据条件封装器setmealLqw的条件查询setmeal构成的集合
List<Setmeal> setmeals = setmealService.list(setmealLqw);
// 2.5 如果setmeal集合不为空,说明待删除菜品已绑定套餐,抛出业务异常
if (!setmeals.isEmpty()) {
// 2.6 获取绑定套餐名称,方便用户删除对应套餐
List<String> setmealNames = setmeals.stream().map(Setmeal::getName).collect(Collectors.toList());
// 2.7 把绑定套餐名称也返回到前端页面,方便用户删除对应套餐
throw new CustomException(setmealNames + "套餐正在售卖该菜品,删除失败,请删除套餐后重试");
}
// 批量删除菜品
this.removeByIds(ids);
// 删除菜品口味
LambdaQueryWrapper<DishFlavor> dishFlavorLqw = new LambdaQueryWrapper<>();
dishFlavorLqw.in(DishFlavor::getDishId, ids);
return dishFlavorService.remove(dishFlavorLqw);
}
打开表现层 src/main/java/edu/ouc/controller/DishController.java :
// 删除(批量删除)菜品
@DeleteMapping
public R<String> remove(@RequestParam List<Long> ids) {
if (dishService.removeWithFlavor(ids)) {
return R.success("删除成功");
}
return R.error("删除失败");
}
@Transactional 。套餐管理页面:

套餐分类会在 “套餐管理” 新建套餐时充当属性:

要选择该套餐应该包含哪些菜品:

SetmealDishes 集合中的每个 setmealDish 逐一取出,通过 stream 流给每个 setmealDish 设置绑定套餐 ID setmeal_id 。当用户点击 “新建套餐” 按钮后,进入添加套餐的页面,前端立马以 GET 方式向后端 URL 为 /dish/list 发起请求,并携带一个分类 ID categoryId :

可以发现,该请求携带的分类 ID categoryId 正是 category 数据表中 sort 字段为 1 的分类,我这里是粤菜排第一,所以发送的是粤菜这个分类的 ID 给后端。因为当用户点击 “+添加菜品” 按钮时,需要把系统里已有的菜品信息展示给用户选择。

因此,需要回到菜品控制层、菜品业务层去添加展示分类所有菜品的功能。
当用户填写好新增套餐信息,点击 ”保存“ 时,查看浏览器控制台:



可以看到,新增套餐时,前端以 POST 方式向后端发起请求,请求 URL 是 /setmeal ,并把套餐信息都发送到后端。
套餐管理涉及到的数据表就是 setmeal 和 setmeal_dish 。
setmeal 是单纯记录有什么套餐:

相应的字段介绍:
| 字段 | 描述 |
|---|---|
| id | 套餐ID |
| category_id | 套餐分类ID |
| name | 套餐名称 (唯一约束) |
| price | 套餐价格 |
| status | 售卖状态,0停售,1启售 |
| code | 商品码 |
| description | 套餐描述 |
| image | 套餐图片 |
| create_time | 创建日期时间 |
| update_time | 修改日期时间 |
| create_user | 创建人ID |
| update_user | 修改人ID |
| is_deleted | 是否已删除 |
而 setmeal_dish 则是记录每个套餐所关联的菜品:

相应的字段介绍:
| 字段 | 描述 |
|---|---|
| id | 这张表自己的套餐菜品ID主键 |
| setmeal_id | setmeal 数据表的套餐ID |
| dish_id | dish 数据表的菜品ID |
| name | 菜品名称 |
| price | 菜品价格 |
| copies | 菜品份数 |
| sort | 排序 |
| create_time | 创建日期时间 |
| update_time | 修改日期时间 |
| create_user | 创建人ID |
| update_user | 修改人ID |
| is_deleted | 是否已删除 |
创建 src/main/java/edu/ouc/entity/Setmeal.java :
@Data
public class Setmeal {
private static final Long serialVersionUID = 123L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@JsonSerialize(using = ToStringSerializer.class)
private Long categoryId;
private String name;
private BigDecimal price;
private Integer status;
private String code;
private String description;
private String image;
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
创建数据表 setmeal_dish 的实体类 src/main/java/edu/ouc/entity/SetmealDish.java :
@Data
public class SetmealDish {
private static final Long serialVersionUID = 38L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@JsonSerialize(using = ToStringSerializer.class)
private Long setmealId;
@JsonSerialize(using = ToStringSerializer.class)
private Long dishId;
// 菜品名称(冗余字段)
private String name;
// 菜品原价
private BigDecimal price;
// 菜品份数
private Integer copies;
// 排序
private Integer sort;
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
由于涉及到两张数据表的多表操作,因此必须创建 DTO 实体类来封装两张表的字段信息。在继承 Setmeal 类的基础上,扩展了 2 个属性:套餐菜品集合 setmealDishes 和套餐所属的类别名称 categoryName 。
创建 src/main/java/edu/ouc/dto/SetmealDto.java :
@Data
public class SetmealDto extends Setmeal {
private List<SetmealDish> setmealDishes;
private String categoryName;
}
创建 src/main/java/edu/ouc/mapper/SetmealMapper.java :
@Mapper
public interface SetmealMapper extends BaseMapper<Setmeal> {
}
创建数据表 setmeal_dish 的 Mapper src/main/java/edu/ouc/mapper/SetmealDishMapper.java :
@Mapper
public interface SetmealDishMapper extends BaseMapper<SetmealDish> {
}
菜品表 dish 业务层接口添加根据分类 ID 展示所有的菜品。此处有一个技巧,前端虽然传输过来的是 分类 ID ,但是我们却可以通过 Dish 类来接收,以增强通用性。
此外,获取菜品列表时还有一个业务逻辑:停售的菜品不应展示在页面上,因此要在条件过滤器上添加这一条件。
src/main/java/edu/ouc/service/IDishService.java :
// 根据指定过滤条件查询菜品
List<Dish> list(Dish dish);
菜品表 dish 业务层接口实现类 src/main/java/edu/ouc/service/impl/DishServiceImpl.java :
// 根据指定过滤条件查询菜品
@Override
public List<Dish> list(Dish dish) {
// 1.创建条件过滤器
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
// 2.添加过滤条件:根据分类ID查询菜品
lqw.eq(dish.getCategoryId() != null, Dish::getCategoryId, dish.getCategoryId());
// 3.添加排序条件:根据sort字段升序排列菜品,再根据最后修改时间降序排列
lqw.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
// 4.条件过滤条件:只查询启售的菜品
lqw.eq(Dish::getStatus, 1);
// 5.调用数据层的查询方法
return this.list(lqw);
}
接下来是用户点击 “保存” 按钮之后的逻辑,主要任务是把前端传输来的信息保存到两张数据表 setmeal 和 setmeal_dish 中。
【注意】多表操作切记添加事务注解 @Transactional !!!
因为涉及到多表的插入,因此用 setmeal 的数据传输对象 SetmealDto 来接收。
当用户点击 “保存” 按钮时,要把 SetmealDishes 集合中的每个 setmealDish 逐一取出,通过 stream 流给每个 setmealDish 设置绑定套餐 ID setmeal_id 。
套餐表 setmeal 创建业务层接口 src/main/java/edu/ouc/service/ISetmealService.java :
public interface ISetmealService extends IService<Setmeal> {
// 新增套餐,同时插入套餐对应菜品
Boolean saveWithSetmealDishes(SetmealDto setmealDto);
}
套餐表 setmeal 创建业务层实现类 src/main/java/edu/ouc/service/impl/SetmealServiceImpl.java :
@Slf4j
@Service
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements ISetmealService {
@Autowired
private ISetmealDishService setmealDishService;
@Autowired
private ICategoryService categoryService;
// 新增套餐,同时插入套餐对应菜品
@Override
@Transactional // 多表操作切记添加事务注解
public Boolean saveWithSetmealDishes(SetmealDto setmealDto) {
// 1.保存套餐的基本信息
this.save(setmealDto);
// 2.获取套餐菜品集合
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
// 3.以stream流的方式设置每个套餐菜品的setmeal_id
setmealDishes = setmealDishes.stream().peek(setmealDish -> {
setmealDish.setSetmealId(setmealDto.getId());
}).collect(Collectors.toList());
// 4.批量保存套餐菜品
return setmealDishService.saveBatch(setmealDishes);
}
}
套餐菜品关系表 setmeal_dish 创建套餐菜品关联表的业务层接口 src/main/java/edu/ouc/service/ISetmealDishService.java :
public interface ISetmealDishService extends IService<SetmealDish> {
}
套餐菜品关系表 setmeal_dish 创建套餐菜品关联表的业务层接口的实现类 src/main/java/edu/ouc/service/impl/SetmealDishServiceImpl.java :
@Slf4j
@Service
public class SetmealDishServiceImpl extends ServiceImpl<SetmealDishMapper, SetmealDish> implements ISetmealDishService {
}
菜品表 dish 表现层 src/main/java/edu/ouc/controller/DishController.java :
// 根据指定过滤条件查询菜品
@GetMapping("/list")
public R<List<Dish>> listByCategoryId(Dish dish) {
return R.success(dishService.list(dish));
}
controller 层只需要创建套餐的 controller 层即可。
创建 src/main/java/edu/ouc/controller/SetmealController.java :
@Slf4j
@RestController
@RequestMapping("/setmeal")
public class SetmealController {
@Autowired
private ISetmealService setmealService;
@Autowired
private ISetmealDishService setmealDishService;
}
略。
本节要实现的功能是:当用户点击 ”套餐管理“ 后,把套餐都分页展示出来。
此外,还需要把套餐的分类名称显示出来:

需要查询的数据表有 setmeal 和 categoyr 。因此传输的实体类对象应该是 SetmealDto 。只是涉及到多表查询,不需要加上事务注解。
当用户点击 ”套餐管理“ 后,页面以 GET 请求方式向后端请求,请求 URL 为 /setmeal/page 。并把当前页码和每页记录数发送到后端。


当用户搜索套餐名字时,会再添加多一个查询条件 name :



套餐 setmeal 业务层接口 ISetmealService.java :
public interface ISetmealService extends IService<Setmeal> {
// 新增套餐,同时插入套餐对应菜品
Boolean saveWithSetmealDishes(SetmealDto setmealDto);
// 带搜索功能的分页查询
Page<SetmealDto> getPage(Long currentPage, Long pageSize, String name);
}
套餐 setmeal 业务层接口的实现类 SetmealServiceImpl.java :
// 带搜索功能的分页查询
@Override
public Page<SetmealDto> getPage(Long currentPage, Long pageSize, String name) {
// 1.创建Setmeal的分页构造器
Page<SetmealDto> dtoPage = new Page<>(currentPage, pageSize);
// 2.创建SetmealDto的分页构造器
Page<Setmeal> page = new Page<>(currentPage, pageSize);
// 3.创建Setmeal的条件查询过滤器
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
// 4.条件过滤条件:根据套餐名称模糊匹配
lqw.like(name != null, Setmeal::getName, name);
// 5.调用数据层的分页查询+条件查询
this.page(page, lqw);
// 6.复制分页构造器:除了记录其他都复制
BeanUtils.copyProperties(page, dtoPage, "records");
// 7.获取Setmeal Page的记录集合
List<Setmeal> setmeals = page.getRecords();
// 8.通过集合stream流逐一给SetmealDtos赋上类别名
List<SetmealDto> setmealDtos = setmeals.stream().map(setmeal -> {
// 8.1 创建SetmealDto对象
SetmealDto setmealDto = new SetmealDto();
// 8.2 把setmeal中的信息复制到setmealDto中
BeanUtils.copyProperties(setmeal, setmealDto);
// 8.3 获取类别名称
String categoryName = categoryService.getById(setmeal.getCategoryId()).getName();
// 8.4 给setmealDto设置类别名称
setmealDto.setCategoryName(categoryName);
// 8.5 返回setmealDto对象
return setmealDto;
}).collect(Collectors.toList());
// 给设置Records
dtoPage.setRecords(setmealDtos);
return dtoPage;
}
【注意】凡是涉及到多表联查,都要使用 DTO 的实体类。
打开表现层 src/main/java/edu/ouc/controller/SetmealController.java :
// 带搜索功能的分页查询
@GetMapping("/page")
public R<Page<SetmealDto>> getPage(Long page, Long pageSize, String name) {
return R.success(setmealService.getPage(page, pageSize, name));
}

首先是待修改套餐信息回显,”修改“ 按钮以 GET 请求方式向后端请求,请求 URL 为 /setmeal/套餐ID 。


然后是把修改好的套餐信息以 PUT 请求方式,把修改后的信息传输到后端:



setmeal 和 setmeal_dish 两张表,应当以 SetmealDto 类返回。setmeal 和 setmeal_dish 两张表,牢记一定要使用事务注解。套餐业务层接口 ISetmealService.java :
public interface ISetmealService extends IService<Setmeal> {
// 新增套餐,同时插入套餐对应菜品
Boolean saveWithSetmealDishes(SetmealDto setmealDto);
// 带搜索功能的分页查询
Page<SetmealDto> getPage(Long currentPage, Long pageSize, String name);
// 根据套餐ID查询单个带套餐菜品的套餐信息
SetmealDto getByIdWithDishes(Long id);
// 修改套餐信息和套餐关联菜品
Boolean updateWithDishes(SetmealDto setmealDto);
}
回显功能套餐业务层接口的实现类 SetmealServiceImpl.java :
// 根据套餐ID查询单个带套餐菜品的套餐信息
@Override
public SetmealDto getByIdWithDishes(Long id) {
// 1.获取套餐的基本信息
Setmeal setmeal = this.getById(id);
// 2.获取套餐所有关联菜品构成的列表
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
lqw.eq(SetmealDish::getSetmealId, id);
lqw.orderByDesc(SetmealDish::getUpdateTime);
List<SetmealDish> setmealDishes = setmealDishService.list(lqw);
// 3.把套餐基本信息和套餐菜品封装到SetmealDto对象中
SetmealDto setmealDto = new SetmealDto();
BeanUtils.copyProperties(setmeal, setmealDto);
setmealDto.setSetmealDishes(setmealDishes);
return setmealDto;
}
修改功能套餐业务层接口的实现类 SetmealServiceImpl.java :
// 修改套餐信息和套餐关联菜品
@Override
@Transactional // 修改两张表不能忘记事务注解
public Boolean updateWithDishes(SetmealDto setmealDto) {
// 1.修改setmeal基本信息
this.updateById(setmealDto);
// 2.把setmeallDish信息抽取出来
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
// 3.通过集合的stream流给套餐关联的菜品逐一赋上套餐ID
setmealDishes = setmealDishes.stream().peek(setmealDish -> {
setmealDish.setSetmealId(setmealDto.getId());
}).collect(Collectors.toList());
// 4.把setmeal_dish表中原有的套餐菜品删除掉
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
lqw.eq(SetmealDish::getSetmealId, setmealDto.getId());
setmealDishService.remove(lqw);
// 5.把新的套餐菜品插入到setmeal_dish表中
return setmealDishService.saveBatch(setmealDishes);
}
【注意】凡是涉及到多表联查,都要使用 DTO 的实体类。
打开表现层 src/main/java/edu/ouc/controller/DishController.java :
// 根据菜品ID查询菜品信息和对应的口味信息
@GetMapping("/{id}")
public R<DishDto> getById(@PathVariable Long id) {
return R.success(dishService.getByIdWithFlavor(id));
}
// 修改菜品,同时修改菜品对应的口味数据
@PutMapping
public R<String> update(@RequestBody DishDto dishDto) {
if (dishService.updateWithFlavor(dishDto)) {
return R.success("修改成功");
}
return R.error("修改失败");
}
修改功能基本完成后,回显时前端有个 Bug ,原本已选的菜品名称无法显示,但是新选的菜品则可以正常显示。

解决办法是在 src/main/resources/static/backend/page/combo/add.html 的第 335 行代码,把代码从原来的:
<span>{{ item.dishName }}span>
改成:
<span>{{ item.name }}span>
即可。
因为菜品实体类 Dish 中菜品名称的属性是用 name 表示而不是 dishName :

修改后,重启服务,就能正确显示了:

略。
单个套餐停售与批量停售并没有什么区别,只是携带的套餐 ID 是一个还是多个而已。因此就把单个套餐与多个套餐的停售/启售都合并到一个方法里。
按 “停售” ,修改当前套餐的售卖状态。

注意:已经是启售状态的套餐,再点击 “批量启售” 要不要给予相应的提示呢?
”停售“ 按钮以 POST 请求方式向后端请求,请求 URL 为 /setmeal/status/0 。同时携带要停售的套餐的 ID 。
单个套餐停售与批量停售并没有什么区别,只是携带的套餐 ID 是一个还是多个而已。


”启售“ 按钮以 POST 请求方式向后端请求,请求 URL 为 /setmeal/status/1 。同时携带要启售的套餐的 ID 。


把单个菜品与多个菜品的停售/启售都合并到一个方法里。
【注意】多个 ID 要使用集合接收时,其前面要加上 @RequestParam 注解。
业务层接口 ISetmealService.java :
// (批量)套餐启售/停售
Boolean updateStatus(Integer status, List<Long> ids);
如果一个套餐停售,其实并不会影响其关联菜品的售卖状态。其关联菜品仍然可以作为一个单体继续售卖。
如果一个套餐启售,需要确认:
业务层接口实现类 SetmealServiceImpl.java :
// (批量)套餐启售/停售
@Override
public Boolean updateStatus(Integer status, List<Long> ids) {
// 启售套餐前,应检查套餐关联菜品是否存在且在售
if (status == 1) {
// 根据套餐IDs从setmeal_dish表查询对应的菜品IDs
Set<Long> dishIds = setmealDishService.getDishIdsBySetmealId(ids);
// 创建dish的条件包装器
LambdaQueryWrapper<Dish> lqw1 = new LambdaQueryWrapper<>();
// 添加过滤条件:查询停售的dish
lqw1.eq(Dish::getStatus, 0);
// 根据条件封装器lqw查询满足条件的dish实体类对象构成的集合
List<Dish> dishes = dishService.list(lqw1);
// 如果dish集合不为空,说明套餐菜品中有停售菜品,套餐不能启售,抛出业务异常
if (!dishes.isEmpty()) {
// 获取已停售菜品的名称
List<String> dishNames = dishes.stream().map(Dish::getName).collect(Collectors.toList());
// 向前端页面展示已停售菜品的名称,方便用户启售对应菜品
throw new CustomException("套餐所关联菜品" + dishNames + "已停售,启售失败");
}
// 检查菜品有没有被删除
LambdaQueryWrapper<Dish> lqw2 = new LambdaQueryWrapper<>();
lqw2.in(Dish::getId, dishIds);
List<Dish> dishes1 = dishService.list(lqw2);
// 如果套餐菜品个数与dish数据表中根据菜品ID查询回来的个数不相等,说明dish数据表中有菜品被删除了
if (dishes1.size() != dishIds.size()) {
throw new CustomException("套餐中有菜品被删除,启售失败");
}
}
// 1.根据套餐ID集合批量查询套餐
List<Setmeal> setmeals = this.listByIds(ids);
// 2.使用stream流逐一修改其售卖状态
setmeals = setmeals.stream().peek(setmeal -> setmeal.setStatus(status)).collect(Collectors.toList());
// 3.批量修改
return this.updateBatchById(setmeals);
}
打开表现层 SetmealController.java :
// (批量)套餐启售/停售
@PostMapping("/status/{status}")
public R<String> updateStatus(@PathVariable Integer status, @RequestParam List<Long> ids) {
if (setmealService.updateStatus(status, ids)) {
return R.success("修改成功");
}
return R.error("修改失败");
}
略。
套餐删除功能涉及到两张数据表,分别是 setmeal 和 setmeal_dish 。用户点击 “删除” 按钮后,后端首先根据套餐 ID 删除 setmeal_dish 中的关联菜品,再删除 setmeal 中对应的套餐记录。

”删除“ 按钮以 DELETE请求方式向后端请求,请求 URL 为 /dish 。同时携带要删除套餐的 ID 。
单个套餐删除与批量删除并没有什么区别,只是携带的套餐 ID 是一个还是多个而已。


业务层接口 ISetmealService.java :
// 根据套餐ID删除(批量删除)套餐
Boolean removeWithDish(List<Long> ids);
【我的思路】关于业务层接口实现类 SetmealServiceImpl.java ,我先按自己的思路写了一版:就是在判断是否存在在售套餐时,采用了集合的 stream 流逐一判断:
// 根据套餐ID删除(批量删除)套餐
@Override
@Transactional // 删除两张表必须添加事务注解
public Boolean removeWithDish(List<Long> ids) {
// 1.首先判断当前套餐的售卖状态
// 1.1 获取待删除套餐构成的集合
List<Setmeal> setmeals = this.listByIds(ids);
// 1.2 使用stream流逐一检查集合内的套餐元素售卖状态
boolean isRemovable = setmeals.stream().anyMatch(setmeal -> 1 == setmeal.getStatus());
// 1.3 如果存在在售的套餐,则抛出业务异常,无法删除
if (isRemovable) {
throw new CustomException("所删除套餐中有在售套餐,删除失败,请停售后再删除");
}
// 2.能走到这里,就是已经全部停售了。首先根据套餐ID删除数据表setmeal_dish中的关联菜品
// 2.1创建过滤条件封装器
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
// 2.2添加过滤条件:IN()
lqw.in(SetmealDish::getSetmealId, ids);
// 2.3删除setmeal_dish表中所有的套餐关联菜品
setmealDishService.remove(lqw);
// 3.删除setmeal表中的套餐记录
return this.removeByIds(ids);
}
代码功能实现没问题。但弊端是,如果第 8 行返回的集合中套餐很多,逐一检查其售卖状态是一种低效的方式。
【老师思路】业务层接口实现类 SetmealServiceImpl.java 。老师的思路巧妙之处在于,两个查询条件:① 根据入参 ids ;② status = 1 。如果查询的 COUNT > 0 ,说明待删除套餐中有在售套餐,不能删除,抛出业务异常。
// 根据套餐ID删除(批量删除)套餐
@Override
@Transactional // 删除两张表必须添加事务注解
public Boolean removeWithDish(List<Long> ids) {
// 1.首先判断当前套餐的售卖状态
// 1.1 创建过滤条件封装器
LambdaQueryWrapper<Setmeal> wq = new LambdaQueryWrapper<>();
// 1.2 添加包含条件IN(),待删除套餐的ID
wq.in(Setmeal::getId, ids);
// 1.3 添加过滤条件:在售的套餐
wq.eq(Setmeal::getStatus, 1);
// 1.4 如果查询结果记录数大于0,说明待删除套餐中包含在售套餐,直接抛出业务异常
if (this.count(wq) > 0) {
throw new CustomException("所删除套餐中包含在售套餐,删除失败,请停售后再删除");
}
// 2.能走到这里,就是已经全部停售了。首先删除setmeal表中的套餐记录
this.removeByIds(ids);
// 3.根据套餐ID删除数据表setmeal_dish中的关联菜品
// 3.1创建过滤条件封装器
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
// 3.2添加过滤条件:IN()
lqw.in(SetmealDish::getSetmealId, ids);
// 3.3删除setmeal_dish表中所有的套餐关联菜品
return setmealDishService.remove(lqw);
}
打开表现层 SetmealController.java :
// 根据套餐ID删除(批量删除)套餐
@DeleteMapping
public R<String> removeWithDishes(@RequestParam List<Long> ids) {
if (setmealService.removeWithDish(ids)) {
return R.success("删除成功");
}
return R.error("删除失败");
}
删除在售套餐:

删除失败:

- 由于我不想花钱开通手机验证码登录,因此我使用邮箱验证码登录来替换短信验证码。
这里笔者使用 QQ 邮箱,首先要开启 POP3/STMP 服务,获取一个 16 位的授权码。


把上述手机短信换成邮箱即可。
用户登录涉及到数据表 user ,笔者这里把字段 phone 重命名成了 email ,没有改变字段的数据类型,以对应用户的邮箱。
ALTER TABLE `user`
CHANGE phone email VARCHAR(100);
修改后 user 表字段:

| 字段 | 描述 |
|---|---|
| id | 用户ID,主键 |
| name | 用户姓名 |
| 邮箱 | |
| sex | 性别:0女1男 |
| id_number | 身份证号码 |
| avatar | 用户头像 |
| status | 状态:0禁用;1正常 |
【注意】重命名了字段后,前端代码原来的手机地方可能会出 Bug ,稍后要花点时间去修。
- 手机号(邮箱)是区分不同用户的标识,在用户登录的时候判断所输入的手机号(邮箱)是否存储在表中。如果不在表中,说明该用户为一个新的用户,将该用户自动保在user表中。
user 把对应的实体类、数据层、业务层和控制层的架子搭起来。创建 src/main/java/edu/ouc/entity/User.java :
@Data
public class User implements Serializable {
private static final Long serialVersionUID = 2L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String name;
private String email;
private String sex;
private String idNumber;
private String avatar;
private Integer status;
}
创建 src/main/java/edu/ouc/mapper/UserMapper.java :
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
创建 src/main/java/edu/ouc/service/IUserService.java :
public interface IUserService extends IService<User> {
}
创建 src/main/java/edu/ouc/service/impl/UserServiceImpl.java :
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
}
创建 src/main/java/edu/ouc/controller/UserController.java :
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserServiceImpl userService;
}
导入邮箱验证码登录所需的依赖坐标:
<dependency>
<groupId>javax.activationgroupId>
<artifactId>activationartifactId>
<version>1.1.1version>
dependency>
<dependency>
<groupId>javax.mailgroupId>
<artifactId>mailartifactId>
<version>1.4.7version>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-emailartifactId>
<version>1.4version>
dependency>
然后编写一个工具类 src/main/java/edu/ouc/utils/MailUtils.java ,用于发送邮件验证码。
public class MailUtils {
// 发送邮件验证码
public static void sendTestMail(String email, String code) throws MessagingException {
// 1.创建Properties 类用于记录邮箱的一些属性
Properties pros = new Properties();
// 1.1 表示SMTP发送邮件,必须进行身份验证
pros.put("mail.smtp.auth", "true");
// 1.2 此处填写SMTP服务器
pros.put("mail.smtp.host", "smtp.qq.com");
// 1.3 端口号,QQ邮箱端口587
pros.put("mail.smtp.port", "587");
// 1.4 此处填写,写信人的账号
pros.put("mail.user", "sihangxie@qq.com");
// 1.5 此处填写16位STMP口令
pros.put("mail.password", "你的STMP口令");
// 2.构建授权信息,用于进行SMTP进行身份验证
Authenticator authenticator = new Authenticator() {
protected javax.mail.PasswordAuthentication getPasswordAuthentication() {
// 2.1 用户名
String userName = pros.getProperty("mail.user");
// 2.2 16位STMP口令
String password = pros.getProperty("mail.password");
return new javax.mail.PasswordAuthentication(userName, password);
}
};
// 3.使用环境属性和授权信息,创建邮件会话
Session mailSession = Session.getInstance(pros, authenticator);
// 4.创建邮件消息对象
MimeMessage message = new MimeMessage(mailSession);
// 4.1 设置发件人
InternetAddress from = new InternetAddress(pros.getProperty("mail.user"));
message.setFrom(from);
// 4.2 设置收件人
InternetAddress to = new InternetAddress(email);
message.setRecipient(Message.RecipientType.TO, to);
// 4.3 设置邮件标题
message.setSubject("【瑞吉外卖】邮箱登录验证码");
// 4.4 设置邮件的正文
message.setContent("尊敬的用户:您好!\r\n您的登录验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");
// 5.最后,发送邮件
Transport.send(message);
}
// 获取六位随机验证码
public static String getCode() {
// 由于数字 1 、 0 和字母 O 、l 有时分不清楚,所以,没有数字 1 、 0
String[] beforeShuffle = {"2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F",
"G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a",
"b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
"w", "x", "y", "z"};
// 将数组转换成集合
List<String> list = Arrays.asList(beforeShuffle);
// 打乱集合顺序,以达到随机的效果
Collections.shuffle(list);
// 创建StringBuilder,不是线程安全的
StringBuilder sb = new StringBuilder();
// 将集合转变成StringBuilder字符串
for (String s : list) {
sb.append(s);
}
// 返回sb字符串中第10~17位的5位验证码,这个区间其实随便设的
return sb.substring(10, 16);
}
}
修改 src/main/java/edu/ouc/filter/LoginCheckFilter.java 中第 44 行代码,添加不需要拦截的地址:
// 2.定义不需要拦截的URL地址数组
String[] urls = new String[]{
"/employee/login", // 登录页面
"/employee/logout", // 退出登录
"/backend/**", // 后台页面的页面的静态资源
"/front/**", // 移动端页面的静态资源
"/user/login", // 用户登录
"/user/sendMsg" // 发送登录验证码
};
并再添加一段代码,用于判断 C 端用户是否已经登录:
// 6.如果需要处理,判断C端用户是否登录
if (request.getSession().getAttribute("user") != null) {
// 能进入说明已经登录,直接放行
Long userId = (Long) request.getSession().getAttribute("user");
log.info("手机用户{}已登录", userId);
// 把当前登录用户的ID保存到ThreadLocal中
BaseContext.setCurrentUserId(userId);
// 放行
filterChain.doFilter(request, response);
return;
}
打开 src/main/resources/static/front/api/login.js ,添加获取邮箱验证码的函数:
function sendMsgApi(data) {
return $axios({
'url': '/user/sendMsg',
'method': 'post',
data
})
}
这样,用户点击【获取验证码】时,前端就以 POST 方式向后端 URL 为 /user/sendMsg 发送请求,负载是用户的电子邮箱。


由于我们把手机号码登录替换成邮箱登录,因此我们需要对原来的登录界面作一些修改。打开 src/main/resources/static/front/page/login.html 。
修改第 25 行代码为:
<el-input placeholder=" 请输入电子邮箱" v-model="form.email" maxlength='50'/>el-input>
修改第 39 行代码为:这是关于登录按钮颜色转换的
<el-button type="primary"
:class="{btnSubmit:1===1,btnNoPhone:!form.code||!form.email,btnPhone:form.code&&form.email}"
@click="btnLogin">
修改第 82 行代码的正则表达式为:
const regex = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/; // 邮箱正则表达式
修改第 73 行代码为:
sendMsgApi({email:this.form.email})
修改第 108 行代码为:
const res = await loginApi(this.form) // 向后端发送POST请求
其他地方,中文把 “手机号码” 改成 “电子邮箱“ ;英文把 ”phone“ 改成 ”email“ 即可。
此外,为了方便调试,还需把前端请求的超时时间设置得长一点。打开 src/main/resources/static/front/js/request.js ,修改第 8 行代码为:
// 超时
timeout: 1000000
黑马的源代码中,用户点击完【获取验证码】后,仍然可以不断点击【获取验证码】来频繁获取邮箱验证码。这会给邮箱服务器带来极大的负担。
因此,我主动加上了验证码获取倒计时功能。用户必须等待 60 秒后才能再一次获取验证码邮件,可以极大降低邮箱服务器的负担。
打开 src/main/resources/static/front/page/login.html ,再第 32 行代码后添加倒计时的显示区域:
<span v-show="!show" class="count">重新获取({{count}} s)span>
第 59 行,Vue 的数据模型中,添加show 、count 、timer 3 个数据模型:
data() {
return {
show: true, // 控制【获取验证码】以及倒计时的显示开关
count: '', // 倒计时时长
timer: null, // 计时器
form: {
email: '',
code: ''
},
msgFlag: false,
loading: false
}
},
第 77 行,在方法 getCode() 中添加相关代码:
getCode() {
const TIME_COUNT = 60; // 验证码倒计时时长60s
this.form.code = ''
// const regex = /^(13[0-9]{9})|(15[0-9]{9})|(17[0-9]{9})|(18[0-9]{9})|(19[0-9]{9})$/; // 手机号正则表达式
const regex = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/; // 邮箱正则表达式
if (regex.test(this.form.email)) {
this.msgFlag = false
// this.form.code = (Math.random() * 1000000).toFixed(0)
const res = sendMsgApi({email: this.form.email}) // 向后端发送用户的邮箱,让后端给用户发送验证码邮件
this.$notify({type: 'success', message: '验证码发送成功'})
if (!this.timer) {
this.count = TIME_COUNT; // 设置倒计时时长
this.show = false; // 不显示【获取验证码】,显示倒计时
this.timer = setInterval(() => {
if (this.count > 0 && this.count <= TIME_COUNT) {
this.count--;
} else { // 倒计完了
this.show = true; // 不显示倒计时,显示【获取验证码】
clearInterval(this.timer); // 清空
this.timer = null; // 清空
}
}, 1000)
}
} else {
this.msgFlag = true
}
},
同时,倒计时区域的样式是 class="count" ,我想让它显示为浅灰色。打开 src/main/resources/static/front/styles/login.css 添加 Class 选择器:
#login .divContainer .count {
position: absolute;
right: 20rem;
top: 20rem;
cursor: pointer;
opacity: 1;
font-size: 12rem;
font-family: PingFangSC, PingFangSC-Regular;
font-weight: 400;
text-align: left;
color: #D8D8D8; /*浅灰色*/
letter-spacing: 0px;
}
打开 src/main/resources/static/front/page/login.html ,第 106 行的 btnLogin() 方法,我修改成:
async btnLogin() {
if (this.form.email && this.form.code) {
this.loading = true
const res = await loginApi(this.form)
this.loading = false
if (res.code === 1) {
localStorage.setItem('userInfo', JSON.stringify(res.data)) // 存储后端返回的用户对象
sessionStorage.setItem("userEmail", this.form.email)
window.requestAnimationFrame(() => {
this.$notify({type: 'success', message: res.data})
window.location.href = '/front/index.html'
})
} else {
this.$notify({type: 'warning', message: res.msg});
}
}
if (!this.form.email) {
this.$notify({type: 'warning', message: '请输入电子邮箱'});
} else if (!this.form.code) {
this.$notify({type: 'warning', message: '请输入验证码'});
}
}
用户 user 业务层接口 IUserService.java :
// 发送邮箱验证码
Boolean sendMsg(User user, HttpSession session) throws MessagingException;
用户 user 业务层接口实现类 UserServiceImpl.java :
// 发送邮箱验证码
@Override
public Boolean sendMsg(User user, HttpSession session) throws MessagingException {
// 1.获取前端传来的用户邮箱
String email = user.getEmail();
// 2.如果邮箱不为空才进行下一步操作
if (!email.isEmpty()) {
// 2.1 随机生成六位数验证码
String code = MailUtils.getCode();
// 2.2 发送验证码邮件
MailUtils.sendMail(email, code);
// 2.3 把获得的验证码存入session保存作用域,方便后面拿出来比对
session.setAttribute(email, code);
return true;
}
return false;
}
登录时的请求分析,当用户输入完邮箱和验证码,点击【登录】时,前端以 POST 方式向后端 URL 为 /user/login 的地址发送请求。携带的数据是用户的邮箱和验证码。


控制层接收有两种方式:
code 属性;【难点】如果把登录验证码的有效性控制在一分钟?
用户 user 业务层接口 IUserService.java :
// 移动端用户登录
User login(Map<String, String> map, HttpSession session);
用户 user 业务层接口实现类 UserServiceImpl.java ,下面代码都是我独立构思完成的,和老师几乎一样:
// 移动端用户登录登录
@Override
public User login(Map<String, String> map, HttpSession session) {
// 获取前端传送来的用户邮箱
String email = map.get("email");
// 获取前端传送来的验证码
String code = map.get("code");
// 验证邮箱和验证码是否为空,如果为空则直接登录失败
if (email.isEmpty() || code.isEmpty()) {
throw new CustomException("邮箱或验证码不能为空");
}
// 如果邮箱和验证码不为空,前往调用数据层查询数据库有无该用户
// 获取之前存在session保存作用域中的正确验证码
String trueCode = (String) session.getAttribute(email);
// 比对用户输入的验证码和真实验证码,错了直接登录失败
if (!code.equals(trueCode)) {
throw new CustomException("验证码错误");
}
// 验证码匹配,开始调用数据库查询
LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>();
lqw.eq(User::getEmail, email);
User user = this.getOne(lqw);
// 如果数据库中没有该用户,就是新用户,要添加新用户
if (user == null) {
// 添加新用户
user = new User();
user.setEmail(email);
this.save(user);
}
// 最后把这个登录用户存到session保存作用域中,表示已登录,让拦截器放行
session.setAttribute("user", user.getId());
return user;
}
我的独立构思是:只要程序调用了在 MailUtils.java 的发送邮件方法 sendMail() ,就会同时开启一个新线程,该线程先 sleep() 60 秒,时间到后调用 getCode() 方法获取新验证码,并覆盖写入原来的 session 保存作用域中。
难点是,Thread 新线程应该写在哪里?以及从哪里开始 start() ?
最后,我把下面这段代码写到了 UserServiceImpl.java 的 sendMsg() 方法中,大概在 39 行左右:
// 启动多线程来限定验证码的时效性
new Thread(() -> {
try {
// 验证码的有效时长
Thread.sleep(60000L);
// 更换新验证码
session.setAttribute(email, MailUtils.getCode());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
经过测试,功能是没有问题的。
打开用户 user 表现层 UserController.java :
// 发送邮箱验证码
@PostMapping("/sendMsg")
public R<String> sendMsg(@RequestBody User user, HttpSession session) throws MessagingException {
if (userService.sendMsg(user, session)) {
return R.success("验证码发送成功");
}
return R.error("验证码发送失败");
}
// 移动端用户登录登录
@PostMapping("/login")
public R<User> login(@RequestBody Map<String, String> map, HttpSession session) {
User user = userService.login(map, session);
return R.success(user);
}
略。
用户点击【退出登录】时,会退出当前用户,并返回登录界面。清空本地存储和会话存储的用户信息存储。

前端以 POST 方式向后端 URL 为 /user/loginout 的地址发送请求。

用户 user 业务层接口 IUserService.java :
// 移动端用户退出登录
Boolean logout(HttpSession session);
用户 user 业务层接口实现类 UserServiceImpl.java :
// 移动端用户退出登录
@Override
public Boolean logout(HttpSession session) {
Long userId = BaseContext.getCurrentUserId();
User user = this.getById(userId);
String email = user.getEmail();
// 清除Session保存作用域中保存的数据
session.removeAttribute("user");
session.removeAttribute(email);
return true;
}
用户 user 表现层 UserController.java :
// 移动端用户退出登录
@PostMapping("/loginout")
public R<String> logout(HttpSession session) {
if (userService.logout(session)) {
return R.success("退出成功");
}
return R.error("退出失败");
}
地址簿涉及到数据表 address_book ,各个字段的详情如下所示:

| 字段 | 描述 |
|---|---|
| id | 地址ID,主键 |
| user_id | C 端用户 ID |
| consignee | 联系人 |
| sex | 性别 |
| phone | 联系电话 |
| province_code | 省份编码 |
| province_name | 省份名称 |
| city_code | 城市编码 |
| city_name | 城市名 |
| district_code | 区县编码 |
| district_name | 区县名称 |
| detail | 详细地址信息 |
| label | 标签:公司、家、学校 |
| is_default | 是否是默认地址:0不是1是 |
| create_time | 创建日期时间 |
| update_time | 修改日期时间 |
| create_user | 创建人ID |
| update_user | 修改人ID |
| is_deleted | 逻辑删除 |
address_book 把对应的实体类、数据层、业务层和控制层的架子搭起来。创建 src/main/java/edu/ouc/entity/AddressBook.java :
@Data
public class AddressBook implements Serializable {
private static final long serialVersionUID = 3L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
//用户id
private Long userId;
//收货人
private String consignee;
//手机号
private String phone;
//性别 0 女 1 男
private String sex;
//省级区划编号
private String provinceCode;
//省级名称
private String provinceName;
//市级区划编号
private String cityCode;
//市级名称
private String cityName;
//区级区划编号
private String districtCode;
//区级名称
private String districtName;
//详细地址
private String detail;
//标签
private String label;
//是否默认 0 否 1是
private Integer isDefault;
//创建时间
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
//更新时间
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
//创建人
@TableField(fill = FieldFill.INSERT)
private Long createUser;
//修改人
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
//是否删除
private Integer isDeleted;
}
创建 src/main/java/edu/ouc/mapper/AddressBookMapper.java :
@Mapper
public interface AddressBookMapper extends BaseMapper<AddressBook> {
}
创建 src/main/java/edu/ouc/service/IAddressBookService.java :
public interface IAddressBookService extends IService<AddressBook> {
}
创建 src/main/java/edu/ouc/service/impl/AddressBookServiceImpl.java :
@Slf4j
@Service
public class AddressBookServiceImpl extends ServiceImpl<AddressBookMapper, AddressBook> implements IAddressBookService {
}
创建 src/main/java/edu/ouc/controller/UserController.java :
@Slf4j
@RestController
@RequestMapping("/addressBook")
public class AddressBookController {
@Autowired
private AddressBookServiceImpl addressBookService;
}
用户填写好信息后,点击【保存地址】:

前端以 POST 方式向后端 URL 为 /addressBook 的地址发送请求。携带的数据是联系人姓名、联系电话、详细地址、标签和性别。


地址簿 AddressBook 业务层接口 IAddressBookService.java :
// 新增地址
AddressBook saveAdd(AddressBook addressBook);
地址簿 AddressBook 业务层接口实现类 AddressBookServiceImpl.java :
// 新增地址
@Override
public AddressBook saveAdd(AddressBook addressBook) {
// 获取当前登录用户的ID,并设为当前地址的userId
addressBook.setUserId(BaseContext.getCurrentUserId());
// 调用数据层保存新地址
this.save(addressBook);
return addressBook;
}
地址簿 AddressBook 表现层 AddressBookController.java :
// 新增地址
@PostMapping
public R<AddressBook> save(@RequestBody AddressBook addressBook) {
return R.success(addressBookService.saveAdd(addressBook));
}
前端以 GET 方式向后端 URL 为 /addressBook/list 的地址发送请求。

地址簿 AddressBook 业务层接口 IAddressBookService.java :
// 地址展示
List<AddressBook> getList();
地址簿 AddressBook 业务层接口实现类 AddressBookServiceImpl.java :
// 地址展示
@Override
public List<AddressBook> getList() {
// 获取当前用户ID
Long userId = BaseContext.getCurrentUserId();
// 查询这个用户所有的地址信息
LambdaQueryWrapper<AddressBook> lqw = new LambdaQueryWrapper<>();
lqw.eq(userId != null, AddressBook::getUserId, userId);
lqw.orderByDesc(AddressBook::getUpdateTime);
return this.list(lqw);
}
地址簿 AddressBook 表现层 AddressBookController.java :
// 地址展示
@GetMapping("/list")
public R<List<AddressBook>> getList() {
return R.success(addressBookService.getList());
}
前端以 PUT 方式向后端 URL 为 /addressBook/default 的地址发送请求。负载是将要设置为默认地址的地址 ID 。


默认地址只能设置一个,这个功能的难点在于,同一用户的前提下,如果始终保持地址簿上只有一个地址是被勾选为默认地址,且数据库中始终只有一个地址的 is_default 字段为 1 ,其余为 0 。
地址簿 AddressBook 业务层接口 IAddressBookService.java :
// 设为默认地址
AddressBook setDefault(AddressBook addressBook);
地址簿 AddressBook 业务层接口实现类 AddressBookServiceImpl.java :
// 【我的思路】(不推荐,用LambdaQueryWrapper有点蠢)
// 设为默认地址
@Override
public AddressBook setDefault(AddressBook addressBook) {
// 获取想要设为默认地址的地址ID
Long id = addressBook.getId();
// 获取当前登陆的用户ID
Long userId = BaseContext.getCurrentUserId();
// 先查询当前用户的地址里字段is_default为1的记录
LambdaQueryWrapper<AddressBook> lqw = new LambdaQueryWrapper<>();
lqw.eq(AddressBook::getIsDefault, 1);
lqw.eq(AddressBook::getUserId, userId);
AddressBook defaultAdd = this.getOne(lqw);
// 如果已经存在默认地址,先把它设为0
if (defaultAdd != null) {
// 取消它的默认地址
defaultAdd.setIsDefault(0);
// 更新到数据库
this.updateById(defaultAdd);
}
// 现在可以直接设为默认地址
LambdaQueryWrapper<AddressBook> newLqw = new LambdaQueryWrapper<>();
newLqw.eq(AddressBook::getUserId, userId);
newLqw.eq(AddressBook::getId, id);
defaultAdd = this.getOne(newLqw);
defaultAdd.setIsDefault(1);
this.updateById(defaultAdd);
return defaultAdd;
}
// 【老师思路】(推荐,用LambdaUpdateWrapper)
// 1.先把当前用户的所有地址的is_default字段设为0
// 1.1 创建更新条件封装器
LambdaUpdateWrapper<AddressBook> luw = new LambdaUpdateWrapper<>();
// 1.2 添加更新条件:指定当前登录用户ID
luw.eq(AddressBook::getUserId, BaseContext.getCurrentUserId());
// 1.3 添加更新添加:把所有记录的is_default字段设为0
luw.set(AddressBook::getIsDefault, 0);
// 1.4 调用数据层更新方法,入参是更新条件封装器luw
this.update(luw);
// 2.再把当前传入的地址设为默认地址
// 2.1 把传入的地址对象的isDefault属性设为1
addressBook.setIsDefault(1);
// 2.2 调用数据层的更新方法,注意null不会参与更新,只会更新不为null的字段,详见下面的SQL语句
this.updateById(addressBook); //UPDATE address_book SET is_default=?, update_time=?, update_user=? WHERE id=?
return addressBook;
地址簿 AddressBook 表现层 AddressBookController.java :
// 设为默认地址
@PutMapping("/default")
public R<AddressBook> setDefault(@RequestBody AddressBook addressBook) {
return R.success(addressBookService.setDefault(addressBook));
}
【回显】前端以 GET 方式向后端 URL 为 /addressBook/{id} 的地址发送请求。

【更新】前端以 PUT 方式向后端 URL 为 /addressBook 的地址发送请求。携带的是 AddressBook 对象信息。

地址簿 AddressBook 业务层接口 IAddressBookService.java :
无
地址簿 AddressBook 业务层接口实现类 AddressBookServiceImpl.java :
无
地址簿 AddressBook 表现层 AddressBookController.java :
// 查询单个地址的信息,用于编辑回显
@GetMapping("/{id}")
public R<AddressBook> getAdd(@PathVariable Long id) {
AddressBook addressBook = addressBookService.getById(id);
if (addressBook != null) {
return R.success(addressBook);
}
return R.error("没有找到该对象");
}
// 更新
@PutMapping
public R<String> updateAdd(@RequestBody AddressBook addressBook) {
if (addressBookService.updateById(addressBook)) {
return R.success("保存成功");
}
return R.success("保存失败");
}
修复 address-edit.html 第 111 行代码:
this.activeIndex = this.labelList.indexOf(this.form.label); // 修复回显标签错误的bug
前端以 DELETE 方式向后端 URL 为 /addressBook 的地址发送请求。负载是要删除的地址 ID 。


地址簿 AddressBook 业务层接口 IAddressBookService.java :
无
地址簿 AddressBook 业务层接口实现类 AddressBookServiceImpl.java :
无
地址簿 AddressBook 表现层 AddressBookController.java :
// 删除
@DeleteMapping
public R<String> remove(@RequestParam Long ids) {
if (addressBookService.removeById(ids)) {
return R.success("删除成功");
}
return R.error("删除失败");
}
右侧展示不出来分类页面,是因为前端代码规定了必须把分类列表和购物车列表同时查询成功才能成功显示。

前端代码中如下:
Promise.all([categoryListApi(), cartListApi({})]).then(res => {
...
}
而查询购物车列表我们还没写,因此会报 404 错误:

在开发代码之前,需要梳理一下前端页面和服务端的交互过程:
front/index.html ) 发送 ajax 请求,获取分类数据 (菜品分类和套餐分类);开发菜品展示功能,其实就是在服务端编写代码去处理前端页面发送的这 2 次请求即可。
【注意】首页加载完成后,还发送了一次 ajax 请求用于加载购物车数据。如果获取购物车数据失败,则整个页面都是展示失败的。为了开发方便,我们先提供一份 JSON 假数据:
{"code": 1, "msg": null, "data": [], "map": {}}
然后修改 src/main/resources/static/front/api/main.js 中 cartListApi() 函数的 URL 请求地址为:
//获取购物车内商品的集合
function cartListApi(data) {
return $axios({
// 'url': '/shoppingCart/list',
'url': '/front/cartData.json', // 先暂时用假数据
'method': 'get',
params: {...data}
})
}
修改之后,现在菜品就能成功展示了:

菜品业务层实现类 DishServiceImpl.java 中的菜品列表方法 list() 有个 Bug ,就是列表元素 dish 不包含菜品口味:

而客户下单是要选择菜品口味的,因此集合的泛型应该重构为 DishDto 。
菜品 dish 业务层接口 IDishService.java :
// 根据指定过滤条件查询菜品
List<DishDto> list(Dish dish);
菜品 dish 业务层接口实现类 DishServiceImpl.java :
// 根据指定过滤条件查询菜品
@Override
public List<DishDto> listWithFlavor(Dish dish) {
// 1.创建条件过滤器
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
// 2.添加过滤条件:根据分类ID查询菜品
lqw.eq(dish.getCategoryId() != null, Dish::getCategoryId, dish.getCategoryId());
// 3.添加排序条件:根据sort字段升序排列菜品,再根据最后修改时间降序排列
lqw.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
// 4.条件过滤条件:只查询启售的菜品
lqw.eq(Dish::getStatus, 1);
// 5.调用数据层的查询方法
List<Dish> dishes = this.list(lqw);
// 6.使用集合的stream流逐一把dish封装成dishDto
return dishes.stream().map(dishItem -> {
// 6.1 创建dishDto对象
DishDto dishDto = new DishDto();
// 6.2 把dish的所有属性值复制到dishDto对象中
BeanUtils.copyProperties(dishItem, dishDto);
// 6.3 创建DishFlavor的查询条件封装器
LambdaQueryWrapper<DishFlavor> dfLqw = new LambdaQueryWrapper<>();
// 6.4 添加查询条件:按dishId查询
dfLqw.eq(DishFlavor::getDishId, dishItem.getId());
// 6.5 根据查询条件查询菜品的口味集合
List<DishFlavor> dishFlavors = dishFlavorService.list(dfLqw);
// 6.6 把查询到的菜品口味集合设置到dishDto对象中
dishDto.setFlavors(dishFlavors);
// 6.7 返回封装好的dishDto对象
return dishDto;
}).collect(Collectors.toList());
}
菜品 dish 控制层 DishController.java :
// 根据指定过滤条件查询菜品
@GetMapping("/list")
public R<List<DishDto>> listByCategoryId(Dish dish) {
return R.success(dishService.listWithFlavor(dish));
}
重构完成之后,用户菜品页面就能显示展示规格了:


在用户点击套餐时,前端以 GET 方式向后端 URL 为 /setmeal/list 发送请求。携带的参数是套餐 ID 和 启售。


套餐 setmeal 业务层接口 IDishService.java :
// 根据条件查询套餐集合
List<Setmeal> list(Setmeal setmeal);
套餐 setmeal 业务层接口实现类 DishServiceImpl.java :
// 根据条件查询套餐集合
@Override
public List<Setmeal> list(Setmeal setmeal) {
// 1.创建查询条件封装器
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
// 2.添加查询条件:根据类别ID查询
lqw.eq(setmeal.getCategoryId() != null, Setmeal::getCategoryId, setmeal.getCategoryId());
// 3.添加查询条件:根据售卖状态查询
lqw.eq(setmeal.getStatus() != null, Setmeal::getStatus, setmeal.getStatus());
// 4.调用数据层返回套餐对象构成的集合
return this.list(lqw);
}
套餐 setmeal 控制层 DishController.java :
// 根据条件查询套餐集合
@GetMapping("/list")
public R<List<Setmeal>> list(Setmeal setmeal) {
return R.success(setmealService.list(setmeal));
}
完成后,套餐页面可以正常展示了。

+ 将当前套餐加入购物车。在购物车中可以修改菜品和套餐的数量,也可以清空购物车。购物车涉及到数据表 shopping_cart ,各个字段的详情如下所示:

| 字段 | 描述 |
|---|---|
| id | 购物车中商品 ID,主键 |
| name | 菜品名称/套餐名称 |
| image | 商品图片 |
| user_id | C 端用户 ID |
| dish_id | 菜品 ID |
| setmeal_id | 套餐 ID |
| dish_flavor | 菜品口味 |
| number | 商品份数 |
| amount | 总金额 |
| create_time | 创建日期时间 |
shopping_cart 把对应的实体类、数据层、业务层和控制层的架子搭起来。创建 src/main/java/edu/ouc/entity/ShoppingCart.java :
@Data
public class ShoppingCart {
private static final Long serialVersionUID = 4L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String name;
private String image;
@JsonSerialize(using = ToStringSerializer.class)
private Long userId;
@JsonSerialize(using = ToStringSerializer.class)
private Long dishId;
@JsonSerialize(using = ToStringSerializer.class)
private Long setmealId;
private String dishFlavor;
private Integer number;
private BigDecimal amount;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}
创建 src/main/java/edu/ouc/mapper/ShoppingCartMapper.java :
@Mapper
public interface ShoppingCartMapper extends BaseMapper<ShoppingCart> {
}
创建 src/main/java/edu/ouc/service/IShoppingCartService.java :
public interface IShoppingCartService extends IService<ShoppingCart> {
}
创建 src/main/java/edu/ouc/service/impl/ShoppingCartServiceImpl.java :
@Slf4j
@Service
public class ShoppingCartServiceImpl extends ServiceImpl<ShoppingCartMapper, ShoppingCart> implements IShoppingCartService {
}
创建 src/main/java/edu/ouc/controller/ShoppingCartController.java :
@Slf4j
@RestController
@RequestMapping("/shoppingCart")
public class ShoppingCartController {
@Autowired
private ShoppingCartServiceImpl shoppingCartService;
}
用户选择好菜品后,点击【加入购物车】:

前端以 POST 方式向后端 URL 为 /shoppingCart/add 的地址发送请求。携带的数据是购物车数据。


由于购物车的实体类 ShoppingCart 中需要自动填充的字段只有 createTime ,其他 3 个都没有。因此要对 src/main/java/edu/ouc/common/MyMetaObjectHandler.java 稍作修改:
@Component // 让Spring管理
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
// 当执行插入数据时自动填充
@Override
public void insertFill(MetaObject metaObject) {
// 实体类中创建了这个属性才会自动填充
if (metaObject.hasSetter("createTime")) {
metaObject.setValue("createTime", LocalDateTime.now());
}
if (metaObject.hasSetter("updateTime")) {
metaObject.setValue("updateTime", LocalDateTime.now());
}
// 获取当前线程的登录用户的ID
Long id = BaseContext.getCurrentUserId();
if (metaObject.hasSetter("createUser")) {
metaObject.setValue("createUser", id);
}
if (metaObject.hasSetter("updateUser")) {
metaObject.setValue("updateUser", id);
}
}
// 当执行更新数据时自动填充
@Override
public void updateFill(MetaObject metaObject) {
// 获取当前线程的登录用户的ID
Long id = BaseContext.getCurrentUserId();
if (metaObject.hasSetter("updateUser")) {
metaObject.setValue("updateUser", id);
}
if (metaObject.hasSetter("updateTime")) {
metaObject.setValue("updateTime", LocalDateTime.now());
}
}
}
购物车 ShoppingCart 业务层接口 IShoppingCartService.java :
// 添加菜品到购物车
ShoppingCart add(ShoppingCart shoppingCart);
购物车 ShoppingCart 业务层接口实现类 ShoppingCartServiceImpl.java :
// 添加菜品到购物车
@Override
public ShoppingCart add(ShoppingCart shoppingCart) {
// 获取当前登录用户的 ID
Long userId = BaseContext.getCurrentUserId();
// 给传入的购物车菜品设置用户ID
shoppingCart.setUserId(userId);
// 查询一下是否是首次添加
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
// 添加查询条件:根据用户ID查询
lqw.eq(ShoppingCart::getUserId, userId);
// 判断用户添加的是菜品还是套餐
if (shoppingCart.getDishId() != null) {
// 用户添加的是菜品,添加菜品ID作为查询条件
lqw.eq(ShoppingCart::getDishId, shoppingCart.getDishId());
} else {
// 否则,用户添加的是套餐,添加套餐ID作为查询条件
lqw.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
}
// 调用数据层查询购物车
ShoppingCart shoppingCartSel = this.getOne(lqw);
// 如果查询结果为空,则是第一次添加,把number字段设为1,插入
if (shoppingCartSel == null) {
shoppingCart.setNumber(1);
// 调用数据层插入购物车数据
this.save(shoppingCart);
// 返回已经写入用户ID的对象
return shoppingCart;
}
// 否则就不是第一次加入购物车,就直接在number字段上加1,更新
shoppingCartSel.setNumber(shoppingCartSel.getNumber() + 1);
// 更新
this.updateById(shoppingCartSel);
return shoppingCartSel;
}
购物车 ShoppingCart 表现层 ShoppingCartController.java :
// 添加菜品到购物车
@PostMapping("/add")
public R<ShoppingCart> add(@RequestBody ShoppingCart shoppingCart) {
return R.success(shoppingCartService.add(shoppingCart));
}
在开发菜品展示功能时,为了不让购物车报错修改了购物车前端页面代码,现在要将其修改回来。打开 src/main/resources/static/front/api/main.js :
//获取购物车内商品的集合
function cartListApi(data) {
return $axios({
'url': '/shoppingCart/list',
'method': 'get',
params: {...data}
})
}
再把 cartData.json 假数据删掉。
用户点击购物车的小骑手图标:

前端以 GET 方式向后端 URL 为 /shoppingCart/list 的地址发送请求。

购物车 ShoppingCart 业务层接口 IShoppingCartService.java :
// 查询当前用户的购物车中所有信息
List<ShoppingCart> getUserList();
购物车 ShoppingCart 业务层接口实现类 ShoppingCartServiceImpl.java :
// 查询当前用户的购物车中所有信息
@Override
public List<ShoppingCart> getUserList() {
// 获取当前用户ID
Long userId = BaseContext.getCurrentUserId();
// 创建查询条件封装器
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
lqw.eq(userId != null, ShoppingCart::getUserId, userId);
// 按时间升序排
lqw.orderByAsc(ShoppingCart::getCreateTime);
// 查询当前用户的所有购物车信息
return this.list(lqw);
}
购物车 ShoppingCart 表现层 ShoppingCartController.java :
// 查询当前用户的购物车中所有信息
@GetMapping("/list")
public R<List<ShoppingCart>> list() {
return R.success(shoppingCartService.getUserList());
}
用户打开购物车后,点击【清空】按钮:

前端以 DELETE 方式向后端 URL 为 /shoppingCart/clean 的地址发送请求。

购物车 ShoppingCart 业务层接口 IShoppingCartService.java :
// 清空购物车
Boolean clean();
购物车 ShoppingCart 业务层接口实现类 ShoppingCartServiceImpl.java :
// 清空购物车
@Override
public Boolean clean() {
// 获取当前用户ID
Long userId = BaseContext.getCurrentUserId();
// 创建查询条件封装器
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
lqw.eq(userId != null, ShoppingCart::getUserId, userId);
// 按用户ID删除所有记录
return this.remove(lqw);
}
购物车 ShoppingCart 表现层 ShoppingCartController.java :
// 清空购物车
@DeleteMapping("/clean")
public R<String> clean() {
if (shoppingCartService.clean()) {
return R.success("清空成功");
}
return R.error("清空失败");
}
用户点击商品的【-】号:

前端以 POST 方式向后端 URL 为 /shoppingCart/sub 的地址发送请求。负载是菜品 ID 或套餐 ID 。


购物车 ShoppingCart 业务层接口 IShoppingCartService.java :
// 购物车商品减一
Boolean sub(ShoppingCart shoppingCart);
购物车 ShoppingCart 业务层接口实现类 ShoppingCartServiceImpl.java :
// 购物车商品减一
@Override
public Boolean sub(ShoppingCart shoppingCart) {
// 1.获取当前用户ID
Long userId = BaseContext.getCurrentUserId();
// 2.创建查询条件封装器
LambdaQueryWrapper<ShoppingCart> lqw = new LambdaQueryWrapper<>();
// 2.1 添加查询添加:按用户ID查询
lqw.eq(userId != null, ShoppingCart::getUserId, userId);
// 3.判断传来的是菜品还是套餐
if (shoppingCart.getDishId() != null) {
// 3.1 删除的是菜品
lqw.eq(ShoppingCart::getDishId, shoppingCart.getDishId());
} else {
// 3.2 删除的是套餐
lqw.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
}
// 4.查询当前购物车里该菜品/套餐的数量
ShoppingCart shoppingCartSel = this.getOne(lqw);
if (1 < shoppingCartSel.getNumber()) {
// 4.1 数量大于一,直接减一
shoppingCartSel.setNumber(shoppingCartSel.getNumber() - 1);
// 4.2更新到数据库
return this.updateById(shoppingCartSel);
}
// 5.其他情况直接从购物车删除此商品
return this.remove(lqw);
}
购物车 ShoppingCart 表现层 ShoppingCartController.java :
// 购物车商品减一
@PostMapping("/sub")
public R<String> sub(@RequestBody ShoppingCart shoppingCart) {
if (shoppingCartService.sub(shoppingCart)) {
return R.success("删除成功");
}
return R.error("删除失败");
}
至此,购物车功能就开发完毕了。
购物车涉及到订单表 orders 和订单明细表 order_detail ,各个字段的详情如下所示:
订单表 orders :

| 字段 | 描述 |
|---|---|
| id | 订单 ID,主键 |
| number | 订单号 |
| status | 订单状态:1待付款;2待派送;3已派送;4已完成;5已取消 |
| user_id | C 端用户 ID |
| address_book_id | 地址簿 ID |
| order_time | 下单时间 |
| checkout_time | 支付完成的时间 |
| pay_method | 支付方式 |
| amount | 订单金额 |
| remark | 备注 |
| phone | 用户手机号 |
| address | 详细地址信息 |
| user_name | 用户名 |
| consignee | 收货人名 |
订单明细表 order_detail :

| 字段 | 描述 |
|---|---|
| id | 订单明细 ID,主键 |
| name | 菜品名称/套餐名称 |
| image | 商品图片 |
| order_id | 订单 ID |
| dish_id | 菜品 ID |
| setmeal_id | 套餐 ID |
| dish_flavor | 菜品口味 |
| number | 商品份数 |
| amount | 总金额 |
orders 和 order_detail 把对应的实体类、数据层、业务层和控制层的架子搭起来。创建订单表 orders 实体类 src/main/java/edu/ouc/entity/Order.java :
@Data
public class Order {
private static final Long serialVersionUID = 5L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
// 订单号
private String number;
// 订单状态
private Integer status;
@JsonSerialize(using = ToStringSerializer.class)
private Long userId;
@JsonSerialize(using = ToStringSerializer.class)
private Long addressBookId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime orderTime;
// 支付时间
@TableField(fill = FieldFill.INSERT)
private LocalDateTime checkoutTime;
private Integer payMethod;
private BigDecimal amount;
private String remark;
private String phone;
private String address;
private String userName;
// 收获联系人
private String consignee;
}
创建订单明细表 order_detail 实体类 src/main/java/edu/ouc/entity/OrderDetail.java :
@Data
public class OrderDetail {
private static final Long serialVersionUID = 8L;
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String name;
private String image;
@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;
@JsonSerialize(using = ToStringSerializer.class)
private Long dishId;
@JsonSerialize(using = ToStringSerializer.class)
private Long setmealId;
private String dishFlavor;
private Integer number;
private BigDecimal amount;
}
添加要自动填充的下单时间字段和支付时间字段,打开 src/main/java/edu/ouc/common/MyMetaObjectHandler.java :
// 如果实体类中有下单时间,才会自动填充
if (metaObject.hasSetter("orderTime")) {
metaObject.setValue("orderTime", LocalDateTime.now());
}
if (metaObject.hasSetter("checkoutTime")) {
metaObject.setValue("checkoutTime", LocalDateTime.now());
}
创建订单表 orders 数据层 src/main/java/edu/ouc/mapper/OrderMapper.java :
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
创建订单明细表 order_detail 数据层 src/main/java/edu/ouc/mapper/OrderDetailMapper.java :
@Mapper
public interface OrderDetailMapper extends BaseMapper<OrderDetail> {
}
创建订单表 orders 业务层接口 src/main/java/edu/ouc/service/IOrderService.java :
public interface IOrderService extends IService<Order> {
}
创建订单明细表 order_detail 业务层接口 src/main/java/edu/ouc/service/IOrderDetailService.java :
public interface IOrderDetailService extends IService<OrderDetail> {
}
创建订单表 orders 业务层接口实现类 src/main/java/edu/ouc/service/impl/OrderServiceImpl.java :
@Slf4j
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService {
}
创建订单明细表 order_detail 业务层接口实现类 src/main/java/edu/ouc/service/impl/OrderDetailServiceImpl.java :
@Slf4j
@Service
public class OrderDetailServiceImpl extends ServiceImpl<OrderDetailMapper, OrderDetail> implements IOrderDetailService {
}
创建订单表 orders 控制层src/main/java/edu/ouc/controller/OrderController.java :
@Slf4j
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderServiceImpl orderService;
}
创建订单明细表 order_detail 控制层src/main/java/edu/ouc/controller/OrderDetailController.java :
@Slf4j
@RestController
@RequestMapping("/orderDetail")
public class OrderDetailController {
@Autowired
private OrderDetailServiceImpl orderDetailService;
}
用户点击【去结算】后,需要获取当前用户的默认地址展示在结算页面上。
前端以 GET 方式向后端 URL 为 /addressBook/default 的地址发送请求。

地址簿 AddressBook 业务层接口 IAddressBookService.java :
// 获取当前用户的默认地址
AddressBook getDefault();
地址簿 AddressBook 业务层接口实现类 AddressBookServiceImpl.java :
// 获取当前用户的默认地址
@Override
public AddressBook getDefault() {
// 1.获取当前用户ID
Long userId = BaseContext.getCurrentUserId();
// 2.创建查询条件封装器
LambdaQueryWrapper<AddressBook> lqw = new LambdaQueryWrapper<>();
// 3.添加查询条件:根据用户ID进行查询
lqw.eq(userId != null, AddressBook::getUserId, userId);
// 4.添加查询条件:查询是默认地址的地址
lqw.eq(AddressBook::getIsDefault, 1);
// 5.调用数据层根据查询条件封装器查询
return this.getOne(lqw);
}
地址簿 AddressBook 表现层 AddressBookController.java :
// 获取当前用户的默认地址
@GetMapping("/default")
public R<AddressBook> getDefault() {
return R.success(addressBookService.getDefault());
}
在订单结算页面显示用户的默认地址:

用户点击【去支付】后,就会提交当前订单。
前端以 POST 方式向后端 URL 为 /order/submit 的地址发送请求。

负载是,备注信息 remark 、支付方式 paymethod 、地址ID addressBookId 。

订单提交的时候也会同时把订单详情插入到订单明细表 order_detail 中。
订单表 orders 业务层接口 IOrderService.java :
// 提交(添加)订单
Boolean submit(Orders orders);
订单表 orders 业务层接口实现类 OrderServiceImpl.java :
@Autowired
private OrderDetailServiceImpl orderDetailService;
@Autowired
private ShoppingCartServiceImpl shoppingCartService;
@Autowired
private AddressBookServiceImpl addressBookService;
@Autowired
private UserServiceImpl userService;
// 提交(添加)订单
@Override
@Transactional // 涉及到两张表的插入操作需要打开事务控制
public Boolean submit(Orders orders) {
// 1.获取当前登录用户ID
Long userId = BaseContext.getCurrentUserId();
// 2.调用购物车ShoppingCart业务层的获取购物车信息
LambdaQueryWrapper<ShoppingCart> shoppingCartLqw = new LambdaQueryWrapper<>();
shoppingCartLqw.eq(userId != null, ShoppingCart::getUserId, userId);
List<ShoppingCart> shoppingCarts = shoppingCartService.list(shoppingCartLqw);
// 如果购物车为空,则抛出业务异常
if (shoppingCarts.isEmpty()) {
throw new CustomException("购物车为空,无法结算");
}
// 3.调用地址簿AddressBook业务层获取当前派送的地址信息
AddressBook addressBook = addressBookService.getById(orders.getAddressBookId());
// 如果地址信息为空,则抛出业务异常
if (addressBook == null) {
throw new CustomException("地址信息为空,无法下单");
}
// 4.调用用户业务层user表获取用户信息
User user = userService.getById(userId);
// 5.为订单对象的属性一一赋值
long orderId = IdWorker.getId();
// 使用AtomicInteger计算商品总金额,保证高并发下的线程安全
AtomicInteger amount = new AtomicInteger(0);
// 6.新增订单明细,用购物车的stream流复制
List<OrderDetail> orderDetails = shoppingCarts.stream().map(shoppingCart -> {
OrderDetail orderDetail = new OrderDetail();
// 复制属性值
orderDetail.setOrderId(orderId);
orderDetail.setNumber(shoppingCart.getNumber());
orderDetail.setDishFlavor(shoppingCart.getDishFlavor());
orderDetail.setDishId(shoppingCart.getDishId());
orderDetail.setSetmealId(shoppingCart.getSetmealId());
orderDetail.setName(shoppingCart.getName());
orderDetail.setImage(shoppingCart.getImage());
orderDetail.setAmount(shoppingCart.getAmount());
// 计算订单总金额
amount.addAndGet(shoppingCart.getAmount().multiply(new BigDecimal(shoppingCart.getNumber())).intValue());
return orderDetail;
}).collect(Collectors.toList());
// 生成并设置订单号
orders.setNumber(String.valueOf(orderId));
// 设置下单用户ID
orders.setUserId(userId);
// 设置订单状态为待派送
orders.setStatus(2);
// 设置商品总金额
orders.setAmount(new BigDecimal(amount.get()));
// 设置订单客户手机号
orders.setPhone(addressBook.getPhone());
// 设置收货人姓名
orders.setConsignee(addressBook.getConsignee());
// 设置用户名
orders.setUserName(user.getName());
// 设置地址详情,包含省市区
orders.setAddress((addressBook.getProvinceName() == null ? "" : addressBook.getProvinceName())
+ (addressBook.getCityName() == null ? "" : addressBook.getCityName())
+ (addressBook.getDistrictName() == null ? "" : addressBook.getDistrictName())
+ addressBook.getDetail());
// 7.调用订单数据层新增订单
this.save(orders);
// 8.批量新增订单明细
orderDetailService.saveBatch(orderDetails);
// 9.下单完成后清空购物车数据
return shoppingCartService.remove(shoppingCartLqw);
}
订单表 orders 表现层 OrderController.java :
// 提交(添加)订单
@PostMapping("/submit")
public R<String> submit(@RequestBody Orders orders) {
if (orderService.submit(orders)) {
return R.success("下单成功");
}
return R.error("下单失败");
}
用户点击【个人中心】后,就会展示最近订单信息。
前端以 GET 方式向后端 URL 为 /order/userPage 的地址发送请求。

负载是,当前页码 page 、每页多少条记录 pageSize 。

由于查询分页订单的同时要返回订单明细,因此要创建订单 DTO 类 OrderDto 来把订单 Orders 和订单明细 OrderDetail 封装在一起。
创建 src/main/java/edu/ouc/dto/OrderDto.java :
@Data
public class OrderDto extends Orders {
private List<OrderDetail> orderDetails;
}
订单表 orders 业务层接口 IOrderService.java :
// 获取订单分页展示
Page<OrderDto> getPage(Long page, Long pageSize);
订单表 orders 业务层接口实现类 OrderServiceImpl.java :
// 获取订单分页展示
@Override
public Page<OrderDto> getPage(Long page, Long pageSize) {
// 1.创建分页封装器
Page<Orders> ordersPage = new Page<>(page, pageSize);
// 2.创建OrderDto的分页封装器
Page<OrderDto> dtoPage = new Page<>();
// 3.创建Orders的查询条件封装器
LambdaQueryWrapper<Orders> lqw = new LambdaQueryWrapper<>();
// 3.1 添加查询条件:按下单时间降序排列
lqw.orderByDesc(Orders::getOrderTime);
// 3.2 条件查询条件:按当前用户ID查询
Long userId = BaseContext.getCurrentUserId();
lqw.eq(userId != null, Orders::getUserId, userId);
// 4.Orders分页查询
this.page(ordersPage, lqw);
// 5.除了Record都复制
BeanUtils.copyProperties(ordersPage, dtoPage, "records");
// 6.获取当前用户所有的order对象
List<Orders> orders = this.list(lqw);
// 7.通过stream流逐一包装成OrderDto对象
List<OrderDto> orderDtos = orders.stream().map(order -> {
// 7.1 创建OrderDto对象
OrderDto orderDto = new OrderDto();
// 7.2 拷贝属性
BeanUtils.copyProperties(order, orderDto);
// 7.3 调用OrderDetail业务层获取订单明细集合
LambdaQueryWrapper<OrderDetail> orderDetailLqw = new LambdaQueryWrapper<>();
orderDetailLqw.eq(OrderDetail::getOrderId, order.getNumber());
List<OrderDetail> orderDetails = orderDetailService.list(orderDetailLqw);
// 7.4 设置orderDto的订单明细属性
orderDto.setOrderDetails(orderDetails);
// 7.5 返回orderDto
return orderDto;
}).collect(Collectors.toList());
// 8.设置dtoPage的records属性
dtoPage.setRecords(orderDtos);
return dtoPage;
}
订单表 orders 表现层 OrderController.java :
// 获取订单分页展示
@GetMapping("/userPage")
public R<Page<OrderDto>> getPage(Long page, Long pageSize) {
return R.success(orderService.getPage(page, pageSize));
}
用户点击【订单明细】或者输入订单搜索条件点击【查询】后,前端以 GET 方式向后端 URL 为 /order/page 的地址发送请求。

负载为分页信息与订单号查询、按下单时间范围查询:

订单表 orders 业务层接口 IOrderService.java :
// 后台管理端获取订单分页展示
Page<OrderDto> getAllPage(Long page, Long pageSize, String number, String beginTime, String endTime);
订单表 orders 业务层接口实现类 OrderServiceImpl.java :
// 后台管理端获取订单分页展示
@Override
public Page<OrderDto> getAllPage(Long page, Long pageSize, String number, String beginTime, String endTime) {
// 1.创建分页封装器
Page<Orders> ordersPage = new Page<>(page, pageSize);
// 2.创建OrderDto的分页封装器
Page<OrderDto> dtoPage = new Page<>();
// 3.创建Orders的查询条件封装器
LambdaQueryWrapper<Orders> lqw = new LambdaQueryWrapper<>();
// 3.1 添加查询条件:按下单时间降序排列
lqw.orderByDesc(Orders::getOrderTime);
// 3.2 添加查询条件:按订单号查询
lqw.like(number != null, Orders::getNumber, number);
// 3.3 添加查询条件: 动态SQL-字符串使用StringUtils.isNotEmpty这个方法来判断
lqw.gt(StringUtils.isNotEmpty(beginTime), Orders::getOrderTime, beginTime);
lqw.lt(StringUtils.isNotEmpty(endTime), Orders::getOrderTime, endTime);
// 4.Orders分页查询
this.page(ordersPage, lqw);
// 5.除了Record都复制
BeanUtils.copyProperties(ordersPage, dtoPage, "records");
// 6.获取当前用户所有的order对象
List<Orders> orders = this.list(lqw);
// 7.通过stream流逐一包装成OrderDto对象
List<OrderDto> orderDtos = orders.stream().map(order -> {
// 7.1 创建OrderDto对象
OrderDto orderDto = new OrderDto();
// 7.2 拷贝属性
BeanUtils.copyProperties(order, orderDto);
// 7.3 调用OrderDetail业务层获取订单明细集合
LambdaQueryWrapper<OrderDetail> orderDetailLqw = new LambdaQueryWrapper<>();
orderDetailLqw.eq(OrderDetail::getOrderId, order.getNumber());
List<OrderDetail> orderDetails = orderDetailService.list(orderDetailLqw);
// 7.4 设置orderDto的订单明细属性
orderDto.setOrderDetails(orderDetails);
// 7.5 返回orderDto
return orderDto;
}).collect(Collectors.toList());
// 8.设置dtoPage的records属性
dtoPage.setRecords(orderDtos);
return dtoPage;
}
订单表 orders 表现层 OrderController.java :
// 后台管理端获取订单分页展示
@GetMapping("/page")
public R<Page<OrderDto>> page(Long page, Long pageSize, String number, String beginTime, String endTime) {
return R.success(orderService.getAllPage(page, pageSize, number, beginTime, endTime));
}
用户点击【派送】后,前端以 PUT 方式向后端 URL 为 /order 的地址发送请求。

负载是订单号和要修改的状态:

订单表 orders 业务层接口 IOrderService.java :
// 修改订单状态
Boolean update(Orders order);
订单表 orders 业务层接口实现类 OrderServiceImpl.java :
// 修改订单状态
@Override
public Boolean update(Orders order) {
return this.updateById(order);
}
订单表 orders 表现层 OrderController.java :
// 修改订单状态
@PutMapping
public R<String> update(@RequestBody Orders order) {
if (orderService.update(order)) {
return R.success("修改成功");
}
return R.error("修改失败");
}
至此,海大送餐基础功能就全部开发完毕了。完结撒花!