• Activiti工作流从入门到入土(基于IDEA开发)


    本来想着闲来无事,前面在项目中刚刚用到了工作流 Activiti 框架,写写博客的,但是,事情总是纷纷杂杂,一直拖延到现在,这一节原本想要写一下关于 Activiti 的 API ,但是,想着太多这样的博客了,而且显得太生硬,难以理解,所以,这些 API 就在实际的 demo 中来讲解。

    一、建立流程图

    在开始做工作流之前,我们首先应该把具体的业务在工作流的部署流程图体现出来,并且都测试通过,这样就相当于成功了一半,后面的具体业务的开发就相对轻松一些了。

    首先,我们先看一看在 idea 中有哪些控件,常用的控件进行了标注。

    下面我们讲一下建立一个流程图的具体过程。

    首先,我们需要拉入一个开始节点到 bpmn 文件中,这是图像化的界面,只需要拉入即可。

    然后,我们从控件中拉入一个 UserTask 用户任务节点到 bpmn 文件中。

    这样子就有了两个审批节点了,如果还需要其他的一些业务需求,我们还可以加入一些网关,这里就暂时不加了。

    最后,我们只需要一个结束节点 EndEvent 就完成了这个工作流的部署图的绘制。

    我们最后看一下完整的例子。

    看似已经完成了整个流程图的绘制,但美中不足的是我们目前并没有设置导师审批和辅导员审批到底由谁来审批,所以,我们还是需要来瞅一瞅怎么设置审批人员。

    首先,我们需要选中一个审批节点,例如,选中导师审批这个节点。

    其次,我们就显而易见的可以在 idea 编辑器的左侧看到一个名为 BPMN editor 的属性框,里面包括一个用户任务节点的可以设置的所有属性。

    注意:候选用户、候选组、任务监听器,这三个属性这里暂时不讲,后面再补充。

    由于,这一步我们需要设置审批人,所以,我们需要在 Assignee 这个属性中设置我们的审批人。

    如上图,这里设置导师审批这个节点的审批人为 sihai 。设置审批人除了直接设置之外,还有两种方式设置,后面再补充。

    另外一个审批节点也通过这种方式设置就可以完成审批人的设置了。

    very good,这样就基本完成了一个流程图的创建。接下来,我们将通过实例来具体讲解Activiti 的 API 的讲解。

    二、实例讲解 API

    在上面这个流程图的创建中,我们还没有生成 png 图片,所以,如果不知道如何生成的,可以参考之前的这篇文章:Activiti工作流从入门到入土:整合spring

    既然是讲解 API ,那么还是先看一下主要有哪些 API 吧,这样才有一个整体把握。

    这些 API 具体怎么用,接下来一一道来。

    2.1 流程定义

    既然是流程定义,那肯定少不了如何部署流程定义了。

    2.1.1 部署流程定义方法1

     

     @Autowired
        private ProcessEngine processEngine;
        @Autowired
        private TaskService taskService;
        @Autowired
        private RuntimeService runtimeService;
        @Autowired
        private HistoryService historyService;
    
        /**
         * 部署流程定义(从classpath)
         */
        @Test
        public void deploymentProcessDefinition_classpath(){
            Deployment deployment = processEngine.getRepositoryService()//与流程定义和部署对象相关的Service
                    .createDeployment()//创建一个部署对象
                    .name("流程定义")//添加部署的名称
                    .addClasspathResource("bpmn/hello.bpmn")//从classpath的资源中加载,一次只能加载一个文件
                    .addClasspathResource("bpmn/hello.png")//从classpath的资源中加载,一次只能加载一个文件
                    .deploy();//完成部署
            System.out.println("部署ID:"+deployment.getId());
            System.out.println("部署名称:"+deployment.getName());
        }
    

    注意:这里用的是整合 spring 之后的 junit 测试环境,如何整合 spring 请看这篇文章:Activiti工作流从入门到入土:整合spring

    输出结果:

    这样,我们就部署了这个流程。那么具体是怎么操作的呢,我们再来看看整个过程。

    • 获取流程引擎对象:这个跟 spring 整合了。

    • 通过流程引擎获取了一个 RepositoryService 对象(仓库对象)

    • 由仓库的服务对象产生一个部署对象配置对象,用来封装部署操作的相关配置。

    • 这是一个链式编程,在部署配置对象中设置显示名,上传流程定义规则文件

    • 向数据库表中存放流程定义的规则信息。

    其实,这一步操作,用到了 Activiti 数据库中的三张表,分别是:act_re_deployment(部署对象表),act_re_procdef(流程定义表),act_ge_bytearray(资源文件表)。

    我们看看这三张表的变化:
    1)act_re_deployment

     

    可以看到,部署ID和部署名称就存在这张表中。

    2)act_re_procdef

    这张表中,存放了部署的Deployment_ID部署流程的id、bpmn资源文件名称、png图片名称等信息。

    3)act_ge_bytearray

    存储流程定义相关的部署信息。即流程定义文档的存放地。每部署一次就会增加两条记录,一条是关于 bpmn 规则文件的,一条是图片的(如果部署时只指定了 bpmn 一个文件,activiti 会在部署时解析 bpmn 文件内容自动生成流程图)。两个文件不是很大,都是以二进制形式存储在数据库中。

    2.1.2 部署流程定义方法2

     

     /**
         * 部署流程定义(从zip)
         */
        @Test
        public void deploymentProcessDefinition_zip(){
            InputStream in = this.getClass().getClassLoader().getResourceAsStream("bpmn/hello.zip");
            ZipInputStream zipInputStream = new ZipInputStream(in);
            Deployment deployment = processEngine.getRepositoryService()//与流程定义和部署对象相关的Service
                    .createDeployment()//创建一个部署对象
                    .name("流程定义")//添加部署的名称
                    .addZipInputStream(zipInputStream)//指定zip格式的文件完成部署
                    .deploy();//完成部署
            System.out.println("部署ID:"+deployment.getId());//
            System.out.println("部署名称:"+deployment.getName());//
        }
    
    

    项目结构如下:

     

    输出结果:

     

    如此看来,也是没有任何问题的,唯一的区别只是压缩成zip格式的文件,使用zip的输入流用作部署流程定义,其他使用并无区别。

    部署了流程定义之后,我们应该想查看一下流程定义的一些信息。

    2.1.3 查看流程定义

     

    /**
         * 查询流程定义
         */
        @Test
        public void findProcessDefinition(){
            List<ProcessDefinition> list = processEngine.getRepositoryService()//与流程定义和部署对象相关的Service
                    .createProcessDefinitionQuery()//创建一个流程定义的查询
                    /**指定查询条件,where条件*/
    //                      .deploymentId(deploymentId)//使用部署对象ID查询
    //                      .processDefinitionId(processDefinitionId)//使用流程定义ID查询
    //                      .processDefinitionKey(processDefinitionKey)//使用流程定义的key查询
    //                      .processDefinitionNameLike(processDefinitionNameLike)//使用流程定义的名称模糊查询
    
                    /**排序*/
                    .orderByProcessDefinitionVersion().asc()//按照版本的升序排列
    //                      .orderByProcessDefinitionName().desc()//按照流程定义的名称降序排列
    
                    /**返回的结果集*/
                    .list();//返回一个集合列表,封装流程定义
    //                      .singleResult();//返回惟一结果集
    //                      .count();//返回结果集数量
    //                      .listPage(firstResult, maxResults);//分页查询
            if(list!=null && list.size()>0){
                for(ProcessDefinition pd:list){
                    System.out.println("流程定义ID:"+pd.getId());//流程定义的key+版本+随机生成数
                    System.out.println("流程定义的名称:"+pd.getName());//对应hello.bpmn文件中的name属性值
                    System.out.println("流程定义的key:"+pd.getKey());//对应hello.bpmn文件中的id属性值
                    System.out.println("流程定义的版本:"+pd.getVersion());//当流程定义的key值相同的相同下,版本升级,默认1
                    System.out.println("资源名称bpmn文件:"+pd.getResourceName());
                    System.out.println("资源名称png文件:"+pd.getDiagramResourceName());
                    System.out.println("部署对象ID:"+pd.getDeploymentId());
                    System.out.println("*********************************************");
                }
            }
        }
    

    输出结果:

     

    查询流程定义小结:

    • 流程定义和部署对象相关的Service都是 RepositoryService ,后面会发现关于流程定义的都是 RepositoryService

    • 通过这个 createProcessDefinitionQuery() 方法来设置一些查询参数,比如通过条件、降序升序等。

    2.1.4 删除流程定义

    通过删除部署 ID 为2501的信息。

     

    /**
         * 删除流程定义
         */
        @Test
        public void deleteProcessDefinition(){
            //使用部署ID,完成删除,指定部署对象id为2501删除
            String deploymentId = "2501";
            /**
             * 不带级联的删除
             *    只能删除没有启动的流程,如果流程启动,就会抛出异常
             */
    //      processEngine.getRepositoryService()//
    //                      .deleteDeployment(deploymentId);
    
            /**
             * 级联删除
             *    不管流程是否启动,都能可以删除
             */
            processEngine.getRepositoryService()//
                    .deleteDeployment(deploymentId, true);
            System.out.println("删除成功!");
        }
    

    输出结果:

     

    到数据库查看,发现 act_re_deployment 中的数据已经不存在了。

    • 这里还是通过 getRepositoryService() 方法获取部署定义对象,然后指定 ID 删除信息。

    2.1.5 获取流程定义文档的资源

    这里的作用主要是查询图片,通过图片可以在后面做流程展示用的。我们看看具体怎么查看。

     

    /**
         * 查看流程图
         *
         * @throws IOException
         */
        @Test
        public void viewPic() throws IOException {
            /**将生成图片放到文件夹下*/
            String deploymentId = "5001";
            //获取图片资源名称
            List<String> list = processEngine.getRepositoryService()//
                    .getDeploymentResourceNames(deploymentId);
            //定义图片资源的名称
            String resourceName = "";
            if (list != null && list.size() > 0) {
                for (String name : list) {
                    if (name.indexOf(".png") >= 0) {
                        resourceName = name;
                    }
                }
            }
    
            //获取图片的输入流
            InputStream in = processEngine.getRepositoryService()//
                    .getResourceAsStream(deploymentId, resourceName);
    
            //将图片生成到F盘的目录下
            File file = new File("F:/" + resourceName);
    
            //将输入流的图片写到磁盘
            FileUtils.copyInputStreamToFile(in, file);
        }
    

    在F盘下,可以找到图片。

     

    2.1.6 查询最新版本的流程定义

     

     /**
         * 查询最新版本的流程定义
         */
        @Test
        public void findLastVersionProcessDefinition() {
            List<ProcessDefinition> list = processEngine.getRepositoryService()//
                    .createProcessDefinitionQuery()//
                    .orderByProcessDefinitionVersion().asc()//使用流程定义的版本升序排列
                    .list();
            /**
             map集合的特点:当map集合key值相同的情况下,后一次的值将替换前一次的值
             */
            Map<String, ProcessDefinition> map = new LinkedHashMap<String, ProcessDefinition>();
            if (list != null && list.size() > 0) {
                for (ProcessDefinition pd : list) {
                    map.put(pd.getKey(), pd);
                }
            }
            List<ProcessDefinition> pdList = new ArrayList<ProcessDefinition>(map.values());
            if (pdList != null && pdList.size() > 0) {
                for (ProcessDefinition pd : pdList) {
                    System.out.println("流程定义ID:" + pd.getId());//流程定义的key+版本+随机生成数
                    System.out.println("流程定义的名称:" + pd.getName());//对应hello.bpmn文件中的name属性值
                    System.out.println("流程定义的key:" + pd.getKey());//对应hello.bpmn文件中的id属性值
                    System.out.println("流程定义的版本:" + pd.getVersion());//当流程定义的key值相同的相同下,版本升级,默认1
                    System.out.println("资源名称bpmn文件:" + pd.getResourceName());
                    System.out.println("资源名称png文件:" + pd.getDiagramResourceName());
                    System.out.println("部署对象ID:" + pd.getDeploymentId());
                    System.out.println("*********************************************************************************");
                }
            }
        }
    

    输出结果:

     

    2.1.7 流程定义总结

    1、部署流程定义用到了 Activiti 的下面的几张表。

    • act_re_deployment:部署对象表
    • act_re_procdef:流程定义表
    • act_ge_bytearray:资源文件表
    • act_ge_property:主键生成策略表

    2、我们发现部署流程定义的操作都是在 RepositoryService 这个类下进行操作的,我们只需要通过 getRepositoryService() 拿到对象,通过链式规则就可以进行部署流程定义的所有操作。

    2.2 工作流完整实例的使用

    这一节,我们通过一个完整的例子,来总结一下前面讲过的一些基本的知识,这样能够更好的学习前面以及后面的知识点,这也算是一个过渡的章节。

    回到第一节的建立流程图,我们已经将基本的 bpmn 图已经建立好了,但是,需要做一个完整的实例,我们还是需要补充一些内容的,这样才能够把这样的一个实例做好,我们先把第一节的那个 bpmn 图拿过来。

    首先,我们需要明确:这个图到目前为止,我们只是简简单单的把流程给画出来了,比如,我们需要审核的时候,是需要具体到某一个具体的人员去审核的,所以,我们需要给每个节点设置审核的具体人员。

    注意:设置节点的审核人员后面还会分一节细讲,这里只是做一个简单的实例,所以,只需要这里能够看懂,做好就ok了。

    设置审核人员步骤

    首先,我们需要选中一个节点,例如,下图中的“导师审批”节点。

     

    接下来,在左边的工具栏,我们会看到好多选项,有一项为 Assignee ,我们需要在这个选项中设置我们这个节点需要设置的审批人。

    Assignee设置格式:直接使用英文或者中文都可以,例如,sihai,更复杂的设置后面再讲。

    下面的节点设置也是跟上面一模一样。

    辅导员审批的审批人员是:欧阳思海。

     

    perfect,这样流程图的任务就完成了,下面我们就可以进行这个实例的测试阶段了。

    1)部署流程定义
    部署流程定义,在前面的章节已经讲过了,有两种方式进行处理,一种是加载 bpmn 文件和 png 文件,还有一种是将这两个文件压缩成 zip 格式的压缩文件,然后加载。这里我们使用第一种方式进行处理。

     

    /**
         * 部署流程定义(从classpath)
         */
        @Test
        public void deploymentProcessDefinition_classpath() {
            Deployment deployment = processEngine.getRepositoryService()//与流程定义和部署对象相关的Service
                    .createDeployment()//创建一个部署对象
                    .name("hello")//添加部署的名称
                    .addClasspathResource("bpmn/hello.bpmn")//从classpath的资源中加载,一次只能加载一个文件
                    .addClasspathResource("bpmn/hello.png")//从classpath的资源中加载,一次只能加载一个文件
                    .deploy();//完成部署
            log.info("部署ID:" + deployment.getId());
            log.info("部署名称:" + deployment.getName());
        }
    
    

    现在流程定义已经有了,下面我们就需要启动这个流程实例。

    关于关于这一步做了什么事情,可以在前面的章节查看。

    2)启动流程实例

     

     /**
         * 启动流程实例
         */
        @Test
        public void startProcessInstance(){
            //1、流程定义的key,通过这个key来启动流程实例
            String processDefinitionKey = "hello";
            //2、与正在执行的流程实例和执行对象相关的Service
            // startProcessInstanceByKey方法还可以设置其他的参数,比如流程变量。
            ProcessInstance pi = processEngine.getRuntimeService()
                    .startProcessInstanceByKey(processDefinitionKey);//使用流程定义的key启动流程实例,key对应helloworld.bpmn文件中id的属性值,使用key值启动,默认是按照最新版本的流程定义启动
            log.info("流程实例ID:"+pi.getId());//流程实例ID
            log.info("流程定义ID:"+pi.getProcessDefinitionId());//流程定义ID
        }
    

     


    注意: processDefinitionKey 是 bpmn 文件的名称。

     

    步骤
    1 获取到 runtimeService 实例。
    2 通过 bpmn 文件的名称,也就是 processDefinitionKey 来启动流程实例。
    3 启动流程后,流程的任务就走到了导师审批节点。

    下面就是查询个人任务了,我们可以查询导师审批节点的任务。

    3)查询个人任务

     

    /**
         * 查询当前人的个人任务
         */
        @Test
        public void findPersonalTask(){
            String assignee = "sihai";
            List<Task> list = processEngine.getTaskService()//与正在执行的任务管理相关的Service
                    .createTaskQuery()//创建任务查询对象
                    /**查询条件(where部分)*/
                    .taskAssignee(assignee)//指定个人任务查询,指定办理人
    //                      .taskCandidateUser(candidateUser)//组任务的办理人查询
    //                      .processDefinitionId(processDefinitionId)//使用流程定义ID查询
    //                      .processInstanceId(processInstanceId)//使用流程实例ID查询
    //                      .executionId(executionId)//使用执行对象ID查询
                    /**排序*/
                    .orderByTaskCreateTime().asc()//使用创建时间的升序排列
                    /**返回结果集*/
    //                      .singleResult()//返回惟一结果集
    //                      .count()//返回结果集的数量
    //                      .listPage(firstResult, maxResults);//分页查询
                    .list();//返回列表
            if(list!=null && list.size()>0){
                for(Task task:list){
                    log.info("任务ID:"+task.getId());
                    log.info("任务名称:"+task.getName());
                    log.info("任务的创建时间:"+task.getCreateTime());
                    log.info("任务的办理人:"+task.getAssignee());
                    log.info("流程实例ID:"+task.getProcessInstanceId());
                    log.info("执行对象ID:"+task.getExecutionId());
                    log.info("流程定义ID:"+task.getProcessDefinitionId());
                    log.info("********************************************");
                }
            }
        }
    

    通过 sihai 这个审批人,查询到了下面的信息。

     

    分析步骤
    1 首先通过 getTaskService 方法,获取到 TaskService 对象。
    2 通过 createTaskQuery 方法创建查询对象。
    3 通过 taskAssignee 方法设置审核人。
    4 对于结果的返回,我们可以通过 orderByTaskCreateTime().asc() 设置排序等其他信息。

    这里需要注意一点,查询到的一个重要的信息是:任务 id(taskId),下一步,我们需要通过这个任务 id ,来完成任务。

    4)办理个人任务

     

    /**
         * 完成我的任务
         */
        @Test
        public void completePersonalTask() {
            //任务ID,上一步查询得到的。
            String taskId = "7504";
            processEngine.getTaskService()//与正在执行的任务管理相关的Service
                    .complete(taskId);
            log.info("完成任务:任务ID:" + taskId);
        }
    

    通过上一步的任务 id :7504,完成任务。

    步骤
    1 首先,通过 getTaskService 方法拿到 TaskService 对象。
    2 调用 complete 方法,给定具体的任务 id 完成任务。

    5)查询流程状态(判断流程走到哪一个节点)
    这个接口还是十分需要的,当我们在具体的业务中,我们需要判断我们的流程的状态是什么状态,或者说我们的流程走到了哪一个节点的时候,这一个接口就让我们实现业务省了非常多的事情。

     

    /**
         * 查询流程状态(判断流程走到哪一个节点)
         */
        @Test
        public void isProcessActive() {
            String processInstanceId = "7501";
            ProcessInstance pi = processEngine.getRuntimeService()//表示正在执行的流程实例和执行对象
                    .createProcessInstanceQuery()//创建流程实例查询
                    .processInstanceId(processInstanceId)//使用流程实例ID查询
                    .singleResult();
            if (pi == null) {
                log.info("流程已经结束");
            } else {
                log.info("流程没有结束");
                //获取任务状态
                log.info("节点id:" + pi.getActivityId());
            }
        }
    

    步骤:
    1 获取到流程实例 ProcessInstance 对象。
    2 通过 getActivityId 方法获取到实例 Id(节点 id )。

    那么拿到了节点 Id 有什么作用呢?
    其实,有了这个 Id 之后,我们就可以判断流程走到哪一步了。例如,上面的输出的节点 id 是 _4,这个 _4 就是对应 辅导员审批节点的 id,所以,我们就可以判读流程其实是已经走到这个节点了,后期需要在页面显示流程状态的时候就发挥作用了。

    6)查询流程执行的历史信息
    通过查看 activiti 5 的官方 API 接口,发现查看历史信息有下面的查询接口。

    下面我们通过上面的实例对下面的方法一一进行测试。

    历史活动实例查询接口

     

    /**
         * 历史活动查询接口
         */
        @Test
        public void findHistoryActivity() {
            String processInstanceId = "7501";
            List<HistoricActivityInstance> hais = processEngine.getHistoryService()//
                    .createHistoricActivityInstanceQuery()
                    .processInstanceId(processInstanceId)
                    .list();
            for (HistoricActivityInstance hai : hais) {
                log.info("活动id:" + hai.getActivityId()
                        + "   审批人:" + hai.getAssignee()
                        + "   任务id:" + hai.getTaskId());
                log.info("************************************");
            }
        }
    

    通过这个接口不仅仅查到这些信息,还有其他的方法,可以获取更多的关于历史活动的其他信息。

    历史流程实例查询接口

     

    /**
         * 查询历史流程实例
         */
        @Test
        public void findHistoryProcessInstance() {
            String processInstanceId = "7501";
            HistoricProcessInstance hpi = processEngine.getHistoryService()// 与历史数据(历史表)相关的Service
                    .createHistoricProcessInstanceQuery()// 创建历史流程实例查询
                    .processInstanceId(processInstanceId)// 使用流程实例ID查询
                    .orderByProcessInstanceStartTime().asc().singleResult();
            log.info(hpi.getId() + "    " + hpi.getProcessDefinitionId() + "    " + hpi.getStartTime() + "    "
                    + hpi.getEndTime() + "     " + hpi.getDurationInMillis());
        }
    
    

    这个接口可以查询到关于历史流程实例的所有信息。

    历史任务实例查询接口

     

     /**
         * 查询历史任务
         */
        @Test
        public void findHistoryTask() {
            String processInstanceId = "7501";
            List<HistoricTaskInstance> list = processEngine.getHistoryService()// 与历史数据(历史表)相关的Service
                    .createHistoricTaskInstanceQuery()// 创建历史任务实例查询
                    .processInstanceId(processInstanceId)//
                    .orderByHistoricTaskInstanceStartTime().asc().list();
            if (list != null && list.size() > 0) {
                for (HistoricTaskInstance hti : list) {
                    log.info("
     任务Id:" + hti.getId() + "    任务名称:" + hti.getName() + "    流程实例Id:" + hti.getProcessInstanceId() + "
     开始时间:"
                            + hti.getStartTime() + "   结束时间:" + hti.getEndTime() + "   持续时间:" + hti.getDurationInMillis());
                }
            }
        }
    

    这个查询接口可以查询到历史任务信息。

    历史流程变量查询接口

     

    /**
         * 查询历史流程变量
         */
        @Test
        public void findHistoryProcessVariables() {
            String processInstanceId = "7501";
            List<HistoricVariableInstance> list = processEngine.getHistoryService()//
                    .createHistoricVariableInstanceQuery()// 创建一个历史的流程变量查询对象
                    .processInstanceId(processInstanceId)//
                    .list();
            if (list != null && list.size() > 0) {
                for (HistoricVariableInstance hvi : list) {
                    log.info("
    " + hvi.getId() + "   " + hvi.getProcessInstanceId() + "
    " + hvi.getVariableName()
                            + "   " + hvi.getVariableTypeName() + "    " + hvi.getValue());
                }
            }
        }
    

    在这个实例中没有设置流程变量,所以,这里是查询不到任何历史信息的。

    这个接口主要是关于历史流程变量的设置的一些信息。

    历史本地接口查询接口

     

    /**
         * 通过执行sql来查询历史数据,由于activiti底层就是数据库表。
         */
        @Test
        public void findHistoryByNative() {
            HistoricProcessInstance hpi = processEngine.getHistoryService()
                    .createNativeHistoricProcessInstanceQuery()
                    .sql("查询底层数据库表的sql语句")
                    .singleResult();
            log.info("
    " + hpi.getId() + "    " + hpi.getProcessDefinitionId() + "    " + hpi.getStartTime()
                    + "
    " + hpi.getEndTime() + "     " + hpi.getDurationInMillis());
        }
    
    

    这个接口是提供直接通过 sql 语句来查询历史信息的,我们只需要在 sql() 方法中写原生的 sql 语句就可以进行数据查询。

    写到这里,我想应该通过这样的一个完整的实例将 Activiti 工作流的 API 都介绍的差不多了,这一节到这里也就要说拜拜了。再回看一下文章开头的 API 接口,这也算是这一节的总结。

  • 相关阅读:
    数组删除元素注意事项
    点击下拉菜单以外的区域,关闭弹窗
    webpack学习笔记(六)优化
    webpack学习笔记(五)
    webpack学习笔记(四)
    webpack学习笔记(三)
    webpack学习笔记(二)
    es6 笔记
    vue学习笔记——组件的优化
    vue学习笔记——路由
  • 原文地址:https://www.cnblogs.com/xulijun137/p/12689388.html
Copyright © 2020-2023  润新知