瑞吉外卖05-套餐管理

本笔记源自黑马程序员的视频课程——《瑞吉外卖》,总结了课程笔记、相关知识点以及可能遇到的问题解决方案,并且增加了课程中未实现的功能,供读者参考。笔记全面且条理清晰,希望帮助读者学习和理解这个外卖项目。
本项目全部笔记见:外卖项目笔记合集

1. 套餐管理业务开发

1.1 新增套餐

1.1.1 需求分析

套餐就是菜品的集合。

后台系统中可以管理套餐信息,通过”新增套餐”功能来添加一个新的套餐,在添加套餐时,需要选择当前套餐所属的套餐分类和包含的菜品,并且需要上传套餐对应的图片,在移动端会按照套餐分类来展示对应的套餐。

图片

1.1.2 数据模型

新增套餐,其实就是将新增页面录入的套餐信息插入到setmeal表,还需要向setmeal_dish表插入套餐和菜品关联数据。所以在新增套餐时,涉及到两个表:

1、setmeal表(套餐表)

图片

2、setmeal_dish表(套餐-菜品关系表)

图片

1.1.3 准备工作

在开发业务功能前,先将需要用到的类和接口基本结构创建好:

1、实体类SetmealDish(从课程资料中导入,Setmeal实体类前面已经导入过了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Data
public class SetmealDish implements Serializable {

private static final long serialVersionUID = 1L;

private Long id;

//套餐id-和setmeal表关联
private Long setmealId;

//菜品id-和dish表关联
private Long dishId;

//菜品名称-和dish表关联(冗余字段 可以没有 可以根据dish_id从dish表里现场查,也可以存在setmeal_dish表里)
private String name;
@Data
public class SetmealDto extends Setmeal {

private List<SetmealDish> setmealDishes;

private String categoryName;
}
//菜品原价
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(从课程资料中导入)

1
2
3
4
5
6
7
@Data
public class SetmealDto extends Setmeal {

private List<SetmealDish> setmealDishes;

private String categoryName;
}

3、Mapper接口SetmealDishMapper

1
2
3
@Mapper
public interface SetmealDishMapper extends BaseMapper<SetmealDish>{
}

4、业务层接口SetmealDishService

1
2
public interface SetmealDishService extends IService<SetmealDish> {
}

5、业务层实现类SetmealDishServicelmpl

1
2
3
4
5
6
7
8
@Service
@Slf4j
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService {

@Autowired
private SetmealDishService setmealDishService;

}

6、控制层SetmealController

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j
@RestController //@ResponseBody + @Controller
@RequestMapping("/setmeal")
public class SetmealController {
@Autowired
private SetmealService setmealService;

@Autowired
private SetmealDishService setmealDishService;

@Autowired
private CategoryService categoryService;
}

1.1.4 梳理交互过程及代码开发

在开发代码之前,需要梳理一下新增套餐时,前端页面和服务端的交互过程:

1、点击“新建套餐”,会请求页面backend/page/combo/add.html,并发送ajax请求/category/list?type=2&page=1&pageSize=1000,请求服务端获取套餐分类数据并展示到下拉框中。此处已经在CategoryController中写过了,可以直接获取到套餐分类。

图片

2、页面发送ajax请求/category/list?type=1,请求服务端获取菜品分类数据并展示到添加菜品窗口中。此处已经在CategoryController中写过了,可以直接获取到套餐分类。

3、页面发送ajax请求/dish/list?categoryId=xxx(第一个菜品分类id),请求服务端,根据菜品分类,查询第一个菜品分类对应的所有菜品数据。这样一来,点击“套餐菜品:添加菜品”时,在弹出的页面内可以直接显示第一个分类的所有菜品。效果如下:

图片

并且,在“添加菜品”弹出页面内选择其他菜品分类时,也会发送请求到/dish/list?categoryId=xxx(对应的菜品分类id)

需要服务器处理该请求,获取该分类id的所有菜品对象,并响应回来,展示菜品在页面上。

代码开发

在DishController中新增list方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 根据分类id, 查出该分类的所有菜品
* 处理请求:/dish/list?categoryId=xxx
* @param dish
* @return
*/
@GetMapping("/list")
public R<List<Dish>> list(Dish dish){

// select * from dish where category_id = ? AND status = ?
//构造查询条件
LambdaQueryWrapper<Dish> lambdaQueryWrapper=new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(Dish::getStatus,1); //只查启售的菜品,停售(status=0)过滤掉
lambdaQueryWrapper.eq(dish.getCategoryId()!=null, Dish::getCategoryId, dish.getCategoryId());
lambdaQueryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);

List<Dish> list = dishService.list(lambdaQueryWrapper);

return R.success(list);
}

说明:

该方法的返回值:由于查出的是多个菜品dish对象,所以用List集合

4、页面发送请求进行图片上传,请求服务端将图片保存到服务器。已经在前面写过。

5、页面发送请求进行图片下载,将上传的图片进行回显。已经在前面写过。

6、点击“保存”按钮,发送ajax请求,将套餐相关数据以json形式提交到服务端。发送请求给/setmeal,method=post。需要服务器保存套餐相关数据,并返回响应。

“保存套餐”的实现思路:

json发来的数据里,有Setmeal类的属性categoryId, name, price, status, code, description, image,也有Setmeal类中没有的setmealDishes属性,所以控制器方法形参要用SetmealDto来接收,SetmealDto类中有setmealDishes属性。

在控制器方法中,需要做以下这些事:

(1)把json数据中,属于Setmeal类的属性category_id, name, price, status, code, description, image保存到setmeal表;

(2)保存(insert)后,由框架生成该setmeal的主键id(该套餐的id)

(3)把刚才自动生成的setmeal_id(setmeal表的主键id)值,逐个赋给setmealDishes对象的setmeal_id属性,就能够对应上同一套餐和多个菜品了。

(4)而setmeal_dish表还需要插入dish_id, name, price, copies等字段的数据,这些都由setmealDishes属性(List类型)封装,可以直接通过setmealDishService.saveBatch(setmealDishes)新增数据。

图片

代码开发

1、在SetmealController新增save方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
@Autowired
private SetmealService setmealService;

@Autowired
private SetmealDishService setmealDishService;

@PostMapping
public R<String> save(@RequestBody SetmealDto setmealDto){
log.info("套餐信息: {}",setmealDto);
setmealService.saveWithDish(setmealDto);
return R.success("新增套餐成功");
}
}

2、在SetmealService中新增saveWithDish方法,并在SetmealServiceImpl中实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService{

@Autowired
private SetmealDishService setmealDishService;

/**
* 新增套餐,同时保存与菜品的关联关系
* @param setmealDto
*/
@Override
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
//先把setmeal表中需要的数据填上
//将json发来的该套餐的category_id, name, price, status, code, description, image(都封装在setmealDto中)保存到setmeal表;
this.save(setmealDto);

//执行save之后,框架会生成该setmeal套餐的主键id

//将setmeal_dish表中需要的数据填上(保存套餐和菜品的关联信息)
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();//获取传来的setmealDto中的setmealDishes属性的值
//传来的各个setmealDish对象中有copies, dishId, name, price数据
//所以获取到的List中的每个setmealDish对象中,只差setmeal_id和sort了。
//其中setmeal_id都是刚才框架生成的套餐主键id。多个setmealDish对象共享一个套餐主键id,因为都在同一个套餐里
setmealDishes = setmealDishes.stream().map((item) ->{ //每个item是setmealDish对象
item.setSetmealId(setmealDto.getId());
return item;
}).collect(Collectors.toList());

//将添加了套餐id的setmealDishes 保存到setmeal_dish表中
setmealDishService.saveBatch(setmealDishes);
}
}

1.2 套餐分页查询

1.2.1 需求分析

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

1.2.2 梳理交互过程

在开发代码之前,需要梳理一下套餐分页查询时前端页面和服务端的交互过程:

1、页面(backend/page/combo/list.html)发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端,获取分页数据。

这一步的具体过程:

list.html加载后执行钩子函数created(),其中执行init()方法,将page、pageSize、name赋值,并封装在params中,执行getSetmealPage(params)方法,发GET请求到/setmeal/page ,携带params参数,并需要服务器响应回来分页信息pageInfo。前端相关代码:

图片

2、页面发送请求,请求服务端进行图片下载,用于页面图片展示。前面已经处理过。

1.2.3 代码开发

在SetmealController中新增page方法,处理1.2.2中的第1条请求即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 套餐分页查询
* 处理请求/setmeal/page?page=1&pageSize=10&name=xxxx,并响应分页信息pageInfo给前端
* @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> setmealDtoPage = new Page<>(); //方法结束时,需要响应一个Page类型的分页构造器对象。
// 且该对象中需含有"分类名称categoryName"。所以还需要构造一个SetmealDto实体对应的Page对象,否则查不到"套餐分类"

//条件构造器
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
//添加查询条件(模糊查询):
queryWrapper.like(StringUtils.isNotEmpty(name), Setmeal::getName, name);
//添加排序条件:按照Dish表里的更新时间 倒序排列,即最近更新的排在前面(orderBy)
queryWrapper.orderByDesc(Setmeal::getUpdateTime);
//执行分页查询(用setmealService,查setmeal表)
setmealService.page(pageInfo, queryWrapper); //处理好又会封装到Page类型的pageInfo中,pageInfo中含有数据列表records、总记录数total等信息

//接下来需要改造,额外返回category_id对应的分类名categoryName

//对象拷贝:setmealDtoPage构建后没有赋值,如何赋值?
//(1)可以直接把 从setmeal表中查出的pageInfo各属性值 拷贝给setmealDtoPage(除records)
//(2)再把records转换为SetmealDto类型的:添加上categoryName,设置给setmealDtoPage
//第(1)步:
BeanUtils.copyProperties(pageInfo, setmealDtoPage, "records");//不拷贝records属性(页面上的数据信息就是records,需要处理)
//格式:BeanUtils.copyProperties(源, 目的, 拷贝什么属性);

//第(2)步:处理records:
List<Setmeal> records = pageInfo.getRecords();//把pageInfo里的records部分拿出来。
// 需要处理该List集合:原本是List<Setmeal>类型的records,不含分类名称;
// 需要变成List<SetmealDto>类型的records,含分类名称。
// 然后设置setmealDtoPage的records为新的集合list:setmealDtoPage.setRecords(list);
List<SetmealDto> list = records.stream().map((item)->{ //item:遍历出的每一个菜品对象setmeal
SetmealDto setmealDto = new SetmealDto();

BeanUtils.copyProperties(item, setmealDto);//把setmeal的普通属性 copy给 我们自己创建的setmealDto对象
Long categoryId = item.getCategoryId();//获取当前遍历到的 setmeal对象的 categoryId
//根据id,从数据库查询分类对象,以获取分类名称
Category category = categoryService.getById(categoryId);
if(category != null){
String categoryName = category.getName();//获取分类名称
setmealDto.setCategoryName(categoryName);//把categoryName赋值给 我们自己创建的setmealDto对象的 categoryName属性
}
return setmealDto;
}).collect(Collectors.toList());

setmealDtoPage.setRecords(list);//把加了categoryName的新list赋给setmealDtoPage的records属性

return R.success(setmealDtoPage); //响应回的数据含有"分类名称"
}

1.3 删除套餐(含批量操作)

1.3.1 需求分析

在套餐管理列表页面,点击“删除”按钮,可以删除对应的套餐信息。也可以通过复选框选择多个套餐,点击“批量删除”按钮,一次删除多个套餐。

图片

注意,对于状态为“售卖中”的套餐不能删除,需要先停售,再删除。

1.3.2 梳理交互过程

在开发代码之前,需要梳理一下删除套餐时前端页面和服务端的交互过程:

1、删除单个套餐时,页面发送ajax请求,根据套餐id删除对应套餐

图片

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

图片

开发删除套餐功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可。

观察删除单个套餐和批量删除套餐的请求信息可以发现,两种请求的地址和请求方式都是相同的,不同的则是传递的id个数,所以在服务端可以提供一个方法来统一处理。

1.3.3 代码开发

需要服务器对请求进行处理,从数据库中删除id对应的套餐,并响应回前端。

在SetmealController中新增delete方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 删除套餐(支持批量)
* 处理请求/setmeal?ids=xxx
* 注意:只能删除停售(status=0)的套餐,启售(status=1)的套餐不能删除
* 所以要先判断套餐的status,如果为0,执行删除。如果为1,返回提示信息
* 同时还要删除setmeal_dish表中 该套餐对应的信息
* @param ids
* @return
*/
@Transactional
@DeleteMapping
public R<String> delete(String[] ids){
int count = 0; //计数:ids中有多少个启售的套餐
for(String id : ids){
Setmeal setmeal = setmealService.getById(id);
if(setmeal.getStatus() == 0){ //停售(status=0)的套餐
//删除setmeal表中的停售套餐
setmealService.removeById(id);

//同时还要删除setmeal_dish表中 该套餐对应的信息(这一段老师未写,自己改的)
// select * from setmeal_dish where setmeal_id = ?
LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SetmealDish::getSetmealId, id);
setmealDishService.remove(queryWrapper);
} else { //启售(status=1)的套餐
count++; //启售的套餐数量加1
}
}
if(count > 0 && count == ids.length){ //全是启售的
return R.error("选中的套餐均为'启售'状态,不可删除");
}else { //如果有至少1个套餐是停售的,删除了选中的停售套餐
return R.success("套餐删除成功")
}
}

1.4 启售/停售套餐(含批量操作)

这一节为自己补充实现的功能,经测试暂未发现问题,可供参考。如有问题欢迎讨论。

1.4.1 需求分析

点“启售/停售”,或者选中多个勾选框后,点击“批量启售/批量停售”,并点击“确认”后,发送/setmeal/status/0?ids=xxx到服务器,需要服务器处理。

图片

服务器需要先根据id找到该套餐,再更改setmeal实体对象的status,然后用实体更新setmeal表中的数据。

1.4.2 代码开发

在SetmealController中新增switchStatus方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 启售停售切换(支持批量)处理/setmeal/status/0?ids=xxx
* @param status
* @param ids
* @return
*/
@PostMapping("/status/{status}")
public R<String> switchStatus(@PathVariable Integer status, String[] ids){
for (String id : ids){
Setmeal setmeal = setmealService.getById(id);
setmeal.setStatus(status);
setmealService.updateById(setmeal);
}
return R.success("状态修改成功");
}

1.5 修改套餐

这一节为自己补充实现的功能,经测试暂未发现问题,可供参考。如有问题欢迎讨论。

1.5.1 需求分析

图片

在套餐管理列表页面的某套餐一行,点击”修改”按钮,请求/backend/page/combo/add.html?id=xxx,跳转到修改套餐页面。需要服务端在该页面回显该套餐信息。用户进行修改后点击“确定”按钮,完成修改操作。

图片

1.5.2 梳理交互过程

在开发代码之前,需要梳理一下修改套餐时,前端页面和服务端的交互过程:

1、在list.html页面点击“修改”,执行addSetMeal(scope.row.id)方法,请求/backend/page/combo/add.html?id=xxxx页面,其中id是要修改的这一行套餐的id。

2、在add.html页面加载完成后,执行钩子函数,并执行getDishTypeList()方法,请求/category/list?type=2&page=1&pageSize=1000,method=get,请求获取所有的套餐分类(商务套餐/儿童套餐)。在前面已经写过,此处可以直接获取到。

图片

3、在add.html页面加载完成后,执行钩子函数,并执行getDishType()方法,请求/category/list?type=1,method=get,获取“+添加菜品”按钮处要显示的所有的菜品分类。在前面已经写过,此处可以直接获取到。

图片

4、在add.html页面加载完成后,执行钩子函数(见第2步后的图)。由于url中有id,所以执行init()方法,其中执行querySetmealById(this.id)方法,请求/setmeal/套餐id,请求方式GET,获取套餐信息以回显到页面。

需要服务器查询,并响应该套餐id的name, price, categoryName(第3步已经获取到所有的category对象), status, categoryId, setmealDishes, image, description等信息,封装到data中传回前端,以回显到修改页面上。

5、同时请求/dish/list?categoryId=xxxx,获取“+添加菜品”按钮处要显示的某分类的所有菜品。已经写过。

6、同时请求服务端进行图片下载,用于页图片回显。已经写过。

7、填写表单后,点击“保存”,发送json请求给/setmeal,请求方式PUT。需要服务器处理,将修改后的信息保存到数据表。

1.5.3 代码开发

我们需要处理1.5.2中的第4条和第7条的请求,其余的已经写过。

1、(对应1.5.2中第4条)修改页面的信息回显:

处理请求/setmeal/1633386599796461569,路径参数是套餐的id。

服务端需要将该套餐id对应的name, price, categoryName(1.5.2的第3条中已经获取到), setmealDishes, image, description等信息响应给前端。

图片

SetmealController中:

1
2
3
4
5
6
7
8
9
10
/**
* 修改某个套餐时,需要回显该套餐id的信息
* @param id
* @return
*/
@GetMapping("/{id}")
public R<SetmealDto> querySetmeal(@PathVariable Long id){ //此处id是套餐的id
   SetmealDto setmealDto = setmealService.getByIdWithDish(id);
   return R.success(setmealDto);
}

SetmealService中添加getByIdWithDish方法,并在SetmealServiceImpl中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 根据id,查询套餐(含套餐内的菜品)
* 需要响应给前端的:
* 套餐id, 套餐分类categoryId, 套餐name, 套餐价格price, 套餐状态status, code, description, image, 公共字段 ————都包含在setmeal对象中
* 和setmealDishes(集合,盛装多个setmealDish对象,其中含有id, setmealId, 菜品dishId, 菜品name, 菜品price, copies, sort, 公共字段)————需由setmealDto对象来盛装
* @param id 套餐setmeal的id
* @return
*/
@Override
@Transactional
public SetmealDto getByIdWithDish(Long id) { //此处的id,在setmeal表中,是id;在setmeal_dish表中,是setmeal_id
   //查询套餐基本信息
   Setmeal setmeal = this.getById(id);//在setmeal表中,根据id查出套餐
//包含的字段:套餐的id, 套餐分类categoryId, 套餐name, 套餐价格price, 套餐状态status, code, description, image, 公共字段

   SetmealDto setmealDto = new SetmealDto();
   BeanUtils.copyProperties(setmeal, setmealDto);
   //copy后,setmealDto中还差setmealDishes属性。可以去setmeal_dish表中,根据setmeal_id查出相应的多个setmeal_dish对象,组成集合

   //select * from setmeal_dish where setmeal_id = ?
   LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
   queryWrapper.eq(SetmealDish::getSetmealId, id); //查出该套餐(setmeal_id)中的多个setmeal_dish对象
   List<SetmealDish> list = setmealDishService.list(queryWrapper);//将查出的多个结果组成集合
   setmealDto.setSetmealDishes(list); //将集合list赋给setmealDto中空缺的setmealDishes属性

   return setmealDto;
}

2、(对应前端第7条)将修改后的信息保存到数据表

填写表单后,点击“保存”,发送json请求给/setmeal,请求方式PUT。需要服务器处理,将新的信息更新到数据表。更新仍然使用先删除再修改的方法,需要补充setmeal_id,才能和套餐对应上。

SetmealController中:

1
2
3
4
5
6
7
8
9
10
11
/**
* 修改套餐信息
* 处理json请求/setmeal,请求方式PUT。
* 需要服务器将新的信息保存到数据表。
* @return
*/
@PutMapping
public R<String> update(@RequestBody SetmealDto setmealDto){
   setmealService.updateWithDish(setmealDto);
   return R.success("修改成功");
}

SetmealService中添加getByIdWithDish方法,并在SetmealServiceImpl中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 修改套餐信息setmeal,并更新套餐包含的菜品(setmeal_dishes)
* 需要把setmealDto的数据更新到setmeal表和setmeal_dish表中
* @param setmealDto
*/
@Override
@Transactional
public void updateWithDish(SetmealDto setmealDto) {
//更新setmeal表的基本信息
this.updateById(setmealDto); //通过setmealService的updateById方法,将setmealDto封装的数据更新到setmeal表

//现在还需要把setmealDto中的setmealDishes部分(List<SetmealDish>),更新到setmeal_dish表中

//更新setmeal_dish表信息:先delete,再insert
//(1) delete: delete from setmeal_dish where setmeal_id = ?
LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SetmealDish::getSetmealId, setmealDto.getId());//setmealDto.getId()就是当前套餐setmeal的id,在setmeal表中是id字段,在setmeal_dish表中是setmeal_id字段
setmealDishService.remove(queryWrapper);

//(2) insert:将setmealDto的setmealDishes中的数据 重新插入setmeal_dish表中
// 将setmeal_id添加进setmealDto中的一个个setmealDishes对象中,关联上
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();

setmealDishes = setmealDishes.stream().map((item) -> {//每个item就是一个setmealDish对象,一个套餐的setmealDishes中有多个setmealDish对象
item.setSetmealId(setmealDto.getId());
return item;
}).collect(Collectors.toList());

setmealDishService.saveBatch(setmealDishes);//将setmealDto中的setmealDishes部分,更新到setmeal_dish表中
}

2. 手机验证码登录

2.1 短信发送

2.1.1 短信服务介绍

目前市面上有很多第三方提供的短信服务,这些第三方短信服务会和各个运营商(移动、联通、电信)对接,我们只需要注册成为会员并且按照提供的开发文档进行调用就可以发送短信。需要说明的是,这些短信服务一般都是收费服务。

常用短信服务:

  • 阿里云
  • 华为云
  • 腾讯云
  • 京东
  • 梦网
  • 乐信

2.1.2 阿里云短信服务-介绍

阿里云短信服务(Short Message Service)是广大企业客户快速触达手机用户所优选使用的通信能力。调用API或用群发助手,即可发送验证码、通知类和营销类短信;国内验证短信秒级触达,到达率最高可达99%;国际/港澳台短信覆盖200多个国家和地区,安全稳定,广受出海企业选用。

应用场景:

  • 验证码
  • 短信通知
  • 推广短信

2.1.3 阿里云短信服务-注册账号

阿里云官网: https://www.aliyun.com/

在官网首页进行注册。

2.1.4 阿里云短信服务-设置短信签名

注册成功后,点击登录按钮进行登录。登录后进入短信服务管理页面,选择国内消息菜单。

图片

图片

短信签名是短信发送者的署名,表示发送方的身份。

2.1.5 阿里云短信服务-设置短信模板

切换到【模板管理】标签页:

图片

短信模板包含短信发送内容、场景、变量信息。

2.1.6 阿里云短信服务-设置AccessKey

1、光标移动到用户头像上,在弹出的窗口中点击【AccessKey管理】∶

图片

2、点击“开始使用子用户AccessKey”(非管理员的权限,更安全)

图片

3、创建用户:创建用户-填写登录名、显示名-勾选“编程访问”-会生成AccessKey ID & AccessKey Secret,复制存起来。

图片

4、设置权限:点击刚创建的用户-权限管理-添加权限-搜索“SMS“,选择“AliyunDysmsFullAccess”和“AliyunDysmsReadOnlyAccess”两个权限

图片

2.1.7 代码开发

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

具体开发步骤:

1、导入maven坐标

1
2
3
4
5
6
7
8
9
10
11
<!--阿里云短信 用于手机验证码登录-->
<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、调用API(此处暂时了解即可,老师暂时没有用短信验证,是使用控制台进行验证的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 短信发送工具类
*/
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", "", "");//此处需要填入access key id和secret
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();
}
}
}

2.2 手机验证码登录

2.2.1 需求分析

为了方便用户登录,移动端通常都会提供通过手机验证码登录的功能。

手机验证码登录的优点:

  • 方便快捷,无需注册,直接登录
  • 使用短信验证码作为登录凭证,无需记忆密码
  • 安全

登录流程:

输入手机号>获取验证码>输入验证码>点击登录>登录成功

注意:通过手机验证码登录,手机号是区分不同用户的标识。

2.2.2 数据模型

通过手机验证码登录时,涉及的表为user表,即用户表。结构如下:

图片

2.2.3 梳理交互过程

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

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

2、在登录页面输入验证码,点击【登录】按钮,执行btnLogin({phone:this.form.phone})方法,发送ajax请求/user/login,method=post,服务端需要处理登录请求。

如果服务端响应回来登录成功,则跳转到/front/index.html

2.2.4 准备工作

在开发业务功能前,先将需要用到的类和接口基本结构创建好:

1、实体类User(直接从课程资料中导入即可)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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

1
2
3
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

3、Service接口UserService

1
2
public interface UserService extends IService<User> {
}

4、Service实现类UserServicelmpl

1
2
3
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

5、控制层UserController

1
2
3
4
5
6
7
8
9
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

@Autowired
private UserService userService;

}

6、工具类SMSutils、 ValidateCodeutils(直接从课程资料中导入即可)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 短信发送工具类
*/
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", "", ""); //此处需要填入access key id和secret
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();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 随机生成验证码工具类
*/
public class ValidateCodeUtils {
/**
* 随机生成验证码
* @param length 长度为4位或者6位
* @return
*/
public static Integer generateValidateCode(int length){
Integer code = null;
if(length == 4){
code = new Random().nextInt(9999);//生成随机数,最大为9999
if(code < 1000){
code = code + 1000;//保证随机数为4位数字
}
}else if(length == 6){
code = new Random().nextInt(999999);//生成随机数,最大为999999
if(code < 100000){
code = code + 100000;//保证随机数为6位数字
}
}else{
throw new RuntimeException("只能生成4位或6位数字验证码");
}
return code;
}

/**
* 随机生成指定长度字符串验证码
* @param length 长度
* @return
*/
public static String generateValidateCode4String(int length){
Random rdm = new Random();
String hash1 = Integer.toHexString(rdm.nextInt());
String capstr = hash1.substring(0, length);
return capstr;
}
}

2.2.5 代码开发

1、修改过滤器

前面我们已经完成了LoginCheckFilter过滤器的开发,此过滤器用于检查用户的登录状态。我们在进行手机验证码登录时,发送的请求需要在此过滤器处理时直接放行。

图片

LoginCheckFilter过滤器添加4-2部分,完整的LoginCheckFilter过滤器如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/**
* @ClassName LoginCheckFilter
* @Description 登录检查过滤器(也可以用拦截器interceptor实现,此项目中用过滤器实现)
* 检查用户是否已经完成登录
*/
@Slf4j
@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
//@WebFilter替代web.xml配置过滤器。filterName指定过滤器名称(随意);urlPatterns指定拦截的请求:/* 工程路径下所有的请求都拦截
public class LoginCheckFilter implements Filter {

//路径匹配器,支持通配符
public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();//专门用于路径比较

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;

//1、获取本次请求的URI
String requestURI = request.getRequestURI();
log.info("拦截到请求:{}", requestURI);

//2、判断本次请求是否需要处理
//不需要处理的请求路径:
String[] urls = new String[]{
"/employee/login",
"/employee/logout",
"/backend/**", // /backend下的页面、静态资源其实可以看,但是不给看页面上的数据(动态请求)
"/front/**",
"/user/sendMsg", //移动端 发送验证短信
"/user/login" //移动端 登录
};
boolean check = check(urls, requestURI);

//3、如果不需要处理,则直接放行
if(check){
log.info("本次请求{}无需处理", requestURI); // {}是占位符,由后面的参数填充
chain.doFilter(request, response); //放行
return; //放行之后,后面代码不需要执行了,直接return结束
}

//4-1、判断登录状态,如果已登录,则直接放行
// 并且把登录用户的id拿到,放到threadLocal中
HttpSession session = request.getSession();
if(session.getAttribute("employee") != null){ //判断后台员工登录状态
//已登录
log.info("用户已登录,用户id为:{}", session.getAttribute("employee")); //session中,"employee"记录的是登录用户的id

/*
为了实现"公共字段自动填充"功能。
我们可以在LoginCheckFilter的doFilter方法中获取当前登录用户id,
并调用ThreadLocal的set方法来设置当前线程的线程局部变量的值(用户id),
然后在MyMetaObjectHandler的updateFill方法中调用ThreadLocal的get方法来获得当前线程所对应的线程局部变量的值(用户id)
*/
Long empId = (Long) session.getAttribute("employee");
BaseContext.setCurrentId(empId);
chain.doFilter(request, response); //放行
return;
}
//4-2、判断登录状态,如果已登录,则直接放行
// 并且把登录用户的id拿到,放到threadLocal中
//此处有bug:如果用户登录了,也可以进入管理后台。后期可以优化
if(session.getAttribute("user") != null){ //判断前台用户登录状态
//已登录
log.info("用户已登录,用户id为:{}", session.getAttribute("employee")); //session中,"employee"记录的是登录用户的id

Long userId = (Long) session.getAttribute("user");
BaseContext.setCurrentId(userId);
chain.doFilter(request, response); //放行
return;
}
else {
//5、如果未登录 向浏览器响应数据 返回未登录结果
log.info("用户未登录");
response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
//通过输出流向浏览器响应数据,给R.error传回msg ——"NOTLOGIN"。
//在request.js页面的前端响应拦截器处,会拦截服务器给的响应。如果code==0,且msg显示NOTLOGIN,说明用户未登录,会跳转到登录页面

return; //不放行,结束方法
}
}

/**
* 检查本次请求是否需要放行
* @param urls
* @param requestURI
* @return
*/
public boolean check(String[] urls, String requestURI){
for(String url : urls){
boolean match = PATH_MATCHER.match(url, requestURI);
if(match){
return true; //匹配上了
}
}
return false; //匹配不上
}
}

2、前端资源

资料/前端资源/front部分代码不全,建议直接拷贝资料中的day05的front资源。

或者也可以按如下步骤手动修改:

(1)修改/front/api/login.js中内容,新增:

1
2
3
4
5
6
7
function sendMsgApi(data) {
return $axios({
'url': '/user/sendMsg',
'method': 'post',
data
})
}

(2)修改/front/page/login.html中getCode()方法:

1
2
// this.form.code = (Math.random()*1000000).toFixed(0)
sendMsgApi({phone:this.form.phone})

3、UserController中新增方法,处理发送验证码的请求

处理请求/user/sendMsg,生成验证码code,在IDEA的console显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 发送手机短信验证码
* 用户点"获取验证码"后,处理ajax请求/user/sendMsg,data为phone电话号
* 参数:可以用String phone接收,此处用更大的User user接收,含有同名的phone参数。通用性更强
* @param user
* @param session
* @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); //可以在IDEA的console显示

//调用阿里云提供的短信服务API,发送验证码短信。为了隐私,以及因为测试专用模板只能使用固定的4-6位纯数字,变量传不进去,所以这里不发短信,只在控制台模拟,自己看code填写
//SMSUtils.sendMessage(code);

//将生成的验证码保存到session,一会进行校验
session.setAttribute(phone, code); //以手机号的string形式为键,验证码为值
return R.success("手机验证码短信发送成功");
}
return R.error("手机短信发送失败");
}

4、在UserController新增login方法,处理登录请求

目标:处理请求/user/login。

思路:验证用户填写的phone对应的code,是否是服务器(sendMsg方法)刚才生成的code。如果是,则登录成功,向session中保存该user的id。如果不一致,则登录失败。如果数据库中不存在该手机号,则会重新注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 移动端用户登录
* 前端带着参数code & phone,请求/user/login
* 参数可以用UserDto(其中扩展了String code属性)。也可以声明Map类型的map。
* 参数传来的是json形式:{phone=13444433333, code=1233},服务器通过在形参中使用@RequestBody注解,可以获取json格式的请求参数(map)
* @param map
* @param session
* @return
*/
@PostMapping("/login")
public R<User> login(@RequestBody Map<String, String> map, HttpSession session){
log.info("map: {}", map.toString());
String phone = map.get("phone"); //获取手机号
String code = map.get("code"); //获取验证码
//从session中获取 sendMsg()中保存的 该手机号对应的验证码
Object codeInSession = session.getAttribute(phone);
//进行验证码比对(页面提交的验证码和Session中保存的验证码比对)
if(codeInSession != null && codeInSession.equals(code)){
//如果能比对成功,登录成功。则需要将用户的id放进session。
//如果验证码填写正确,但是没有此用户,则说明是新用户,需要注册。

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); //状态 0:禁用,1:正常
userService.save(user);
}
session.setAttribute("user", user.getId());
return R.success(user);
}
return R.error("登录失败");
}

补充:老师示范的代码没有保存用户名,所以用户名在user表中为null。后续下单后,在后台“订单明细”页面会看不到用户名。此处可以完善一下,修改login()方法的代码,将新用户注册部分改为:

1
2
3
4
5
6
7
8
9
10
if(user == null){//当前phone不在user表中,查不到该phone,说明当前登录的是新用户,需要自动注册
user = new User();
user.setPhone(phone);
user.setStatus(1); //状态 0:禁用,1:正常
//新加代码:目的是在注册时,给user表的name字段赋值为:"用户"+userId的后六位
log.info("user_id为:{}", user.getId());
user.setId(IdWorker.getId());
user.setName("用户" + (user.getId() + 1000000L) % 1000000L);//假如倒数第六位是0,需要保证结果是六位数,可以在取模前先扩展到至少六位数。
userService.save(user);//设置用户名后,再保存该用户到user表
}