新增套餐
套餐分页查询
删除套餐
短信发送
手机验证码登录
SSM实战-外卖项目-05- 新增套餐(涉及2张表DML,需要事务,设计DTO,分类菜品等多表查询)、分页查询(多表,DTO)、批量删除套餐(停售才可删除,多表delete,中间表’套餐-菜品’关联记录也要)、套餐修改(多表数据回显)、套餐批量启售/停售(业务需求没说,得提前协商好)
开始移动端开发:短信发送(阿里云一步步申请测试接口,可以的,就是审核有点慢,签名和模板都正式一点,否则审核不过)、封装短信发送工具类、多线程销毁验证码、 手机验证码登录
套餐就是菜品的集合。
后台系统中可以管理套餐信息,通过新增套餐功能来添加一个新的套餐,在添加套餐时需要选择当前套餐所属的套餐分类和包含的菜品,并且需要上传套餐对应的图片,在移动端会按照套餐分类来展示对应的套餐。
新增套餐,其实就是将新增页面录入的套餐信息插入到setmeal表,还需要向setmeal_dish表插入套餐和菜品关联数据。所以在新增套餐时,涉及到两个表:
表 | 说明 | 备注 |
---|---|---|
setmeal | 套餐表 | 存储套餐的基本信息 |
setmeal_dish | 套餐菜品关系表 | 存储套餐关联的菜品的信息(一个套餐可以关联多个菜品) |
两张表具体的表结构如下:
1). 套餐表setmeal
在该表中,套餐名称name字段是不允许重复的,在建表时,已经创建了唯一索引。
2). 套餐菜品关系表setmeal_dish
在该表中,菜品的名称name,菜品的原价price 实际上都是冗余字段,因为我们在这张表中存储了菜品的ID(dish_id),根据该ID我们就可以查询出name,price的数据信息,而这里我们又存储了name,price,这样的话,我们在后续的查询展示操作中,就不需要再去查询数据库获取菜品名称和原价了,这样可以简化我们的操作。
在开发业务功能前,先将需要用到的类和接口基本结构创建好,在做这一块儿的准备工作时,我们无需准备Setmeal的相关实体类、Mapper接口、Service接口及实现,因为之前在做分类管理的时候,我们已经引入了Setmeal的相关基础代码。 接下来,我们就来完成以下的几步准备工作:
1). 实体类 SetmealDish
ps.直接从课程资料中导入即可,Setmeal实体前面课程中已经导入过了。
所属包: cn.whu.reggie.entity
可以用MP代码生成器生成,但是得修改成驼峰命名
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 套餐菜品关系
*/
@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格式的数据,其中包含套餐的基本信息,还包含套餐关联的菜品集合。直接从课程资料中导入即可。
所属包: cn.whu.reggie.dto
// 数据传输对象 肯定针对前端传过来的数据格式而封装
/*
简单分析一下前端新增套餐时该提交哪些数据:
1) 套餐基本信息 => extends Setmeal
2) 套餐关联哪些菜品 => List (多对多关系 就是中间表多条记录维护的这种关系)
3) 套餐分类 => categoryName (其实已经有categoryId了 这个字段完全辅助字段 可有可无 有会更方便 少根据id查一次)
*/
@Data
public class SetmealDto extends Setmeal {
private List<SetmealDish> setmealDishes;//套餐关联的菜品集合 (中间表多条记录维护)
private String categoryName;//分类名称 (辅助字段,查询时少根据categoryId查一次)
}
3). Mapper接口 SetmealDishMapper
所属包: cn.whu.reggie.mapper
可以用MP代码生成器生成,但是得修改成驼峰命名
删除xml,加上@Mapper注解
@Mapper
public interface SetmealDishMapper extends BaseMapper<SetmealDish> {
}
4). 业务层接口 SetmealDishService
所属包: cn.whu.reggie.service
可以用MP代码生成器生成,但是得修改命名
public interface SetmealDishService extends IService<SetmealDish> {
}
5). 业务层实现类 SetmealDishServiceImpl
所属包: com.itheima.reggie.service.impl
可以用MP代码生成器生成,但是得修改成驼峰命名
@Service
@Slf4j
public class SetmealDishServiceImpl extends ServiceImpl<SetmealDishMapper,SetmealDish> implements SetmealDishService {
}
6). 控制层 SetmealController
套餐管理的相关业务,我们都统一在 SetmealController 中进行统一处理操作。
所属包: cn.whu.reggie.service.impl
/**
* 套餐管理
*/
@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
@Autowired
private SetmealService setmealService;
@Autowired
private SetmealDishService setmealDishService;
}
服务端的基础准备工作我们准备完毕之后,在进行代码开发之前,需要梳理一下新增套餐时前端页面和服务端的交互过程:
1). 点击新建套餐按钮,访问页面(backend/page/combo/add.html),页面加载发送ajax请求,请求服务端获取套餐分类数据并展示到下拉框中(已实现)
获取套餐分类列表的功能我们不用开发,之前已经开发完成了,之前查询时type传递的是1,查询菜品分类; 本次查询时,传递的type为2,查询套餐分类列表。
2). 访问页面(backend/page/combo/add.html),页面加载时发送ajax请求,请求服务端获取菜品分类数据并展示到添加菜品窗口中(已实现)
本次查询分类列表,传递的type为1,表示需要查询的是菜品的分类。查询菜品分类的目的,是添加套餐关联的菜品时,我们需要根据菜品分类,来过滤查询菜品信息。查询菜品分类列表的代码已经实现, 具体展示效果如下:
3). 当点击添加菜品窗口左侧菜单的某一个分类, 页面发送ajax请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中
dishList绑定在dishAddList变量里面,前端UI就可以拿到了
4). 页面发送请求进行图片上传,请求服务端将图片保存到服务器(已实现)
5). 页面发送请求进行图片下载,将上传的图片进行回显(已实现)
6). 点击保存按钮,发送ajax请求,将套餐相关数据以json形式提交到服务端
经过上述的页面解析及流程分析,我们发现这里需要发送的请求有5个,分别是 :
A. 根据传递的参数,查询套餐分类列表 (已实现)
B. 根据传递的参数,查询菜品分类列表 (已实现)
C. 图片上传 (已实现)
D. 图片下载展示 (已实现)
E. 根据菜品分类ID,查询菜品列表
F. 保存套餐信息
而对于以上的前4个功能我们都已经实现, 所以我们接下来需要开发的功能主要是最后两项, 具体的请求信息如下:
1). 根据分类ID查询菜品列表
请求 | 说明 |
---|---|
请求方式 | GET |
请求路径 | /dish/list |
请求参数 | ?categoryId=1397844263642378242 |
2). 保存套餐信息
请求 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /setmeal |
请求参数 | json格式数据 |
传递的json格式数据如下:
{
"name": "儿童套餐",
"categoryId": "1413342269393674242",
"price": 100,
"code": "",
"image": "9f7994ad-d02c-4259-bf04-3d26c44104d1.jpeg",
"description": "好吃",
"dishList": [],
"status": 1,
"idType": "1413342269393674242",
"setmealDishes": [
{
"copies": 3,
"dishId": "1644336736018776066",
"name": "马铃薯",
"price": 300
},
{
"copies": 2,
"dishId": "1644235293140062209",
"name": "土豆",
"price": 100
}
]
}
上面我们已经分析了接下来我们需要实现的两个功能,接下来我们就需要根据上述的分析,来完成具体的功能实现。
在当前的需求中,我们只需要根据页面传递的菜品分类的ID(categoryId)来查询菜品列表即可,我们可以直接定义一个DishController的方法,声明一个Long类型的categoryId,这样做是没问题的。但是考虑到该方法的拓展性,我们在这里定义方法时,通过Dish这个实体来接收参数。(自己写完全没考虑到)
在DishController中定义方法list,接收Dish类型的参数:
在查询时,需要根据菜品分类categoryId进行查询,并且还要限定菜品的状态为起售状态(status为1),然后对查询的结果进行排序。(自己写完全没考虑到)
/**
* 根据条件查询对应的菜品数据 (多么通用的方法啊 自己写就只会写一个根据categoryId查询)
* @param categoryId
* @return
*/
@GetMapping("/list")
public R<List<Dish>> list(Dish dish){ // 千万注意是Long不是Integer 封装不上一直报404,找了半天的错误,要死
log.info("根据id查询所有商品: (舍弃这种写法 根据xx查询 完全可以通用一点啊 直接用实体类接受条件)");
log.info("根据条件所有商品: dish = {}",dish);
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
// 添加查询条件1:此处根据id查询
lqw.eq(dish.getCategoryId()!=null,Dish::getCategoryId,dish.getCategoryId());
// 添加查询条件2:启售商品
lqw.eq(Dish::getStatus,1);
// 排序条件:(交互性更好) sort升序,更新时间降序
lqw.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
List<Dish> dishList = dishService.list(lqw);
return R.success(dishList);
}
上面这么写的好处,稍作修改,就可以变成根据该表任何字段,甚至联合字段,进行条件查询,非常得通用!!
trick:(好点子都以trick标记 以后方便ctrl+F查询定位)
自己写就只会写成下面这个熊样,完全没有通用性,以及交互性,这就是差距啊
根据xx查询:想想能否写一个通用的条件查询呢?
查询list:想想排序条件提高交互性
日志多了,需要一边打断点,一边看日志,才方便,最新的日志,就是刚执行的代码的日志,eg:sql语句
@GetMapping("/list1")
public R<List<Dish>> list1(Integer categoryId){ // 千万注意是Long不是Integer 封装不上一直报404,找了半天的错误,要死
log.info("根据id查询所有商品: categoryId = {}",categoryId);
LambdaQueryWrapper<Dish> lqw = new LambdaQueryWrapper<>();
lqw.eq(Dish::getCategoryId,dish.getCategoryId());
List<Dish> dishList = dishService.list(lqw);
return R.success(dishList);
}
代码编写完毕,我们重新启动服务器,进行测试,可以通过debug断点跟踪的形式查看页面传递的参数封装情况,及响应给页面的数据信息。
在进行套餐信息保存时,前端提交的数据,不仅包含套餐的基本信息,还包含套餐关联的菜品列表数据 setmealDishes。所以这个时候我们使用Setmeal就不能完成参数的封装了,我们需要在Setmeal的基本属性的基础上,再扩充一个属性 setmealDishes 来接收页面传递的套餐关联的菜品列表,而我们在准备工作中,导入进来的SetmealDto能够满足这个需求。
1). SetmealController中定义方法save,新增套餐
在该Controller的方法中,我们不仅需要保存套餐的基本信息,还需要保存套餐关联的菜品数据,所以我们需要再该方法中调用业务层方法,完成两块数据的保存。
套餐表:setmeal 要保存
中间表:setmeal_dish 也要保存、
保存套餐涉及两张表很好理解,Setmeal实体肯定不够用,所以新建了SetmealDto专门数据传输实体类,里面加上List
也就是中间表的多条记录,然后中间表还可以加上套餐的一些需要展示的字段(冗余),虽然冗余,但是后期查询套餐时,就基本不用管dish菜品表了,直接查套餐表setmeal和中间表setmeal_dish即可。
页面传递的数据是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
具体逻辑:
A. 保存套餐基本信息
B. 获取套餐关联的菜品集合,并为集合中的每一个元素赋值套餐ID(setmealId)
C. 批量保存套餐关联的菜品集合
代码实现:
先注入:SetmealDishService
@Autowired
private SetmealDishService setmealDishService;
再分别插入两张表
操作两张表,不要忘记了事务
/**
* 新增套餐,同时需要保存套餐和菜品的关联关系
* @param setmealDto
*/
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
// 打断点或者输出,看看setmealDto, setmealDishes有哪些缺失字段 有就在这里补充啊
// 先保存套餐基本信息
// 没有确实字段
this.save(setmealDto);//子类可以直接赋值给父类 //此行执行前id=null 此行执行后id=1645440490797514753
// 再保存套餐关联的菜品信息,也就是保存中间表setmeal_dish多条记录
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
//log.info(setmealDishes.toString());
// Debug发现: sort和setmealId两个属性为null,但是sort有数据库默认值0 所以给setmealId赋下值就ok了
// 啥值?当然是setmealDto.getId()啦 所以一定都先save(setmealDto) 才会有id,而且id会回显到setmealDto实体中
setmealDishes.forEach(setmealDish->setmealDish.setSetmealId(setmealDto.getId()));
setmealDishService.saveBatch(setmealDishes);
}
代码编写完毕,我们重新启动服务器,进行测试,可以通过debug断点跟踪的形式查看页面传递的参数封装情况,及套餐相关数据的保存情况。
录入表单数据:
debug跟踪数据封装:
系统中的套餐数据很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。
在进行套餐数据的分页查询时,除了传递分页参数以外,还可以传递一个可选的条件(套餐名称)。查询返回的字段中,包含套餐的基本信息之外,还有一个套餐的分类名称,在查询时,需要关联查询这个字段。
在开发代码之前,需要梳理一下套餐分页查询时前端页面和服务端的交互过程:
1). 访问页面(backend/page/combo/list.html),页面加载时,会自动发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端,获取分页数据
2). 在列表渲染展示时,页面发送请求,请求服务端进行图片下载,用于页面图片展示(已实现)
而对于以上的流程中涉及到2个功能,文件下载功能我们已经实现,本小节我们主要实现列表分页查询功能, 具体的请求信息如下:
请求 | 说明 |
---|---|
请求方式 | GET |
请求路径 | /setmeal/page |
请求参数 | ?page=1&pageSize=10&name=xxx |
上述我们已经分析列表分页查询功能的请求信息,接下来我们就在SetmealController中创建套餐分页查询方法。
该方法的逻辑如下:
1). 构建分页条件对象
2). 构建查询条件对象,如果传递了套餐名称,根据套餐名称模糊查询, 并对结果按修改时间降序排序
3). 执行分页查询
4). 组装数据并返回
代码实现 :
/**
* 套餐分页查询
* @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);
}
基本分页查询代码编写完毕后,重启服务,测试列表查询,我们发现, 列表页面的数据可以展示出来, 但是套餐分类名称没有展示出来。
这是因为在服务端仅返回分类ID(categoryId), 而页面展示需要的是categoryName属性。
在查询套餐信息时, 只包含套餐的基本信息, 并不包含套餐的分类名称, 所以在这里查询到套餐的基本信息后, 还需要根据分类ID(categoryId), 查询套餐分类名称(categoryName),并最终将套餐的基本信息及分类名称信息封装到SetmealDto(在第一小节已经导入)中。
@Data
public class SetmealDto extends Setmeal {
private List<SetmealDish> setmealDishes; //套餐关联菜品列表
private String categoryName;//套餐分类名称
}
完善后代码:
得先在SetmealController里 再注入一个CategoryService
@Autowired
private CategoryService categoryService;
@GetMapping("/page")
// 返回值类型可以不写Page的泛型 不用写Page 写了可读性高一点
public R<Page<SetmealDto>> page(int page,int pageSize,String name){
log.info("套餐信息分页查询: 页码page={}, 页大小pageSize={}, 模糊条件={}",page,pageSize,name);
// 分页构造器对象
Page<Setmeal> pageInfo = new Page<>(page, pageSize);// 父类没办法直接赋值给子类 这里得先写Setmeal,然后再慢慢copy给子类
Page<SetmealDto> setmealDtoPage = new Page<>();
// 条件构造器
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
// 模糊查询条件
if(name!=null) name = name.trim();
lqw.like(name!=null,Setmeal::getName,name);
// 排序条件(查询最好都加上,提高用户体验) (没有sort字段 就只按照updateTime排序了)
lqw.orderByDesc(Setmeal::getUpdateTime); // 注意是Desc 降序排列
setmealService.page(pageInfo,lqw);
// 封装SetmealDto
// 普通属性直接复制
BeanUtils.copyProperties(pageInfo, setmealDtoPage,"records");//records不能复制 不是想要的类型
// setmealDishes慢慢转子类
List<Setmeal> records = pageInfo.getRecords();
List<SetmealDto> list = records.stream().map((setmeal)->{
SetmealDto setmealDto = new SetmealDto();
// 普通属性直接复制
BeanUtils.copyProperties(setmeal, setmealDto);
// 没有的属性,也即拓展的两个属性 自己加 查询的时候好像不需要setmealDishes 只需要CategoryName
Long cid = setmeal.getCategoryId();
Category category = categoryService.getById(cid);
if(category!=null) {//判断category会好很多
setmealDto.setCategoryName(category.getName());
}
return setmealDto;
}).collect(Collectors.toList());
setmealDtoPage.setRecords(list);
return R.success(setmealDtoPage);
}
代码完善后,重启服务,测试列表查询,我们发现, 抓取浏览器的请求响应数据,我们可以获取到套餐分类名称categoryName,也可以在列表页面展示出来 。
在套餐管理列表页面,点击删除按钮,可以删除对应的套餐信息。也可以通过复选框选择多个套餐,点击批量删除按钮一次删除多个套餐。注意,对于状态为售卖中的套餐不能删除,需要先停售,然后才能删除。
在开发代码之前,需要梳理一下删除套餐时前端页面和服务端的交互过程:
1). 点击删除, 删除单个套餐时,页面发送ajax请求,根据套餐id删除对应套餐
2). 删除多个套餐时,页面发送ajax请求,根据提交的多个套餐id删除对应套餐
开发删除套餐功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可,一次请求为根据ID删除,一次请求为根据ID批量删除。
观察删除单个套餐和批量删除套餐的请求信息可以发现,两种请求的地址和请求方式都是相同的,不同的则是传递的id个数,所以在服务端可以提供一个方法来统一处理。
具体的请求信息如下:
请求 | 说明 |
---|---|
请求方式 | DELETE |
请求路径 | /setmeal |
请求参数 | ?ids=1423640210125656065,1423338765002256385 |
删除套餐的流程及请求信息,我们分析完毕之后,就来完成服务端的逻辑开发。在服务端的逻辑中, 删除套餐时, 我们不仅要删除套餐, 还要删除套餐与菜品的关联关系。
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
该业务层方法具体的逻辑为:
A. 查询该批次套餐中是否存在售卖中的套餐, 如果存在, 不允许删除
B. 删除套餐数据
C. 删除套餐关联的菜品数据
代码实现为:
/**
* 根据ids批量删除套餐
* 当然还得删除setmeal_dish中间表所有该套餐的 "套餐-菜品" 关联记录
* 涉及两张表的delete: setmeal和setmeal_dish
* @param ids
*/
@Transactional // 涉及多表DML 一定得加事务
public void removeWithDish(List<Long> ids) {
// 感受: 没有外键 完全用java代码自己维护关联关系 也挺好的 先删setmeal表 再删中间表setmeal_dish 都不会有问题
// 1. 先查询套餐启售状态,确定是否可以删除
// List setmeals = this.listByIds(ids); // 我的方法 根据id查询所有商品 再循环判断status
// 教程方法 直接 select count(*) from setmeal where id in (ids) and status = 1;
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
lqw.in(Setmeal::getId,ids);
lqw.eq(Setmeal::getStatus,1);
int count = this.count(lqw);
// 若不能删除,抛出一个业务异常★
// 该批套餐不能有一个处于启售状态 (菜品有启售无所谓,又不会删除菜品)
//for (Setmeal setmeal : setmeals) if(setmeal.getStatus()==1) throw new CustomException("有套餐正在启售中,不能删除");
if(count>0) throw new CustomException("有套餐正在启售中,不能删除");
// 2. 若可以删除,先删除中间表关联记录吧 (这样以后加了外键也不会有冲突)
LambdaQueryWrapper<SetmealDish> sdLqw = new LambdaQueryWrapper<>();
sdLqw.in(SetmealDish::getSetmealId,ids);//根据套餐id批量删除
setmealDishService.remove(sdLqw);
// 3. 再删除套餐本身
this.removeByIds(ids);//根据主键批量删除
}
由于当前的业务方法中存在多次数据库操作,为了保证事务的完整性,需要在方法上加注解 @Transactional 来控制事务。
4). 完善SetmealController代码
@DeleteMapping
//@RequestParam是告诉MVC这是普通Collection类型,别看到引用类型就当作POJO去封装
// 用List是因为removeByIds()的参数就是Collection, 写数组类型还得自己转一次
public R<String> delete(@RequestParam List<Long> ids){
log.info("根据id批量删除(单个id就是单删): ids={}",ids);
setmealService.removeWithDish(ids);//删除套餐,还要删除中间表setmeal_dish的"套餐-菜品"关联记录
return R.success("删除成功!");
}
代码完善后,重启服务,测试套餐的删除功能,主要测试以下几种情况。
1). 删除正在启用的套餐
2). 执行批量操作, 删除两条记录, 一个启售的, 一个停售的
由于当前我们并未实现启售/停售功能,所以我们需要手动修改数据库表结构的status状态,将其中的一条记录status修改为0。
3). 删除已经停售的套餐信息,执行删除之后, 检查数据库表结构 setmeal , setmeal_dish表中的数据
一些功能教程没有做完 自己做着试试
1)根据setmeal.id回显数据
SetmealController
/**
* 根据id查询套餐信息 包含中间表的关联数据信息
* @param id
* @return
*/
@GetMapping("/{id}")
public R<SetmealDto> getById(@PathVariable Long id){
log.info("根据id查询套餐信息(修改回显需要): id={}",id);
SetmealDto setmealDto = setmealService.getByIdWithSetmealDish(id);
return R.success(setmealDto);
}
SetmealService 接口
/**
* 根据setmeal套餐id,回显记录 (setmeal表)
* 还涉及套餐关联的“套餐-菜品”中间表信息(setmeal_dish表),需要查两张表
* @param id
* @return
*/
SetmealDto getByIdWithSetmealDish(Long id);
SetmealServiceImpl 实现类
/**
* 根据setmeal套餐id,回显记录 (setmeal表)
* 还涉及套餐关联的“套餐-菜品”中间表信息(setmeal_dish表),需要查两张表
* @param id
* @return
*/
@Override
public SetmealDto getByIdWithSetmealDish(Long id) {
// 1、根据id到套餐表setmeal查询套餐基本信息
Setmeal setmeal = this.getById(id);
// 2、根据套餐id到中间表setmeal_dish查询关联记录信息
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
lqw.eq(SetmealDish::getSetmealId,id);
List<SetmealDish> setmealDishes = setmealDishService.list(lqw);
// 3、最终要返回封装好的SetmealDto, 其中包含setmeal基本信息和 setmealDishes 关联记录
SetmealDto setmealDto = new SetmealDto();
// 3.1、Setmeal基本信息直接复制 (父->子 只能复制)
BeanUtils.copyProperties(setmeal,setmealDto);
// 3.2、setmealDishes 得单独设置了
// 直接从数据库里查到的记录 肯定不会有确实字段
setmealDto.setSetmealDishes(setmealDishes);
return setmealDto;
}
{
"id": "1645698209181790209",
"categoryId": "1413386191767674881",
"name": "套餐1",
"price": 100,
"status": 1,
"code": "",
"description": "好吃",
"image": "310e1f3d-95c2-4b4f-9d27-4954e57e3add.jpg",
"createTime": "2023-04-11 16:00:03",
"createUser": "1",
"updateUser": "1",
"isDeleted": 0,
"setmealDishes": [
{
"copies": 2,
"dishId": "1644357343485767681",
"name": "腊肉",
"price": 300
},
{
"copies": 1,
"dishId": "1644336736018776066",
"name": "马铃薯",
"price": 300
}
],
"categoryName": null,
"idType": "1413386191767674881"
}
显然修改传过来的也是SetmealDto
@Data
public class SetmealDto extends Setmeal {
private List<SetmealDish> setmealDishes;//套餐关联的菜品集合 (中间表多条记录维护)
private String categoryName;//分类名称 (辅助字段,查询时少根据categoryId查一次)
}
SetmealController
/**
* 根据id更新套餐信息
* 但是还得更新中间表 该套餐关联的 "套餐-菜品" 记录信息
* @param setmealDto
* @return
*/
@PutMapping
public R<String> updateById(@RequestBody SetmealDto setmealDto){
log.info("修改套餐信息:{}",setmealDto);
setmealService.updateWithSetmealDish(setmealDto);
return R.success("修改成功");
}
SetmealService 接口
/**
* 根据id更新套餐信息
* 同时还得更新关联的中间表 “套餐-菜品” 记录
* @param setmealDto
*/
void updateWithSetmealDish(SetmealDto setmealDto);
SetmealServiceImpl 实现类
/**
* 根据id更新套餐信息
* 同时还得更新关联的中间表 “套餐-菜品” 记录
* @param setmealDto
*/
@Transactional // 多表DML 需要 事务
public void updateWithSetmealDish(SetmealDto setmealDto) {
// 1、先更新套餐基本信息
this.updateById(setmealDto); // 子类可以直接赋值给父类
// 打断点发现setmeal没有缺失字段
// 2、再更新套餐关联的中间表 "套餐-菜品" 一条条记录
// 复杂逻辑简单化 先清空该套餐在中间表的所有记录
LambdaQueryWrapper<SetmealDish> lqw = new LambdaQueryWrapper<>();
lqw.eq(SetmealDish::getSetmealId,setmealDto.getId()); // 打断点 看sql语句 就能判断有没有问题 eq就能多删 不需要in
setmealDishService.remove(lqw);
// 再重新插入该套餐的新的所有记录
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
// 打断点看看有没有确实记录,很可能有的:果然除了填充字段和id正常null外,sort和setmealId也为null,
// sort有默认值0, setmealId必须手动赋值了
setmealDishes.forEach(setmealDish->setmealDish.setSetmealId(setmealDto.getId()));
setmealDishService.saveBatch(setmealDishes);
}
测试OK~
/**
* 根据ids批量修改状态 启售/停售
* @param status
* @param ids
* @return
*/
@PostMapping("/status/{status}")
public R<String> updateByIds(@PathVariable int status,@RequestParam List<Long> ids) {
Setmeal setmeal = new Setmeal();
setmeal.setStatus(status);
LambdaQueryWrapper<Setmeal> lqw = new LambdaQueryWrapper<>();
lqw.in(Setmeal::getId,ids);//根据ids批量修改状态 启售/停售
setmealService.update(setmeal,lqw);
return R.success("批量起售/停售成功!");
}
测试OK~
在我们接下来要实现的移动端的业务开发中,第一块儿我们需要开发的功能就是移动端的登录功能,而移动端的登录功能,比较流行的方式就是基于短信验证码进行登录,那么这里涉及到了短信发送的知识,所以本章节,我们就来讲解,在项目开发中,我们如何发送短信。
在项目中,如果我们要实现短信发送功能,我们无需自己实现,也无需和运营商直接对接,只需要调用第三方提供的短信服务即可。目前市面上有很多第三方提供的短信服务,这些第三方短信服务会和各个运营商(移动、联通、电信)对接,我们只需要注册成为会员,并且按照提供的开发文档进行调用就可以发送短信。需要说明的是,这些短信服务一般都是收费服务。
常用短信服务:
阿里云
华为云
腾讯云
京东
梦网
乐信
本项目在选择短信服务的第三方服务提供商时,选择的是阿里云短信服务。
阿里云短信服务(Short Message Service)是广大企业客户快速触达手机用户所优选使用的通信能力。调用API或用群发助手,即可发送验证码、通知类和营销类短信;国内验证短信秒级触达,到达率最高可达99%;国际/港澳台短信覆盖200多个国家和地区,安全稳定,广受出海企业选用。
应用场景:
场景 | 案例 |
---|---|
验证码 | APP、网站注册账号,向手机下发验证码; 登录账户、异地登录时的安全提醒; 找回密码时的安全验证; 支付认证、身份校验、手机绑定等。 |
短信通知 | 向注册用户下发系统相关信息,包括: 升级或维护、服务开通、价格调整、 订单确认、物流动态、消费确认、 支付通知等普通通知短信。 |
推广短信 | 向注册用户和潜在客户发送通知和推广信息,包括促销活动通知、业务推广等商品与活动的推广信息。增加企业产品曝光率、提高产品的知名度。 |
阿里云短信服务官方网站: https://www.aliyun.com/product/sms
可以访问官网,熟悉一下短信服务:
阿里云官网:https://www.aliyun.com/
有账号直接登录,无账号,注册一个
注册成功后,点击登录按钮进行登录。登录后进入控制台, 在左上角的菜单栏中搜索短信服务。第一次使用,需要点击,并开通短信服务。
新用户可以个人免费领取短信服务
自己摸索一下,跟着提示来申请一些没有的东西即可
开通短信服务之后,进入短信服务管理页面,选择国内消息菜单,我们需要在这里添加短信签名。
那么什么是短信签名呢?
短信签名是短信发送者的署名,表示发送方的身份。我们要调用阿里云短信服务发送短信,签名是比不可少的部分。
那么接下来,我们就需要来添加短信签名。
注意:
目前,阿里云短信服务申请签名主要针对企业开发,个人申请时有一定难度的,在审核时,会审核资质,需要上传营业执照 ;
所以,我们课程中,主要是演示一下短信验证码如何发送,大家只需要学习这块儿的开发流程、实现方式即可,无需真正的发送短信。如果以后在企业中做项目,需要发送短信,我们会以公司的资质去申请对应的签名。
…
…
但是其实申请个人免费的短信服务,也是可以的,学习测试使用嘛,耐心等待即可
切换到【模板管理】标签页:
那么什么是模板呢?
短信模板包含短信发送内容、场景、变量信息。模板的详情如下:
最终我们,给用户发送的短信中,具体的短信内容,就是上面配置的这个模板内容,将${code}占位符替换成对应的验证码数据即可。如下:
【xxxxx】您好,您的验证码为173822,请勿泄露于他人!
送的模版本身没有签名
还无法匹配自定义签名
所以还是自定义一个模版吧
等待签名审核通过,我们可以点击左上角的按钮,添加模板,然后填写模板的基本信息及设置的模板内容:
添加的短信模板,也是需要进行审核的只有审核通过,才可以正常使用。
有了签名之后可以申请自己的模版啦,啥都用自己申请的,送的真不好用。
等待审核通过,就申请成功
AccessKey 是访问阿里云 API 的密钥,具有账户的完全权限,我们要想在后面通过API调用阿里云短信服务的接口发送短信,那么就必须要设置AccessKey。
我们点击右上角的用户头像,选择"AccessKey管理",这时就可以进入到AccessKey的管理界面。
进入到AccessKey的管理界面之后,提示两个选项 “继续使用AccessKey” 和 “开始使用子用户AccessKey”,两个区别如下:
1). 继续使用AccessKey
如果选择的是该选项,我们创建的是阿里云账号的AccessKey,是具有账户的完全权限,有了这个AccessKey以后,我们就可以通过API调用阿里云的服务,不仅是短信服务,其他服务(OSS,语音服务,内容安全服务,视频点播服务…等)也可以调用。 相对来说,并不安全,当前的AccessKey泄露,会影响到我当前账户的其他云服务。
2). 开始使用子用户AccessKey
可以创建一个子用户,这个子用户我们可以分配比较低的权限,比如仅分配短信发送的权限,不具备操作其他的服务的权限,即使这个AccessKey泄漏了,也不会影响其他的云服务, 相对安全。
接下来就来演示一下,如何创建子用户AccessKey。
Secret要记住,后面好像查不到了,只得删除重新创建.(所以立刻复制备份)
上述我们已经创建了子用户, 但是这个子用户,目前没有任何权限,接下来,我们需要为创建的这个用户来分配权限。
经过上述的权限配置之后,那么新创建的这个 reggie 用户,仅有短信服务操作的权限,不具备别的权限,即使当前的AccessKey泄漏了,也只会影响短信服务,其他服务是不受影响的。
如果在使用的过程中 AccessKey 不小心泄漏了,我们可以在阿里云控制台中, 禁用或者删除该AccessKey。
然后再创建一个新的AccessKey, 保存好AccessKeyId和AccessKeySecret。
注意: 创建好了AccessKey后,请及时保存AccessKeyId 和 AccessKeySecret ,弹窗关闭后将无法再次获取该信息,但您可以随时创建新的 AccessKey。
使用阿里云短信服务发送短信,可以参照官方提供的文档即可。
官方文档: https://help.aliyun.com/product/44282.html?spm=5176.12212571.help.dexternal.57a91cbewHHjKq
官方快速入门
我们根据官方文档的提示,引入对应的依赖,然后再引入对应的java代码,就可以发送消息了。
先导入下面具体实现的依赖,然后用人家官网提供的代码测试一下
注意:
1、测试签名(模版)的只能发短信给申请测试签名时输入的手机号
2、提前给自己阿里云账户里充点钱,否则会报:余额不足
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.exceptions.ServerException;
import com.aliyuncs.profile.DefaultProfile;
import com.google.gson.Gson;
public class SendSmsTest {
public static void main(String[] args) {
DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "L..填自己创建用户时的ID ..u", "l..填自己创建用户时的secret..c");
IAcsClient client = new DefaultAcsClient(profile);
SendSmsRequest request = new SendSmsRequest();
request.setPhoneNumbers("17..填自己申请测试签名时的手机号..8");//接收短信的手机号码
request.setSignName("自己申请的测试签名");//短信签名名称 赠送的似乎没有签名
request.setTemplateCode("SMS_..填自己的模版Code..");//短信模板CODE
request.setTemplateParam("{\"code\":\"1998\"}");//短信模板变量对应的实际值 注意得是json格式
// 得是这种json格式: {"code":"1998"}
try {
SendSmsResponse response = client.getAcsResponse(request);
System.out.println(new Gson().toJson(response));
} catch (ServerException e) {
e.printStackTrace();
} catch (ClientException e) {
System.out.println("ErrCode:" + e.getErrCode());
System.out.println("ErrMsg:" + e.getErrMsg());
System.out.println("RequestId:" + e.getRequestId());
}
}
}
SDK : SDK 就是 Software Development Kit 的缩写,翻译过来——软件开发工具包,辅助开发某一类软件的相关文档、范例和工具的集合都可以叫做SDK。在我们与第三方接口交互时, 一般都会提供对应的SDK,来简化我们的开发。
具体实现:
1). pom.xml
<dependency>
<groupId>com.aliyungroupId>
<artifactId>aliyun-java-sdk-coreartifactId>
<version>4.5.16version>
dependency>
<dependency>
<groupId>com.aliyungroupId>
<artifactId>aliyun-java-sdk-dysmsapiartifactId>
<version>2.1.0version>
dependency>
2). 将官方提供的main方法,稍作修改,封装为一个工具类
一个验证码的发送方法
一个验证码失效方法,也即隔指定时间在session中删除该验证码
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.profile.DefaultProfile;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpSession;
import java.util.Timer;
import java.util.TimerTask;
/**
* 短信发送工具类
*/
@Slf4j
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", "LTAI5tCe6mHKiLJJs7YK4VFu", "loz8jOqHorAl2FJQy5KBPdaCd4cE0c");
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("短信发送成功:"+response.getMessage());
System.out.println(response.getCode());
}catch (ClientException e) {
log.info("{} 的短信发送失败",phoneNumbers);
e.printStackTrace();
}
}
/**
* 设置session里字段值的失效时间
* @param t 失效时间 单位 秒
*/
public static void deadTime(Integer ts, String phone, HttpSession session){
try {
final Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
session.removeAttribute(phone);
log.info("{} 的手机验证码已经失效, 因为有效时间到了",phone);
timer.cancel();
}
},ts*1000);//ts以秒为单位
} catch (Exception e) {
log.error("销毁session里的验证码时出错了~");
e.printStackTrace();
}
}
}
然后也测试一下工具类:
@Test
public void testSMSUtils(){
String phone = "自己的号码";
String code = "2002"; // 要发送的验证码
SMSUtils.sendMessage("自己的签名","自己的模版code",phone,code);
}
手机也能成功收到,OK啦~
好好注册,可以跑通
为了方便用户登录,移动端通常都会提供通过手机验证码登录的功能。手机验证码登录有如下优点:
1). 方便快捷,无需注册,直接登录
2). 使用短信验证码作为登录凭证,无需记忆密码
3). 安全
登录流程:
输入手机号 > 获取验证码 > 输入验证码 > 点击登录 > 登录成功
注意:通过手机验证码登录,手机号是区分不同用户的标识。
通过手机验证码登录时,涉及的表为user表,即用户表。结构如下:
在开发代码之前,需要梳理一下登录时前端页面和服务端的交互过程:
1). 在登录页面(front/page/login.html)输入手机号,点击【获取验证码】按钮,页面发送ajax请求,在服务端调用短信服务API给指定手机号发送验证码短信。
http://localhost:8080/front/page/login.html
由于是H5开发的,会自动适应屏幕大小,电脑浏览器访问就要调一下页面
按 F12-》 先修改宽度 -》 再切换到手机模式
提供的代码有问题,之前在前端就生成了验证码,需要修改为后端动态获取
getCode(){
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})$/;
if (regex.test(this.form.phone)) {
this.msgFlag = false
//this.form.code = (Math.random()*1000000).toFixed(0)
sendMsgApi({phone : this.form.phone});
}else{
this.msgFlag = true
}
},
function sendMsgApi(data){
return $axios({
'url':'/user/sendMsg',
'method':'post',
data
})
}
2). 在登录页面输入验证码,点击【登录】按钮,发送ajax请求,在服务端处理登录请求。
const res = await loginApi({phone:this.form.phone})
改成:
const res = await loginApi(this.form) // 直接传整个表单就行了 前前缀phone:和{}也去掉
整个表单都要提交过去,不仅仅只是提交一个手机号 (表单里还有验证码呢)
(我怀疑提供资料的人这块就跳过了,没有真的做阿里云验证码获取. 或者人家做了,也不会给你他的短信接口的,所以故意把页面改成了这样)
如果服务端返回的登录成功,页面将会把当前登录用户的手机号存储在sessionStorage中,并跳转到移动的首页页面。
开发手机验证码登录功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可,分别是获取短信验证码 和 登录请求,具体的请求信息如下:
其实浏览器端F12捕获,最为快捷,前提是人家的前端页面已经写好了:
1). 获取短信验证码
请求 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /user/sendMsg |
请求参数 | {“phone”:“13100001111”} |
2). 登录
请求 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /user/login |
请求参数 | {“phone”:“13100001111”, “code”:“1111”} |
错误示例:
在开发业务功能前,先将需要用到的类和接口基本结构创建好:
用代码生成器生成即可:
package cn.whu.reggie.utils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.config.DataSourceConfig;
import com.baomidou.mybatisplus.generator.config.GlobalConfig;
import com.baomidou.mybatisplus.generator.config.PackageConfig;
import com.baomidou.mybatisplus.generator.config.StrategyConfig;
public class CodeGenerator2 {
public static void main(String[] args) {
//1. 创建代码生成器对象,执行生成代码操作
AutoGenerator autoGenerator = new AutoGenerator();
// 中间三大配置 让他一步步生成我们想要的代码样式
//设置全局配置
GlobalConfig globalConfig = new GlobalConfig();
globalConfig.setOutputDir(System.getProperty("user.dir") + "/src/main/java"); //设置代码生成位置 不是项目下的module 不需要写项目名了
globalConfig.setOpen(false); //设置生成完毕后是否打开生成代码所在的目录
globalConfig.setAuthor("whu"); //设置作者
globalConfig.setFileOverride(false); //设置是否覆盖原始生成的文件 不能覆盖 万一按错了 就悲剧了
autoGenerator.setGlobalConfig(globalConfig);
//设置包名相关配置
PackageConfig packageInfo = new PackageConfig();
packageInfo.setParent("cn.whu.reggie"); //设置生成的包名,与代码所在位置不冲突,二者叠加组成完整路径
autoGenerator.setPackageInfo(packageInfo);
//策略设置
StrategyConfig strategyConfig = new StrategyConfig();
strategyConfig.setInclude("user"); //设置当前参与生成的表名,参数为可变参数 // 不写就是生成所有表
strategyConfig.setRestControllerStyle(true); //设置是否启用Rest风格
strategyConfig.setEntityLombokModel(true); //设置是否启用lombok 实体类就自动用lombok了
autoGenerator.setStrategy(strategyConfig);
//2. 数据源相关配置:读取数据库中的信息,根据数据库表结构生成代码
DataSourceConfig dataSource = new DataSourceConfig();
dataSource.setDriverName("com.mysql.cj.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true");
dataSource.setUsername("root");
dataSource.setPassword("1234");
autoGenerator.setDataSource(dataSource);
//3. 执行生成操作
autoGenerator.execute();
}
}
1). 实体类 User
所属包: cn.whu.reggie.entity
修改一下idNumber的驼峰命名即可
@Data
@EqualsAndHashCode(callSuper = false)
public class User implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Long id;
/**
* 姓名
*/
private String name;
/**
* 手机号
*/
private String phone;
/**
* 性别
*/
private String sex;
/**
* 身份证号
*/
private String idNumber;
/**
* 头像
*/
private String avatar;
/**
* 状态 0:禁用,1:正常
*/
private Integer status;
}
2). Mapper接口 UserMapper
所属包: cn.whu.reggie.mapper
F2重命名:
IUserMapper
->UserMapper
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
3). 业务层接口 UserService
所属包: cn.whu.reggie.service
public interface UserService extends IService<User> {
}
4). 业务层实现类 UserServiceImpl
所属包: cn.whu.reggie.service.impl
无需修改
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}
5). 控制层 UserController
所属包: cn.whu.reggie.controller
注入一下UserService
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
}
6). 工具类SMSUtils、ValidateCodeUtils(直接从课程资料中导入即可)
所属包: cn.whu.reggie.utils
SMSUtils : 是我们上面改造的阿里云短信发送的工具类 ;
ValidateCodeUtils : 是验证码生成的工具类 ;
SMSUtils
package cn.whu.reggie.utils;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.profile.DefaultProfile;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpSession;
import java.util.Timer;
import java.util.TimerTask;
/**
* 短信发送工具类
*/
@Slf4j
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", "LTAI5tCe6mHKiLJJs7YK4VFu", "loz8jOqHorAl2FJQy5KBPdaCd4cE0c");
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("短信发送成功:"+response.getMessage());
System.out.println(response.getCode());
}catch (ClientException e) {
log.info(phoneNumbers+"的短信发送失败");
e.printStackTrace();
}
}
/**
* 设置session里字段值的失效时间
* @param t 失效时间 单位 秒
*/
public static void deadTime(Integer ts, String phone, HttpSession session){
try {
final Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
session.removeAttribute(phone);
log.info("{} 的手机验证码已经失效");
timer.cancel();
}
},ts);
} catch (Exception e) {
log.error("销毁session里的验证码时出错了~");
e.printStackTrace();
}
}
}
ValidateCodeUtils
package cn.whu.reggie.utils;
import java.util.Random;
/**
* 随机生成验证码工具类
*/
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;
}
}
前面我们已经完成了LoginCheckFilter过滤器的开发,此过滤器用于检查用户的登录状态。我们在进行手机验证码登录时,发送的两个请求(获取验证码和登录)需要在此过滤器处理时直接放行。
对于移动的端的页面,也是用户登录之后,才可以访问的,那么这个时候就需要在 LoginCheckFilter 中进行判定,如果移动端用户已登录,我们获取到用户登录信息,存入ThreadLocal中(在后续的业务处理中,如果需要获取当前登录用户ID,直接从ThreadLocal中获取),然后放行。
cn.whu.reggie.filter.LoginCheckFilter 里加上如下代码
Long userId = (Long) request.getSession().getAttribute("userId");
//D-2. 判断移动端登录状态,如果已登录,则直接放行
if(userId!=null){
log.info("用户已经登录,直接放行,用户id为{}",userId);
// 用户id绑定到当前线程 (MyMetaObjectHandler里公共字段自动填充用)
BaseContext.setCurrentId(userId);
chain.doFilter(request,response);//已经登录上了 直接放行
return;
}
注意后面移动端用户登录成功,其session中的key得取名叫做
"userId"
了
在UserController中创建方法,处理登录页面的请求,为指定手机号发送短信验证码,同时需要将手机号对应的验证码保存到Session,方便后续登录时进行比对。
/**
* 发送手机短信验证码
* @param user
* @return
*/
@PostMapping("/sendMsg")
public R<String> sendMsg(@RequestBody User user, HttpSession session){ //参数里直接能注入session 好
log.info("获取短信验证码: 手机号为:{}",user.getPhone());
// 1. 获取手机号
String phone = user.getPhone();
if(phone == null||phone.trim().length()==0) return R.error("手机号不正确,短信发送失败");
// 2. 生成随机4位验证码
String code = String.valueOf(ValidateCodeUtils.generateValidateCode(4));
log.info("code: {}",code);
// 3. 调用阿里云提供的短信服务API完成短信发送
SMSUtils.sendMessage("填自己的签名","填自己的模板code",phone,code);//测试阶段其实可以注释这一,不必真的发送,不过0.045也即是4.5分钱一条,也不贵就是了
// 工具类里已经有异常处理了 也会打印反馈消息
// 4. 将生成的验证码保存到Session
session.setAttribute(phone,code); // 直接手机号phone作为key, 验证码code作为值
// 5. 设置session失效时间 单位为秒
SMSUtils.deadTime(5*60,phone,session);
return R.success("手机短信验证码发送成功~");
}
然后可以将失效时间设置为10s,再写一个单独的controller获取code,看看过期前能否取到,过期后是否是null
/**
* 获取session里的验证码
* 测试短信验证码失效时间用
* @return
*/
// 注意拦截器里给本方法 放行
@GetMapping("/sessionCode")
@ResponseBody
public String getSessionCode(HttpSession session) {
String code = (String) session.getAttribute("13112345678");
log.info("code: {}",code);
return "code: "+code;
// 测试没有问题,过期确实失效 新线程法真好用
}
测试:
1)先访问登陆页面请求发送验证码
http://localhost:8080/front/page/login.html
2)10s内访问获取验证码的测试controller
http://localhost:8080/user/sessionCode
稍等片刻,控制台打印日志。(注意测试时,超时时间设置为10s就ok了)
再访问测试controller
符合预期,ok啦
在UserController中增加登录的方法 login,该方法的具体逻辑为:
1). 获取前端传递的手机号和验证码
2). 从Session中获取到手机号对应的正确的验证码
3). 进行验证码的比对 , 如果比对失败, 直接返回错误信息
4). 如果比对成功, 需要根据手机号查询当前用户, 如果用户不存在, 则自动注册一个新用户
5). 将登录用户的ID存储Session中
具体代码实现:
前端传过来的是json格式,但是却没有对呀的pojo (phone+code),要么自己自定义UserDto
这里采用一种简单的方式: 直接@RequestBody Map map 接受json格式数据,直接封装为map
/**
* 移动端用户登录
* 手机号+短信验证码 登陆
* @param map
* @param session
* @return
*/
@PostMapping("/login")
public R<User> login(@RequestBody Map map, HttpSession session){
log.info("验证码登陆: map接收任意格式的json, map={}",map);
//1). 获取前端传递的手机号和验证码
String phone = (String) map.get("phone"); //.toString() 为空null报异常 不过你非法访问,报异常也很好
String code = (String) map.get("code");
log.info("手机号:{} 验证码:{}",phone,code);
//2). 从Session中获取到手机号对应的正确的验证码
String sessionCode = (String) session.getAttribute(phone); // 这么写即使获取null也不会报错 .toString会报错的
//3). 进行验证码的比对 , 如果比对失败, 直接返回错误信息
if(sessionCode==null) return R.error("验证码已失效或未生成验证码");
if(StringUtils.isNotEmpty(code)&&!code.equals(sessionCode)) return R.error("验证码错误");
//4). 如果比对成功, 需要根据手机号查询当前用户, 如果用户不存在, 则自动注册一个新用户
LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>();
lqw.eq(User::getPhone,phone);
User user = userService.getOne(lqw);
if(user==null){
log.info("手机号 {} 暂无账号,自动注册",phone);
user = new User();
user.setPhone(phone); // 真的就设置一个值就OK了 其他值要么默认值,要么等用户登陆后自己修改设置
user.setStatus(1);//数据库 Status 默认值竟然是0
userService.save(user); // 插入后就会有id回显的(显然是save方法帮你设置的值)
}
//5). 将登录用户的ID存储Session中 //否则拦截器那关过不了哦
session.setAttribute("userId",user.getId());
// 登陆成功返回user对象 浏览器端也要展示用户名等信息嘛
return R.success(user);
}
代码完成后,重启服务,测试短信验证码的发送及登录功能。
1). 测试错误验证码的情况
2). 测试正确验证码的情况
检查user表,用户的数据也插入进来了:
sessionStorage.setItem("user",JSON.stringify(res.data))
之前仅仅保存了一个手机号码
登陆成功可以到浏览器端查看
@PostMapping("/loginout")
public R<String> loginout(HttpSession session){
log.info("移动端退出登陆");
// 从session中移除当前用户id
session.removeAttribute("userId");
return R.success("已退出登陆");
}
SSM实战-外卖项目-05-新增套餐(涉及2张表DML,需要事务,设计DTO,分类菜品等多表查询)、分页查询(多表,DTO)、批量删除套餐(停售才可删除,多表delete,中间表’套餐-菜品’关联记录也要)、套餐修改(多表数据回显)、套餐批量启售/停售(业务需求没说,得提前协商好)
套餐的保存和删除,涉及两张表的DML, 业务逻辑复杂,代码操作也复杂,但是提前写好需求文档,整理好思路,也很简单,多敲敲这种多表操作!
开始移动端开发:短信发送(阿里云一步步申请测试接口,可以的,就是审核有点慢,签名和模板都正式一点,否则审核不过)、封装短信发送工具类、多线程销毁验证码、 手机验证码登录
短信接口申请,不难,但是很烦,主要阿里那边审核一次要1~2个小时,审核不过又得重新提交申请,又得等,所以耐心一点,都能做到