# 苍穹外卖fly_takeout **Repository Path**: DXZWalker/sky-takeout ## Basic Information - **Project Name**: 苍穹外卖fly_takeout - **Description**: 黑马苍穹外卖,java启蒙项目、注释详细、笔记 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 8 - **Created**: 2024-03-05 - **Last Updated**: 2024-03-05 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 苍穹外卖笔记(9.13已完结) gitee链接:[https://gitee.com/huiflydepaopao/sky-takeout](https://gitee.com/huiflydepaopao/sky-takeout) 模块概览: 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. 导出运营数据Excel报表 ## 一、登陆注册、员工管理、分类管理 ### 1. 登陆注册 登陆: Controller ``` @PostMapping("/login") @ApiOperation(value = "员工登录") public Result login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {//传进来一个json对象 //返回数据是什么类型,泛型就是什么类型 log.info("员工登录:{}", employeeLoginDTO); //日志级别:等级由低到高:debug claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims); EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder() .id(employee.getId()) .userName(employee.getUsername()) .name(employee.getName()) .token(token) .build(); return Result.success(employeeLoginVO); } ``` Service层 ``` public Employee login(EmployeeLoginDTO employeeLoginDTO) { String username = employeeLoginDTO.getUsername(); String password = employeeLoginDTO.getPassword(); //1、根据用户名查询数据库中的数据 Employee employee = employeeMapper.getByUsername(username); //2、处理各种异常情况(用户名不存在、密码不对、账号被锁定) if (employee == null) { //账号不存在 throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND); } //密码比对 //对前端传过来的明文密码进行md5加密处理 password = DigestUtils.md5DigestAsHex(password.getBytes()); if (!password.equals(employee.getPassword())) { //密码错误 throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR); } if (employee.getStatus() == StatusConstant.DISABLE) { //账号被锁定 throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED); } //3、返回实体对象 return employee; } ``` MD5加密: `password = DigestUtils.md5DigestAsHex(password.getBytes());//需要是Byte数组` JWT令牌: ``` Map claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID, employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims); ``` 创建和解析JWT令牌 ``` public static String createJWT(String secretKey, long ttlMillis, Map claims) { // 指定签名的时候使用的签名算法,也就是header那部分 SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 生成JWT的时间 long expMillis = System.currentTimeMillis() + ttlMillis; Date exp = new Date(expMillis); // 设置jwt的body JwtBuilder builder = Jwts.builder() // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的 .setClaims(claims) // 设置签名使用的签名算法和签名使用的秘钥 .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) // 设置过期时间 .setExpiration(exp); return builder.compact(); } /** * Token解密 * * @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个 * @param token 加密后的token * @return */ public static Claims parseJWT(String secretKey, String token) { // 得到DefaultJwtParser Claims claims = Jwts.parser() // 设置签名的秘钥 .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) // 设置需要解析的jwt .parseClaimsJws(token).getBody(); return claims; } ```` ### 2. 新增员工 ``` @PostMapping @ApiOperation("新增员工") public Result save(@RequestBody EmployeeDTO employeeDTO){ log.info("新增员工:{}",employeeDTO); employeeService.save(employeeDTO); return Result.success(); } ``` **对象属性拷贝**,把dto中的数据拷贝到实体中,需要保证属性名一致 `BeanUtils.copyProperties(employeeDTO, employee);` ``` //设置当前记录的创建时间和修改时间 employee.setCreateTime(LocalDateTime.now()); employee.setUpdateTime(LocalDateTime.now()); 对于LocalDataTime类型,可以直接调用LocalDateTime.now() //设置当前记录创建人id和修改人id //employee.setCreateUser(BaseContext.getCurrentId()); //employee.setUpdateUser(BaseContext.getCurrentId()); ``` ``` @Insert("insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user,status) " + "values " + "(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})") @AutoFill(value = OperationType.INSERT) void insert(Employee employee); ``` **开启驼峰命名法** ``` mybatis: #mapper配置文件 mapper-locations: classpath:mapper/*.xml type-aliases-package: com.sky.entity configuration: #开启驼峰命名 map-underscore-to-camel-case: true ``` **全局异常处理器**,遇到sql异常 ``` @RestControllerAdvice public class GlobalExceptionHandler{ @ExceptionHandler(Exception.class)//要捕获什么异常 public Result ex(Exveption ex){ ex.printStackTrace(); return Result.error("出现异常"); } } ``` ``` @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { /** * 捕获业务异常 * @param ex * @return */ @ExceptionHandler public Result exceptionHandler(BaseException ex){ log.error("异常信息:{}", ex.getMessage()); return Result.error(ex.getMessage()); } /** * 处理SQL异常 * @param ex * @return */ @ExceptionHandler public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){ //Duplicate entry 'zhangsan' for key 'employee.idx_username' String message = ex.getMessage(); if(message.contains("Duplicate entry")){ String[] split = message.split(" "); String username = split[2]; String msg = username + MessageConstant.ALREADY_EXISTS; return Result.error(msg); }else{ return Result.error(MessageConstant.UNKNOWN_ERROR); } } } ``` 在拦截器中就能取到用户id(根据JWT令牌) ``` public class BaseContext { public static ThreadLocal threadLocal = new ThreadLocal<>(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void removeCurrentId() { threadLocal.remove(); } } ``` `BaseContext.setCurrentId(empId);` 调用这个可以把empId存到Threadlocal中,底层为一个哈希表,每个线程单独内存空间。 **threadlocal底层** ``` public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); } static class ThreadLocalMap { static class Entry extends WeakReference> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal k, Object v) { super(k); value = v; } } ``` ### 3. 员工分页查询 **pagehelper,底层用的是threadlocal实现** 导入坐标 ``` com.github.pagehelper pagehelper-spring-boot-starter ${pagehelper} ``` 返回值必须是page,page本质是list集合 ``` public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) { // select * from employee limit 0,10 //开始分页查询 页码,每页记录数 PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize()); // Page page = employeeMapper.pageQuery(employeePageQueryDTO); long total = page.getTotal(); List records = page.getResult(); return new PageResult(total, records); } ``` xml映射文件 ``` ``` mysql中有concat动态拼接函数 源代码 ``` @GetMapping("/page") @ApiOperation("员工分页查询") public Result page(EmployeePageQueryDTO employeePageQueryDTO){ log.info("员工分页查询,参数为:{}", employeePageQueryDTO); PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO); return Result.success(pageResult); } ``` **日期在页面显示异常** 没有转的话,LocalDateTime是一个数组对象 方法一: @JsonFormat(pattern=“yyyy-MM-dd HH:mm:ss") private LocalDateTime updateTime; ``` //@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; ``` 方法二: 在WebMvcConfigurtion中扩展SpringMVC的消息转换器,统一对日期进行格式化处理。 Web层的配置类一般都会继承WebMvcConfigurationSupport 重写该父类的一个方法extendMessageConverters,**扩展消息转换器** **后端对返回给前端的数据进行一个格式化处理** ``` protected void extendMessageConverters(List> converters) { log.info("扩展消息转换器..."); //创建一个消息转换器对象 MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); //需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据 converter.setObjectMapper(new JacksonObjectMapper()); //将自己的消息转化器加入容器中 converters.add(0,converter);//把自己的消息转换器排到第一位 } ``` **对象转换器** java对象和json数据之间转换,序列化和反序列化的过程。 ``` /** * 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象] * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON] */ public class JacksonObjectMapper extends ObjectMapper { public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; //public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm"; public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss"; public JacksonObjectMapper() { super(); //收到未知属性时不报异常 this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false); //反序列化时,属性不存在的兼容处理 this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); SimpleModule simpleModule = new SimpleModule() .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))) .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT))) .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT))) .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT))); //注册功能模块 例如,可以添加自定义序列化器和反序列化器 this.registerModule(simpleModule); } } ``` ### 4. 启用禁用员工账号 **路径参数传递** ``` @PostMapping("/status/{status}") @ApiOperation("启用禁用员工账号") //一个是路径参数,一个不是 public Result startOrStop(@PathVariable Integer status,Long id){ log.info("启用禁用员工账号:{},{}",status,id); employeeService.startOrStop(status,id); return Result.success(); } 如果路径参数名不一致 @PostMapping("/status/{status}") public Result startOrStop(@PathVariable("status") Integer status,Long id){ ``` **xml映射文件**,parameterType="Employee"这个可以不写,而且对于"Employee",如果在配置文件中,`type-aliases-package: com.sky.entity`,配置了这个,就可以不写全类名。 ``` mybatis: #mapper配置文件 mapper-locations: classpath:mapper/*.xml type-aliases-package: com.sky.entity configuration: #开启驼峰命名 map-underscore-to-camel-case: true ``` ``` update employee name = #{name}, username = #{username}, password = #{password}, phone = #{phone}, sex = #{sex}, id_Number = #{idNumber}, update_Time = #{updateTime}, update_User = #{updateUser}, status = #{status}, where id = #{id} ``` ### 5. 编辑员工 查询出来员工信息,记得不要带上密码 ``` public Employee getById(Long id) { Employee employee = employeeMapper.getById(id); employee.setPassword("****"); return employee; } ``` 浏览器调试F12中,Preview是格式化后,Response是原始 **编辑员工分两步,先查后修改** ``` @GetMapping("/{id}") @ApiOperation("根据id查询员工信息") public Result getById(@PathVariable Long id){ Employee employee = employeeService.getById(id); return Result.success(employee); } /** * 编辑员工信息 * @param employeeDTO * @return */ @PutMapping @ApiOperation("编辑员工信息") public Result update(@RequestBody EmployeeDTO employeeDTO){ log.info("编辑员工信息:{}", employeeDTO); employeeService.update(employeeDTO); return Result.success(); } ``` 对于employeeDTO转为employee类型,使用属性拷贝 ``` Employee employee = new Employee(); BeanUtils.copyProperties(employeeDTO, employee); employee.setUpdateTime(LocalDateTime.now());//更新时间 employee.setUpdateUser(BaseContext.getCurrentId());//更新人 ``` ### 6. 分类管理 技术点同上 拷贝进来的代码不一定会自动编译,需要手动编译 ## 二、菜品管理 ### 7. 公共字段自动填充 技术点:枚举、注解、AOP、反射 创建时间、修改时间、创建人、修改人这4个公共字段。 为mapper方法加注解AutoFill,标识需要进行公共字段自动填充 自定义切面类AutoFillAspect,统一拦截加入了AutoFill注解的方法,通过反射为公共字段赋值。 在Mapper的方法上接入AutoFill注解。 ``` public enum OperationType { 更新操作 UPDATE, 插入操作 INSERT } @Target(ElementType.METHOD)当前注解加在什么位置 @Retention(RetentionPolicy.RUNTIME) public @interface AutoFill { //数据库操作类型:UPDATE INSERT OperationType value(); } ``` **补充注解基本知识** ``` public @interface MyAnnotation { // 定义注解的成员 String value(); // 这是一个名为"value"的成员 int count() default 1; // 这是一个名为"count"的成员,带有默认值 } @MyAnnotation(value = "Hello", count = 3) public class MyClass { // 类的代码 } ``` **对于AutoFillAspect类** **切点、execution表达式** ``` /** * 自定义切面,实现公共字段自动填充处理逻辑 */ @Aspect @Component @Slf4j public class AutoFillAspect { /** * 切入点 */ 所有的类,所有的方法,所有的参数类型 @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)") public void autoFillPointCut(){} /** * 前置通知,在通知中进行公共字段的赋值 */ @Before("autoFillPointCut()")指定切入点 public void autoFill(JoinPoint joinPoint){连接点 log.info("开始进行公共字段自动填充..."); //获取到当前被拦截的方法上的数据库操作类型 MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象 AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象 OperationType operationType = autoFill.value();//获得数据库操作类型 //获取到当前被拦截的方法的参数--实体对象 做一个约定,实体对象放第一个 Object[] args = joinPoint.getArgs(); if(args == null || args.length == 0){ return; } Object entity = args[0];实体 //准备赋值的数据 LocalDateTime now = LocalDateTime.now(); Long currentId = BaseContext.getCurrentId(); //根据当前不同的操作类型,为对应的属性通过反射来赋值 if(operationType == OperationType.INSERT){ //为4个公共字段赋值 try { 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(); } } } } ``` ``` 使用 @AutoFill(value = OperationType.UPDATE) void update(Employee employee); ``` ### 8. 新增菜品、分页查询菜品、删除菜品、修改菜品 **新增菜品** #### **文件上传** ``` @RestController @RequestMapping("/admin/common") @Api(tags = "通用接口") @Slf4j public class CommonController { @Autowired private AliOssUtil aliOssUtil; /** * 文件上传 * @param file * @return */ @PostMapping("/upload") @ApiOperation("文件上传") public Result 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); } } ``` yml文件 ``` sky: alioss: endpoint: ${sky.alioss.endpoint} access-key-id: ${sky.alioss.access-key-id} access-key-secret: ${sky.alioss.access-key-secret} bucket-name: ${sky.alioss.bucket-name} ``` AliOssProperties配置属性类,读取配置文件,封装成java对象。 能够自动转换横线和驼峰命名法 ``` @Component @ConfigurationProperties(prefix = "sky.alioss") @Data public class AliOssProperties { private String endpoint; private String accessKeyId;能够自动转换横线和驼峰命名法 private String accessKeySecret; private String bucketName; } ``` **AliOssUtil** 需要调用该工具类-得赋值! 通过配置类方式初始化该值。 ``` @Data @AllArgsConstructor @Slf4j public class AliOssUtil { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; /** * 文件上传 * * @param bytes * @param objectName * @return */ public String upload(byte[] bytes, String objectName) { // 创建OSSClient实例。 OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); try { // 创建PutObject请求。 ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes)); } catch (OSSException oe) { System.out.println("Caught an OSSException, which means your request made it to OSS, " + "but was rejected with an error response for some reason."); System.out.println("Error Message:" + oe.getErrorMessage()); System.out.println("Error Code:" + oe.getErrorCode()); System.out.println("Request ID:" + oe.getRequestId()); System.out.println("Host ID:" + oe.getHostId()); } catch (ClientException ce) { System.out.println("Caught an ClientException, which means the client encountered " + "a serious internal problem while trying to communicate with OSS, " + "such as not being able to access the network."); System.out.println("Error Message:" + ce.getMessage()); } finally { if (ossClient != null) { ossClient.shutdown(); } } //文件访问路径规则 https://BucketName.Endpoint/ObjectName StringBuilder stringBuilder = new StringBuilder("https://"); stringBuilder .append(bucketName) .append(".") .append(endpoint) .append("/") .append(objectName); log.info("文件上传到:{}", stringBuilder.toString()); return stringBuilder.toString(); } } ``` 注入,装配 **需要调用该工具类-得赋值!通过配置类方式初始化该值。** ``` @Configuration @Slf4j public class OssConfiguration { @Bean @ConditionalOnMissingBean public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){ log.info("开始创建阿里云文件上传工具类对象:{}",aliOssProperties); return new AliOssUtil(aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret(), aliOssProperties.getBucketName()); } } ``` **接收前端数据** //口味 ` private List flavors = new ArrayList<>();` 当涉及到多个表的操作 @Transactional 开启注解事务 ``` @SpringBootApplication @EnableTransactionManagement //开启注解方式的事务管理 @Slf4j @EnableCaching//开发缓存注解功能 @EnableScheduling //开启任务调度 public class SkyApplication { public static void main(String[] args) { SpringApplication.run(SkyApplication.class, args); log.info("server started"); } } ``` 批量插入口味表 `dishFlavorMapper.insertBatch(flavors);` `void insertBatch(List flavors);` ``` insert into dish_flavor (dish_id, name, value) VALUES (#{df.dishId},#{df.name},#{df.value}) ``` **插入后返回主键值** ``` 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}) ``` **Lamada表达式** ``` if (flavors != null && flavors.size() > 0) { flavors.forEach(dishFlavor -> { dishFlavor.setDishId(dishId); }); //向口味表插入n条数据 dishFlavorMapper.insertBatch(flavors); } ``` #### 菜品分页查询 ` like concat('%',#{name},'%')` ``` ``` #### 删除菜品 请求参数 ids=1,2,3 接受该字符串 方式一:`(String ids)` 方式二:`(@RequestParam Listids)` #### 修改菜品 逻辑同上,无新增知识点 ## 三、营业状态设置、Redis ### 9. 店铺营业状态设置 **为了存储一个数据而单独建一张表,不如采用Redis。** Spring Data Redis 导入maven坐标 配置redis数据源 编写配置类,创建RedisTemplate 通过redistemlpate ``` @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){ log.info("开始创建redis模板对象..."); RedisTemplate redisTemplate = new RedisTemplate(); //设置redis的连接工厂对象 redisTemplate.setConnectionFactory(redisConnectionFactory); //设置redis key的序列化器 redisTemplate.setKeySerializer(new StringRedisSerializer()); return redisTemplate; } ``` ## 四、微信登陆、商品浏览 ### 10. 微信登陆 ``` HttpClient HttpClients CloseableHttpClient HttpGet HttpPost ``` 创建HttpClient对象 创建Http请求对象 调用HttpClient的execute方法发送请求 解析返回结果 关闭资源 ``` public void testPOST() throws Exception{ // 创建httpclient对象 CloseableHttpClient httpClient = HttpClients.createDefault(); //创建请求对象 HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login"); JSONObject jsonObject = new JSONObject(); jsonObject.put("username","admin"); jsonObject.put("password","123456"); StringEntity entity = new StringEntity(jsonObject.toString()); //指定请求编码方式 entity.setContentEncoding("utf-8"); //数据格式 entity.setContentType("application/json"); httpPost.setEntity(entity); //发送请求 CloseableHttpResponse response = httpClient.execute(httpPost); //解析返回结果 int statusCode = response.getStatusLine().getStatusCode(); System.out.println("响应码为:" + statusCode); HttpEntity entity1 = response.getEntity(); String body = EntityUtils.toString(entity1); System.out.println("响应数据为:" + body); //关闭资源 response.close(); httpClient.close(); } ``` #### 工具类,发get请求和post 指定了过期时间,传入参数为map和url ``` /** * Http工具类 */ public class HttpClientUtil { static final int TIMEOUT_MSEC = 5 * 1000; /** * 发送GET方式请求 * @param url * @param paramMap * @return */ public static String doGet(String url,Map paramMap){ // 创建Httpclient对象 CloseableHttpClient httpClient = HttpClients.createDefault(); String result = ""; CloseableHttpResponse response = null; try{ URIBuilder builder = new URIBuilder(url); if(paramMap != null){ for (String key : paramMap.keySet()) { builder.addParameter(key,paramMap.get(key)); } } URI uri = builder.build(); //创建GET请求 HttpGet httpGet = new HttpGet(uri); //发送请求 response = httpClient.execute(httpGet); //判断响应状态 if(response.getStatusLine().getStatusCode() == 200){ result = EntityUtils.toString(response.getEntity(),"UTF-8"); } }catch (Exception e){ e.printStackTrace(); }finally { try { response.close(); httpClient.close(); } catch (IOException e) { e.printStackTrace(); } } return result; } /** * 发送POST方式请求 * @param url * @param paramMap * @return * @throws IOException */ public static String doPost(String url, Map paramMap) throws IOException { // 创建Httpclient对象 CloseableHttpClient httpClient = HttpClients.createDefault(); CloseableHttpResponse response = null; String resultString = ""; try { // 创建Http Post请求 HttpPost httpPost = new HttpPost(url); // 创建参数列表 if (paramMap != null) { List paramList = new ArrayList(); for (Map.Entry param : paramMap.entrySet()) { paramList.add(new BasicNameValuePair(param.getKey(), param.getValue())); } // 模拟表单 UrlEncodedFormEntity entity = new UrlEncodedFormEntity(paramList); httpPost.setEntity(entity); } httpPost.setConfig(builderRequestConfig()); // 执行http请求 response = httpClient.execute(httpPost); resultString = EntityUtils.toString(response.getEntity(), "UTF-8"); } catch (Exception e) { throw e; } finally { try { response.close(); } catch (IOException e) { e.printStackTrace(); } } return resultString; } /** * 发送POST方式请求 * @param url * @param paramMap * @return * @throws IOException */ public static String doPost4Json(String url, Map paramMap) throws IOException { // 创建Httpclient对象 CloseableHttpClient httpClient = HttpClients.createDefault(); CloseableHttpResponse response = null; String resultString = ""; try { // 创建Http Post请求 HttpPost httpPost = new HttpPost(url); if (paramMap != null) { //构造json格式数据 JSONObject jsonObject = new JSONObject(); for (Map.Entry param : paramMap.entrySet()) { jsonObject.put(param.getKey(),param.getValue()); } StringEntity entity = new StringEntity(jsonObject.toString(),"utf-8"); //设置请求编码 entity.setContentEncoding("utf-8"); //设置数据类型 entity.setContentType("application/json"); httpPost.setEntity(entity); } httpPost.setConfig(builderRequestConfig()); // 执行http请求 response = httpClient.execute(httpPost); resultString = EntityUtils.toString(response.getEntity(), "UTF-8"); } catch (Exception e) { throw e; } finally { try { response.close(); } catch (IOException e) { e.printStackTrace(); } } return resultString; } private static RequestConfig builderRequestConfig() { return RequestConfig.custom() .setConnectTimeout(TIMEOUT_MSEC) .setConnectionRequestTimeout(TIMEOUT_MSEC) .setSocketTimeout(TIMEOUT_MSEC).build(); } } ``` 微信小程序目录结构 app.js逻辑 app.json小程序公共配置 app.wxss小程序公共样式表 wxss样式表 #### 小程序登陆流程 wx.login()获取code(小程序) wx.request()发送code(小程序到开发者服务器) 登陆凭证校验接口(开发者服务器->微信接口服务) appid+appsecret+code 返回session_key+openid等 自定义登陆状态产生一个token,包含openid、session_key关联 返回自定义状态,前端存入Storage wx.request()发起业务请求,携带该自定义登陆态。 user表中有openid为微信用户唯一标识。 ``` sky: wechat: appid: ${sky.wechat.appid} secret: ${sky.wechat.secret} jwt: user-secret-key: itheima user-ttl: 7200000 user-token-name: authentication//和前端确认,参数名为authentication ``` 通过JwtProperties来进行配置,JWT。还有小程序的appid,secret的配置。加上Component注解后,该对象为Bean。 ``` @Component @ConfigurationProperties(prefix = "sky.jwt") @Data public class JwtProperties { /** * 管理端员工生成jwt令牌相关配置 */ private String adminSecretKey; private long adminTtl; private String adminTokenName; /** * 用户端微信用户生成jwt令牌相关配置 */ private String userSecretKey; private long userTtl; private String userTokenName; } ``` 登陆Controller层代码 ``` @PostMapping("/login") @ApiOperation("微信登录") public Result login(@RequestBody UserLoginDTO userLoginDTO){ User user = userService.wxLogin(userLoginDTO); //为微信用户生成jwt令牌 Map claims = new HashMap<>(); claims.put(JwtClaimsConstant.USER_ID,user.getId()); String token = JwtUtil.createJWT(jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), claims); UserLoginVO userLoginVO = UserLoginVO.builder() .id(user.getId()) .openid(user.getOpenid()) .token(token) .build(); return Result.success(userLoginVO); } ``` ServiceImpl代码 ``` public User wxLogin(UserLoginDTO userLoginDTO) { String openid = getOpenid(userLoginDTO.getCode()); //判断openid是否为空,如果为空表示登录失败,抛出业务异常 if(openid == null){ throw new LoginFailedException(MessageConstant.LOGIN_FAILED); } //判断当前用户是否为新用户 User user = userMapper.getByOpenid(openid); //如果是新用户,自动完成注册 if(user == null){ user = User.builder() .openid(openid) .createTime(LocalDateTime.now()) .build(); userMapper.insert(user); } //返回这个用户对象 return user; } private String getOpenid(String code){ //调用微信接口服务,获得当前微信用户的openid Map map = new HashMap<>(); map.put("appid",weChatProperties.getAppid()); map.put("secret",weChatProperties.getSecret()); map.put("js_code",code); map.put("grant_type","authorization_code"); String json = HttpClientUtil.doGet(WX_LOGIN, map); JSONObject jsonObject = JSON.parseObject(json); String openid = jsonObject.getString("openid"); return openid; } ``` 下一步,定义拦截器、注册该自定义的拦截器。 部分代码 ``` @Component @Slf4j public class JwtTokenUserInterceptor implements HandlerInterceptor { @Autowired private JwtProperties jwtProperties; /** * 校验jwt * * @param request * @param response * @param handler * @return * @throws Exception */ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //判断当前拦截到的是Controller的方法还是其他资源 if (!(handler instanceof HandlerMethod)) { //当前拦截到的不是动态方法,直接放行 return true; } //1、从请求头中获取令牌 String token = request.getHeader(jwtProperties.getUserTokenName()); //2、校验令牌 try { log.info("jwt校验:{}", token); Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token); Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString()); log.info("当前用户的id:", userId); BaseContext.setCurrentId(userId); //3、通过,放行 return true; } catch (Exception ex) { //4、不通过,响应401状态码 response.setStatus(401); return false; } } ``` 注册自定义拦截器 ``` @Configuration @Slf4j public class WebMvcConfiguration extends WebMvcConfigurationSupport { @Autowired private JwtTokenAdminInterceptor jwtTokenAdminInterceptor; @Autowired private JwtTokenUserInterceptor jwtTokenUserInterceptor; /** * 注册自定义拦截器 * @param registry */ protected void addInterceptors(InterceptorRegistry registry) { log.info("开始注册自定义拦截器..."); registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns("/admin/**") .excludePathPatterns("/admin/employee/login"); registry.addInterceptor(jwtTokenUserInterceptor) .addPathPatterns("/user/**") .excludePathPatterns("/user/user/login") .excludePathPatterns("/user/shop/status"); } @Bean public Docket docket1(){ log.info("准备生成接口文档..."); ApiInfo apiInfo = new ApiInfoBuilder() .title("苍穹外卖项目接口文档") .version("2.0") .description("苍穹外卖项目接口文档") .build(); Docket docket = new Docket(DocumentationType.SWAGGER_2) .groupName("管理端接口") .apiInfo(apiInfo) .select() //指定生成接口需要扫描的包 .apis(RequestHandlerSelectors.basePackage("com.sky.controller.admin")) .paths(PathSelectors.any()) .build(); return docket; } ``` ### 11. 商品浏览 增删改查 ## 五、缓存菜品、购物车 ### 12. 缓存菜品、套餐 #### 缓存菜品 ``` @GetMapping("/list") @ApiOperation("根据分类id查询菜品") public Result> list(Long categoryId) { //构造redis中的key,规则:dish_分类id String key = "dish_" + categoryId; //查询redis中是否存在菜品数据 List list = (List) redisTemplate.opsForValue().get(key); if(list != null && list.size() > 0){ //如果存在,直接返回,无须查询数据库 return Result.success(list); } Dish dish = new Dish(); dish.setCategoryId(categoryId); dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品 //如果不存在,查询数据库,将查询到的数据放入redis中 list = dishService.listWithFlavor(dish); redisTemplate.opsForValue().set(key, list); return Result.success(list); } ``` redisTemplate.delete()无法识别通配符,得先通过Set keys=redisTemplate.keys(pattern)得到所有的keys获取出来。 ``` @PostMapping @ApiOperation("新增菜品") public Result save(@RequestBody DishDTO dishDTO) { log.info("新增菜品:{}", dishDTO); dishService.saveWithFlavor(dishDTO); //清理缓存数据 String key = "dish_" + dishDTO.getCategoryId(); cleanCache(key); return Result.success(); } private void cleanCache(String pattern){ Set keys = redisTemplate.keys(pattern); redisTemplate.delete(keys); } ``` #### SpringCache 提供了一层抽象,底层可以切换不同的缓存实现,例如 EHCache Caffeine Redis 常用注解: @EnableCaching 启动类上,开启缓存注解功能 @Cacheable 在方法执行前查询缓存中是否有数据,如果有数据直接返回,没有则调用方法将方法返回值放到缓存中---没有result关键字。 @CachePut 将方法返回值放在缓存中 @CacheEvict 将一条或者多条数据从缓存中删除 @CacheEvict(cacheNames="userCache", allEntries=true)//全部删除掉 @CachePut(cacheNames="userCache",key="#user.id")//如果使用Spring Cache 缓存数据,key的生成:userCache::user.id key="#result.id"//对象导航 key="#p0.id" key="#a0.id" key="#root.args[0].id" 两个":"会产生一个empty文件 SpringCache代理对象,可能不会调用到getbyid的方法。 #### 缓存套餐 ``` org.springframework.boot spring-boot-starter-data-redis org.springframework.boot spring-boot-starter-cache ``` **把admin中加上CacheEvict清理缓存数据** ``` @GetMapping("/list") @ApiOperation("根据分类id查询套餐") @Cacheable(cacheNames = "setmealCache",key = "#categoryId") //key: setmealCache::100 public Result> list(Long categoryId) { Setmeal setmeal = new Setmeal(); setmeal.setCategoryId(categoryId); setmeal.setStatus(StatusConstant.ENABLE); List list = setmealService.list(setmeal); return Result.success(list); } ``` ### 13. 添加、查看、清空购物车 单表查询速度会快很多,数据库设计可以设置冗余字段。 流程: 判断当前加入到购物车中的商品是否已经存在了 如果已经存在了,只需要将数量加一 如果不存在,需要插入一条购物车数据 判断本次添加到购物车的是菜品还是套餐 本次添加到购物车的是菜品 本次添加到购物车的是套餐 **技术栈**:CRUD Long userId = BaseContext.getCurrentId(); ### 14. 导入地址簿模块 **技术栈**:CRUD ## 六、用户下单、订单支付 ### 15. 用户下单 **技术栈**:CRUD 加入事务注解 批量插入 ``` List orderDetailList = new ArrayList<>(); //3. 向订单明细表插入n条数据 for (ShoppingCart cart : shoppingCartList) { OrderDetail orderDetail = new OrderDetail();//订单明细 BeanUtils.copyProperties(cart, orderDetail); orderDetail.setOrderId(orders.getId());//设置当前订单明细关联的订单id orderDetailList.add(orderDetail); } orderDetailMapper.insertBatch(orderDetailList); ``` mapper ``` insert into order_detail (name, image, order_id, dish_id, setmeal_id, dish_flavor, number, amount) values (#{od.name},#{od.image},#{od.orderId},#{od.dishId},#{od.setmealId},#{od.dishFlavor},#{od.number},#{od.amount}) ``` ### 16. 订单支付 #### 微信小程序支付流程 微信用户进入微信小程序下单 微信小程序给商户系统下单 商户系统给微信小程序返回订单编号 微信小程序向商户系统申请微信支付 商户系统向微信后台调用微信下单接口 微信后台返回预支付交易标识 商户系统将组合数据再次签名 商户系统返回给微信小程序支付参数 用户在小程序确认支付 小程序向微信后台调起微信支付、 微信后台向小程序返回支付结果 微信小程序显示支付结果 微信后台向商户系统推送支付结果 商户系统更新订单状态 JSAPI下单:商户系统调用该接口在微信支付服务后生成 预支付交易单。 #### 内网穿透 内网穿透获取临时域名,cpolar网站 cpolar.exe authtoken ZDEzNDliZGQtMzUzMy00ZTM5LWI0MjctY2IyMDY4Y2JjYjVi cpolar.exe http 8080 ``` @Component @ConfigurationProperties(prefix = "sky.wechat") @Data public class WeChatProperties { private String appid; //小程序的appid private String secret; //小程序的秘钥 private String mchid; //商户号 private String mchSerialNo; //商户API证书的证书序列号 private String privateKeyFilePath; //商户私钥文件 private String apiV3Key; //证书解密的密钥 private String weChatPayCertFilePath; //平台证书 private String notifyUrl; //支付成功的回调地址 private String refundNotifyUrl; //退款成功的回调地址 } ``` #### 代码开发 微信用户进入微信小程序下单 微信小程序给商户系统下单 商户系统给微信小程序返回订单编号 ##### 生成预支付交易单 ``` @Data public class OrdersPaymentDTO implements Serializable { //订单号 private String orderNumber; //付款方式 private Integer payMethod; } ``` ``` @PutMapping("/payment") @ApiOperation("订单支付") public Result payment(@RequestBody OrdersPaymentDTO ordersPaymentDTO) throws Exception { log.info("订单支付:{}", ordersPaymentDTO); OrderPaymentVO orderPaymentVO = orderService.payment(ordersPaymentDTO); log.info("生成预支付交易单:{}", orderPaymentVO); return Result.success(orderPaymentVO); } ``` 微信小程序向商户系统申请微信支付 商户系统向微信后台调用微信下单接口 **微信后台返回预支付交易标识** 商户系统将组合数据再次签名 商户系统返回给微信小程序支付参数 ``` 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; } ``` ``` public JSONObject pay(String orderNum, BigDecimal total, String description, String openid) throws Exception { //统一下单,生成预支付交易单 String bodyAsString = jsapi(orderNum, total, description, openid); //解析返回结果 JSONObject jsonObject = JSON.parseObject(bodyAsString); System.out.println(jsonObject); String prepayId = jsonObject.getString("prepay_id"); if (prepayId != null) { String timeStamp = String.valueOf(System.currentTimeMillis() / 1000); String nonceStr = RandomStringUtils.randomNumeric(32); ArrayList list = new ArrayList<>(); list.add(weChatProperties.getAppid()); list.add(timeStamp); list.add(nonceStr); list.add("prepay_id=" + prepayId); //二次签名,调起支付需要重新签名 StringBuilder stringBuilder = new StringBuilder(); for (Object o : list) { stringBuilder.append(o).append("\n"); } String signMessage = stringBuilder.toString(); byte[] message = signMessage.getBytes(); Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(PemUtil.loadPrivateKey(new FileInputStream(new File(weChatProperties.getPrivateKeyFilePath())))); signature.update(message); String packageSign = Base64.getEncoder().encodeToString(signature.sign()); //构造数据给微信小程序,用于调起微信支付 JSONObject jo = new JSONObject(); jo.put("timeStamp", timeStamp); jo.put("nonceStr", nonceStr); jo.put("package", "prepay_id=" + prepayId); jo.put("signType", "RSA"); jo.put("paySign", packageSign); return jo; } return jsonObject; } ``` ``` private String jsapi(String orderNum, BigDecimal total, String description, String openid) throws Exception { JSONObject jsonObject = new JSONObject(); jsonObject.put("appid", weChatProperties.getAppid()); jsonObject.put("mchid", weChatProperties.getMchid()); jsonObject.put("description", description); jsonObject.put("out_trade_no", orderNum); jsonObject.put("notify_url", weChatProperties.getNotifyUrl()); JSONObject amount = new JSONObject(); amount.put("total", total.multiply(new BigDecimal(100)).setScale(2, BigDecimal.ROUND_HALF_UP).intValue()); amount.put("currency", "CNY"); jsonObject.put("amount", amount); JSONObject payer = new JSONObject(); payer.put("openid", openid); jsonObject.put("payer", payer); String body = jsonObject.toJSONString(); return post(JSAPI, body); } ``` ``` private String post(String url, String body) throws Exception { CloseableHttpClient httpClient = getClient(); HttpPost httpPost = new HttpPost(url); httpPost.addHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.toString()); httpPost.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString()); httpPost.addHeader("Wechatpay-Serial", weChatProperties.getMchSerialNo()); httpPost.setEntity(new StringEntity(body, "UTF-8")); CloseableHttpResponse response = httpClient.execute(httpPost); try { String bodyAsString = EntityUtils.toString(response.getEntity()); return bodyAsString; } finally { httpClient.close(); response.close(); } } private String get(String url) throws Exception { CloseableHttpClient httpClient = getClient(); HttpGet httpGet = new HttpGet(url); httpGet.addHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.toString()); httpGet.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString()); httpGet.addHeader("Wechatpay-Serial", weChatProperties.getMchSerialNo()); CloseableHttpResponse response = httpClient.execute(httpGet); try { String bodyAsString = EntityUtils.toString(response.getEntity()); return bodyAsString; } finally { httpClient.close(); response.close(); } } ``` ``` private CloseableHttpClient getClient() { PrivateKey merchantPrivateKey = null; try { //merchantPrivateKey商户API私钥,如何加载商户API私钥请看常见问题 merchantPrivateKey = PemUtil.loadPrivateKey(new FileInputStream(new File(weChatProperties.getPrivateKeyFilePath()))); //加载平台证书文件 X509Certificate x509Certificate = PemUtil.loadCertificate(new FileInputStream(new File(weChatProperties.getWeChatPayCertFilePath()))); //wechatPayCertificates微信支付平台证书列表。你也可以使用后面章节提到的“定时更新平台证书功能”,而不需要关心平台证书的来龙去脉 List wechatPayCertificates = Arrays.asList(x509Certificate); WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create() .withMerchant(weChatProperties.getMchid(), weChatProperties.getMchSerialNo(), merchantPrivateKey) .withWechatPay(wechatPayCertificates); // 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签 CloseableHttpClient httpClient = builder.build(); return httpClient; } catch (FileNotFoundException e) { e.printStackTrace(); return null; } } ``` ##### 用户在小程序确认支付后微信后台为商户系统返回支付结果 **用户在小程序确认支付** 小程序向微信后台调起微信支付、 微信后台向小程序返回支付结果 微信小程序显示支付结果 微信后台向商户系统推送支付结果 商户系统更新订单状态 ``` @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 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(); } ``` ## 七、订单状态定时处理、来单提醒和客户催单 ### 17. 订单状态定时处理 #### Spring Task Spring框架提供的:定时任务框架 cron表达式:定义任务触发的时间。 cron表达式在线生成器:https://cron.qqe2.com/ (坐标为spring-context) 启动类添加注解@EnableScheduling开启。 ``` @Component @Slf4j public class MyTask { /** * 定时任务 每隔5秒触发一次 */ @Scheduled(cron = "0/5 * * * * ?") public void executeTask(){ log.info("定时任务开始执行:{}", new Date()); } } ``` #### 订单状态定时处理 两种场景:下单一直未支付订单处于“待支付”状态、用户收货后管理端未点击完成按钮,订单处于“派送中”状态。 时间的加减法。 `LocalDateTime time = LocalDateTime.now().plusMinutes(-15);` ``` @Scheduled(cron = "0 * * * * ? ") //每分钟触发一次 public void processTimeoutOrder(){ log.info("定时处理超时订单:{}", LocalDateTime.now()); LocalDateTime time = LocalDateTime.now().plusMinutes(-15); // select * from orders where status = ? and order_time < (当前时间 - 15分钟) List ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time); if(ordersList != null && ordersList.size() > 0){ for (Orders orders : ordersList) { orders.setStatus(Orders.CANCELLED); orders.setCancelReason("订单超时,自动取消"); orders.setCancelTime(LocalDateTime.now()); orderMapper.update(orders); } } } /** * 处理一直处于派送中状态的订单 */ @Scheduled(cron = "0 0 1 * * ?") //每天凌晨1点触发一次 public void processDeliveryOrder(){ log.info("定时处理处于派送中的订单:{}",LocalDateTime.now()); LocalDateTime time = LocalDateTime.now().plusMinutes(-60); List ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time); if(ordersList != null && ordersList.size() > 0){ for (Orders orders : ordersList) { orders.setStatus(Orders.COMPLETED); orderMapper.update(orders); } } } ``` ### 18. 来单提醒、客户催单 #### WebSocket 基于TCP的网络协议,完成一次握手就可以创建持久性连接,进行双向数据传输。 http协议为短连接 websocket : Handshake、Acknowledgement、双向消息、Connection 都是基于TCP协议 应用场景:视频弹幕、网页聊天、体育实况更新(页面没有更新,但是数据变了)、股票报价实时更新。 请求路径变成ws。 配置websocked ``` /** * WebSocket配置类,用于注册WebSocket的Bean */ @Configuration public class WebSocketConfiguration { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } ``` ``` /** * WebSocket服务 */ @Component @ServerEndpoint("/ws/{sid}") public class WebSocketServer { //存放会话对象 private static Map 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 sessions = sessionMap.values(); for (Session session : sessions) { try { //服务器向客户端发送消息 session.getBasicRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } } ``` ``` @Component public class WebSocketTask { @Autowired private WebSocketServer webSocketServer; /** * 通过WebSocket每隔5秒向客户端发送消息 */ //@Scheduled(cron = "0/5 * * * * ?") public void sendMessageToClient() { webSocketServer.sendToAllClient("这是来自服务端的消息:" + DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalDateTime.now())); } } ``` #### 来单提醒 当客户支付后,调用WebSocket的相关API实现服务端向客户端推送消息 ``` @Autowired private WebSocketServer webSocketServer; //通过websocket向客户端浏览器推送消息 type orderId content Map map = new HashMap(); map.put("type",1); // 1表示来单提醒 2表示客户催单 map.put("orderId",ordersDB.getId()); map.put("content","订单号:" + outTradeNo); String json = JSON.toJSONString(map); webSocketServer.sendToAllClient(json); ``` #### 客户催单 用户端功能 ``` @Autowired private WebSocketServer webSocketServer; public void reminder(Long id) { // 根据id查询订单 Orders ordersDB = orderMapper.getById(id); // 校验订单是否存在 if (ordersDB == null) { throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR); } Map map = new HashMap(); map.put("type",2); //1表示来单提醒 2表示客户催单 map.put("orderId",id); map.put("content","订单号:" + ordersDB.getNumber()); //通过websocket向客户端浏览器推送消息 webSocketServer.sendToAllClient(JSON.toJSONString(map)); } ``` ## 八、数据统计图形报表 Apache ECharts为一个前端框架 ### 20. 营业额统计、用户统计、订单统计 **日期类型接收** ``` @GetMapping("/turnoverStatistics") @ApiOperation("营业额统计") public Result turnoverStatistics( @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin, @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){ log.info("营业额数据统计:{},{}",begin,end); return Result.success(reportService.getTurnoverStatistics(begin,end)); } ``` **以逗号分隔:**`StringUtils.join(dateList, ",")` **获取一天最小的时刻**`LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);` **给mapper传递参数可以使用map封装** ``` Map map = new HashMap(); map.put("begin", beginTime); map.put("end", endTime); map.put("status", Orders.COMPLETED); Double turnover = orderMapper.sumByMap(map); turnover = turnover == null ? 0.0 : turnover; turnoverList.add(turnover); ``` **mapper文件** ``` ``` **开发细节**: `turnover = turnover == null ? 0.0 : turnover;` 具体实现代码: ``` public TurnoverReportVO getTurnoverStatistics(LocalDate begin, LocalDate end) { //当前集合用于存放从begin到end范围内的每天的日期 List dateList = new ArrayList<>(); dateList.add(begin); while (!begin.equals(end)) { //日期计算,计算指定日期的后一天对应的日期 begin = begin.plusDays(1); dateList.add(begin); } //存放每天的营业额 List turnoverList = new ArrayList<>(); for (LocalDate date : dateList) { //查询date日期对应的营业额数据,营业额是指:状态为“已完成”的订单金额合计 LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN); LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX); // select sum(amount) from orders where order_time > beginTime and order_time < endTime and status = 5 Map map = new HashMap(); map.put("begin", beginTime); map.put("end", endTime); map.put("status", Orders.COMPLETED); Double turnover = orderMapper.sumByMap(map); turnover = turnover == null ? 0.0 : turnover; turnoverList.add(turnover); } //封装返回结果 return TurnoverReportVO .builder() .dateList(StringUtils.join(dateList, ",")) .turnoverList(StringUtils.join(turnoverList, ",")) .build(); } ``` ### 23. 销量排名统计 **技术点:sql查询,流(把对象中的属性一个一个取出来转成list,并用逗号分隔)** DTO(Data Transfer Object): DTO 是用于在不同层(如应用层和持久层)之间传输数据的对象。它的主要目的是优化数据传输的性能和减少不必要的数据交互。在一些情况下,数据库表可能具有多个关联表,而在某个层级上,你可能只需要其中的一部分数据。DTO 可以帮助你选择仅需的字段,从而减少网络传输和数据库查询的负担。 VO(Value Object): VO 用于表示一些不可变的数据对象,通常用于封装一些相关的值,例如坐标、时间范围等。 vo为nameList,numberList `List names = salesTop10.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList());` // list里边为对象,搜集对象中为name属性的元素 `String nameList = StringUtils.join(names, ",");` ``` select od.name,sum(od.number) from order_detail od,orders o where od.order_id=o.id and o.status=5 and o.order_time>""and order_time<"" group by od.name order by number desc limit 0,3 ``` 改成动态sql ``` ``` `ListgetSalesTop(LocalDateTime begin,LocalDateTime end);` ``` public SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end) { LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN); LocalDateTime endTime = LocalDateTime.of(end, LocalTime.MAX); List salesTop10 = orderMapper.getSalesTop10(beginTime, endTime); List names = salesTop10.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList()); String nameList = StringUtils.join(names, ","); List numbers = salesTop10.stream().map(GoodsSalesDTO::getNumber).collect(Collectors.toList()); String numberList = StringUtils.join(numbers, ","); //封装返回结果数据 return SalesTop10ReportVO .builder() .nameList(nameList) .numberList(numberList) .build(); } ``` `List names = salesTop10.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList());` `String nameList = StringUtils.join(names, ",");` ## 九、数据统计-Excel报表 ### 24. 工作台(数据看板) 总体技术栈为查询 **使用map传递多个参数** ``` public BusinessDataVO getBusinessData(LocalDateTime begin, LocalDateTime end) { /** * 营业额:当日已完成订单的总金额 * 有效订单:当日已完成订单的数量 * 订单完成率:有效订单数 / 总订单数 * 平均客单价:营业额 / 有效订单数 * 新增用户:当日新增用户的数量 */ Map map = new HashMap(); map.put("begin",begin); map.put("end",end); //查询总订单数 Integer totalOrderCount = orderMapper.countByMap(map); map.put("status", Orders.COMPLETED); //营业额 Double turnover = orderMapper.sumByMap(map); turnover = turnover == null? 0.0 : turnover; //有效订单数 Integer validOrderCount = orderMapper.countByMap(map); Double unitPrice = 0.0; Double orderCompletionRate = 0.0; if(totalOrderCount != 0 && validOrderCount != 0){ //订单完成率 orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount; //平均客单价 unitPrice = turnover / validOrderCount; } //新增用户数 Integer newUsers = userMapper.countByMap(map); return BusinessDataVO.builder() .turnover(turnover) .validOrderCount(validOrderCount) .orderCompletionRate(orderCompletionRate) .unitPrice(unitPrice) .newUsers(newUsers) .build(); } ``` ### 25. 导出运营数据Excel报表 ``` public void exportBusinessData(HttpServletResponse response) { //1. 查询数据库,获取营业数据---查询最近30天的运营数据 LocalDate dateBegin = LocalDate.now().minusDays(30); LocalDate dateEnd = LocalDate.now().minusDays(1); //查询概览数据 BusinessDataVO businessDataVO = workspaceService.getBusinessData(LocalDateTime.of(dateBegin, LocalTime.MIN), LocalDateTime.of(dateEnd, LocalTime.MAX)); //2. 通过POI将数据写入到Excel文件中 InputStream in = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx"); try { //基于模板文件创建一个新的Excel文件 XSSFWorkbook excel = new XSSFWorkbook(in); //获取表格文件的Sheet页 XSSFSheet sheet = excel.getSheet("Sheet1"); //填充数据--时间 sheet.getRow(1).getCell(1).setCellValue("时间:" + dateBegin + "至" + dateEnd); //获得第4行 XSSFRow row = sheet.getRow(3); row.getCell(2).setCellValue(businessDataVO.getTurnover()); row.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate()); row.getCell(6).setCellValue(businessDataVO.getNewUsers()); //获得第5行 row = sheet.getRow(4); row.getCell(2).setCellValue(businessDataVO.getValidOrderCount()); row.getCell(4).setCellValue(businessDataVO.getUnitPrice()); //填充明细数据 for (int i = 0; i < 30; i++) { LocalDate date = dateBegin.plusDays(i); //查询某一天的营业数据 BusinessDataVO 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()); } //3. 通过输出流将Excel文件下载到客户端浏览器 ServletOutputStream out = response.getOutputStream(); excel.write(out); //关闭资源 out.close(); excel.close(); } catch (IOException e) { e.printStackTrace(); } } ```