SpringBoot集成Quartz 2.3.1动态管理定时任务

前言

我写了一个简单的Demo项目,有需要的文末可获取项目github地址,该项目我会一直保持更新。

主要实现功能如下:

  • 基于quartz2.3.1实现动态管理定时任务。
  • 使用swagger实现接口文档。
  • 前后端统一使用JSON格式交互。
  • 使用Hutool工具类直接连接数据库,避免Job任务中不能使用Autowired问题。

swagger文档如下图:

swagger文档
后续文章

一、Quartz简介

Quartz是一款功能强大的任务调度框架,官网介绍说:“Quartz可以执行数以百计甚至数以万计的简单或复杂的定时任务,并且可以集成到任何Java应用程序中”。他具有以下功能:

  • 任务管理:可动态对任务进行管理,包括创建、暂停、删除、恢复等。
  • 任务持久化:任务可通过JDBC存储到数据库中。
  • 任务监听:可以实现侦听器接口来捕获调度事件,以监视或控制作业/触发器的行为。
  • 支持集群和JTA事务。

二、版本介绍

Quartz的最新稳定版本2.3.0于2017年4月19号发布,2018年3月15号发布了2.3.1版本,2.3.2版本仍在GitHub quartz-2.3.x分支下进行中!他们都需要基于JDK7版本,2.3.1版本与2.3.2版本相比于2.3.0没有大的变化,主要都是功能和代码的完善修复。目前Quartz的最新版本2.4.0在Github主分支下更新,该版本将JDK版本升级到了JDK8。更多详细信息可以看Quartz的变更日志:changelog.adoc

本文基于的2.3.1版本完成,使用2.3.0与2.3.2版本替换均不受影响。

三、概念介绍

Quartz中有三个核心元素:

  • Job:被调度的定时任务。
  • Trigger:用来定义定时任务的触发时间。
  • Schedule:这是Quartz框架的核心,他是真正执行定时任务的控制器。

Schedule维护着一个定时任务和触发器的注册表,当两者注册之后,如果触发器到达规定时间触发的时候,Schedule负责执行与触发器关联的定时任务。值得注意的是一个定时任务可以对应多个触发器,当每个触发器触发的时候都会执行其关联的定时任务,但是一个触发器只能对应一个定时任务。
在这里插入图片描述

四、代码示例

看完上面的概念,那么具体需要怎么做呢?

1:导入依赖

本文使用Springboot 2.3.1版本,配置如下Maven依赖即可开始使用Quartz。

<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz</artifactId>
    <version>2.3.1</version>
</dependency>

2:创建定时任务类

Quartz框架中提供一个Job接口,源码如下:

public interface Job {
    void execute(JobExecutionContext context) throws JobExecutionException;
}

当我们需要创建一个定时任务时,只需要实现该接口,并在execute()方法中写自己需要执行的任务即可。

该方法的参数JobExecutionContext主要用来保存定时任务执行期间的上下文信息,我们可以通过JobExecutionContext获取到定时任务的详细信息(后续文章中会详细介绍)。本例创建一个定时任务:打印定时任务的名称。

import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

/**
 * @author frost2
 * @date 2020-09-30 4:11:00
 */
public class HelloJob implements Job {
    
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        //获取我们设置的定时任务名称
        String name = context.getJobDetail().getKey().getName();
        System.out.println("name = " + name);
    }
}

3:启动任务

我们通过如下代码可以启动刚刚创建定时任务HelloJob,他的执行的频率为每30秒执行一次,并始终重复执行。

import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

/**
 * @author frost2
 * @date 2020-9-30 16:22:39
 */
public class StartJob {

    public void addJob() throws SchedulerException {
        SchedulerFactory schedulerFactory = new StdSchedulerFactory();
        Scheduler scheduler = schedulerFactory.getScheduler();
        scheduler.start();

        //定义JobDetail
        JobDetail job = JobBuilder.newJob(HelloJob.class) //设置需要执行的定时任务类
                .withIdentity("myJob", "group1")	//设置任务名称和任务组名称,他们两者唯一标识一个JobDetail
                .build();	//创建JobDetail

        Trigger trigger = TriggerBuilder.newTrigger()	
                .withIdentity("myTrigger", "group1")	//设置触发器名称和触发器组名称,他们两者唯一标识一个JobDetail
                .startNow()		//设置从当前时刻开始执行
                //withSchedule用于设置我们使用哪种触发器,在真正使用的时候是调用这里设置的ScheduleBuilder.build创建一个真正的触发器,这里我们使用SimpleScheduleBuilder。总共有四种触发器,其中我们功能最强大、使用最多是CronScheduleBuilder这我们会在后面讲到。
                .withSchedule(SimpleScheduleBuilder.simpleSchedule() //实例化一个SimpleScheduleBuilder
                        .withIntervalInSeconds(30)	//设置没30秒执行一次
                        .repeatForever())	//设置始终重复执行
                .build();

        scheduler.scheduleJob(job, trigger);	//关联Trigger和JobDetail并启动定时任务。
    }
}

上面这段代码中可以看到几个关键内容:

  1. JobDetail是对定时任务Job的封装,他保存了定时任务的详细信息。
  2. 更关键的是Trigger,他决定了一个任务什么时候执行,怎么执行。
  3. 当调用schedulerFactory.getScheduler()之后,我们的应用程序只有调用scheduler.shutdown()之后才会终止。
  4. 当执行scheduler.start()之后,Scheduler将处于“待机”模式,此时并不会触发Trigger执行定时任务。之后将定义好的Trigger和JobDetail通过scheduler.scheduleJob关联之后,Scheduler才会触发Trigger执行任务。
(1) ScheduleBuilder

通过上面的例子可以看到,一个定时任务执行的关键信息都是通过withSchedule这个方法来设置的,其源码如下:

public <SBT extends T> TriggerBuilder<SBT> withSchedule(ScheduleBuilder<SBT> schedBuilder) {
	this.scheduleBuilder = schedBuilder;
	return (TriggerBuilder<SBT>) this;
}

所以真正重要的就是ScheduleBuilder接口,用来定义任务执行的具体规则。它共有四个实现类,其中最重要也是实际开发中使用最多的是CronScheduleBuilder。CronScheduleBuilder的核心就是Cron表达式,我们只要将定义好的Cron表达式传递到CronScheduleBuilder中,Scheduler就会按照这个表达式定时执行Job任务。

在这里插入图片描述

(2) QuartzUtil 工具类

看完上面的内容,大致已经了解什么是quartz,并清楚如何创建一个quartz定时任务。下面是我封装的一个工具类,可以动态的时候对任务的CRUD。文末还提供一个Demo有需要的可以下载下载下来跑跑看。

package com.frost2.quartz.common.util;

import com.frost2.quartz.common.bean.Code;
import com.frost2.quartz.common.customException.CustomException;
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;
import org.quartz.impl.matchers.GroupMatcher;
import org.quartz.impl.triggers.CronTriggerImpl;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;

/**
 * @author 陈伟平
 * @date 2020-9-17 15:38:43
 */
public class QuartzUtil {

    private static SchedulerFactory schedulerFactory = new StdSchedulerFactory();
    private static Scheduler scheduler;
    private final static String groupName = "DEFAULT";

    static {
        try {
            scheduler = schedulerFactory.getScheduler();
        } catch (SchedulerException e) {
            throw new CustomException(Code.EXECUTION_ERROR);
        }
    }

    /**
     * 传入:任务名称、触发器名称、任务描述、要执行的任务类、cron表达式创建定时任务,
     * 返回是否创建成功
     * <p>
     * 注:
     * 在创建任务时未设置jobGroup和triggerGroup,Job创建后其均为默认值:DEFAULT,
     * 因此新创建的任务的jobName和triggerName,均不能与之前任务的重复.
     *
     * @param jobName     任务名
     * @param triggerName 触发器名
     * @param description 对该任务的秒数(非必须)
     * @param jobClass    要执行的任务
     * @param cron        cron表达式
     * @return true:创建Job成功,false:创建Job失败
     */
    public static <T extends Job> boolean addJob(String jobName, String triggerName, String description,
                                                 Class<T> jobClass, String cron) {
        try {
            scheduler.start();

            JobDetail job = JobBuilder.newJob(jobClass)
                    .withIdentity(jobName, groupName)
                    .withDescription(description)
                    .build();

            Trigger trigger = TriggerBuilder.newTrigger()
                    .withIdentity(triggerName, groupName)
                    .startNow()
                    .withSchedule(CronScheduleBuilder.cronSchedule(cron))
                    .build();

            scheduler.scheduleJob(job, trigger);
            return scheduler.isStarted();
        } catch (Exception e) {
            throw new CustomException(Code.EXECUTION_ERROR);
        }
    }

    /**
     * 修改一个任务的触发时间
     *
     * @param jobName     任务名称
     * @param triggerName 触发器名
     * @param cron        cron表达式
     * @return true:修改Job成功,false:修改Job失败
     */
    public static Boolean rescheduleJob(String jobName, String triggerName, String cron) {
        try {
            TriggerKey triggerKey = TriggerKey.triggerKey(triggerName);
            CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey);
            if (trigger == null) {
                return false;
            }

            checkJobNameAndTriggerName(jobName, trigger);

            Date latestFireTime = new Date();
            if (!trigger.getCronExpression().equalsIgnoreCase(cron)) {
                trigger = TriggerBuilder.newTrigger()
                        .withIdentity(triggerName)
                        .startNow()
                        .withSchedule(CronScheduleBuilder.cronSchedule(cron))
                        .build();
                //rescheduleJob()执行成功返回最近一次执行的时间,如果失败返回null
                latestFireTime = scheduler.rescheduleJob(triggerKey, trigger);
            }
            return null != latestFireTime;
        } catch (SchedulerException e) {
            throw new CustomException(Code.EXECUTION_ERROR);
        }
    }

    /**
     * 根据jobName和triggerName删除该JOB
     *
     * @param jobName     任务名称
     * @param triggerName 触发器名
     * @return true:删除Job成功,false:删除Job失败
     */
    public static boolean removeJob(String jobName, String triggerName) {
        boolean flag;
        try {

            Trigger trigger = scheduler.getTrigger(new TriggerKey(triggerName));
            if (null == trigger) {
                return false;
            }

            checkJobNameAndTriggerName(jobName, trigger);

            TriggerKey triggerKey = trigger.getKey();
            scheduler.pauseTrigger(triggerKey);
            flag = scheduler.unscheduleJob(triggerKey);

            //删除trigger之后无需在删除job,因为相关的job如果不是持久的,则将被自动删除。下面这种写法flag=false
//            if (flag) {
//                flag = scheduler.deleteJob(JobKey.jobKey(jobName));
//            }
        } catch (SchedulerException e) {
            throw new CustomException(Code.EXECUTION_ERROR);
        }
        return flag;
    }

    /**
     * 根据jobName和triggerName查询该JOB
     *
     * @param jobName     任务名称
     * @param triggerName 触发器名
     * @return 该Job相关信息[详见getJobInfo方法]
     */
    public static HashMap<String, String> getJob(String jobName, String triggerName) {
        try {
            JobDetail jobDetail = scheduler.getJobDetail(new JobKey(jobName));
            CronTrigger trigger = (CronTrigger) scheduler.getTrigger(new TriggerKey(triggerName));

            if (null == jobDetail || null == trigger) {
                return new HashMap<>();
            }

            checkJobNameAndTriggerName(jobName, trigger);

            return getJobInfo(jobDetail, trigger);
        } catch (SchedulerException e) {
            throw new CustomException(Code.EXECUTION_ERROR);
        }
    }

    /**
     * 查询所有正在执行的JOB
     *
     * @return 该Job相关信息[详见getJobInfo方法]
     */
    public static List<HashMap<String, String>> getJobs() {
        List<HashMap<String, String>> list = new ArrayList<>();
        try {
            List<String> triggerGroupNames = scheduler.getTriggerGroupNames();
            for (String groupName : triggerGroupNames) {

                GroupMatcher<TriggerKey> groupMatcher = GroupMatcher.groupEquals(groupName);
                //获取所有的triggerKey
                Set<TriggerKey> triggerKeySet = scheduler.getTriggerKeys(groupMatcher);
                for (TriggerKey triggerKey : triggerKeySet) {
                    //获取CronTrigger
                    CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey);
                    //获取trigger对应的JobDetail
                    JobDetail jobDetail = scheduler.getJobDetail(trigger.getJobKey());

                    list.add(getJobInfo(jobDetail, trigger));
                }
            }
        } catch (SchedulerException e) {
            throw new CustomException(Code.EXECUTION_ERROR);
        }
        return list;
    }

    /**
     * @param cron cron表达式
     * @return 最近5次的执行时间
     */
    public static List<String> getRecentTriggerTime(String cron) {
        List<String> list = new ArrayList<>();
        try {
            CronTriggerImpl cronTriggerImpl = new CronTriggerImpl();
            cronTriggerImpl.setCronExpression(cron);
            List<Date> dateList = TriggerUtils.computeFireTimes(cronTriggerImpl, null, 5);
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            dateList.forEach(date -> list.add(dateFormat.format(date)));
        } catch (ParseException e) {
            throw new CustomException(Code.EXECUTION_ERROR);
        }
        return list;
    }

    /**
     * 校验cron表达式是否正确
     *
     * @param cronExpression cron表达式
     * @return true:正确,false:不正确
     */
    @SuppressWarnings("all")
    public static boolean checkCronExpression(String cronExpression) {
        return CronExpression.isValidExpression(cronExpression);
    }

    /**
     * 获取该Job对应的相关信息
     */
    private static HashMap<String, String> getJobInfo(JobDetail jobDetail, CronTrigger trigger) {
        HashMap<String, String> map = new HashMap<>();
        map.put("jobName", jobDetail.getKey().getName());
        map.put("jobGroup", jobDetail.getKey().getGroup());
        map.put("corn", trigger.getCronExpression());
        map.put("triggerName", trigger.getKey().getName());
        map.put("description", jobDetail.getDescription());
        return map;
    }

    /**
     * 校验jobName和triggerName是否匹配
     * 如不匹配抛出自定义异常
     */
    private static void checkJobNameAndTriggerName(String jobName, Trigger trigger) {
        String name = trigger.getJobKey().getName();
        if (!name.equals(jobName)) {
            throw new CustomException(Code.PARAM_FORMAT_ERROR.getCode(), "jobName与triggerName不匹配");
        }
    }

}

五、GitHub

https://github.com/frost-2/QuartzManager

六、声明

本人会经常更新博客,并在文章附上更新时间! 转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weipinggg/article/details/108753457
欢迎大家关注我的公众号:frost2

  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,以下是 SpringBoot 搭配 Quartz 实现动态定时任务的源码: 1. 首先,我们需要引入 QuartzSpringBoot 的依赖: ```xml <!-- Quartz相关依赖 --> <dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz-jobs</artifactId> <version>2.3.2</version> </dependency> <!-- SpringBoot相关依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> ``` 2. 创建定时任务实体类,用于封装定时任务的信息,包括任务名称、任务组、任务类名、任务状态(是否启用)、任务表达式等: ```java @Entity @Table(name = "job_task") @Data public class JobTask implements Serializable { private static final long serialVersionUID = 1L; /** * ID */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /** * 任务名称 */ @NotBlank(message = "任务名称不能为空") private String name; /** * 任务分组 */ @NotBlank(message = "任务分组不能为空") private String group; /** * 任务类名 */ @NotBlank(message = "任务类名不能为空") private String className; /** * 任务状态,0:禁用,1:启用 */ @NotNull(message = "任务状态不能为空") private Integer status; /** * 任务表达式 */ @NotBlank(message = "任务表达式不能为空") private String cronExpression; /** * 创建时间 */ private LocalDateTime createTime; /** * 最后一次修改时间 */ private LocalDateTime updateTime; } ``` 3. 创建定时任务的服务类,用于管理定时任务的增删改查等操作,同时也需要实现 `InitializingBean` 接口,在启动应用时加载已存在的定时任务: ```java @Service @AllArgsConstructor public class JobTaskService implements InitializingBean { private final Scheduler scheduler; private final JobTaskRepository jobTaskRepository; /** * 添加任务 * @param jobTask * @return * @throws Exception */ public boolean addJobTask(JobTask jobTask) throws Exception { if (jobTask == null || StringUtils.isBlank(jobTask.getCronExpression())) { return false; } if (StringUtils.isBlank(jobTask.getName()) || StringUtils.isBlank(jobTask.getClassName())) { throw new Exception("任务名称或任务类名不能为空"); } // 判断任务是否已存在 JobKey jobKey = JobKey.jobKey(jobTask.getName(), jobTask.getGroup()); if (scheduler.checkExists(jobKey)) { return false; } // 构建任务实例 JobDetail jobDetail = JobBuilder.newJob(getClass(jobTask.getClassName()).getClass()) .withIdentity(jobTask.getName(), jobTask.getGroup()) .build(); // 构建任务触发器 CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule(jobTask.getCronExpression()); CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity(jobTask.getName(), jobTask.getGroup()) .withSchedule(cronScheduleBuilder) .build(); // 注册任务和触发器 scheduler.scheduleJob(jobDetail, trigger); // 如果任务状态为启用,则立即启动任务 if (jobTask.getStatus() == 1) { scheduler.triggerJob(jobKey); } // 保存任务信息 jobTask.setCreateTime(LocalDateTime.now()); jobTask.setUpdateTime(LocalDateTime.now()); jobTaskRepository.save(jobTask); return true; } /** * 修改任务 * @param jobTask * @return * @throws Exception */ public boolean modifyJobTask(JobTask jobTask) throws Exception { if (jobTask == null || StringUtils.isBlank(jobTask.getCronExpression())) { return false; } if (StringUtils.isBlank(jobTask.getName()) || StringUtils.isBlank(jobTask.getClassName())) { throw new Exception("任务名称或任务类名不能为空"); } // 判断任务是否存在 JobKey jobKey = JobKey.jobKey(jobTask.getName(), jobTask.getGroup()); if (!scheduler.checkExists(jobKey)) { return false; } // 修改任务触发器 CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule(jobTask.getCronExpression()); CronTrigger newTrigger = TriggerBuilder.newTrigger() .withIdentity(jobTask.getName(), jobTask.getGroup()) .withSchedule(cronScheduleBuilder) .build(); scheduler.rescheduleJob(TriggerKey.triggerKey(jobTask.getName(), jobTask.getGroup()), newTrigger); // 修改任务信息 JobTask oldJobTask = jobTaskRepository.findByNameAndGroup(jobTask.getName(), jobTask.getGroup()); oldJobTask.setClassName(jobTask.getClassName()); oldJobTask.setStatus(jobTask.getStatus()); oldJobTask.setCronExpression(jobTask.getCronExpression()); oldJobTask.setUpdateTime(LocalDateTime.now()); jobTaskRepository.save(oldJobTask); return true; } /** * 删除任务 * @param name * @param group * @return * @throws Exception */ public boolean deleteJobTask(String name, String group) throws Exception { JobKey jobKey = JobKey.jobKey(name, group); if (!scheduler.checkExists(jobKey)) { return false; } scheduler.deleteJob(jobKey); jobTaskRepository.deleteByNameAndGroup(name, group); return true; } /** * 获取所有任务 * @return */ public List<JobTask> getAllJobTask() { return jobTaskRepository.findAll(); } /** * 根据任务名称和分组获取任务信息 * @param name * @param group * @return */ public JobTask getJobTaskByNameAndGroup(String name, String group) { return jobTaskRepository.findByNameAndGroup(name, group); } /** * 获取任务类实例 * @param className * @return * @throws Exception */ private Object getClass(String className) throws Exception { Class<?> clazz = Class.forName(className); return clazz.newInstance(); } /** * 实现 InitializingBean 接口,在启动应用时加载已存在的定时任务 * @throws Exception */ @Override public void afterPropertiesSet() throws Exception { List<JobTask> jobTaskList = jobTaskRepository.findAll(); for (JobTask jobTask : jobTaskList) { if (jobTask.getStatus() == 1) { addJobTask(jobTask); } } } } ``` 4. 创建定时任务的控制器类,用于处理新增、修改、删除等请求: ```java @RestController @AllArgsConstructor @RequestMapping("/job") public class JobTaskController { private final JobTaskService jobTaskService; /** * 添加任务 * @param jobTask * @return * @throws Exception */ @PostMapping public ResponseEntity addJobTask(@RequestBody JobTask jobTask) throws Exception { boolean result = jobTaskService.addJobTask(jobTask); return result ? ResponseEntity.ok("任务添加成功") : ResponseEntity.badRequest().body("任务添加失败"); } /** * 修改任务 * @param jobTask * @return * @throws Exception */ @PutMapping public ResponseEntity modifyJobTask(@RequestBody JobTask jobTask) throws Exception { boolean result = jobTaskService.modifyJobTask(jobTask); return result ? ResponseEntity.ok("任务修改成功") : ResponseEntity.badRequest().body("任务修改失败"); } /** * 删除任务 * @param name * @param group * @return * @throws Exception */ @DeleteMapping("/{name}/{group}") public ResponseEntity deleteJobTask(@PathVariable String name, @PathVariable String group) throws Exception { boolean result = jobTaskService.deleteJobTask(name, group); return result ? ResponseEntity.ok("任务删除成功") : ResponseEntity.badRequest().body("任务删除失败"); } /** * 获取所有任务 * @return */ @GetMapping public ResponseEntity getAllJobTask() { List<JobTask> jobTaskList = jobTaskService.getAllJobTask(); return ResponseEntity.ok(jobTaskList); } /** * 根据任务名称和分组获取任务信息 * @param name * @param group * @return */ @GetMapping("/{name}/{group}") public ResponseEntity getJobTaskByNameAndGroup(@PathVariable String name, @PathVariable String group) { JobTask jobTask = jobTaskService.getJobTaskByNameAndGroup(name, group); return ResponseEntity.ok(jobTask); } } ``` 5. 创建定时任务的启动类,用于启动 SpringBoot 应用: ```java @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } /** * 注册定时任务调度器 * @return * @throws SchedulerException */ @Bean public SchedulerFactoryBean schedulerFactoryBean() throws SchedulerException { SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean(); Properties properties = new Properties(); properties.put("org.quartz.scheduler.instanceName", "ChitGPTScheduler"); properties.put("org.quartz.threadPool.threadCount", "10"); schedulerFactoryBean.setQuartzProperties(properties); schedulerFactoryBean.setStartupDelay(5); return schedulerFactoryBean; } /** * 注册定时任务实例 * @return */ @Bean public Scheduler scheduler() { return schedulerFactoryBean().getScheduler(); } } ``` 以上就是 SpringBoot 搭配 Quartz 实现动态定时任务的源码,希望能对您有所帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值