苍穹外卖项目实战总结(后端)

前言

​ 前前后后花了将近一个月的时间将苍穹外卖这个项目实战完成了,本来以为是从零开始研发,但大部分东西都是现成的,相当于自己只是根据接口文档完成了作业,熟悉了前后端分离的开发模式,要想自己完整的开发这样一个项目估计是要花费很多时间的,在做项目的过程中相当于将前面学习的SSM技术和SpringBoot技术做了一次整合

​ 在学习的过程中我发现自己也有很多拉下的知识点,如反射、全局异常处理器、AOP、Steam流,总之希望自己在能一直保持这样的学习状态和热情(也许还不够热情)😁

项目整体架构和技术选型

整体架构

  • 通过maven的聚合和继承进行分模块开发,分为pojo、common、server、take-out

  • POJO模块下各种包的含义,一般用DTO接受前端传递回来的数据,VO封装数据返回

  • common模块:constant常量类,context项目上下文,enumeration枚举类,exception异常类,json处理json转换的类,properties是Springboot中的一些配置属性类,会把配置文件中的配置项封装成对象,result后端的返回结果,utils工具类。

  • 在各个模块下又根据功能的不同分为不同的包,代码结构清晰

技术选型

  • 用户层:主要使用node.js、Vue2、ElementUI组件、微信小程序、apache echarts技术进行前端页面的开发
  • 网关层:使用Nginx服务器进行反向代理,保障后端服务器的安全,提高访问速度,进行负载均衡
  • 应用层:主要技术有SpringBoot、SpringMVC、SpringTask、SpringCache、JWT、WebSocket、Swagger、阿里云OSS
  • 数据层:MySQL、Redis、Mybatis、Spring data redis、pagehelper

代码调试

在实际项目开发中前端和后端工程应该是同步开发的,在进行测试的话一般是通过接口文档的方式对功能进行测试,而在本项目中前端工程的代码已经给出了,所以用前后端联调的方式进行测试

要注意的是,前后端请求的地址并不一致器,前端请求地址为http://localhost/api,后端请求地址为:http://localhost:8080/,使用的是nginx反向代理的方式将前端的请求发送给后端的

具体功能的开发

这里只会讲自己在开发过程中遇到的问题和一些不懂的点,具体视频讲解b站上都有

本项目约定管理端发出的请求统一用:/admin作为前缀,用户端统一用:/user

通过 application.yml 和application-dev.yml来进行不同环境之间的配置

controller中的bean不能相同,可以指定名称区分@RestController("userDishController")

接口文档的开发

我使用的是Apifox导入的资料中的接口文档,所以在代码开发中就没有写过接口文档

  • Swagger,生成接口文档和在线接口调试页面

  • Knife4j为MVC框架集成Swagger的jar包

    1
    2
    3
    4
    5
    <dependency>
    <groupld>com.github.xiaoymin</groupld>
    <artifactld>knife4j-spring-boot-starter</artifactld>
    <version>3.0.2</version>
    </dependency>
  • 配置类中加入Knife4j的相关配置,设置静态资源映射,都是在server模块的config包下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    /**
    * 通过knife4j生成接口文档
    * @return
    */
    @Bean
    public Docket docket() {
    //Swagger用来定义API文档的元信息,如标题、版本和描述
    ApiInfo apiInfo = new ApiInfoBuilder()
    .title("苍穹外卖项目接口文档")
    .version("2.0")
    .description("苍穹外卖项目接口文档")
    .build();
    //这是Swagger的配置类,用于配置Swagger的文档信息。
    Docket docket = new Docket(DocumentationType.SWAGGER_2)
    .apiInfo(apiInfo)
    .select()
    //指定扫描的包路径,这里只扫描com.sky.controller包下的控制器。
    .apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
    //指定扫描的路径,这里使用PathSelectors.any()表示扫描所有路径。
    .paths(PathSelectors.any())
    .build();
    return docket;
    }

    /**
    * 设置静态资源映射
    * @param registry
    */
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {

    //配置Swagger的文档页面doc.html的静态资源映射。当访问/doc.html时,Spring MVC会从classpath:/META-INF/resources/路径下寻找资源
    registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
    registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
  • 常用注解

员工登录校验功能

  • 对密码进行加密后保存在数据库中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //密码比对
    //md5加密密码,不可逆

    password = DigestUtils.md5DigestAsHex(password.getBytes());

    if (!password.equals(employee.getPassword())) {
    //密码错误,自定义异常和常量字符串
    throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
    }

JWT令牌校验

  1. 导入jwt的maven坐标

  2. 创建配置类,利用yml文件加载配置类的配置

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

    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    sky:
    jwt:
    # 设置jwt签名加密时使用的秘钥
    admin-secret-key: itcast
    # 设置jwt过期时间
    admin-ttl: 7200000000000000
    # 设置前端传递过来的令牌名称
    admin-token-name: token

    user-secret-key: itheima
    user-ttl: 7200000000000000
    user-token-name: authentication
  3. 创建加密和解密工具类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    public class JwtUtil {
    /**
    * 生成jwt
    * 使用Hs256算法, 私匙使用固定秘钥
    *
    * @param secretKey jwt秘钥
    * @param ttlMillis jwt过期时间(毫秒)
    * @param claims 设置的信息
    * @return
    */
    public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
    // 指定签名的时候使用的签名算法,也就是header那部分
    SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    // 生成JWT的时间,ttlMillis就是过期时间
    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;
    }

    }
  4. 数据库匹配成功后发放jwt令牌

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
     //登录成功后,生成jwt令牌
    Map<String, Object> claims = new HashMap<>();
    //JwtClaimsConstant.EMP_ID自定义的常量类
    claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
    //通过创建的工具类生成token
    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();
  5. 利用拦截器拦截请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    /**
    * jwt令牌校验的拦截器
    */
    @Component
    @Slf4j
    public class JwtTokenAdminInterceptor 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.getAdminTokenName());

    //2、校验令牌
    try {
    log.info("jwt校验:{}", token);
    Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
    Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
    log.info("当前员工id:{}", empId);
    //利用线程空间存值
    BaseContext.setCurrentId(empId);

    //3、通过,放行
    return true;
    } catch (Exception ex) {
    //4、不通过,响应401状态码
    response.setStatus(401);
    return false;
    }
    }
    }

  6. 注册拦截器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    /**
    * 配置类,注册web层相关组件
    */
    @Configuration
    @Slf4j
    //TODO 为什么不使用implements WebMvcConfigurer
    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");
    }

    /**
    * 通过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/");
    }

    /**
    * 扩展MVC的消息转换器
    */
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    //创建消息转换器对象
    final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
    //设置对象转换器,将Java对象转为json字符串
    converter.setObjectMapper(new JacksonObjectMapper());

    //将我们自己的转换器放入Spring Mvc框架的容器中
    converters.add(0,converter);

    }
    }

思考

  1. 过滤器和拦截器之间的区别

    • 拦截器是Spring提供的组件,过滤器是Servlet提供的组件

    • 过滤器实现Filter接口,拦截器实现HandlerInterceptor接口

    • 过滤器的范围更广,拦截器执行在过滤器之后

    • 实现的方法不一样,过滤器分为初始化方法,执行方法,销毁方法,拦截器分为资源运行前方法,运行后方法,视图渲染完毕后运行方法

    • 过滤器放行后如果在执行方法中还有代码放行运行完后会继续回到放行处向下运行,而拦截器直接return true,结束了方法的执行

    • 要在Spring框架中使用过滤器,得再启动类上加上@ServletComponentScan注解开启servlet组件支持

    • 过滤器Filter会拦截所有资源,Interceptor只会拦截Spring环境中的资源

  2. 拦截器(Interceptor)是基于Java的反射机制,而过滤器(Filter)是基于函数回调

    截器中可以注入 Spring 的 Bean,能够获取到各种需要的 Service 来处理业务逻辑,而过滤器则不行。

线程

进程是操作系统分配资源的单位,线程是调度的基本单位,线程之间共享进程资源

什么是线程

线程是指在一个进程内执行的独立执行路径,一个进程中可以有多个线程

线程的特点

  1. 轻量级:相比于进程,线程是更轻量级的执行单元。创建和销毁线程的开销较小,可以在短时间内创建大量线程。
  2. 共享资源:线程在同一个进程内共享进程的内存空间和系统资源。这意味着多个线程可以直接访问和修改同一份数据,更容易实现数据共享和通信。
  3. 并发执行:多个线程可以并发执行,实现任务的同时进行。不同线程之间可以按照特定的调度算法分配CPU时间片,从而实现并发处理。
  4. 上下文切换:由于线程是并发执行的,操作系统需要在不同线程之间进行上下文切换。上下文切换是指将一个线程的执行状态保存起来,并恢复另一个线程的执行状态,这个过程会带来一定的开销。
  5. 线程同步:多个线程访问共享资源时可能会出现竞态条件和数据不一致的问题。为了保证数据的一致性和正确性,需要使用线程同步机制,如互斥锁、信号量、条件变量等。
  6. 可以实现并行性:在多核处理器上,多个线程可以并行执行,提高程序的执行效率。通过线程的并行执行,可以将任务划分为多个子任务并同时进行处理,加快任务的完成速度。

这里就简单了解下吧,主要是具体在项目中的使用,到时候学到操作系统的时候在单独在写一篇博客

项目中的使用

通过ThreadLocal线程变量存储管理端和用户端的ID

  1. 原理

    ThreadLocal变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量,ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量,用于线程中的数据隔离

  2. 实现,通过调用类中的静态方法存储用户ID和取出ID,得到谁在操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class BaseContext {
    public static ThreadLocal<Long> 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();
    }

    }

全局异常处理器

处理数据库中主键冲突的问题,如录入的用户名已存在会抛出sql异常

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 全局异常处理器,处理项目中抛出的业务异常
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

/**
* 捕获业务异常
* @param ex
* @return
*/
@ExceptionHandler
public Result exceptionHandler(BaseException ex){
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage());
}
/**
* 处理SQL异常
*/
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
// Duplicate entry 'zhangsan' for key 'employee.idx_username',
//这就处理了所有使用sql语句插入时,插入的值违反了唯一约束的条件
final String message = ex.getMessage();
if(message.contains("Duplicate entry")){
final String[] s = message.split(" ");
//利用字符串分割拿到违反唯一约束的数据的值
final String username = s[2];
String msg = username+ MessageConstant.ALREADY_EXITS;
return Result.error(msg);
}else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}

}

}

细节讲解

  1. @RestControllerAdvice相当于集成了@ControllerAdvice@ResponseBody@ControllerAdvice表示这是一个全局的控制器增强器,可以处理全局的请求,而@ResponseBody表示方法的返回值会自动作为HTTP响应的正文

  2. 通过返回统一的Result对象,可以确保应用对外的错误响应格式一致,前端将错误消息返回到页面上。

    1. BaseException是自定义的异常,它继承非受查异常(编译器不会强制开发者捕获或抛出这种类型的异常,在运行时被检测到并且通常是由程序逻辑错误引起的异常其他自定义的异常类继承它就行了,然后通过全局异常处理器,将获得的异常消息返回给前端
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * 业务异常
    */
    public class BaseException extends RuntimeException {

    public BaseException() {
    }

    public BaseException(String msg) {
    super(msg);
    }

    }

统一对日期格式处理

定制 JSON 的序列化和反序列化行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 对象映射器:基于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);
}
}

加入到MvcConfig中去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 扩展MVC的消息转换器
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
//创建消息转换器对象
final MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
//设置对象转换器,将Java对象转为json字符串
converter.setObjectMapper(new JacksonObjectMapper());

//将我们自己的转换器放入Spring Mvc框架的容器中,0代表第一个
converters.add(0,converter);

}

细节处理

  1. 通过configuregetDeserializationConfig().withoutFeatures方法,确保在解析 JSON 时忽略未知属性,避免因为 JSON 中的额外字段导致解析失败。
  2. 提供自定义的序列化和反序列化格式,而不是使用 Jackson 默认的格式
  3. SimpleModule:这是 Jackson 库中的一个类,用于注册自定义的序列化和反序列化处理器。
  4. converters.add(0,converter);,0代表第一个,优先使用自定义的
  5. 没有将自定义的ObjectMapper注册为消息转换器的一部分,Spring MVC将使用其默认的ObjectMapper,只有将这个转换器添加到Spring MVC的消息转换器列表中时,自定义配置才会生效。确保所有通过Spring MVC处理的JSON数据都会使用自定义ObjectMapper

自动填充公共字段

填充数据库中重复出现的字段,如创建人,修改人,创建时间,修改时间

具体实现

  1. 自定义注解AutoFill,标识需要进行公共字段填充的方法
  2. 自定义切面类拦截加入了AutoFill注解的方法
  3. 通过反射为方法赋值
  4. 通过枚举定义数据库操作类型
  5. 在Mapper方法上添加AutoFil注解,并指定操作类型

代码

  1. 注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * 自定义注解,公共字段填充
    */
    //指定只能挂载在方法上
    @Target(ElementType.METHOD)
    //注解的保留策略
    @Retention(RetentionPolicy.RUNTIME)
    public @interface AutoFill {
    //定义数据库操作方法
    OperationType value();
    }
  2. 枚举类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    * 数据库操作类型
    */
    public enum OperationType {

    /**
    * 更新操作
    */
    UPDATE,

    /**
    * 插入操作
    */
    INSERT

    }
  3. 切面类,AutoFillConstant.SET_CREATE_TIME是定义的常量类,里面放的是方法名称

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73

    /**
    * 自定义切面,实现公共字段填充的逻辑
    */
    //切面的注解
    @Aspect
    @Component
    @Slf4j
    public class AutoFillAspect {

    /**
    * 切入点
    */
    //锁定Mapper包下的所有方法并加了AutoFill注解的
    @Pointcut("execution(* com.sky.mapper.*.*(..))&& @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    /**
    * 前置通知
    */
    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint){
    log.info("开始进行公共字段的填充:");
    //获取当前被拦截到数据库操作的操作类型
    final MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
    final AutoFill annotation = signature.getMethod().getAnnotation(AutoFill.class);//获取方法上的注解对象
    final OperationType value = annotation.value();//操作的数据库类型
    //获取到当前被拦截的参数 实体对象
    final Object[] args = joinPoint.getArgs();
    if(args == null || args.length == 0 ){
    return;
    }
    //默认实体参数在第一位
    Object entity = args[0];

    //准备赋值的数据
    final LocalDateTime now = LocalDateTime.now();
    final Long currentId = BaseContext.getCurrentId();

    //根据不同的操作类型进行赋值,反射原理
    if(value == OperationType.INSERT){
    //是插入操作
    try {
    //获取set方法
    final Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
    final Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
    final Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
    final Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
    //通过反射属性为对象属性赋值
    setCreateUser.invoke(entity,currentId);
    setCreateTime.invoke(entity,now);
    setUpdateTime.invoke(entity,now);
    setUpdateUser.invoke(entity,currentId);

    } catch (Exception e) {
    e.printStackTrace();
    }
    }else if(value == OperationType.UPDATE){
    //更新操作
    try {
    final Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
    final Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
    //通过反射属性为对象属性赋值
    setUpdateTime.invoke(entity,now);
    setUpdateUser.invoke(entity,currentId);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }


    }
    }
  4. 相关常量类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * 公共字段自动填充相关常量
    */
    public class AutoFillConstant {
    /**
    * 实体类中的方法名称
    */
    public static final String SET_CREATE_TIME = "setCreateTime";
    public static final String SET_UPDATE_TIME = "setUpdateTime";
    public static final String SET_CREATE_USER = "setCreateUser";
    public static final String SET_UPDATE_USER = "setUpdateUser";
    }
  5. Mapper包下的使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
     /**
    * 新增员工
    */
    @Insert("insert into employee (name, username, password, phone, sex, id_number, create_time, create_user, status)" +
    "values"+
    "(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{createUser},#{status})")
    //表示是插入操作
    @AutoFill(value = OperationType.INSERT)
    void saveUser(Employee employee);

注意细节

  1. 自定义注解时,注解的挂载位置和保留策略
  2. 切入点的位置,锁定的位置一定要正确
  3. 通过不同的操作类型填充不同的参数
  4. 编写SQL语句还是要带有这些被填充的字段,只是可以不需要传递值

Redis缓存菜品数据

具体实现

  1. 导入Spring Cache和Redis相关maven坐标
  2. 在用户端接口SetmealController的list方法上加入@Cacheable注解,表示方法执行前先查询缓存中有没有数据,有直接返回缓存数据,没有就将方法返回值放到缓存中
  3. 在管理端接口SetmealController有关菜品数据更新,启停售的方法上加入@CacheEvict,将一条或多条数据从缓存中删除

代码

redis数据库配置,通过 application.yml 和application-dev.yml来进行不同环境之间的配置

1
2
3
4
5
# redis数据库配置
redis:
host: ${sky.redis.host}
port: ${sky.redis.port}
database: ${sky.redis.database}

用户端@Cacheable(cacheNames = "setmealCache",key = "#categoryId")

管理端@CacheEvict(cacheNames = "setmealCache",allEntries = true)

使用细节

  1. 使用Spring Cache框架,换数据库只需在maven中更改坐标,yml中改一下配置,不需要改动代码

  2. allEntries = true是删除setmealCache下的所有数据

  3. 使用注解的方式开发相当于是在Redis中创建了一个setmealCache的包,下面存放着key对应的值

小程序开发

HttpClient

客户端编程工具包,相当于在Java程序中自定义发送get或post请求到指定url地址

从 Java 11 开始,HttpClient 被包含在 java.net.http 包中

使用方法
  1. 导入maven坐标

    1
    2
    3
    4
    <dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    </dependency>
  2. 创建HttpClient对象

    1
    CloseableHttpClient httpClient = HttpClients.createDefault();
  3. 创建Http请求对象

    1
    2
    3
    HttpGet request = new HttpGet("http://example.com");
    // 或者
    HttpPost postRequest = new HttpPost("http://example.com");
  4. 发送请求,并接受响应

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    HttpResponse response = httpClient.execute(request);
    StatusLine statusLine = response.getStatusLine();
    int statusCode = statusLine.getStatusCode();
    HttpEntity entity = response.getEntity();

    if (statusCode == HttpStatus.SC_OK) {
    String result = EntityUtils.toString(entity, "UTF-8");
    System.out.println(result);
    } else {
    System.out.println("请求失败,状态码:" + statusCode);
    }
    //关闭资源
    response.close();
    httpclient.close();
工具类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

/**
* Http工具类
*/
public class HttpClientUtil {

static final int TIMEOUT_MSEC = 5 * 1000;

/**
* 发送GET方式请求
* @param url
* @param paramMap
* @return
*/
public static String doGet(String url,Map<String,String> 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<String, String> 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<NameValuePair> paramList = new ArrayList();
for (Map.Entry<String, String> 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;
}
//设置连接参数
private static RequestConfig builderRequestConfig() {
return RequestConfig.custom()
.setConnectTimeout(TIMEOUT_MSEC)
.setConnectionRequestTimeout(TIMEOUT_MSEC)
.setSocketTimeout(TIMEOUT_MSEC).build();
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    private final static String WEIXI_LOGIN = "https://api.weixin.qq.com/sns/jscode2session";

private String getOpenid(UserLoginDTO userLoginDTO) {
//通过临时登录凭证 code 调用接口
Map<String,String> param = new HashMap<>();
param.put("appid",weChatProperties.getAppid());
param.put("secret",weChatProperties.getSecret());
param.put("js_code", userLoginDTO.getCode());
param.put("grant_type","authorization_code");
//将官方文档中的地址和自己小程序的参数传递过去
final String json = HttpClientUtil.doGet(WEIXI_LOGIN, param);
//解析成JSON字符串
final JSONObject jsonString = JSONObject.parseObject(json);
final String openid = jsonString.getString("openid");
return openid;
}

小程序相关配置

  1. yml,主要是appid和secret,可以自己在微信开发者工具中查看,注释的部分是配置微信支付的参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
     wechat:
    appid: ${sky.wechat.appid}
    secret: ${sky.wechat.secret}
    # mchid: ${sky.wechat.mchid}
    # mchSerialNo: ${sky.wechat.mchid}
    # privateKeyFilePath: ${sky.wechat.privateKeyFilePath}
    # apiV3Key: ${sky.wechat.apiV3Key}
    # weChatPayCertFilePath: ${sky.wechat.weChatPayCertFilePath}
    # notifyUrl: ${sky.wechat.notifyUrl}
    # refundNotifyUrl: ${sky.wechat.refundNotifyUrl}
  2. 配置类,通过依赖注入的形式将参数导入到配置类,后续要在代码中使用只要自动装配就好了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @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; //退款成功的回调地址

    }
  3. 微信支付相关功能需要认证的商户号,个人申请的小程序无法实现该功能

Spring Task 定时任务框架

定时自动执行某段Java代码

解决用户下单后一直未支付,订单一直处于未支付状态

管理端未点击完成按钮,订单一直处于派送中的状态

使用方法

  1. 导入maven坐标,Spring-context,springBoot本身的maven坐标中就包含了

  2. 启动类添加@EnableScheduling开启任务调度

  3. 自定义定时任务类

    • cron表达式:配置定时任务触发时间 0 0 12 * * ?:每天的12点整执行。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    /**
    * @Description: 定时任务类,定时处理订单信息
    * @Params:
    * @return:
    * @Author: XiaoYu
    * @date: 2024/12/25 上午9:00
    **/
    @Component
    @Slf4j
    public class OrderTask {

    @Autowired
    private OrdersMapper ordersMapper;
    /**
    * @Description: 定时处理超时订单
    * @Params: []
    * @return: void
    * @Author: XiaoYu
    * @date: 2024/12/25 上午9:02
    **/
    @Scheduled(cron = "0 * * * * ?")
    // @Scheduled(cron = "1/5 * * * * *")
    public void processTimeoutOrder(){
    log.info("处理超时订单:{}", LocalDateTime.now());
    final LocalDateTime localDateTime = LocalDateTime.now().plusMinutes(-15);
    //付款超时订单 status=1 order_time< 当前时间-15分钟
    List<Orders> ordersList = ordersMapper.getStatusAndOrderTime(Orders.PENDING_PAYMENT,localDateTime);
    //TODO isEmpty方法并不会检查集合是否为空,只会判断集合的长度是否为0
    if(ordersList!=null&&!ordersList.isEmpty()){
    for(Orders orders:ordersList){
    orders.setStatus(Orders.CANCELLED);
    orders.setCancelReason("付款超时,订单自动取消");
    orders.setCancelTime(LocalDateTime.now());
    ordersMapper.update(orders);
    }
    }


    }

    /**
    * @Description: 处理一直处于派送中的订单
    * @Params: []
    * @return: void
    * @Author: XiaoYu
    * @date: 2024/12/25 上午9:14
    **/
    @Scheduled(cron = "0 0 1 * * ?")
    // @Scheduled(cron = "0/5 * * * * *")
    public void processDeliveryOrder(){
    log.info("处理一直处于派送中订单:{}", LocalDateTime.now());
    final LocalDateTime localDateTime = LocalDateTime.now().plusMinutes(-60);
    List<Orders> ordersList = ordersMapper.getStatusAndOrderTime(Orders.DELIVERY_IN_PROGRESS,localDateTime);
    if(ordersList!=null&&!ordersList.isEmpty()){
    for(Orders orders:ordersList){
    orders.setStatus(Orders.COMPLETED);
    ordersMapper.update(orders);
    }
    }
    }

    }

WebSocket

基于TCP一种新的网络协议,一次握手两者之间就可以建立持久性的连接,并进行双向数据传输

在本项目是用户向服务端推送消息,比如下单提醒、催单提醒

约定服务端发送来到格式为JSON,有type、orderld、content三个字段

type: 消息类型 1 来单 2 催单

orderld: 订单ID

content: 消息内容

使用方法

  1. 导入maven坐标

    1
    2
    3
    4
    5
    <dependency>
    <groupId>javax.websocket</groupId>
    <artifactId>javax.websocket-api</artifactId>
    <version>1.1</version>
    </dependency>
  2. 导入服务端组件,用于和客户端通信

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    /**
    * @Description: webSocket组件
    * @Params:
    * @return:
    * @Author: XiaoYu
    * @date: 2024/12/25 上午9:40
    **/
    @Component
    @ServerEndpoint("/ws/{sid}")
    @Slf4j
    public class WebSocketServer {

    //存放会话对象
    private static Map<String, Session> sessionMap = new HashMap();

    /**
    * 连接建立成功调用的方法
    */
    @OnOpen
    public void onOpen(Session session, @PathParam("sid") String sid) {
    log.info("客户端: {} 建立连接",sid);
    sessionMap.put(sid, session);
    }

    /**
    * 收到客户端消息后调用的方法
    *
    * @param message 客户端发送过来的消息
    */
    @OnMessage
    public void onMessage(String message, @PathParam("sid") String sid) {
    log.info("收到来自客户端:{}的信息:{}",sid,message);
    }

    /**
    * 连接关闭调用的方法
    *
    * @param sid
    */
    @OnClose
    public void onClose(@PathParam("sid") String sid) {
    log.info("连接断开:{}" , 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();
    }
    }
    }

    }

  3. 导入WebSocketConfiguration配置类,注册WebSocket的服务端组件,加载SpringBoot时就会自动调用

    1
    2
    3
    4
    5
    6
    7
    8
    @Configuration
    public class WebSocketConfiguration {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
    return new ServerEndpointExporter();
    }
    }

  4. 客户支付成功后调用群发的API实现服务端向客户端发送消息,客户端浏览器解析前端推送过来的消息,判断操作类型,进行相应的语音播报

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //给商家发送来单提醒
    final Map map = new HashMap();
    map.put("type",1);//1 来单 2 催单
    //根据订单号查出订单id
    final Orders byNumber = ordersMapper.getByNumber(ordersPaymentDTO.getOrderNumber());
    map.put("orderId",byNumber.getId());//订单Id
    map.put("content","订单号:"+ordersPaymentDTO.getOrderNumber());
    final String jsonString = JSONObject.toJSONString(map);
    //推送到前端页面
    webSocketServer.sendToAllClient(jsonString);

细节

  1. 前端发送的请求是到nginx服务器,通过nginx服务器反向代理的形式在将请求转发到后端服务器
  2. 具体群发的代码应该写在支付成功后的回调函数中,但没有实现支付这个功能,所以写在了支付方法中

Apache ECharts

基于JavaScript的数据可视化图表库,直观、生动、可交互

在本项目中主要是实现各个维度的数据统计以图表的形式展示,我们根据前端定义的接口将数据横纵坐标的值传递过去

主要是遍历每天得到数据,还有就是SQL语句的编写,利用map集合封装数据对数据库

具体代码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
   /**
*以订单数据统计为例,通过DateTimeFormat注解指定日期时间的格式化模式
**/
@GetMapping("/ordersStatistics")
public Result<OrderReportVO> ordersStatistics(
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin ,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){
log.info("订单数据统计:{},{}",begin,end);
return Result.success(reportService.ordersStatistics(begin,end));
}


//serviceImpl中的方法
/*订单数据统计*/
public OrderReportVO ordersStatistics(LocalDate begin, LocalDate end) {
//这个是自定义方法是将begin和结束的时间分割为列表,以天为单位
final List<LocalDate> dateList = getLocalDates(begin,end);

List<Integer> orderCountList = new ArrayList<>();//订单总数列表
List<Integer> validOrderCountList = new ArrayList<>();//有效订单数
for (LocalDate localDate : dateList) {
LocalDateTime beginTime = LocalDateTime.of(localDate,LocalTime.MIN);
LocalDateTime endTime = LocalDateTime.of(localDate,LocalTime.MAX);
Integer orderCount = getOrderCount(beginTime,endTime,null);//当天订单总数
//这种判断形式还可以优化 Optional类
orderCount = orderCount == null ? 0:orderCount;
Integer validOrderCount = getOrderCount(beginTime,endTime,Orders.COMPLETED);;//当天的有效订单数
validOrderCount = validOrderCount == null ? 0:validOrderCount;
orderCountList.add(orderCount);
validOrderCountList.add(validOrderCount);
}

//利用steam流得到订单总数和有效订单总数
Integer totalOrderCount = orderCountList.stream().reduce(Integer::sum).get();
totalOrderCount = totalOrderCount == null ? 0:totalOrderCount;

Integer validOrderCount = validOrderCountList.stream().reduce(Integer::sum).get();
validOrderCount = validOrderCount == null ? 0:validOrderCount;
Double orderCompletionRate = 0.0;
if(totalOrderCount!=0){
orderCompletionRate = validOrderCount.doubleValue()/totalOrderCount;
}


//StringUtils利用这个工具包对列表进行分割·
return OrderReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.orderCountList(StringUtils.join(orderCountList,","))
.validOrderCountList(StringUtils.join(validOrderCountList,","))
.totalOrderCount(totalOrderCount)
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.build();
}

Apache POI

使用POI在Java程序中对Misrcosoft Office各种文件进行读写操作

具体使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/*导出报表*/
public void export(HttpServletResponse response) throws Exception {
//导出最近30天的数据
final LocalDate beginTime = LocalDate.now().minusDays(30);
final LocalDate endTime = LocalDate.now().minusDays(1);
BusinessDataVO businessDataVO = workspaceService.businessData(LocalDateTime.of(beginTime,LocalTime.MIN),LocalDateTime.of(endTime,LocalTime.MAX));
//TODO 从类路径(classpath)中加载
InputStream in = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
//基于模板文件创建一个新的excel文件
final XSSFWorkbook excel = new XSSFWorkbook(in);
//获取第一个sheet页
final XSSFSheet sheet = excel.getSheetAt(0);
//写入概述数据 第二行第二列
sheet.getRow(1).getCell(1).setCellValue("时间:"+beginTime+"至"+endTime);
//获取第四行
XSSFRow row = sheet.getRow(3);
row.getCell(2).setCellValue(businessDataVO.getTurnover());//营业额
row.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate());//订单完成率
row.getCell(6).setCellValue(businessDataVO.getNewUsers());//新增用户数
//第五行
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessDataVO.getValidOrderCount());//有效订单
row.getCell(4).setCellValue(businessDataVO.getUnitPrice());//平均单价

//开始时间
//填充明细数据,生成30天的数据报表
for (int i = 0; i < 30; i++) {
LocalDate date = beginTime.plusDays(i);
//查询某一天订单营业数据
final BusinessDataVO dataVO = workspaceService.businessData(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(dataVO.getTurnover());//营业额
row.getCell(3).setCellValue(dataVO.getValidOrderCount());//有效订单
row.getCell(4).setCellValue(dataVO.getOrderCompletionRate());//订单完成率
row.getCell(5).setCellValue(dataVO.getUnitPrice());//平均单价
row.getCell(6).setCellValue(dataVO.getNewUsers());//新增用户数
}

//将数据写入报表
final ServletOutputStream outputStream = response.getOutputStream();
excel.write(outputStream);

//关闭资源
outputStream.close();
excel.close();
in.close();

}

使用细节

  1. 一般来说表格的格式直接导入模板进行绘制,在spring boot中模板文件要放在resource包下
  2. 重点是表格中如何填充数据