是专门为餐饮企业单独定制的一款外卖软件,包括系统管理后台和移动应用端两部分。
管理后台主要提供给餐饮企业内部员工使用,可以对餐厅的菜品、订单、套餐进行维护管理。
移动端应用主要提供给消费者使用,可以在线浏览菜品,添加购物车,下单等
产品原型适用于展示项目的业务功能,一般由产品经历设计【也就是预览项目功能的html页面】
前端代码已经开发好,直接运行起来就可以
代码说明:
后端工程已经搭建好,直接导入到idea中即可
创建数据库sky_take_out,直接执行sql文件
前端请求怎么发送给后端的?
打开浏览器发现前端发送的请求地址是:http://localhost/api/employee/login
而后端控制器中的处理地址是:http://localhost:8080/admin/employee/login
可以看见请求地址不同,请求端口号也不一样
这是由于nginx的反向代理,也就是将前端发送的动态请求由nginx转发到后端服务器
这样做的好处是什么?
- 提高访问速度:nginx可以缓存一部分数据,如果请求的东西nginx有,那么就可以直接让他访问
- 进行负载均衡:当业务量很大的情况下,可以部署多台服务器,由nginx均衡的将请求分配给各个服务器,减小压力
- 保证后端服务安全:没有直接暴露后端的地址
怎么配置nginx的反向代理呢?
在配置文件中
localtion
字段后面的内容就是需要处理的例如:上面的数据是
localtion /api/
那么如果前端发过来的请求地址中包含/api/
,就会将请求地址中api及前面的部分换成配置文件中反向代理的部分,api
后面的部分直接拼接到反向代理地址后面
怎么配置负载均衡呢?
大部分和反向代理一样,不过请求地址换成了webservers,而webservers是一个关键字,这个关键字中包含多个服务器地址,所以到请求的时候地址就变成了对应的地址
打开YApi网站https://yapi.pro/,因为我们有两个json文件【前端、后端】,所以创建两个工程
然后将json文件导入
可以做到生成后端接口文档,以及在线接口调试
官网:https://swagger.io/
Knife4j是java MVC框架集成Swagger生成Api文档的增强解决方案,只需要在pom中导入依赖
导入 knife4j 的maven坐标
<dependency>
<groupId>com.github.xiaoymingroupId>
<artifactId>knife4j-spring-boot-starterartifactId>
<version>3.0.2version>
dependency>
在配置类中加入 knife4j 相关配置
/**
* 通过knife4j生成接口文档
* @return
*/
@Bean
public Docket docket() {
ApiInfo apiInfo = new ApiInfoBuilder()
.title("苍穹外卖项目接口文档")//生成文档名
.version("2.0")//版本
.description("苍穹外卖项目接口文档")//描述
.build();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo)
.select()
.apis(RequestHandlerSelectors.basePackage("com.sky.controller"))//需要扫描的包
.paths(PathSelectors.any())
.build();
return docket;
}
设置静态资源映射,否则接口文档页面无法访问【固定的】
/**
* 设置静态资源映射
* @param registry
*/
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
@Api:用在类上,说明类的作用
@Api(tags = "员工相关接口")
@ApiOperation:用在方法上,说明方法的作用
@ApiOperation(value = "员工登录")
说明:需求分析要根据产品原型分析,后面就不说了
后台管理系统点击添加按钮之后就会自动跳转到添加员工页面
表单输入完成之后点击保存就会把数据发送到后端
打开开发者工具,点击保存查看发送的请求信息
请求的地址是:http://localhost:8080/employee
发送的数据是json类型的
{name: "杨", phone: "15831000007", sex: "1", idNumber: "130093131231231237", username: "123142341"}
后端Controller接收提交的数据,调用Service将数据进行保存
Service调用Mapper操作数据库把信息添加到employee表(username字段是unique类型的,status字段有默认值1)
然后发送给前端
前端传过来的数据跟我们已有的pojo的参数有太大差距,所以我们专门设计DTO用来接收前端传过来的数据【这里资料已经给了,所以就不再自己创建了】
编写添加员工的Controller方法
前端使用post方式,所以加@PostMapping注解
前端传过来的数据类型是json,所以使用@RequestBody注解
编写Service接口和Impl类
- 因为传过来的是DTO,所以可以使用BeanUtils的copyProperties方法,将已有的属性复制到Bean中
- 其他的属性使用set方法手动设置【设置status、password、更新时间、修改时间、创建者】
- 然后调用Mapper的方法
编写Mapper接口【因为这是一条简单sql,所以直接使用注解,不用再映射文件中写了】
直接使用Swagger接口文档测试就可以
问题:
- 如果录入用户名已经存在,我们没有处理
- 创建人id没有处理
用来解决没有处理的异常信息【项目中这个类已经创建,直接写方法就可以】
common包里创建一个全集异常处理器
加上@ControllerAdvice
注解,使用annotations
属性指定拦截注解
@ControllerAdvice(annotations={RestController.class,Controller.class})//表示会拦截这两个注解
加上@ResponseBody
注解【因为添加用户失败会返回json数据】
编写一个方法,返回R对象
方法上加上@ExceptionHandler
注解,里面是异常的class对象
@ExceptionHandler(SQLIntegrityConstraintViolationException.class)//表示处理的是这个异常【控制台有】
方法的参数是异常类
异常类的getMessage方法可以显示异常信息,所以使用下面的操作
//判断异常信息【控制台:后面的部分】是否包含Duplicate entry这个
//如果包含说明添加的数据已经存在
//错误信息的第三部分是说那个key已经存在
//所以将错误信息拼接起来,返回了
if(ex.getMessage().contains("Duplicate entry")){
String[] split = ex.getMessage().split(" ");//以空格分隔
String msg = split[2] + "已存在";
return R.error(msg);
}
如果是其他错误直接返回R.error(“未知错误”)
ThreadLocal不是一个Thread,而是Thread的局部变量
ThreadLocal为每个线程单独提供一个存储空间,具有线程隔离效果,只有在线程内才能获取到对应的值,线程外则不能访问。
客户端每一次访问,tomacat都会开辟一个线程
所以当我们保证在线程的生命周期只内,能访问到,就可以把值放到里面。
常用方法有下面这些,但是通常还需要进一步封装
因为传过来了三个参数,所以封装到DTO中【资料中已经封装好了】
返回类型是Result
Result是我们自己写的类,它的参数和上面接口中需要的数据正好对应【code、msg、data】
PageResult也是我们封装的,有两个参数records和total,分别对应前端接口中data的数据
添加注解@GetMapping注解
接收对象是DTO
应为使用的是Get方式,参数以query方式【也就是最常见的Get方式】传过来的,因此数据名和参数名一样可以自动封装
编写操作,返回Result,参数是PageResult
编写Service
使用自动分页插件pagehelper【依赖已经导入】
调用Pagehelper.startPage方法【会动态将limit拼接到sql中】,不需要创建对象
然后执行Mapper中的查询方法,返回类型必须是Page【pagehelper要求的】
然后返回PageResult(total,records)【这两个参数Page对象可以提供】
编写Mapper
- 因为使用了动态SQL,所以将sql写到映射文件中
- 映射文件地址yaml中配置了
- 这里可以使用模糊查询
- 最好设置排序
如果测试不成功,可能是token过期了
调试发现日期格式不太正确
完善时间格式
解决方式:
方式一:属性上加上注解,对日期进行格式化【缺点只能处理这一个属性】
@JsonFormat(pattern ="yyyy-MM-dd HH:mm:ss")
方式二:在WebMvcConfiguration中扩展SpringMVC的消息转换器,统一对日期类型进行格式化处理
//这一部分是瑞吉外卖的,和这个是一个解决方法
问题:员工的id是Long型的19位数字,但是js只能保存16位,所以造成js给我们传过来的用户id不正确
解决方法:将Long型的数据统一转换为String字符串
解决原理:后端给前端响应数据的时候会使用到Spring MVC的消息转换器,所以我们扩展一个消息转换器,在这个消息转换器中在java转json中进行统一处理。
处理的时候会调用对象转换器JacksonObjectMapper【不需要自己手写,资料里给了】,这个对象转换器底层基于jackson
注:这个貌似是StringRedisTemplate转json用到的
拷贝JacksonObjectMapper代码到common包中【工程里有了】
在WebMvcConfig中重写extendMessageConverters类
/**
* 扩展mvc框架的消息转换器
* @param converters
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建消息转换器对象
MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
//设置对象转换器,底层使用Jackson将Java对象转为json
messageConverter.setObjectMapper(new JacksonObjectMapper());
//将上面的消息转换器对象追加到mvc框架的转换器集合中
converters.add(0,messageConverter);//放到第一个使用
}
然后就可以直接使用了
请求参数status是根据路径传过来的/admin/employee/status/{status}
请求参数id是query方法直接拼接到地址栏的
所以添加注解@PostMapping(“/admin/employee/status/{status}”)
参数列表使用@PathVariable取出status,另一个可以直接注入
如果参数名和地址栏中的不一样,就需要PathVariable注解有值
编写Service接口和编写impl类
编写Mapper,因为想做成一个通用的修改所以使用动态SQL,SQL语句就在映射文件中写
创建一个根据id获取员工信息的方法
使用@GetMapping注解,因为参数直接在地址里面了,所以使用占位符的方法
@GetMapping("/{id}")
方法的参数列表使用@PathVariable注解,因为参数直接在地址中了
编写代码
/**
* 根据id查询员工信息
* @param id
* @return
*/
@GetMapping("/{id}")
public R<Employee> getById(@PathVariable Long id){
log.info("根据id查询员工信息...");
Employee employee = employeeService.getById(id);
if(employee != null){
return R.success(employee);
}
return R.error("没有查询到对应员工信息");
}
然后编写更新方法,使用@PutMapping,不需要值【因为请求地址就是Controller的请求地址】
然后将EmployeeDTO的值传给Employee对象,然后设置修改时间、id等
然后调用上面写的update方法
说明:因为这一部分操作和员工操作类似,所以就直接导入了。后面可以自己练手
很多表都有下面的公共字段,例如:管理员工、管理菜品
如果后期需要修改,就会很麻烦,所以要完善这一部分
先明确操作类型,然后使用AOP,给这几个操作
技术点:枚举、注解、AOP、反射
自定义AutoFill注解【只用来标识,标识那些类需要自动填充】
创建annotaion包用来存放自定义注解,然后创建AutoFill注解
然后添加注解@Target(ElementType.METHOD)用来说明注解是加到方法上的
然后添加@Retention(RetentionPolicy.RUNTIME)注解
在注解里面指定当前数据库操作类型【可以使用枚举】【定义了之后参数列表的value就可以使用枚举类的值】
OperationType value();//自定义的枚举类型【里面是update和insert】
自定义切面类AutoFillAspect
创建aspect包,用于存放切面类,然后创建AutoFillAspect类
添加注解@Component、@Aspect
使用注解的方式创建切入点
/** *切入点 */ //&&前面一部分是说明拦截哪些方法 //后面这一部分是说明拦截哪个注解 //&& 说明这连个条件都需要成立 @PonitCut("execution(* com.sky.mapper.*.*(..)) && @annotation(come.sky.annotaion.AutoFill)") public void autoFillPoint(){ } /** *前置通知 */ @Before("autoFillPoint")//标识增强哪个方法 public void autoFill(JoinPoint joinPoint){ //获取拦截方法的数据库操作类型【更新还是插入】 MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象 AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//通过方法签名对象可以获得方法上的注解对象 OperationType operationType = autoFill.value();//通过注解对象的value属性可以获得数据库操作类型 //获取拦截方法的参数类型 Object[] args = joinPoint.getArgs();//获取拦截方法的所有参数 if(args == null || args.length == 0){//判断是否有参数 return; } Object entity = args[0];//我们约定第一个参数是employee,所以直接获取第一个参数 //使用反射根据数据库操作类型赋予不同的值 //准备赋值的数据 LocalDateTime now = LocalDateTime.now(); Long currentId = BaseContext.getCurrentId(); if(operationType == OperationType.INSERT){ //为4个公共字段赋值 try { //使用反射获取set方法的Method对象 //为了防止set方法名写错,所以参数列表使用了常量 Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class); Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class); Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class); //通过反射为对象属性赋值 setCreateTime.invoke(entity,now); setCreateUser.invoke(entity,currentId); setUpdateTime.invoke(entity,now); setUpdateUser.invoke(entity,currentId); } catch (Exception e) { e.printStackTrace(); } }else if(operationType == OperationType.UPDATE){ //为2个公共字段赋值 try { Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class); Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class); //通过反射为对象属性赋值 setUpdateTime.invoke(entity,now); setUpdateUser.invoke(entity,currentId); } catch (Exception e) { e.printStackTrace(); } } }
给需要操作的Mapper中的方法添加@AutoFill注解
逻辑外键:数据库中并没有设置,而是我们java代码自己处理
根据类型分类查询已经导入了,所以直接使用即可
导入阿里云oss的依赖【官网有使用说明】
创建一个CommonController【作为通用Controller】
添加注解@RespController、@RequestMapping(“/admin/common”)
编写文件上传方法,参数类型是MultpartFile
添加@PostMapping注解【文件上传必须是post方式】
编写配置属性类,然后再springboot的配置文件中配置【配置oss的地址等】
编写配置类,管理阿里云工具类的创建【方法上除了加@Bean注解,还可以加@ConditionalOnMissingBean注解,当没有这个对象的时候才创建】
/**
* 通用接口
*/
@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {
@Autowired
private AliOssUtil aliOssUtil;
/**
* 文件上传
* @param file
* @return
*/
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file){
log.info("文件上传:{}",file);
try {
//原始文件名
String originalFilename = file.getOriginalFilename();
//截取原始文件名的后缀 dfdfdf.png
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
//构造新文件名称
String objectName = UUID.randomUUID().toString() + extension;
//文件的请求路径
String filePath = aliOssUtil.upload(file.getBytes(), objectName);
return Result.success(filePath);
} catch (IOException e) {
log.error("文件上传失败:{}", e);
}
return Result.error(MessageConstant.UPLOAD_FAILED);
}
}
//ServiceImpl
@Service
@Slf4j
public class DishServiceImpl implements DishService {
@Autowired
private DishMapper dishMapper;
@Autowired
private DishFlavorMapper dishFlavorMapper;
/**
* 新增菜品和对应的口味
*
* @param dishDTO
*/
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
//向菜品表插入1条数据
dishMapper.insert(dish);//后绪步骤实现
//获取insert语句生成的主键值
Long dishId = dish.getId();
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dishId);
});
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);//后绪步骤实现
}
}
}
DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.DishMapper">
//useGeneratedKeys:表示需要自增id(可以把自增的主键返回注入到原对象中)
//keyProperty:表示需要哪个自增属性
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into dish (name, category_id, price, image, description, create_time, update_time, create_user,update_user, status)
values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}, #{status})
insert>
mapper>
<select id="pageQuery" resultType="com.sky.vo.DishVO">
select d.* , c.name as categoryName from dish d left outer join category c on d.category_id = c.id
<where>
<if test="name != null">
and d.name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and d.category_id = #{categoryId}
</if>
<if test="status != null">
and d.status = #{status}
</if>
</where>
order by d.create_time desc
</select>
/**
* 菜品批量删除
*
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("菜品批量删除")
public Result delete(@RequestParam List<Long> ids) {
log.info("菜品批量删除:{}", ids);
dishService.deleteBatch(ids);//后绪步骤实现
return Result.success();
}
@Autowired
private SetmealDishMapper setmealDishMapper;
/**
* 菜品批量删除
*
* @param ids
*/
@Transactional//事务
public void deleteBatch(List<Long> ids) {
//判断当前菜品是否能够删除---是否存在起售中的菜品??
for (Long id : ids) {
Dish dish = dishMapper.getById(id);//后绪步骤实现
if (dish.getStatus() == StatusConstant.ENABLE) {
//当前菜品处于起售中,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
//判断当前菜品是否能够删除---是否被套餐关联了??
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && setmealIds.size() > 0) {
//当前菜品被套餐关联了,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
//删除菜品表中的菜品数据
for (Long id : ids) {
dishMapper.deleteById(id);//后绪步骤实现
//删除菜品关联的口味数据
dishFlavorMapper.deleteByDishId(id);//后绪步骤实现
}
}
DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.SetmealDishMapper">
<select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
select setmeal_id from setmeal_dish where dish_id in
<foreach collection="dishIds" item="dishId" separator="," open="(" close=")">
#{dishId}
foreach>
select>
mapper>
就是常规操作
因为口味需要判断加没加,操作很复杂,所以口味那里先删除,然后再根据传过来的数据插入
导入Redis的依赖
yaml中配置Redis的数据源
编写配置类【用来管理redisTemplate对象】
@Configuration
public class RedisConfig {
/**
* redis序列化的工具配置类,下面这个请一定开启配置
* 127.0.0.1:6379> keys *
* 1) "ord:102" 序列化过
* 2) "\xac\xed\x00\x05t\x00\aord:102" 野生,没有序列化过
* this.redisTemplate.opsForValue(); //提供了操作string类型的所有方法
* this.redisTemplate.opsForList(); // 提供了操作list类型的所有方法
* this.redisTemplate.opsForSet(); //提供了操作set的所有方法
* this.redisTemplate.opsForHash(); //提供了操作hash表的所有方法
* this.redisTemplate.opsForZSet(); //提供了操作zset的所有方法
* @param lettuceConnectionFactory
* @return
*/
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory connectionFactory){
// 准备RedisTemplate对象
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
// 设置连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 设置key的序列化
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
// 设置value的序列化
redisTemplate.setValueSerializer(jsonRedisSerializer);
redisTemplate.setHashValueSerializer(jsonRedisSerializer);
// 返回
return redisTemplate;
}
}
测试一下是否可以使用
因为店铺营业状态只有两种,单独设置一张sql表单很浪费,所以设置到Redis中
HttpClient是客户端编程的工具包【客户端发请求给服务器】
使用HttpClient可以通过java构建、发送HTTP请求
- HttpClient:可以发送HTTP请求
- HttpClients:使用它可以创建HTTP对象
- CloseableHttpClient:是HttpClient的具体实现类
- HttpGet:Get方式的请求
- HttpPost:Post方式的请求
- 创建HttpClient对象
- 创建HTTP请求对象【GET方式就创建GET对象、Post方式就创建Post对象】
- 调用HttpClient的execute方法发送请求
导入依赖
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpclientartifactId>
<version>4.5.13version>
dependency>
使用Get方式发送
public class HttpClientTest {
/**
* 使用HttpClient发送get请求
*/
@Test
public void testGetMethod() throws IOException {
//创建httpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
//创建请求对象,参数是请求的地址
HttpGet httpGet = new HttpGet("http://localhost:8080/user/shop/status");
//发送请求,接收响应结果
CloseableHttpResponse response = httpClient.execute(httpGet);
//获取响应状态码
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("响应状态码为:"+statusCode);
//获取响应体
HttpEntity entity = response.getEntity();
String body = EntityUtils.toString(entity);//通过工具类解析响应体
System.out.println("响应体:"+body);
//关闭资源
response.close();
httpClient.close();
}
}
使用Post方式发送【需要发送参数,所以封装到接送中】
public void testPostMethod() throws JSONException, IOException {
//创建httpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
//创建post对象,参数是请求地址
HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");
//因为需要发送json类型的参数,所以把数据封装到json对象中
JSONObject jsonObject = new JSONObject();//fastJson提供的
jsonObject.put("username","admin");
jsonObject.put("password","123456");
StringEntity stringEntity = new StringEntity(jsonObject.toString());
//指定请求编码方式
stringEntity.setContentEncoding("utf-8");
//设置可以接收的数据格式
stringEntity.setContentType("application/json");
httpPost.setEntity(stringEntity);
//发送请求,获取响应结果
CloseableHttpResponse response = httpClient.execute(httpPost);
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("响应状态码为:"+statusCode);
HttpEntity entity = response.getEntity();
String s = EntityUtils.toString(entity);
System.out.println("响应体为:"+s);
}
为了方便开发,已经封装了一个工具类
注册地址:https://mp.weixin.qq.com/wxopen/waregister?action=step1
微信开发者工具下载地址与更新日志 | 微信开放文档 (qq.com)
需要设置不校验合法域名
一个小程序主体部分由三个文件组成,必须放在项目的根目录下
一个小程序页面由四个文件组成
开发者工具中点击上传
然后到开发者后台网页,提交审核就就能发布上线
common
中的vendor.js
把baseUrl
改为自己的后端ipwx.login
方法获取code
wx.request
方法带着code请求后端appid
、appsecret
、code
】在配置文件中配置小程序信息的配置项【appid、secret】
sky:
wechat:
appid: wxffb3637a228223b8
secret: 84311df9199ecacdf4f12d27b6b9522d
配置为微信用户生成jwt令牌的配置项
sky:
jwt:
# 设置jwt签名加密时使用的秘钥
admin-secret-key: itcast
# 设置jwt过期时间
admin-ttl: 7200000
# 设置前端传递过来的令牌名称
admin-token-name: token
# 设置jwt签名加密时使用的秘钥
user-secret-key: itheima
# 设置jwt过期时间
user-ttl: 7200000
# 设置前端传递过来的令牌名称
user-token-name: authentication
编写用户接口,返回值是UserLoginVo
编写service
- 封装数据发送给微信接口
- 解析微信接口发过来的数据
- 如果openid是空的那么登录失败
- 如果不是空的,看openid是否在数据库中
- 不在数据库中表示是新用户,那么保存到数据库中
- 在数据库中表示是老用户
- 返回VO
编写Mapper【查询用户的openid是否在数据库中、添加用户】
编写用户操作的拦截器
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login")
.addPathPatterns("/user/**").
excludePathPatterns("/user/user/login").
excludePathPatterns("/user/shop/status");
}
如果客户访问量大的时候,数据库访问压力增大,导致用户体验差
通过Redis缓存菜品数据,减少数据库查询操作
将list集合序列化成String
数据库中的餐品数据有变更的时候,要清空Redis中的数据【例如:管理员添加菜品、修改菜品、删除菜品】
直接改造已有的用户端查询菜品方法就可以(DishController中的list方法)
新增菜品、修改菜品、删除菜品、起售停售菜品都需要清理缓存
根据客户端传过来的id,查询Redis,返回菜单列表list
如果list不为空,则菜品存在直接返回数据,无需查询数据库
java后端给Redis放进去什么类型,取出来就是什么类型(强转就可以不需要序列化了)
如果list为空,则缓存中没有,查询数据库,并将数据放到Redis中
返回数据
将数据放到Redis的时候,说序列化异常,把序列化配置中的下面这段代码注释掉就可以了,具体为啥不知道
redisTemplate.setValueSerializer(jsonRedisSerializer);
redisTemplate.setHashValueSerializer(jsonRedisSerializer);
是一个框架,实现了基于注解的缓存功能,只需要加一个注解,就能实现缓存功能。
提供了一层抽象,底层可以千幻不同的缓存实现,例如:EHCache、Caffeine、Redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>2.7.3</version>
</dependency>
注解 | 说明 |
---|---|
@EnableCaching | 开启缓存注解功能,通常加在启动类上 |
@Cacheable | 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中 |
@CachePut | 将方法的返回值放到缓存中 |
@CacheEvict | 将一条或多条数据从缓存中删除 |
给启动类加上EnableCaching注解【说明开启注解缓存功能】
给Controller方法加上对应的注解
例如:保存用户功能,不需要查询是否有没有,所以可以加上@CachePut注解
设置注解的参数
cacheNames:表示key的前缀
key:表示后缀
那么存在Redis中的格式就是
cacheNames::key
例如:@CachePut(cacheNames=“user”,key=“abc”)
那么Redis中的key就是user::abc
如果需要动态设置key属性的值那么就使用#
例如:@CachePut(cacheNames=“user”,key=“#user.id”)那么就会动态获取到id
这里key属性中的user是被修饰的方法中参数的名
如果key属性的值需要根据返回值的设置,那么就是用#result
例如:@CachePut(cacheNames=“user”,key=“#result.id”)那么就会动态获取到返回值对象的id
说明:#result会得到返回值
说明:Cacheable不能用#result
说明:CacheEvict可以删除单个键,也可以批量删除键
单个删除就是正常的方法,批量删除就需要设置allEntries,那么就会删除所有cacheNames
例如:@CacheEvict(cacheNames=“user”,allEntries=true)那么就会删除所有以user开头的
创建购物车controller【添加相关注解】
创建添加购物车方法【添加注解,方法参数是ShoppingCartDTO】
创建购物车Service,添加方法
创建Service实现类
先判断商品是否在购物车,如果不在就添加商品,如果在商品数量加一
创建Mapper接口
创建映射文件,编写动态sql
用户id通过ThreadLocal获得
作业
- 列表查询地址功能
- 设为默认地址功能
- 新增地址功能
- 删除地址功能
- 修改地址功能
- 查询默认地址
因为都是单表查询,没有复杂业务需求,所以代码直接导入
接收前端信息使用OrderSubmintDTO,返回给前端数据使用OrderSubmitVo
创建Controller
处理各种原因的异常【地址薄为空、购物车商品为空】
向订单表插入1条数据
根据返回的id,向订单明细表插入N条数据
然后清空购物车
然后清空当前用户的购物车数据
创建Service
创建Mapper以及映射文件
由于这部分代码是固定的,而且个人账号无法使用微信支付,所以这部分直接导入就好
这两个文件都是微信支付给商户提供的,个人无法获得
解决个人测试的时候,微信无法访问我们本机IP的问题
安装cpolar:cpolar - 安全的内网穿透工具
在安装目录执行命令
然后执行下面的命令
说明:yaml中能有这些属性,是因为定义了配置类 【里面的都是别人的数据,需要改成自己的】
sky:
wechat:
appid: wxcd2e39f677fd30ba
secret: 84fbfdf5ea288f0c432d829599083637
mchid : 1561414331
mchSerialNo: 4B3B3DC35414AD50B1B755BAF8DE9CC7CF407606
privateKeyFilePath: D:\apiclient_key.pem
apiV3Key: CZBK51236435wxpay435434323FFDuv3
weChatPayCertFilePath: D:\wechatpay_166D96F876F45C7D07CE98952A96EC980368ACFC.pem
notifyUrl: https://www.weixin.qq.com/wxpay/pay.php
refundNotifyUrl: https://www.weixin.qq.com/wxpay/pay.php
/**
* 订单支付
*
* @param ordersPaymentDTO
* @return
*/
@PutMapping("/payment")
@ApiOperation("订单支付")
public Result<OrderPaymentVO> payment(@RequestBody OrdersPaymentDTO ordersPaymentDTO) throws Exception {
log.info("订单支付:{}", ordersPaymentDTO);
OrderPaymentVO orderPaymentVO = orderService.payment(ordersPaymentDTO);
log.info("生成预支付交易单:{}", orderPaymentVO);
return Result.success(orderPaymentVO);
}
/**
* 订单支付
* @param ordersPaymentDTO
* @return
*/
OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception;
/**
* 支付成功,修改订单状态
* @param outTradeNo
*/
void paySuccess(String outTradeNo);
@Autowired
private UserMapper userMapper;
@Autowired
private WeChatPayUtil weChatPayUtil;
/**
* 订单支付
*
* @param ordersPaymentDTO
* @return
*/
public OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception {
// 当前登录用户id
Long userId = BaseContext.getCurrentId();
User user = userMapper.getById(userId);
//调用微信支付接口,生成预支付交易单
JSONObject jsonObject = weChatPayUtil.pay(
ordersPaymentDTO.getOrderNumber(), //商户订单号
new BigDecimal(0.01), //支付金额,单位 元
"苍穹外卖订单", //商品描述
user.getOpenid() //微信用户的openid
);
if (jsonObject.getString("code") != null && jsonObject.getString("code").equals("ORDERPAID")) {
throw new OrderBusinessException("该订单已支付");
}
OrderPaymentVO vo = jsonObject.toJavaObject(OrderPaymentVO.class);
vo.setPackageStr(jsonObject.getString("package"));
return vo;
}
/**
* 支付成功,修改订单状态
*
* @param outTradeNo
*/
public void paySuccess(String outTradeNo) {
// 当前登录用户id
Long userId = BaseContext.getCurrentId();
// 根据订单号查询当前用户的订单
Orders ordersDB = orderMapper.getByNumberAndUserId(outTradeNo, userId);
// 根据订单id更新订单的状态、支付方式、支付状态、结账时间
Orders orders = Orders.builder()
.id(ordersDB.getId())
.status(Orders.TO_BE_CONFIRMED)
.payStatus(Orders.PAID)
.checkoutTime(LocalDateTime.now())
.build();
orderMapper.update(orders);
}
/**
* 根据订单号和用户id查询订单
* @param orderNumber
* @param userId
*/
@Select("select * from orders where number = #{orderNumber} and user_id= #{userId}")
Orders getByNumberAndUserId(String orderNumber, Long userId);
/**
* 修改订单信息
* @param orders
*/
void update(Orders orders);
<update id="update" parameterType="com.sky.entity.Orders">
update orders
<set>
<if test="cancelReason != null and cancelReason!='' ">
cancel_reason=#{cancelReason},
if>
<if test="rejectionReason != null and rejectionReason!='' ">
rejection_reason=#{rejectionReason},
if>
<if test="cancelTime != null">
cancel_time=#{cancelTime},
if>
<if test="payStatus != null">
pay_status=#{payStatus},
if>
<if test="payMethod != null">
pay_method=#{payMethod},
if>
<if test="checkoutTime != null">
checkout_time=#{checkoutTime},
if>
<if test="status != null">
status = #{status},
if>
<if test="deliveryTime != null">
delivery_time = #{deliveryTime}
if>
set>
where id = #{id}
update>
package com.sky.controller.notify;
import com.alibaba.druid.support.json.JSONUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.sky.annotation.IgnoreToken;
import com.sky.properties.WeChatProperties;
import com.sky.service.OrderService;
import com.wechat.pay.contrib.apache.httpclient.util.AesUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.entity.ContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
/**
* 支付回调相关接口
*/
@RestController
@RequestMapping("/notify")
@Slf4j
public class PayNotifyController {
@Autowired
private OrderService orderService;
@Autowired
private WeChatProperties weChatProperties;
/**
* 支付成功回调
*
* @param request
*/
@RequestMapping("/paySuccess")
public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
//读取数据
String body = readData(request);
log.info("支付成功回调:{}", body);
//数据解密
String plainText = decryptData(body);
log.info("解密后的文本:{}", plainText);
JSONObject jsonObject = JSON.parseObject(plainText);
String outTradeNo = jsonObject.getString("out_trade_no");//商户平台订单号
String transactionId = jsonObject.getString("transaction_id");//微信支付交易号
log.info("商户平台订单号:{}", outTradeNo);
log.info("微信支付交易号:{}", transactionId);
//业务处理,修改订单状态、来单提醒
orderService.paySuccess(outTradeNo);
//给微信响应
responseToWeixin(response);
}
/**
* 读取数据
*
* @param request
* @return
* @throws Exception
*/
private String readData(HttpServletRequest request) throws Exception {
BufferedReader reader = request.getReader();
StringBuilder result = new StringBuilder();
String line = null;
while ((line = reader.readLine()) != null) {
if (result.length() > 0) {
result.append("\n");
}
result.append(line);
}
return result.toString();
}
/**
* 数据解密
*
* @param body
* @return
* @throws Exception
*/
private String decryptData(String body) throws Exception {
JSONObject resultObject = JSON.parseObject(body);
JSONObject resource = resultObject.getJSONObject("resource");
String ciphertext = resource.getString("ciphertext");
String nonce = resource.getString("nonce");
String associatedData = resource.getString("associated_data");
AesUtil aesUtil = new AesUtil(weChatProperties.getApiV3Key().getBytes(StandardCharsets.UTF_8));
//密文解密
String plainText = aesUtil.decryptToString(associatedData.getBytes(StandardCharsets.UTF_8),
nonce.getBytes(StandardCharsets.UTF_8),
ciphertext);
return plainText;
}
/**
* 给微信响应
* @param response
*/
private void responseToWeixin(HttpServletResponse response) throws Exception{
response.setStatus(200);
HashMap<Object, Object> map = new HashMap<>();
map.put("code", "SUCCESS");
map.put("message", "SUCCESS");
response.setHeader("Content-type", ContentType.APPLICATION_JSON.toString());
response.getOutputStream().write(JSONUtils.toJSONString(map).getBytes(StandardCharsets.UTF_8));
response.flushBuffer();
}
}
SpringTask是Spring框架提供的任务调用工具,可以按照约定的时间自动执行某个代码逻辑
作用:定时自动执行某段Java代码
应用场景:
本质就是一个字符串,通过cron表达式可以定义任务触发的事件
构成规则:分为6或7个域,由空格分隔开,每个域代表一个含义
每个域的含义分别为:秒、分钟、小时、日、月、周【周几】、年【可选】
不指定的位置用?
表达,每个用*
表达
因为有很多表达式很复杂,所以不需要我们自己去写,直接去网上生成即可:https://cron.qqe2.com/
SpringTask没有自己的依赖,只要导入了start依赖,那么他的包就引入了
启动类上加上==
@EnableScheduling
==注解开启任务调度
创建一个类,加上@Component注解
定义一个方法,没有返回值,方法名随意
方法上添加@Scheduled注解,在cron属性中写cron表达式
@Scheduled(cron = "0/5 * * * * ?")//cron里面式cron表达式
用户下了订单,长时间没支付,直到订单超时,那么超时的订单我们怎么让系统自动处理
还有一种就是长时间没有点击送达的订单,怎么自动处理
说明:为什么每天一点才检查,因为我们的项目是专门为某个店铺定制的,一点的时候店铺早就打烊了,所以直接改订单状态对我们的订单没有影响
@Scheduled(cron = "0 * * * * ?")//1分钟检查一次是否有超时未支付订单
public void orderTime(){
log.info("开始处理超时订单");
//根据当前时间查询数据库
Orders orders = new Orders();
//设置查询条件
orders.setOrderTime(LocalDateTime.now().plusMinutes(-15));//当前时间的前15分钟
orders.setPayStatus(0);
orders.setStatus(1);
List<Orders> list=ordersMapper.searchNoPay(orders);
//将查询出来的list集合便遍历
if (list!=null && list.size()>0){
for (Orders o:list){
//修改订单的状态为取消
o.setStatus(6);
o.setPayStatus(2);
o.setCancelReason("用户支付超时");
o.setCancelTime(LocalDateTime.now());
ordersMapper.cancel(o);
}
}
}
/**
* 处理超时送达订单
*/
@Scheduled(cron = "0 0 1 * * ? *")//每天凌晨1点处理未确认收获订单
public void orderSend(){
log.info("开始处理超时送达订单");
//根据当前时间查询数据库
Orders orders = new Orders();
orders.setStatus(4);
List<Orders> list=ordersMapper.serchNoDelivery(orders);
//将查询出来的list集合便遍历
if (list!=null && list.size()>0){
for (Orders o:list){
o.setStatus(5);
//修改订单的状态为送达
ordersMapper.updateStatusById(o);
}
}
}
Web Scocket是基于TCP的一种新的网络协议,它实现了浏览器与服务器全双工通信,浏览器和服务器只需要完成一次握手,两者之间就可以**创建持久性的连接,并进行双向数据传输**
HTTP和WebSocket的对比 | |
---|---|
HTTP协议 | 客户端先发起请求,服务端才能响应。一次请求只能相应一次【单向、短链接、底层是TCP连接】 |
WebSocket协议 | 客户端和服务器握手之后,服务端和客户端可以持久双向通信【双向、长连接、底层是TCP连接】 |
应用场景【需要实时更新的业务】:
- 视频弹幕
- 网页聊天
- 体育实况更新
- 股票基金报价实时更新
要求浏览器能够发送websocket连接
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-websocketartifactId>
dependency>
这是WebSocket的服务组件,用户和客户端通信
/**
* WebSocket服务
*/
@Component
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {
//存放会话对象
private static Map<String, Session> sessionMap = new HashMap();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
System.out.println("客户端:" + sid + "建立连接");
sessionMap.put(sid, session);
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, @PathParam("sid") String sid) {
System.out.println("收到来自客户端:" + sid + "的信息:" + message);
}
/**
* 连接关闭调用的方法
*
* @param sid
*/
@OnClose
public void onClose(@PathParam("sid") String sid) {
System.out.println("连接断开:" + sid);
sessionMap.remove(sid);
}
/**
* 群发
*
* @param message
*/
public void sendToAllClient(String message) {
Collection<Session> sessions = sessionMap.values();
for (Session session : sessions) {
try {
//服务器向客户端发送消息
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
用于注册WebSocket的服务端组件
/**
* WebSocket配置类,用于注册WebSocket的Bean
*/
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
需要提前注入WebSocketServer对象
如果前端需要json类型的,可以使用JSON.toJSONString把数据转成Sting类型的json字符串
在用户付款成功方法中完善业务【用户订单方法需要提前注入webSocketServer对象】
Orders ordersDB = ordersMapper.getByNumberAndUserId(ordersSubmitDTO.getOrderNumber());
//用户下单之后调用websocket发送信息到后台管理端处理订单
Map map=new HashMap();
//下面的这三个数据是这个项目和前端约定好的
map.put("type",1);
map.put("orderId", ordersDB.getId());
map.put("content", "订单号:" + ordersDB.getNumber());
String s = JSON.toJSONString(map);
webSocketServer.sendToAllClient(s);
默认Web Socket的后端配置已经完成
/**
* 用户催单
* @param id
*/
@Override
public void reminder(Long id) {
//已经传过来菜品的id了,直接崔就行了
// 查询订单是否存在
Orders orders = ordersMapper.getById(id);
if (orders == null) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
}
//基于WebSocket实现催单
Map map = new HashMap();
map.put("type", 2);//2代表用户催单
map.put("orderId", id);
map.put("content", "订单号:" + orders.getNumber());
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}
Apache ECharts是一款基于 Javascript 的数据可视化图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表。
官网地址:https://echarts.apache.org/zh/index.htm
后端了解就可以,这是一个前端的奇数,我们知道怎么响应数据就可以
图形的中类:柱状图、折线图、饼形图
使用Echarts,重点在于研究当前图表所需的数据格式。通常是需要后端提供符合格式要求的动态数据,然后响应给前端来展示图表。
Apache Echarts官方提供的快速入门:https://echarts.apache.org/handbook/zh/get-started/
起始日期和结束日期前端已经传过来了,后天只需要返回一个日期字符串、营业额字符串
方法跟其他的代码一样,只不过业务逻辑是根据传过来的日期范围直接计算出日期集合,然后循环日期集合查询数据库,查询这一天已完成订单的总营业额
/**
* 报表
*/
@RestController
@RequestMapping("/admin/report")
@Slf4j
@Api(tags = "统计报表相关接口")
public class ReportController {
@Autowired
private ReportService reportService;
/**
* 营业额数据统计
*
* @param begin
* @param end
* @return
*/
@GetMapping("/turnoverStatistics")
@ApiOperation("营业额数据统计")
public Result<TurnoverReportVO> turnoverStatistics(
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate end) {
return Result.success(reportService.getTurnover(begin, end));
}
}
@Service
@Slf4j
public class ReportServiceImpl implements ReportService {
@Autowired
private OrderMapper orderMapper;
/**
* 根据时间区间统计营业额
* @param begin
* @param end
* @return
*/
public TurnoverReportVO getTurnover(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while (!begin.equals(end)){
begin = begin.plusDays(1);//日期计算,获得指定日期后1天的日期
dateList.add(begin);
}
List<Double> turnoverList = new ArrayList<>();
for (LocalDate date : dateList) {
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
Map map = new HashMap();
map.put("status", Orders.COMPLETED);
map.put("begin",beginTime);
map.put("end", endTime);
Double turnover = orderMapper.sumByMap(map);
turnover = turnover == null ? 0.0 : turnover;//如果这一天没有订单,就设置为0.0
turnoverList.add(turnover);
}
//数据封装
return TurnoverReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.turnoverList(StringUtils.join(turnoverList,","))
.build();
}
}
所谓用户统计,实际上统计的是用户的数量。通过折线图来展示,上面这根蓝色线代表的是用户总量,下边这根绿色线代表的是新增用户数量,是具体到每一天。所以说用户统计主要统计两个数据,一个是总的用户数量,另外一个是新增用户数量。
/**
* 用户数据统计
* @param begin
* @param end
* @return
*/
@GetMapping("/userStatistics")
@ApiOperation("用户数据统计")
public Result<UserReportVO> userStatistics(
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){
return Result.success(reportService.getUserStatistics(begin,end));
}
@Override
public UserReportVO getUserStatistics(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while (!begin.equals(end)){
begin = begin.plusDays(1);
dateList.add(begin);
}
List<Integer> newUserList = new ArrayList<>(); //新增用户数
List<Integer> totalUserList = new ArrayList<>(); //总用户数
for (LocalDate date : dateList) {
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
//新增用户数量 select count(id) from user where create_time > ? and create_time < ?
Integer newUser = getUserCount(beginTime, endTime);
//总用户数量 select count(id) from user where create_time < ?
Integer totalUser = getUserCount(null, endTime);
newUserList.add(newUser);
totalUserList.add(totalUser);
}
return UserReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.newUserList(StringUtils.join(newUserList,","))
.totalUserList(StringUtils.join(totalUserList,","))
.build();
}
/**
* 根据时间区间统计用户数量
* @param beginTime
* @param endTime
* @return
*/
private Integer getUserCount(LocalDateTime beginTime, LocalDateTime endTime) {
Map map = new HashMap();
map.put("begin",beginTime);
map.put("end", endTime);
return userMapper.countByMap(map);
}
订单统计通过一个折现图来展现,折线图上有两根线,这根蓝色的线代表的是订单总数,而下边这根绿色的线代表的是有效订单数,指的就是状态是已完成的订单就属于有效订单,分别反映的是每一天的数据。上面还有3个数字,分别是订单总数、有效订单、订单完成率,它指的是整个时间区间之内总的数据。
原型图:
业务规则:
/**
* 根据时间区间统计订单数量
* @param begin
* @param end
* @return
*/
public OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end){
List<LocalDate> dateList = new ArrayList<>();
dateList.add(begin);
while (!begin.equals(end)){
begin = begin.plusDays(1);
dateList.add(begin);
}
//每天订单总数集合
List<Integer> orderCountList = new ArrayList<>();
//每天有效订单数集合
List<Integer> validOrderCountList = new ArrayList<>();
for (LocalDate date : dateList) {
LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
//查询每天的总订单数 select count(id) from orders where order_time > ? and order_time < ?
Integer orderCount = getOrderCount(beginTime, endTime, null);
//查询每天的有效订单数 select count(id) from orders where order_time > ? and order_time < ? and status = ?
Integer validOrderCount = getOrderCount(beginTime, endTime, Orders.COMPLETED);
orderCountList.add(orderCount);
validOrderCountList.add(validOrderCount);
}
//时间区间内的总订单数
Integer totalOrderCount = orderCountList.stream().reduce(Integer::sum).get();
//时间区间内的总有效订单数
Integer validOrderCount = validOrderCountList.stream().reduce(Integer::sum).get();
//订单完成率
Double orderCompletionRate = 0.0;
if(totalOrderCount != 0){
orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
}
return OrderReportVO.builder()
.dateList(StringUtils.join(dateList, ","))
.orderCountList(StringUtils.join(orderCountList, ","))
.validOrderCountList(StringUtils.join(validOrderCountList, ","))
.totalOrderCount(totalOrderCount)
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.build();
}
/**
* 根据时间区间统计指定状态的订单数量
* @param beginTime
* @param endTime
* @param status
* @return
*/
private Integer getOrderCount(LocalDateTime beginTime, LocalDateTime endTime, Integer status) {
Map map = new HashMap();
map.put("status", status);
map.put("begin",beginTime);
map.put("end", endTime);
return orderMapper.countByMap(map);
}
所谓销量排名,销量指的是商品销售的数量。项目当中的商品主要包含两类:一个是套餐,一个是菜品,所以销量排名其实指的就是菜品和套餐销售的数量排名。通过柱形图来展示销量排名,这些销量是按照降序来排列,并且只需要统计销量排名前十的商品。
原型图:
业务规则:
/**
* 销量排名统计
* @param begin
* @param end
* @return
*/
@GetMapping("/top10")
@ApiOperation("销量排名统计")
public Result<SalesTop10ReportVO> top10(
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){
return Result.success(reportService.getSalesTop10(begin,end));
}
/**
* 查询指定时间区间内的销量排名top10
* @param begin
* @param end
* @return
* */
public SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end){
LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(end, LocalTime.MAX);
List<GoodsSalesDTO> goodsSalesDTOList = orderMapper.getSalesTop10(beginTime, endTime);
String nameList = StringUtils.join(goodsSalesDTOList.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList()),",");
String numberList = StringUtils.join(goodsSalesDTOList.stream().map(GoodsSalesDTO::getNumber).collect(Collectors.toList()),",");
return SalesTop10ReportVO.builder()
.nameList(nameList)
.numberList(numberList)
.build();
}
<!--查询订单完成的详情-->
<select id="getSalesTop10" resultType="com.sky.dto.GoodsSalesDTO">
select od.name name,sum(od.number) number from order_detail od ,orders o
where od.order_id = o.id
and o.status = 5
<if test="begin != null">
and order_time >= #{begin}
</if>
<if test="end != null">
and order_time <= #{end}
</if>
group by name
order by number desc
limit 0, 10
</select>
就是简单的增删改查,直接导入
Apache POI是一个处理office各种文件格式的开源项目,简单来说我们使用POI在java程序中对office文件进行各种读写操作
<dependency>
<groupId>org.apache.poigroupId>
<artifactId>poiartifactId>
<version>3.16version>
dependency>
<dependency>
<groupId>org.apache.poigroupId>
<artifactId>poi-ooxmlartifactId>
<version>3.16version>
dependency>
- 如果没有excel文件
- 在内存中创建excel文件
- 在excel文件中创建sheet页【可以设置名字】
- 在sheet中创建行对象【序号从0开始(0表示第一行)】
- 在行对象中创建单元格【序号从0开始】
- 将内存中的文件写入到硬盘上
//在内存中创建一个Excel文件对象
XSSFWorkbook excel = new XSSFWorkbook();
//创建Sheet页
XSSFSheet sheet = excel.createSheet("itcast");
//在Sheet页中创建行,0表示第1行
XSSFRow row1 = sheet.createRow(0);
//创建单元格并在单元格中设置值,单元格编号也是从0开始,1表示第2个单元格
row1.createCell(1).setCellValue("姓名");
row1.createCell(2).setCellValue("城市");
XSSFRow row2 = sheet.createRow(1);
row2.createCell(1).setCellValue("张三");
row2.createCell(2).setCellValue("北京");
XSSFRow row3 = sheet.createRow(2);
row3.createCell(1).setCellValue("李四");
row3.createCell(2).setCellValue("上海");
FileOutputStream out = new FileOutputStream(new File("D:\\itcast.xlsx"));
//通过输出流将内存中的Excel文件写入到磁盘上
excel.write(out);
//关闭资源
out.flush();
out.close();
excel.close();
- 读取磁盘上已经存在的文件,然后创建excel文件
- 获取sheet页对象
- 获取最后一行有文字的行号
- 然后循环遍历【获得行对象,然后获得单元格对象,然后获取单元格的内容】
FileInputStream in = new FileInputStream(new File("D:\\itcast.xlsx"));
//通过输入流读取指定的Excel文件
XSSFWorkbook excel = new XSSFWorkbook(in);
//获取Excel文件的第1个Sheet页
XSSFSheet sheet = excel.getSheetAt(0);
//获取Sheet页中的最后一行的行号
int lastRowNum = sheet.getLastRowNum();
for (int i = 0; i <= lastRowNum; i++) {
//获取Sheet页中的行
XSSFRow titleRow = sheet.getRow(i);
//获取行的第2个单元格
XSSFCell cell1 = titleRow.getCell(1);
//获取单元格中的文本内容
String cellValue1 = cell1.getStringCellValue();
//获取行的第3个单元格
XSSFCell cell2 = titleRow.getCell(2);
//获取单元格中的文本内容
String cellValue2 = cell2.getStringCellValue();
System.out.println(cellValue1 + " " +cellValue2);
}
//关闭资源
excel.close();
in.close();
- 开发步骤
- 电脑上先创建好excel的模板,放到程序里面
- 查询30天的运营数据
- 将查询到的运营数据写入模板
- 通过输出流将Excel文件下载到客户端浏览器
/**
* 导出运营数据报表
* @param response
*/
@GetMapping("/export")
@ApiOperation("导出运营数据报表")
public void export(HttpServletResponse response){
reportService.exportBusinessData(response);
}
/**导出近30天的运营数据报表
* @param response
**/
public void exportBusinessData(HttpServletResponse response) {
LocalDate begin = LocalDate.now().minusDays(30);
LocalDate end = LocalDate.now().minusDays(1);
//查询概览运营数据,提供给Excel模板文件
BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(begin,LocalTime.MIN), LocalDateTime.of(end, LocalTime.MAX));
InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
try {
//基于提供好的模板文件创建一个新的Excel表格对象
XSSFWorkbook excel = new XSSFWorkbook(inputStream);
//获得Excel文件中的一个Sheet页
XSSFSheet sheet = excel.getSheet("Sheet1");
sheet.getRow(1).getCell(1).setCellValue(begin + "至" + end);
//获得第4行
XSSFRow row = sheet.getRow(3);
//获取单元格
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(6).setCellValue(businessData.getNewUsers());
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getUnitPrice());
for (int i = 0; i < 30; i++) {
LocalDate date = begin.plusDays(i);
//准备明细数据
businessData = workspaceService.getBusinessData(LocalDateTime.of(date,LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX));
row = sheet.getRow(7 + i);
row.getCell(1).setCellValue(date.toString());
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(3).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(5).setCellValue(businessData.getUnitPrice());
row.getCell(6).setCellValue(businessData.getNewUsers());
}
//通过输出流将文件下载到客户端浏览器中
ServletOutputStream out = response.getOutputStream();
excel.write(out);
//关闭资源
out.flush();
out.close();
excel.close();
}catch (IOException e){
e.printStackTrace();
}
}