瑞吉外卖-day05
瑞吉外卖-day05
课程内容
- 管理端
- 新增套餐
- 套餐分页查询
- 删除套餐
- 用户端(app 端)
- 短信发送
- 手机验证码登录
1. 新增套餐
1.1 需求分析
套餐就是菜品的集合。
后台系统中可以管理套餐信息,通过新增套餐功能来添加一个新的套餐,在添加套餐时需要选择当前套餐所属的套餐分类和包含的菜品,并且需要上传套餐对应的图片,在移动端会按照套餐分类来展示对应的套餐。

1.2 数据模型
新增套餐,其实就是将新增页面录入的套餐信息插入到 setmeal 表,还需要向 setmeal_dish 表插入套餐和菜品关联数据。所以在新增套餐时,涉及到两个表:
表 | 说明 | 备注 |
---|---|---|
setmeal | 套餐表 | 存储套餐的基本信息 |
setmeal_dish | 套餐菜品关系表 | 存储套餐关联的菜品的信息(一个套餐可以关联多个菜品) |
两张表具体的表结构如下:
1). 套餐表 setmeal

在该表中,套餐名称name 字段是不允许重复的,在建表时,已经创建了唯一索引。

2). 套餐菜品关系表 setmeal_dish

在该表中,菜品的名称 name,菜品的原价 price 实际上都是冗余字段,因为我们在这张表中存储了菜品的 ID(dish_id),根据该 ID 我们就可以查询出 name,price 的数据信息,而这里我们又存储了 name,price,这样的话,我们在后续的查询展示操作中,就不需要再去查询数据库获取菜品名称和原价了,这样可以简化我们的操作。
1.3 准备工作
1). 实体类 SetmealDish
ps.直接从课程资料中导入即可,Setmeal 实体前面课程中已经导入过了。
所属包: com.itheima.reggie.entity
/**
* 套餐菜品关系
*/
@Data
public class SetmealDish implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
//套餐id
private Long setmealId;
//菜品id
private Long dishId;
//菜品名称 (冗余字段)
private String name;
//菜品原价
private BigDecimal price;
//份数
private Integer copies;
//排序
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;
//是否删除
private Integer isDeleted;
}
2). DTO SetmealDto
该数据传输对象 DTO,主要用于封装页面在新增套餐时传递过来的 json 格式的数据,其中包含套餐的基本信息,还包含套餐关联的菜品集合。直接从课程资料中导入即可。
所属包: com.itheima.reggie.dto
@Data
public class SetmealDto extends Setmeal {
private List<SetmealDish> setmealDishes;//套餐关联的菜品集合
private String categoryName;//分类名称
}
3). Mapper 接口 SetmealDishMapper
所属包: com.itheima.reggie.mapper
@Mapper
public interface SetmealDishMapper extends BaseMapper<SetmealDish> {
}
4). 业务层接口 SetmealDishService
所属包: com.itheima.reggie.service
public interface SetmealDishService extends IService<SetmealDish> {
}
5). 业务层实现类 SetmealDishServiceImpl
所属包: com.itheima.reggie.service.impl
@Service
@Slf4j
public class SetmealDishServiceImpl extends ServiceImpl<SetmealDishMapper,SetmealDish> implements SetmealDishService {
}
6). 控制层 SetmealController
套餐管理的相关业务,我们都统一在 SetmealController 中进行统一处理操作。
所属包: com.itheima.reggie.service.impl
/**
* 套餐管理
*/
@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
@Autowired
private SetmealService setmealService;
@Autowired
private SetmealDishService setmealDishService;
}
1.4 前端页面分析
服务端的基础准备工作我们准备完毕之后,在进行代码开发之前,需要梳理一下新增套餐时前端页面和服务端的交互过程:
1). 点击新建套餐按钮,访问页面(backend/page/combo/add.html),页面加载发送 ajax 请求,请求服务端获取套餐分类数据并展示到下拉框中(已实现)

获取套餐分类列表的功能我们不用开发,之前已经开发完成了,之前查询时 type 传递的是 1,查询菜品分类; 本次查询时,传递的 type 为 2,查询套餐分类列表。
2). 访问页面(backend/page/combo/add.html),页面加载时发送 ajax 请求,请求服务端获取菜品分类数据并展示到添加菜品窗口中(已实现)

本次查询分类列表,传递的 type 为 1,表示需要查询的是菜品的分类。查询菜品分类的目的,是添加套餐关联的菜品时,我们需要根据菜品分类,来过滤查询菜品信息。查询菜品分类列表的代码已经实现, 具体展示效果如下:

3). 当点击添加菜品窗口左侧菜单的某一个分类, 页面发送 ajax 请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中

4). 页面发送请求进行图片上传,请求服务端将图片保存到服务器(已实现)
5). 页面发送请求进行图片下载,将上传的图片进行回显(已实现)

6). 点击保存按钮,发送 ajax 请求,将套餐相关数据以 json 形式提交到服务端

相关信息
经过上述的页面解析及流程分析,我们发送这里需要发送的请求有 5 个,分别是 :
E. 根据分类 ID 查询菜品列表
请求 | 说明 |
---|---|
请求方式 | GET |
请求路径 | /dish/list |
请求参数 | ?categoryId=1397844263642378242 |
F. 保存套餐信息
请求 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /setmeal |
请求参数 | json 格式数据 |
传递的 json 格式数据如下:
点击查看 json 数据
{
"name": "营养超值工作餐",
"categoryId": "1399923597874081794",
"price": 3800,
"code": "",
"image": "9cd7a80a-da54-4f46-bf33-af3576514cec.jpg",
"description": "营养超值工作餐",
"dishList": [],
"status": 1,
"idType": "1399923597874081794",
"setmealDishes": [
{
"copies": 2,
"dishId": "1423329009705463809",
"name": "米饭",
"price": 200
},
{
"copies": 1,
"dishId": "1423328152549109762",
"name": "可乐",
"price": 500
},
{
"copies": 1,
"dishId": "1397853890262118402",
"name": "鱼香肉丝",
"price": 3800
}
]
}
1.5 代码开发 ✏️ 👈
上面我们已经分析了接下来我们需要实现的两个功能,接下来我们就需要根据上述的分析,来完成具体的功能实现。
1.5.1 根据分类查询菜品
功能分析
在当前的需求中,我们只需要根据页面传递的菜品分类的 来查询菜品列表即可,我们可以直接定义一个 DishController 的方法,声明一个 Long 类型的 categoryId,这样做是没问题的。但是考虑到该方法的拓展性,我们在这里定义方法时,通过 Dish 这个实体来接收参数。
在 DishController 中定义方法 list,接收 Dish 类型的参数:
在查询时,需要根据菜品分类 categoryId 进行查询,并且还要限定菜品的状态为起售状态(status 为 1),然后对查询的结果进行排序。
/**
* 根据条件查询对应的菜品数据
* @param dish
* @return
*/
@GetMapping("/list")
public R<List<Dish>> list(Dish dish){
//构造查询条件
LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(dish.getCategoryId() != null ,Dish::getCategoryId,dish.getCategoryId());
//添加条件,查询状态为1(起售状态)的菜品
queryWrapper.eq(Dish::getStatus,1);
//添加排序条件
queryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
List<Dish> list = dishService.list(queryWrapper);
return R.success(list);
}
功能测试
代码编写完毕,我们重新启动服务器,进行测试,可以通过 debug 断点跟踪的形式查看页面传递的参数封装情况,及响应给页面的数据信息。

1.5.2 保存套餐 ❤️ 👈
功能分析
在进行套餐信息保存时,前端提交的数据,不仅包含套餐的基本信息,还包含套餐关联的菜品列表数据 setmealDishes。所以这个时候我们使用 Setmeal 就不能完成参数的封装了,我们需要在 Setmeal 的基本属性的基础上,再扩充一个属性 setmealDishes 来接收页面传递的套餐关联的菜品列表,而我们在准备工作中,导入进来的 SetmealDto 能够满足这个需求。
1️⃣ SetmealController 中定义方法 save,新增套餐
在该 Controller 的方法中,我们不仅需要保存套餐的基本信息,还需要保存套餐关联的菜品数据,所以我们需要再该方法中调用业务层方法,完成两块数据的保存。
页面传递的数据是 json 格式,需要在方法形参前面加上@RequestBody 注解, 完成参数封装。
@PostMapping
public R<String> save(@RequestBody SetmealDto setmealDto){
log.info("套餐信息:{}",setmealDto);
setmealService.saveWithDish(setmealDto);
return R.success("新增套餐成功");
}
2️⃣ SetmealService 中定义方法 saveWithDish
/**
* 新增套餐,同时需要保存套餐和菜品的关联关系
* @param setmealDto
*/
public void saveWithDish(SetmealDto setmealDto);
3️⃣ SetmealServiceImpl 实现方法 saveWithDish
具体逻辑:
- 保存套餐基本信息
- 获取套餐关联的菜品集合,并为集合中的每一个元素赋值套餐 ID(setmealId)
- 批量保存套餐关联的菜品集合
/**
* 新增套餐,同时需要保存套餐和菜品的关联关系
* @param setmealDto
*/
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
//保存套餐的基本信息,操作setmeal,执行insert操作
this.save(setmealDto);
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
setmealDishes.stream().map((item) -> {
item.setSetmealId(setmealDto.getId());
return item;
}).collect(Collectors.toList());
//保存套餐和菜品的关联信息,操作setmeal_dish,执行insert操作
setmealDishService.saveBatch(setmealDishes);
}
功能测试
代码编写完毕,我们重新启动服务器,进行测试,可以通过 debug 断点跟踪的形式查看页面传递的参数封装情况,及套餐相关数据的保存情况。
录入表单数据:

debug 跟踪数据封装:

跟踪数据库保存的数据:

2. 套餐分页查询
2.1 需求分析
系统中的套餐数据很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。

在进行套餐数据的分页查询时,除了传递分页参数以外,还可以传递一个可选的条件(套餐名称)。查询返回的字段中,包含套餐的基本信息之外,还有一个套餐的分类名称,在查询时,需要关联查询这个字段。
2.2 前端页面分析
在开发代码之前,需要梳理一下套餐分页查询时前端页面和服务端的交互过程:
1). 访问页面(backend/page/combo/list.html),页面加载时,会自动发送 ajax 请求,将分页查询参数(page、pageSize、name)提交到服务端,获取分页数据

2). 在列表渲染展示时,页面发送请求,请求服务端进行图片下载,用于页面图片展示

列表分页查询功能, 具体的请求信息如下:
请求 | 说明 |
---|---|
请求方式 | GET |
请求路径 | /setmeal/page |
请求参数 | ?page=1&pageSize=10&name=xxx |
2.3 代码开发 ✏️ 👈
2.3.1 基本信息查询
上述我们已经分析列表分页查询功能的请求信息,接下来我们就在 SetmealController 中创建套餐分页查询方法。
逻辑如下
- 构建分页条件对象
- 构建查询条件对象,如果传递了套餐名称,根据套餐名称模糊查询, 并对结果按修改时间降序排序
- 执行分页查询
- 组装数据并返回
代码实现 :
/**
* 套餐分页查询
* @param page
* @param pageSize
* @param name
* @return
*/
@GetMapping("/page")
public R<Page> page(int page,int pageSize,String name){
//分页构造器对象
Page<Setmeal> pageInfo = new Page<>(page,pageSize);
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
//添加查询条件,根据name进行like模糊查询
queryWrapper.like(name != null,Setmeal::getName,name);
//添加排序条件,根据更新时间降序排列
queryWrapper.orderByDesc(Setmeal::getUpdateTime);
setmealService.page(pageInfo,queryWrapper);
return R.success(pageInfo);
}
2.3.2 问题分析
基本分页查询代码编写完毕后,重启服务,测试列表查询,我们发现, 列表页面的数据可以展示出来, 但是套餐分类名称没有展示出来。

这是因为在服务端仅返回分类 ID(categoryId), 而页面展示需要的是 categoryName 属性。
2.3.3 功能完善 (方式1 纯MP实现,无sql语句 )
在查询套餐信息时, 只包含, 并不包含套餐的, 所以在这里查询到套餐的基本信息后, 还需要根据分类 ID(categoryId), 查询套餐分类名称(categoryName),并最终将(在第一小节已经导入)中。
完善后代码:
@Data
public class SetmealDto extends Setmeal {
private List<SetmealDish> setmealDishes; //套餐关联菜品列表
private String categoryName;//套餐分类名称
}
@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
@Autowired
SetmealService setmealService;
/**
* 套餐分页查询
* @param page
* @param pageSize
* @param name
* @return
*/
@GetMapping("/page")
public R<Page> page(int page,int pageSize,String name){
//分页构造器对象
Page<Setmeal> pageInfo = new Page<>(page,pageSize);
Page<SetmealDto> dtoPage = new Page<>();
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
//添加查询条件,根据name进行like模糊查询
queryWrapper.like(name != null,Setmeal::getName,name);
//添加排序条件,根据更新时间降序排列
queryWrapper.orderByDesc(Setmeal::getUpdateTime);
setmealService.page(pageInfo,queryWrapper);
//对象拷贝
BeanUtils.copyProperties(pageInfo,dtoPage,"records");
List<Setmeal> records = pageInfo.getRecords();
List<SetmealDto> list = records.stream().map((item) -> {
SetmealDto setmealDto = new SetmealDto();
//对象拷贝
BeanUtils.copyProperties(item,setmealDto);
//分类id
Long categoryId = item.getCategoryId();
//根据分类id查询分类对象
Category category = categoryService.getById(categoryId);
if(category != null){
//分类名称
String categoryName = category.getName();
setmealDto.setCategoryName(categoryName);
}
return setmealDto;
}).collect(Collectors.toList());
dtoPage.setRecords(list);
return R.success(dtoPage);
}
}
技术提高
2.3.4 功能完善(方式2 Mybatis+sql 实现 )思路和步骤
思路:先写sql语句进行多表查询,然后通过多表将数据映射到SetmealDto对象中,返回给前端
MP 是对 mybatis 进行增强,因此使用 MP 也可以使用 Mybatis
Mybatis 的 Sql 语句可以用注解,也可以写在 mapper.xml 中
- 第一步:创建一个 SetmealMapper.xml 文件,文件位置如下图
- 第二步:在 SetmealMapper.xml 中
<mapper namespace="接口的全类名">
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.reggie.mapper.SetmealMapper">
</mapper>
- 第三步:定义的
<select id='接口中的方法'>
标签,id 对应接口中的方法 - 第四步:接口中方法的返回值类型和
<select id="方法名" resultType="类型">
要一致(如果是集合,就填写泛型)
- 如果不清楚使用 Mybatis 代理模式开发,可以安装 MybatisX 插件进行辅助
本插件能检测是否符合 Mybatis 代理开发的要求,现象如下:
图片为菜品分页查询,功能很相似,也可以模仿菜品分页查询
@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
@Autowired
SetmealService setmealService;
/**
* 分页查询
* @param page 当前页
* @param pageSize 每页条数
* @param name 检索词
* @return
*/
@GetMapping("/page")
public R<Page<SetmealDto>> page(Integer page, Integer pageSize, String name){
log.info("当前页:{},每页:{}条数 , 检索词:{}",page,pageSize,name);
Page<SetmealDto> dishDtoPage = new Page<>(page, pageSize);
Page<SetmealDto> dishDtoPage1 = setmealService.pageSetmealDto(dishDtoPage,name);
return R.success(dishDtoPage1);
}
}
public interface SetmealService extends IService<Setmeal> {
Page<SetmealDto> pageSetmealDto(Page<SetmealDto> page, @Param("name") String name);
}
@Service
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService {
@Override
public Page<SetmealDto> pageSetmealDto(Page<SetmealDto> page, String name) {
return getBaseMapper().pageSetmealDto(page,name);
}
}
@Mapper
public interface SetmealMapper extends BaseMapper<Setmeal> {
Page<SetmealDto> pageSetmealDto(Page<SetmealDto> page, @Param("name") String name);
}
在 resource 的同级目录创建 SetmealMapper.xml 代码如下
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.reggie.mapper.SetmealMapper">
<select id="pageSetmealDto" resultType="com.itheima.reggie.entity.dto.SetmealDto">
SELECT
setmeal.id,
setmeal.category_id,
setmeal.`name`,
setmeal.price,
setmeal.`status`,
setmeal.`code`,
setmeal.description,
setmeal.image,
setmeal.create_time,
setmeal.update_time,
setmeal.create_user,
setmeal.update_user,
setmeal.is_deleted,
category.`name` category_name
FROM
setmeal
left JOIN category ON category.id = setmeal.category_id
<if test="name!=null and name!=''">
WHERE setmeal.`name` LIKE CONCAT('%',#{name},'%')
</if>
</select>
</mapper>
2.4 功能测试
代码完善后,重启服务,测试列表查询,我们发现, 抓取浏览器的请求响应数据,我们可以获取到套餐分类名称 categoryName,也可以在列表页面展示出来 。

3.套餐禁用启用
3.1 需求分析

3.2 前端页面分析
1). 点击禁用, 页面发送 ajax 请求,根据套餐 id 禁用对应套餐

2).禁用多个套餐时,页面发送 ajax 请求,根据提交的多个套餐 id 禁用对应套餐
具体的请求信息如下:
请求 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /setmeal/status |
请求参数 | /0?ids=1574238528174510082 |
3.3 代码开发 ✏️ 👈
/**
* 如果是多个数据,用集合接收,必须使用,@RequestParam("ids")注解
* @param status
* @param ids
* @return
*/
@PostMapping("/status/{status}")
public R<String> updateStatus(@PathVariable Integer status,@RequestParam("ids") List<Long> ids){
log.info("updateStatus:ids:{},status:{}",ids,status);
//1. 通过传过来的ids 获得所有的菜品
//修改套餐状态只需要修改Setmeal表,因此不需要多表操作
List<Setmeal> setmeals = setmealService.listByIds(ids);
//2. 遍历菜品集合,依次设置状态值
for (Setmeal setmeal : setmeals) {
setmeal.setStatus(status);
}
//3. 修改状态
boolean b = setmealService.updateBatchById(setmeals);
return b?R.success("操作成功"):R.error("操作失败");
}
4. 删除套餐
4.1 需求分析
在套餐管理列表页面,点击删除按钮,可以删除对应的套餐信息。也可以通过复选框选择多个套餐,点击批量删除按钮一次删除多个套餐。注意,对于状态为售卖中的套餐不能删除,需要先停售,然后才能删除。

4.2 前端页面分析
在开发代码之前,需要梳理一下删除套餐时前端页面和服务端的交互过程:
1). 点击删除, 删除单个套餐时,页面发送 ajax 请求,根据套餐 id 删除对应套餐

2). 删除多个套餐时,页面发送 ajax 请求,根据提交的多个套餐 id 删除对应套餐

开发删除套餐功能,其实就是在服务端编写代码去处理前端页面发送的这 2 次请求即可,一次请求为根据 ID 删除,一次请求为根据 ID 批量删除。
观察删除单个套餐和批量删除套餐的请求信息可以发现,两种请求的地址和请求方式都是相同的 ,不同的则是传递的 id 个数,所以在服务端可以提供一个方法来统一处理。
具体的请求信息如下:
请求 | 说明 |
---|---|
请求方式 | DELETE |
请求路径 | /setmeal |
请求参数 | ?ids=1423640210125656065,1423338765002256385 |
4.3 代码开发 ✏️ 👈
删除套餐的流程及请求信息,我们分析完毕之后,就来完成服务端的逻辑开发。在服务端的逻辑中, 删除套餐时, 我们不仅要删除套餐, 还要删除套餐与菜品的关联关系。
1). 在 SetmealController 中创建 delete 方法
我们可以先测试在 delete 方法中接收页面提交的参数,具体逻辑后续再完善:
/**
* 删除套餐
* @param ids
* @return
*/
@DeleteMapping
public R<String> delete(@RequestParam List<Long> ids){
log.info("ids:{}",ids);
return R.success("套餐数据删除成功");
}
编写完代码,我们重启服务之后,访问套餐列表页面,勾选复选框,然后点击"批量删除",我们可以看到服务端可以接收到集合参数 ids,并且在控制台也可以输出对应的数据 。

2). SetmealService 接口定义方法 removeWithDish
/**
* 删除套餐,同时需要删除套餐和菜品的关联数据
* @param ids
*/
public void removeWithDish(List<Long> ids);
3). SetmealServiceImpl 中实现方法 removeWithDish
该业务层方法具体的逻辑为:
- 查询该批次套餐中是否存在售卖中的套餐, 如果存在, 不允许删除
- 删除套餐数据
- 删除套餐关联的菜品数据
/**
* 删除套餐,同时需要删除套餐和菜品的关联数据
* @param ids
*/
@Transactional
public void removeWithDish(List<Long> ids) {
//select count(*) from setmeal where id in (1,2,3) and status = 1
//查询套餐状态,确定是否可用删除
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper();
queryWrapper.in(Setmeal::getId,ids);
queryWrapper.eq(Setmeal::getStatus,1);
int count = this.count(queryWrapper);
if(count > 0){
//如果不能删除,抛出一个业务异常
throw new CustomException("套餐正在售卖中,不能删除");
}
//如果可以删除,先删除套餐表中的数据---setmeal
this.removeByIds(ids);
//delete from setmeal_dish where setmeal_id in (1,2,3)
LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.in(SetmealDish::getSetmealId,ids);
//删除关系表中的数据----setmeal_dish
setmealDishService.remove(lambdaQueryWrapper);
}
由于当前的业务方法中存在多次数据库操作,为了保证事务的完整性,需要在方法上加注解 @Transactional 来控制事务。
4). 完善 SetmealController 代码
/**
* 删除套餐
* @param ids
* @return
*/
@DeleteMapping
public R<String> delete(@RequestParam List<Long> ids){
log.info("ids:{}",ids);
setmealService.removeWithDish(ids);
return R.success("套餐数据删除成功");
}
4.4 功能测试
代码完善后,重启服务,测试套餐的删除功能,主要测试以下几种情况。
1). 删除正在启用的套餐

2). 执行批量操作, 删除两条记录, 一个启售的, 一个停售的
由于当前我们并未实现启售/停售功能,所以我们需要手动修改数据库表结构的 status 状态,将其中的一条记录 status 修改为 0。

3). 删除已经停售的套餐信息,执行删除之后, 检查数据库表结构 setmeal , setmeal_dish 表中的数据

5.套餐修改【拓展功能】 🚀 🚀
5.1 需求分析
回显数据
- 1.通过 id 查询套餐基本信息
- 2.通过 id 查询出菜品信息
- 3.拼接数据返回
修改数据
- 1.修改基本信息
- 2.删除套餐原包含的菜品信息
- 3.添加套餐新包含的菜品信息

经过上述的分析:
套餐分类下拉框的展示(已完成)
图片的下载回显功能(已完成)
根据 ID 查询套餐及套餐菜品信息
请求 说明 请求方式 GET 请求路径 /setmeal/{id}
修改套餐及套餐菜品信息
请求 说明 请求方式 PUT 请求路径 /setmeal
请求参数 json 格式数据
5.2 代码开发
回显数据
- 1.通过 id 查询套餐基本信息
- 2.通过 id 查询出菜品信息
- 3.拼接数据返回
类:com.itheima.reggie.controller.SetmealController
/**
* 修改的回显数据
* 步鄹
* 1.通过id查询套餐基本信息
* 2.通过id查询出菜品信息
* 3.拼接数据返回
*
* @param setmealid
* @return
*/
@GetMapping("/{setmealid}")
public R<SetmealDto> getById(@PathVariable Long setmealid){
SetmealDto setmealDto=setmealService.getByIdWithDish(setmealid);
return R.success(setmealDto);
}
接口:com.itheima.reggie.service.SetmealService
SetmealDto getByIdWithDish(Long setmealid);
类:com.itheima.reggie.service.impl.SetmealServiceImpl
/**
* 通过id获得套餐完整的信息
* @param setmealid
* @return
*/
@Override
public SetmealDto getByIdWithDish(Long setmealid) {
//* 1.通过id查询套餐基本信息
Setmeal setmeal = getById(setmealid);
//* 2.通过id查询出菜品信息
LambdaQueryWrapper<SetmealDish> sdlqw = new LambdaQueryWrapper<>();
sdlqw.eq(SetmealDish::getSetmealId,setmealid);
List<SetmealDish> setmealDishes = setmealDishService.list(sdlqw);
//* 3.拼接数据返回
SetmealDto setmealDto = new SetmealDto();
BeanUtils.copyProperties(setmeal,setmealDto);
setmealDto.setSetmealDishes(setmealDishes);
return setmealDto;
}
保存数据
- 1.修改基本信息
- 2.删除套餐原包含的菜品信息
- 3.添加套餐新包含的菜品信息
类:com.itheima.reggie.controller.SetmealController
Boolean updateWithDish(SetmealDto setmealDto);
接口:com.itheima.reggie.service.SetmealService
/**
* 修改
* @param setmealDto
* @return
*/
@PutMapping
public R<String> update(@RequestBody SetmealDto setmealDto){
Boolean ok=setmealService.updateWithDish(setmealDto);
return ok?R.success("修改成功"):R.error("修改失败");
}
类:com.itheima.reggie.service.impl.SetmealServiceImpl
@Override
public Boolean updateWithDish(SetmealDto setmealDto) {
/// 1.修改基本数据
boolean b = updateById(setmealDto);
/// 2.修改菜品数据(先删除 后添加)
Long setmealid = setmealDto.getId();
LambdaUpdateWrapper<SetmealDish> sdluw = new LambdaUpdateWrapper<>();
sdluw.eq(SetmealDish::getSetmealId,setmealid);
setmealDishService.remove(sdluw);
/// 后添加
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
// 给菜品列表设置1个值: 套餐id
for (SetmealDish setmealDish : setmealDishes) {
setmealDish.setSetmealId(setmealDto.getId());
}
// 批量保存
boolean b1 = setmealDishService.saveBatch(setmealDishes);
return b&&b1;
}
5.3 测试

6. 短信发送 🍐 🚀

在我们接下来要实现的移动端的业务开发中,第一块儿我们需要开发的功能就是移动端的登录功能,而移动端的登录功能,比较流行的方式就是基于短信验证码进行登录,那么这里涉及到了短信发送的知识,所以本章节,我们就来讲解,在项目开发中,我们如何发送短信。
6.1 短信服务介绍
在项目中,如果我们要实现短信发送功能,我们无需自己实现,也无需和运营商直接对接,只需要调用第三方提供的短信服务即可。目前市面上有很多第三方提供的短信服务,这些第三方短信服务会和各个运营商(移动、联通、电信)对接,我们只需要注册成为会员,并且按照提供的开发文档进行调用就可以发送短信。需要说明的是,这些短信服务一般都是收费服务。
常用短信服务:
- 阿里云
- 华为云
- 腾讯云
- 京东
- 梦网
- 乐信
本项目在选择短信服务的第三方服务提供商时,选择的是阿里云短信服务。
6.2 阿里云短信服务介绍
阿里云短信服务(Short Message Service)是广大企业客户快速触达手机用户所优选使用的通信能力。调用 API 或用群发助手,即可发送验证码、通知类和营销类短信;国内验证短信秒级触达,到达率最高可达 99%;国际/港澳台短信覆盖 200 多个国家和地区,安全稳定,广受出海企业选用。
应用场景:
场景 | 案例 |
---|---|
APP、网站注册账号,向手机下发验证码; 登录账户、异地登录时的安全提醒; 找回密码时的安全验证; 支付认证、身份校验、手机绑定等。 | |
向注册用户下发系统相关信息,包括: 升级或维护、服务开通、价格调整、 订单确认、物流动态、消费确认、 支付通知等普通通知短信。 | |
向注册用户和潜在客户发送通知和推广信息,包括促销活动通知、业务推广等商品与活动的推广信息。增加企业产品曝光率、提高产品的知名度。 |

6.3 阿里云短信服务准备
5.3.2 开通短信服务
注册成功后,点击登录按钮进行登录。登录后进入控制台, 在左上角的菜单栏中搜索短信服务。第一次使用,需要点击,并开通短信服务。


5.3.3 设置短信签名
开通短信服务之后,进入短信服务管理页面,选择国内消息菜单,我们需要在这里添加短信签名。

那么什么是短信签名呢?
短信签名是短信发送者的署名,表示发送方的身份。我们要调用阿里云短信服务发送短信,签名是比不可少的部分。

那么接下来,我们就需要来添加短信签名。

注意:目前,阿里云短信服务申请签名主要针对企业开发,个人申请时有一定难度的,在审核时,会审核资质,需要上传营业执照 ; 所以,我们课程中,主要是演示一下短信验证码如何发送,大家只需要学习这块儿的开发流程、实现方式即可,无需真正的发送短信。如果以后在企业中做项目,需要发送短信,我们会以公司的资质去申请对应的签名。
5.3.4 设置短信模板
切换到【模板管理】标签页:

那么什么是模板呢?
短信模板包含短信发送内容、场景、变量信息。模板的详情如下:

最终我们,给用户发送的短信中,具体的短信内容,就是上面配置的这个模板内容,将${code}占位符替换成对应的验证码数据即可。如下:
【xxxxx】您好,您的验证码为173822,5分钟之内有效,不要泄露给他人!
我们可以点击右上角的按钮,添加模板,然后填写模板的基本信息及设置的模板内容:

5.3.5 设置 AccessKey
AccessKey 是访问阿里云 API 的密钥,具有账户的完全权限,我们要想在后面通过 API 调用阿里云短信服务的接口发送短信,那么就必须要设置 AccessKey。
我们点击右上角的用户头像,选择"AccessKey 管理",这时就可以进入到 AccessKey 的管理界面。

进入到 AccessKey 的管理界面之后,提示两个选项 "继续使用 AccessKey" 和 "开始使用子用户 AccessKey",两个区别如下:
1). 继续使用 AccessKey
如果选择的是该选项,我们创建的是阿里云账号的 AccessKey,是具有账户的完全权限,有了这个 AccessKey 以后,我们就可以通过 API 调用阿里云的服务,不仅是短信服务,其他服务(OSS,语音服务,内容安全服务,视频点播服务...等)也可以调用。 相对来说,并不安全,当前的 AccessKey 泄露,会影响到我当前账户的其他云服务。
2). 开始使用子用户 AccessKey
可以创建一个子用户,这个子用户我们可以分配比较低的权限,比如仅分配短信发送的权限,不具备操作其他的服务的权限,即使这个 AccessKey 泄漏了,也不会影响其他的云服务, 相对安全。
接下来就来演示一下,如何创建子用户 AccessKey。

5.3.6 配置权限
上述我们已经创建了子用户, 但是这个子用户,目前没有任何权限,接下来,我们需要为创建的这个用户来分配权限。

5.3.7 禁用/删除 AccessKey
如果在使用的过程中 AccessKey 不小心泄漏了,我们可以在阿里云控制台中, 禁用或者删除该 AccessKey。

然后再创建一个新的 AccessKey, 保存好 AccessKeyId 和 AccessKeySecret。

6.4 代码开发
使用阿里云短信服务发送短信,可以参照官方提供的文档即可。

我们根据官方文档的提示,引入对应的依赖,然后再引入对应的 java 代码,就可以发送消息了。

SDK : SDK 就是 Software Development Kit 的缩写,翻译过来——软件开发工具包,辅助开发某一类软件的相关文档、范例和工具的集合都可以叫做 SDK。在我们与第三方接口相互时, 一般都会提供对应的 SDK,来简化我们的开发。
1).导入依赖
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.5.16</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>2.1.0</version>
</dependency>
2). 将官方提供的 main 方法封装为一个工具类
/**
* 短信发送工具类
*/
public class SMSUtils {
/**
* 发送短信
* @param signName 签名
* @param templateCode 模板
* @param phoneNumbers 手机号
* @param param 参数
*/
public static void sendMessage(String signName, String templateCode,String phoneNumbers,String param){
DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "xxxxxxxxxxxxxxxx", "xxxxxxxxxxxxxx");
IAcsClient client = new DefaultAcsClient(profile);
SendSmsRequest request = new SendSmsRequest();
request.setSysRegionId("cn-hangzhou");
request.setPhoneNumbers(phoneNumbers);
request.setSignName(signName);
request.setTemplateCode(templateCode);
request.setTemplateParam("{\"code\":\""+param+"\"}");
try {
SendSmsResponse response = client.getAcsResponse(request);
System.out.println("短信发送成功");
}catch (ClientException e) {
e.printStackTrace();
}
}
}
注意:
由于我们个人目前无法申请阿里云短信服务,所以这里我们只需要把流程跑通,具体的短信发送可以实现。
7. 手机验证码登录 ✏️
7.1 需求分析
为了方便用户登录,移动端通常都会提供通过手机验证码登录的功能。手机验证码登录有如下优点:
1). 方便快捷,无需注册,直接登录 2). 使用短信验证码作为登录凭证,无需记忆密码 3). 安全
登录流程
输入手机号 > 获取验证码 > 输入验证码 > 点击登录 > 登录成功
注意:通过手机验证码登录,是区分不同用户的标识。
7.2 数据模型
通过手机验证码登录时,涉及的表为 ,即用户表。结构如下:

7.3 前端页面分析
在开发代码之前,需要梳理一下登录时前端页面和服务端的交互过程:

1). 在登录页面(front/page/login.html)输入手机号,点击【获取验证码】按钮,页面发送 ajax 请求,在服务端调用短信服务 API 给指定手机号发送验证码短信。

2). 在登录页面输入验证码,点击【登录】按钮,发送 ajax 请求,在服务端处理登录请求。

如果服务端返回的登录成功,页面将会把当前登录用户的手机号存储在 sessionStorage中,并跳转到移动的首页页面。
开发手机验证码登录功能,其实就是在服务端编写代码去处理前端页面发送的这 2 次请求即可,分别是获取短信验证码 和 登录请求,具体的请求信息如下:
相关信息
1). 获取短信验证码
请求 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /user/sendMsg |
请求参数 | {"phone":"13100001111"} |
2). 登录
请求 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /user/login |
请求参数 | {"phone":"13100001111", "code":"1111"} |
7.4 代码开发 ✏️ 👈
7.4.1 准备工作
在开发业务功能前,先将需要用到的类和接口基本结构创建好:
1️⃣ 实体类 User(直接从课程资料中导入即可)
所属包: com.itheima.reggie.entity
/**
* 用户信息
*/
@Data
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
//姓名
private String name;
//手机号
private String phone;
//性别 0 女 1 男
private String sex;
//身份证号
private String idNumber;
//头像
private String avatar;
//状态 0:禁用,1:正常
private Integer status;
}
2️⃣ Mapper 接口 UserMapper
所属包: com.itheima.reggie.mapper
@Mapper
public interface UserMapper extends BaseMapper<User>{
}
3️⃣ 业务层接口 UserService
所属包: com.itheima.reggie.service
public interface UserService extends IService<User> {
}
4️⃣ 业务层实现类 UserServiceImpl
所属包: com.itheima.reggie.service.impl
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper,User> implements UserService{
}
5️⃣ 控制层 UserController
所属包: com.itheima.reggie.controller
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Autowired
private UserService userService;
}
6️⃣ 工具类 SMSUtils、ValidateCodeUtils(直接从课程资料中导入即可)
所属包: com.itheima.reggie.utils

7.4.2 功能实现 ❤️ 👈
1️⃣ 修改 LoginCheckFilter
前面我们已经完成了 LoginCheckFilter 过滤器的开发,此过滤器用于检查用户的登录状态。我们在进行手机验证码登录时,发送的两个请求(获取验证码和登录 )需要在此过滤器处理时直接放行。

String[] urls = new String[]{
"/employee/login", // 登陆页 直接放行
"/employee/logout",//注销页 直接放行
"/backend/**", //所有的pc端的 前端资源 放行
"/front/**", //所有的移动端 前端资源 放行
"/common/**", //上传文件和下载文件 放心
"/user/sendMsg",
"/user/login"
};
对于移动的端的页面,也是用户登录之后,才可以访问的,那么这个时候就需要在 LoginCheckFilter 中进行判定,如果移动端用户已登录,我们获取到用户登录信息,存入 ThreadLocal 中(在后续的业务处理中,如果需要获取当前登录用户 ID,直接从 ThreadLocal 中获取),然后放行。
增加如下逻辑:
//4-2、判断登录状态,如果已登录,则直接放行
if(request.getSession().getAttribute("user") != null){
log.info("用户已登录,用户id为:{}",request.getSession().getAttribute("user"));
Long userId = (Long) request.getSession().getAttribute("user");
BaseContext.setCurrentId(userId);
filterChain.doFilter(request,response);
return;
}
代码位置(完整代码在下方):
2️⃣ 发送短信验证码
在 UserController 中创建 sendMsg 方法 ,处理登录页面的请求,为指定手机号发送短信验证码,同时需要将手机号对应的验证码保存到 Session,方便后续登录时进行比对。
类:com.itheima.reggie.controller.UserController
/**
* 发送手机短信验证码
* @param user
* @return
*/
@PostMapping("/sendMsg")
public R<String> sendMsg(@RequestBody User user, HttpSession session){
//获取手机号
String phone = user.getPhone();
if(StringUtils.isNotEmpty(phone)){
//生成随机的4位验证码
String code = ValidateCodeUtils.generateValidateCode(4).toString();
log.info("code={}",code);
//调用阿里云提供的短信服务API完成发送短信
//SMSUtils.sendMessage("瑞吉外卖","",phone,code);
//需要将生成的验证码保存到Session
session.setAttribute(phone,code);
return R.success("手机验证码短信发送成功");
}
return R.error("短信发送失败");
}
这里发送短信我们只需要调用封装的工具类中的方法即可,我们这个功能流程跑通,在测试中我们不用真正的发送短信,只需要将验证码信息,通过日志输出,登录时,我们直接从控制台就可以看到生成的验证码(实际上也就是发送到我们手机上的验证码)
3️⃣ 验证码登录
在 UserController 中增加登录的方法 login ,该方法的具体逻辑为:
1). 获取前端传递的手机号和验证码 2). 从 Session 中获取到手机号对应的正确的验证码 3). 进行验证码的比对 , 如果比对失败, 直接返回错误信息 4). 如果比对成功, 需要根据手机号查询当前用户, 如果用户不存在, 则自动注册一个新用户 5). 将登录用户的 ID 存储 Session 中
具体代码实现:
/**
* 移动端用户登录
* @param map
* @param session
* @return
*/
@PostMapping("/login")
public R<User> login(@RequestBody Map map, HttpSession session){
log.info(map.toString());
//获取手机号
String phone = map.get("phone").toString();
//获取验证码
String code = map.get("code").toString();
//从Session中获取保存的验证码
Object codeInSession = session.getAttribute(phone);
//进行验证码的比对(页面提交的验证码和Session中保存的验证码比对)
if(codeInSession != null && codeInSession.equals(code)){
//如果能够比对成功,说明登录成功
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone,phone);
User user = userService.getOne(queryWrapper);
if(user == null){
//判断当前手机号对应的用户是否为新用户,如果是新用户就自动完成注册
user = new User();
user.setPhone(phone);
user.setStatus(1);
userService.save(user);
}
session.setAttribute("user",user.getId());
return R.success(user);
}
return R.error("登录失败");
}
点击查看过滤器完整代码
package com.itheima.reggie.filter;
/**
*
* 1.创建LoginCheckFilter,表示拦截所有的请求
* 2.在启动类ReggieApplication中配置一个注解@ServletComponentScan,用来识别Web的注解如:@WebFilter
* 3. 在doFilter中书写逻辑:
* A. 获取本次请求的 URI
* B. 判断本次请求, 是否需要登录, 才可以访问
* C. 如果不需要,则直接放行
* D. 判断登录状态,如果已登录,则直接放行
* E. 如果未登录, 则返回未登录结果
*/
@Slf4j
@WebFilter("/*")
public class LoginCheckFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
Thread thread = Thread.currentThread();
log.info("过滤器 当前线程id:{}",thread.getId());
HttpServletRequest request= (HttpServletRequest) servletRequest;
// * A. 获取本次请求的 URI
String uri = request.getRequestURI();
// * B. 判断本次请求, 是否需要登录, 才可以访问
// 定义一个白名单
String[] urls = new String[]{
"/employee/login", // 登陆页 直接放行
"/employee/logout",//注销页 直接放行
"/backend/**", //所有的pc端的 前端资源 放行
"/front/**", //所有的移动端 前端资源 放行
"/common/**", //上传文件和下载文件 放心
"/user/sendMsg",
"/user/login"
};
boolean check = check(urls, uri);
log.info("当前的路径:{},是否需要拦截:{}",uri,check?"不需要":"需要");
if (check){
// * C. 如果不需要,则直接放行
filterChain.doFilter(servletRequest,servletResponse);
return;
}
//管理端
HttpSession session = request.getSession();
Long employeeid = (Long) session.getAttribute("employee");
if (employeeid!=null){
// 存用户id
BaseContext.setCurrentId(employeeid);
// * D. 判断登录状态,如果已登录,则直接放行
filterChain.doFilter(servletRequest,servletResponse);
return;
}
//用户端:判断登录状态,如果已登录,则直接放行
if(request.getSession().getAttribute("user") != null){
log.info("用户已登录,用户id为:{}",request.getSession().getAttribute("user"));
Long userId = (Long) request.getSession().getAttribute("user");
BaseContext.setCurrentId(userId);
filterChain.doFilter(request,servletResponse);
return;
}
// * E. 如果未登录, 则返回未登录结果 R类型的数据
servletResponse.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
}
/**
* 路径匹配,检查本次请求是否需要放行
* @param urls 白名单
* @param requestURI 请求的路径
* @return
*/
public boolean check(String[] urls,String requestURI){
// 路径匹配器,能够匹配路径--如果匹配上了,就返回true 否则返回false
AntPathMatcher PATH_MATCHER = new AntPathMatcher();
for (String url : urls) {
boolean match = PATH_MATCHER.match(url, requestURI);
if(match){
return true;
}
}
return false;
}
}
7.5 功能测试
代码完成后,重启服务,测试短信验证码的发送及登录功能。
1). 测试错误验证码的情况

2). 测试正确验证码的情况

检查 user 表,用户的数据也插入进来了:
