Java21 + SpringBoot3集成easy-captcha实现验证码显示和登录校验

news2024/11/16 1:35:37

文章目录

    • 前言
    • 相关技术简介
      • easy-captcha
    • 实现步骤
      • 引入maven依赖
      • 定义实体类
      • 定义登录服务类
      • 定义登录控制器
      • 前端登录页面实现
      • 测试和验证
    • 总结
    • 附录
      • 使用`Session`缓存验证码
      • 前端登录页面实现代码

前言

近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。

本项目为前后端分离开发,后端基于Java21SpringBoot3开发,后端使用Spring SecurityJWTSpring Data JPA等技术栈,前端提供了vueangularreactuniapp微信小程序等多种脚手架工程。

本文主要介绍在SpringBoot3项目中如何集成easy-captcha生成验证码,JDK版本是Java21,前端使用Vue3开发。

项目地址:https://gitee.com/breezefaith/fast-alden

相关技术简介

easy-captcha

easy-captcha是生成图形验证码的Java类库,支持gif、中文、算术等类型,可用于Java Web、JavaSE等项目。

参考地址:

  • Github:https://github.com/whvcse/EasyCaptcha

实现步骤

引入maven依赖

pom.xml中添加easy-captcha以及相关依赖,并引入Lombok用于简化代码。

<dependencies>
  <!-- easy-captcha -->
  <dependency>
    <groupId>com.github.whvcse</groupId>
    <artifactId>easy-captcha</artifactId>
    <version>1.6.2</version>
  </dependency>
  <!--    解决easy-captcha算术验证码报错问题    -->
  <dependency>
    <groupId>org.openjdk.nashorn</groupId>
    <artifactId>nashorn-core</artifactId>
    <version>15.4</version>
  </dependency>
  <!-- Lombok -->
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.30</version>
    <optional>true</optional>
  </dependency>
</dependencies>

笔者使用的JDK版本是Java21SpringBoot版本是3.2.0,如果不引入nashorn-core,生成验证码时会报错java.lang.NullPointerException: Cannot invoke "javax.script.ScriptEngine.eval(String)" because "engine" is null。有开发者反馈使用Java 17时也遇到了同样的问题,手动引入nashorn-core后即可解决该问题。

详细堆栈和截图如下:

java.lang.NullPointerException: Cannot invoke "javax.script.ScriptEngine.eval(String)" because "engine" is null
	at com.wf.captcha.base.ArithmeticCaptchaAbstract.alphas(ArithmeticCaptchaAbstract.java:42) ~[easy-captcha-1.6.2.jar:na]
	at com.wf.captcha.base.Captcha.checkAlpha(Captcha.java:156) ~[easy-captcha-1.6.2.jar:na]
	at com.wf.captcha.base.Captcha.text(Captcha.java:137) ~[easy-captcha-1.6.2.jar:na]
	at com.fast.alden.admin.service.impl.AuthServiceImpl.generateVerifyCode(AuthServiceImpl.java:72) ~[classes/:na]
  ......

image.png

定义实体类

为了方便后端校验,获取验证码的请求除了要返回验证码图片本身,还要返回一个验证码的唯一标识,所以笔者定义了一个实体类VerifyCodeEntity

/**
 * 验证码实体
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class VerifyCodeEntity implements Serializable {
    /**
     * 验证码Key
     */
    private String key;

    /**
     * 验证码图片,base64压缩后的字符串
     */
    private String image;

    /**
     * 验证码文本值
     */
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    private String text;
}

使用@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)注解可以使text属性不会被序列化后返回给前端。

为实现登录功能,还要定义一个登录参数类LoginParam

@Data
public class LoginParam {
    /**
     * 用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
    /**
     * 验证码Key
     */
    private String verifyCodeKey;
    /**
     * 验证码
     */
    private String verifyCode;
}

定义登录服务类

在登录服务类中,我们需要定义以下方法:

  1. 生成验证码

    在该方法中使用easy-captcha生成一个验证码,生成的验证码除了要返回给前端,还需要在后端进行缓存,这样才能实现前后端的验证码校验。本文中给出了两种缓存验证码的方式,一种是基于RedisTemplate缓存至Redis,一种是缓存至Session,读者可根据需要选择性使用,推荐使用**Redis**。在本文附录中给出了缓存至Session的实现方式。

  2. 登录

    在登录方法中首先校验验证码是否正确,然后再校验用户名和密码是否正确,校验通过后生成Token返回给前端。本文中该方法仅给出验证码校验相关的逻辑,其他逻辑请自行实现。

@Service
public class AuthService {
    private final RedisTemplate<String, Object> redisTemplate;

    public AuthService(
        RedisTemplate<String, Object> redisTemplate
    ) {
        this.redisTemplate = redisTemplate;
    }

    public VerifyCodeEntity generateVerifyCode() throws IOException {
        // 创建验证码对象
        Captcha captcha = new ArithmeticCaptcha();

        // 生成验证码编号
        String verifyCodeKey = UUID.randomUUID().toString();
        String verifyCode = captcha.text();

        // 获取验证码图片,构造响应结果
        VerifyCodeEntity verifyCodeEntity = new VerifyCodeEntity(verifyCodeKey, captcha.toBase64(), verifyCode);

        // 存入Redis,设置120s过期
        redisTemplate.opsForValue().set(verifyCodeKey, verifyCode, 120, TimeUnit.SECONDS);

        return verifyCodeEntity;
    }

    public String login(LoginParam param) {
        // 校验验证码
        // 获取用户输入的验证码
        String actual = param.getVerifyCode();
        // 判断验证码是否过期
        if (redisTemplate.getExpire(param.getVerifyCodeKey(), TimeUnit.SECONDS) < 0) {
            throw new RuntimeException("验证码过期");
        }
        // 从redis读取验证码并删除缓存
        String expect = (String) redisTemplate.opsForValue().get(param.getVerifyCodeKey());
        redisTemplate.delete(param.getVerifyCodeKey());

        // 比较用户输入的验证码和缓存中的验证码是否一致,不一致则抛错
        if (!StringUtils.hasText(expect) || !StringUtils.hasText(actual) || !actual.equalsIgnoreCase(expect)) {
            throw new RuntimeException("验证码错误");
        }

        // 校验用户名和密码,校验成功后生成token返回给前端,具体逻辑省略
        String token = "";

        return token;
    }
}

定义登录控制器

/**
 * 登录控制器
 */
@RestController("/auth")
public class AuthController {
    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    /**
     * 获取验证码
     */
    @GetMapping("/verify-code")
    public VerifyCodeEntity generateVerifyCode() throws IOException {
        return authService.generateVerifyCode();
    }

    /**
     * 登录
     */
    @PostMapping("/login")
    public String login(@RequestBody @Validated LoginParam param) {
        return authService.login(param);
    }
}

前端登录页面实现

此前端页面基于Vue3的组合式API和Element Plus开发,使用Axios向后端发送请求,因代码较长,将其放在附录中,请移步至附录查看。

测试和验证

image.png

总结

本文介绍了如何基于Java21SpringBoot3集成easy-captcha实现验证码显示和登录校验,给出了详细的实现代码,如有错误,还望批评指正。

在后续实践中我也是及时更新自己的学习心得和经验总结,希望与诸位看官一起进步。

附录

使用Session缓存验证码

使用Session缓存验证码时还需要借助ScheduledExecutorServiceTimerQuartz等实现一个延迟任务,用于从Session中删除超时的验证码。

@Service
public class AuthService {
    private final ScheduledExecutorService scheduledExecutorService;

    public AuthService(
        ScheduledExecutorService scheduledExecutorService
    ) {
        this.scheduledExecutorService = scheduledExecutorService;
    }

    public VerifyCodeEntity generateVerifyCode() throws IOException {
        // 创建验证码对象
        Captcha captcha = new ArithmeticCaptcha();

        // 生成验证码编号
        String verifyCodeKey = UUID.randomUUID().toString();
        String verifyCode = captcha.text();

        // 获取验证码图片,构造响应结果
        VerifyCodeEntity verifyCodeEntity = new VerifyCodeEntity(verifyCodeKey, captcha.toBase64(), verifyCode);

        // 存入session,设置120s过期
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpSession session = attributes.getRequest().getSession();
        session.setAttribute(verifyCodeKey, verifyCode);
        // 超时后删除验证码缓存
        // 以下是使用ScheduledExecutorService实现
        scheduledExecutorService.schedule(() -> {
            session.removeAttribute(verifyCode);
        }, 120, TimeUnit.SECONDS);
        // // 以下是使用Timer实现超时后删除验证码
        // Timer timer = new Timer();
        // timer.schedule(new TimerTask() {
        //     @Override
        //     public void run() {
        //         session.removeAttribute(verifyCode);
        //     }
        // }, 120 * 1000L);

        return verifyCodeEntity;
    }

    public String login(LoginParam param) {
        // 校验验证码
        // 获取用户输入的验证码
        String actual = param.getVerifyCode();

        // 从Session读取验证码并删除缓存
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpSession session = attributes.getRequest().getSession();
        String expect = (String) session.getAttribute(param.getVerifyCodeKey());
        session.removeAttribute(param.getVerifyCodeKey());

        // 比较用户输入的验证码和缓存中的验证码是否一致,不一致则抛错
        if (!StringUtils.hasText(expect) || !StringUtils.hasText(actual) || !actual.equalsIgnoreCase(expect)) {
            throw new RuntimeException("验证码错误");
        }

        // 校验用户名和密码,校验成功后生成token返回给前端,具体逻辑省略
        String token = "";

        return token;
    }
}

以上代码中使用ScheduledExecutorService设置了一个延迟任务,120s后从Session中删除验证码,还需要声明一个ScheduledExecutorService的Bean。

/**
 * 线程池配置
 */
@Configuration
public class ThreadPoolConfig {
    /**
     * 核心线程池大小
     */
    private final int corePoolSize = 50;

    @Bean
    public ScheduledExecutorService scheduledExecutorService() {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }
}

前端登录页面实现代码

<script setup>
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElForm, ElFormItem, ElInput, ElButton, ElCheckbox } from 'element-plus';
import { CircleCheck, Lock, User, Search, Refresh, Plus, Edit, Delete, View, Upload, Download, Share, Close } from "@element-plus/icons-vue";
import axios, { AxiosError } from 'axios';
import bg from "@/assets/login/bg.png";

const router = useRouter();

const entity = ref({});
const rememberMe = ref(true);
const REMEMBER_ME_KEY = "remember_me";
const formRef = ref();
const loading = ref(false);
const verifyCodeUrl = ref("");

const rules = reactive({
    username: [
        {
            required: true,
            message: '请输入用户名',
            trigger: 'blur'
        }
    ],
    password: [
        {
            validator: (rule, value, callback) => {
                if (!value) {
                    callback(new Error("请输入密码"));
                } else {
                    callback();
                }
            },
            trigger: "blur"
        }
    ],
    verifyCode: [
        {
            required: true,
            message: '请输入验证码',
            trigger: 'blur'
        },
    ],
});

// 点击登录按钮
const login = async () => {
    const formEl = formRef.value;
    loading.value = true;
    if (!formEl) {
        loading.value = false;
        return;
    }
    await formEl.validate(async (valid, fields) => {
        if (valid) {
            try {
                const res = await login$(entity.value);
                // 从响应中获取token
                const token = res.data.data;
                if (token) {
                    // 将token存入Pinia,authStore请自行定义
                    // authStore.authenticate({ token });

                    // warning: 此方式直接将用户名密码明文存入localStorage,并不安全
                    // todo:寻找更合理方式实现“记住我”
                    if (rememberMe.value) {
                        localStorage.setItem(REMEMBER_ME_KEY, JSON.stringify({
                            username: entity.value.username,
                            password: entity.value.password,
                        }));
                    } else {
                        localStorage.removeItem(REMEMBER_ME_KEY);
                    }

                    ElMessage({ message: "登录成功", type: "success" });
                    router.push("/");
                }else{
                    ElMessage({ message: "登录失败", type: "error" });
                }
            } catch (err) {
                if (err instanceof AxiosError) {
                    const msg = err.response?.data?.message || err.message;
                    ElMessage({ message: msg, type: "error" });
                }
                updateVerifyCode();
                throw err;
            } finally {
                loading.value = false;
            }
        } else {
            loading.value = false;
            return fields;
        }
    });
};

// 获取验证码请求
const getVerifyCode$ = async () => {
    return axios.get(`/api/v1.0/admin/auth/verify-code?timestamp=${new Date().getTime()}`, false);
}

// 登录请求
const login$ = async (param) => {
    return axios.post(`/api/v1.0/admin/auth/login`, {
        ...param,
    });
}

// 更新验证码图片
const updateVerifyCode = async () => {
    const res = await getVerifyCode$();
    verifyCodeUrl.value = `${res.data.data?.image}`;
    entity.value.verifyCodeKey = res.data.data?.key;
}

/** 使用公共函数,避免`removeEventListener`失效 */
function onkeypress({ code }) {
    if (code === "Enter" || code === "NumpadEnter") {
        login();
    }
}

// 页面加载时读取localStorage,如果有记住的用户名密码则加载至界面
const load = async () => {
    const tmp = localStorage.getItem(REMEMBER_ME_KEY);
    if (tmp) {
        const e = JSON.parse(tmp);
        entity.value.username = e.username;
        entity.value.password = e.password;
    }
}

onMounted(async () => {
    window.document.addEventListener("keypress", onkeypress);

    updateVerifyCode();

    load();
});

onBeforeUnmount(() => {
    window.document.removeEventListener("keypress", onkeypress);
});

</script>

<template>
    <img class="login-bg" :src="bg" />
    <div class="login-container">
        <div class="login-box">
            <ElForm class="login-form" ref="formRef" :model="entity" :rules="rules" size="large">
                <h3 class="title">后台管理系统</h3>
                <ElFormItem prop="username">
                    <ElInput clearable v-model="entity.username" placeholder="用户名/手机号/邮箱" :prefix-icon="User" />
                </ElFormItem>

                <ElFormItem prop="password">
                    <ElInput clearable show-password v-model="entity.password" placeholder="密码" :prefix-icon="Lock" />
                </ElFormItem>

                <ElFormItem class="verify-code-row" prop="verifyCode">
                    <ElInput clearable v-model="entity.verifyCode" placeholder="验证码" :prefix-icon="CircleCheck">
                        <template #append>
                            <img :src="verifyCodeUrl" class="verify-code" @click="updateVerifyCode()" />
                        </template>
                    </ElInput>
                </ElFormItem>

                <ElFormItem>
                    <ElCheckbox v-model="rememberMe" label="记住我"></ElCheckbox>
                </ElFormItem>

                <ElFormItem>
                    <ElButton class="w-full" style="width: 100%" size="default" type="primary" :loading="loading" @click="login()">
                        登录
                    </ElButton>
                </ElFormItem>
            </ElForm>
        </div>
    </div>
</template>

<style lang="scss">
.login-bg {
    position: fixed;
    height: 100%;
    left: 0;
    bottom: 0;
    z-index: -1;
}

.login-container {
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    position: absolute;
    display: flex;
    justify-items: center;
    justify-content: center;

    .login-box {
        display: flex;
        align-items: center;
        text-align: center;

        .login-form {
            width: 360px;

            .verify-code-row {
                .el-input-group__append {
                    padding: 0;
                }

                .verify-code {
                    height: 40px;
                }
            }
        }
    }
}
</style>

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1402838.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Linux C | 进程】进程环境 | 什么是进程?进程的开始、终止、存储空间布局、命令行参数、环境变量

&#x1f601;博客主页&#x1f601;&#xff1a;&#x1f680;https://blog.csdn.net/wkd_007&#x1f680; &#x1f911;博客内容&#x1f911;&#xff1a;&#x1f36d;嵌入式开发、Linux、C语言、C、数据结构、音视频&#x1f36d; &#x1f923;本文内容&#x1f923;&a…

如何无公网ip实现SSH远程访问本地局域网openEuler系统?

文章目录 1. 本地SSH连接测试2. openEuler安装Cpolar3. 配置 SSH公网地址4. 公网远程SSH连接5. 固定连接SSH公网地址6. SSH固定地址连接测试 欧拉操作系统(openEuler, 简称“欧拉”)是面向数字基础设施的操作系统,支持服务器、云计算、边缘openEuler是面向数字基础设施的操作系…

2023.1.17 关于 Redis 持久化 AOF 策略详解

目录 引言 AOF 策略 实例演示一 缓冲区 重写机制 手动触发 自动触发 AOF 重写流程 实例演示二 引言 Redis 实现持久化的两大策略 RDB ——> Redis DataBase&#xff08;定期备份&#xff09;AOF ——> Append Only File&#xff08;实时备份&#xff09; 注意&…

Linux中的软件包管理器yum

目录 1.什么是软件包 2.关于 rzsz 3.查看软件包 4.如何安装软件 5.如何卸载软件 1.什么是软件包 ● 在Linux下安装软件, 一个通常的办法是下载到程序的源代码, 并进行编译, 得到可执行程序. ● 但是这样太麻烦了, 于是有些人把一些常用的软件提前编译好, 做成软件包(可以理…

EasyExcelFactory 导入导出功能的实战使用

EasyExcelFactory 导入导出功能的实战使用分享&#xff1a; 1、jar包引入 <!-- 阿里巴巴Excel处理--><dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.0.6</version></dependen…

华为AC+FIT AP组网配置

AC配置 vlan batch 100 to 101dhcp enableip pool apgateway-list 192.168.100.254 network 192.168.100.0 mask 255.255.255.0 interface Vlanif100ip address 192.168.100.254 255.255.255.0dhcp select globalinterface GigabitEthernet0/0/1port link-type trunkport trun…

本地部署轻量级web开发框架Flask并实现无公网ip远程访问开发界面

文章目录 1. 安装部署Flask2. 安装Cpolar内网穿透3. 配置Flask的web界面公网访问地址4. 公网远程访问Flask的web界面 本篇文章主要讲解如何在本地安装Flask&#xff0c;以及如何将其web界面发布到公网进行远程访问。 Flask是目前十分流行的web框架&#xff0c;采用Python编程语…

14027.ptp 控制流

文章目录 1 ptp 控制流1.1 控制流分层 1 ptp 控制流 1.1 控制流分层 大体分为4层&#xff1a;1 ptp4l层&#xff1a; 获取配置文件、创建时钟、poll监控文件描述符。2 clock时钟层&#xff1a;提供提供clock_poll、clock_create、clock_sync 等3 port 端口层&#xff1a;port…

数据结构与算法:图

文章目录 图1) 概念有向 vs 无向度权路径环图的连通性 2) 图的表示3) Java 表示4) DFS5) BFS6) 拓扑排序7) 最短路径DijkstraBellman-FordFloyd-Warshall 8) 最小生成树PrimKruskal 图 1) 概念 图是由顶点&#xff08;vertex&#xff09;和边&#xff08;edge&#xff09;组成…

如何搭建MariaDB并实现无公网ip环境远程连接本地数据库

&#x1f308;个人主页: Aileen_0v0 &#x1f525;热门专栏: 华为鸿蒙系统学习|计算机网络|数据结构与算法 ​&#x1f4ab;个人格言:“没有罗马,那就自己创造罗马~” 文章目录 1. 配置MariaDB数据库1.1 安装MariaDB数据库1.2 测试局域网内远程连接 2. 内网穿透2.1 创建隧道映射…

【C++】stack与queue的模拟实现

&#x1f440;樊梓慕&#xff1a;个人主页 &#x1f3a5;个人专栏&#xff1a;《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C》《Linux》《算法》 &#x1f31d;每一个不曾起舞的日子&#xff0c;都是对生命的辜负 前言 stack与queue的实现比较简…

Mysql - 定点型(DECIMAL)的使用详解及练习

目录 &#x1f436;1. 前言&#xff1a; &#x1f436;2. DECIMAL类型简介 &#x1f436;3. Decimal使用实战 &#x1f96a;#结论1&#xff1a;小数位不足会自动补0 &#x1f96a;#结论2&#xff1a;小数位超出会截断 并按四舍五入处理。 &#x1f96a;#结论3&#xff1…

代码随想录算法训练营第34天 |1005.K次取反后最大化的数组和 134. 加油站 135. 分发糖果

1005.K次取反后最大化的数组和 题目链接&#xff1a;1005.K次取反后最大化的数组和 给定一个整数数组 A&#xff0c;我们只能用以下方法修改该数组&#xff1a;我们选择某个索引 i 并将 A[i] 替换为 -A[i]&#xff0c;然后总共重复这个过程 K 次。&#xff08;我们可以多次选…

【明道云】学习笔记1-了解APaaS

【背景】 APaaS (Application Platform As A Service) &#xff0c;即应用程序平台即服务&#xff0c;这是基于PaaS&#xff08;平台即服务&#xff09;的一种解决方案&#xff0c;支持应用程序在云端的开发、部署和运行&#xff0c;提供软件开发中的基础工具给用户&#xff0…

【JavaEE进阶】 Spring Boot⽇志

文章目录 &#x1f38b;关于日志&#x1f6a9;为什么要学习⽇志&#x1f6a9;⽇志的⽤途&#x1f6a9;日志的简单使用 &#x1f384;打印⽇志&#x1f6a9;程序中得到⽇志对象&#x1f6a9;使⽤⽇志对象打印⽇志 &#x1f38d;⽇志格式的说明&#x1f6a9;⽇志级别的作用&#…

Java - 深入理解加密解密和签名算法

文章目录 应用的接口安全性问题可能来源加密解密Why保护数据隐私防止未经授权的访问防止数据泄露 对称加密 VS 单向加密 VS 非对称加密一、对称加密二、单向加密&#xff08;哈希加密&#xff09;三、非对称加密 常用的对称加密算法1. AES&#xff08;高级加密标准&#xff09;…

【Java】学习一门开发语言,从TA的Hello World开始

欢迎来到《小5讲堂》 大家好&#xff0c;我是全栈小5。 这是《Java》序列文章&#xff0c;每篇文章将以博主理解的角度展开讲解&#xff0c; 特别是针对知识点的概念进行叙说&#xff0c;大部分文章将会对这些概念进行实际例子验证&#xff0c;以此达到加深对知识点的理解和掌握…

第15届蓝桥杯嵌入式省赛准备第二天总结笔记(使用STM32cubeMX创建hal库工程+按键输入)

一.查看电路图 按键是使用的PB0,PB1,PB2,PA0四个引脚&#xff0c;然后使用CubeMX配置引脚&#xff0c;4个脚都配置为输入模式和上拉。 程序生成之后把不用的删掉&#xff0c;需要的留下&#xff0c;这里我把函数名改了。 然后写按键扫描读取程序&#xff0c;这里参考的正点原子…

【Linux】—— 命名管道详解

命名管道是一种在操作系统中用于进程间通信的机制&#xff0c;它允许不同的进程之间通过管道进行数据交换。与匿名管道相比&#xff0c;命名管道具有更多的灵活性和功能。在本博客中&#xff0c;我们将深入探讨命名管道的概念、用途以及如何在编程中使用它们。 目录 &#xff…

【cucumber】cucumber-reporting生成测试报告

原始的cucumber report 比较粗糙 我们可以通过cucumber-reporting 插件对报告进去优化 在pom.xml里面添加cuccumber-reporting 插件 <!-- 根据 cucumber json文件 美化测试报告--><dependency><groupId>net.masterthought</groupId><artifactId>…