Activiti7 探索系列一核心库的使用

本文为翻译文章,如有不合理处请查看原文https://hub.alfresco.com/t5/alfresco-process-services/activiti-7-deep-dive-series-using-the-core-libraries/ba-p/288484

简介

Activiti 7是Alfresco经过实战考验的Activiti工作流引擎的演变,完全被采用在云环境中运行。 它是根据Cloud Native应用程序概念构建的,与之前的Activiti版本在架构方面有所不同。 我们在之前的文章中还有一个新的Activiti Modeler。在本文中,我们将使用新的Activiti 7 Process Runtime和Task Runtime Java API来试用Activiti 7流程引擎。 我们将从Spring Boot 2应用程序执行此操作。 我们需要的所有Activiti 7 Java工件都可以在Alfresco的Maven Repository(Nexus)中找到。Spring Boot应用程序还将包含Web组件(即Spring MVC),因此我们可以创建一个小的ReST API来用于启动进程以及与进程和任务交互。 Activiti 7提供了一个ReST API,但是当我们只使用核心库时,我们不打算在本节中使用它。 在这里,我们只创建自己的简单ReST API,它将使用Activiti 7 Java库(即Process Runtime和Task Runtime)。这个新的API旨在提供Cloud Native方法的明确途径。 它们还包括用户的安全和身份管理。新API还简化了一些常见用例。在本文中,我们将使用Activiti 7 Core库实际构建一个简单的业务流程管理(BPM)应用程序/解决方案。 这通常不是你要做的事情,但能够理解Activiti 7提供的API是一个很好的练习。

Activiti 7 Deep Dive文章系列

本文是详细介绍Activiti 7的系列文章的一部分,应按列出的顺序阅读。

  1. 部署和运行业务流程
  2. 使用Modeler设计业务流程
  3. 构建、部署和运行自定义业务流程
  4. 使用核心库-本文

前期条件

  1. 已阅读并阅读了“Activiti 7  - 使用建模器设计业务流程”一文
  2. JDK已安装
  3. Maven已安装

源代码

您可以在此处找到与本文相关的源代码:

https://github.com/gravitonian/activiti7-api-basic-process

搭建springboot2应用

使用Spring Boot应用程序非常容易。 只需访问https://start.spring.io/并填写应用程序的数据,如下所示:

 

确保将Spring Boot版本2.0.x与Activiti 7 Beta 1  -  3一起使用,Beta 4应与版本2.1.x一致。

你不必像我一样使用相同的Group(org.activiti.training)和Artifact(activiti7-api-basic-process-usertask-servicetask-events)名称,只需使用您喜欢的任何名称即可。 但是,如果您从本文中复制代码,则使用相同的包名称(即同一组)可能会更容易。 搜索H2和Web依赖关系,以便它们包含在Maven POM中。 然后单击“Generate Project”按钮。 完成的Spring Boot 2 Maven项目将自动下载为ZIP。

标准spring boot应用测试

在继续使用Activiti之前,让我们确保Spring Boot应用程序正常工作。 这涉及两个步骤。 首先构建应用程序JAR,然后运行应用程序JAR。

构建应用程序jar:

 运行应用程序jar:

 Ctrl-C退出应用程序以继续下面的配置。

添加依赖(Activiti 7 Dependencies)

除了Activiti 7依赖项之外,Spring Boot应用程序具有我们需要的大多数依赖项。 所以让我们添加它们。 我们可以使用BOM(物料清单)依赖关系,它将引入所有需要的Activiti 7依赖关系管理配置,包括所有依赖关系的正确版本。

Pom.xml添加以下内容:

 这将导入Activiti 7的所有依赖项管理配置。现在我们只需要添加一个Activiti 7依赖项,它支持运行Activiti流程引擎,服务任务实现(如云连接器)和事件处理程序实现(如流程和任务侦听器)。 将以下依赖项添加到pom.xml:

 这将带来所有Activiti和Spring依赖项需要运行嵌入在Spring Boot应用程序中的Activiti 7流程引擎。 我还可以编写我们的服务任务实现和我们的流程引擎事件处理程序。

我们还不能使用这些新的依赖项运行应用程序,因为它将在resources / processes目录中查找流程定义。 如果此目录不存在,则抛出异常并停止应用程序。

应用程序添加流程定义

我们现在将我们在之前的一篇文章中设计的流程定义XML文件添加到项目中。 在src / main / resources目录下创建一个名为processes的新目录。 然后将.bpmn20.xml文件复制到此目录中。 您现在应该看到这样的目录结构:

 

这就是我们需要做的,我们现在可以测试启动应用程序了。

测试包含Activiti库和进程定义的Spring Boot App

我们现在可以打包并运行应用程序,以查看所有Activiti库是否已正确加载和是否正确读取了流程定义而没有错误。

 

添加与流程引擎交互的Rest 调用

我们现在可以使用Activiti 7流程引擎运行时库运行应用程序,因此我们可以创建一些标准的Spring MVC rest调用来与流程引擎和可用的流程定义进行交互

添加一些用户和组并启用Web安全性

为了能够与Process Runtime API进行交互,我们需要使用具有ROLE_ACTIVITI_USER角色的用户进行身份验证。 如果我们只是直接从Java代码调用Process Runtime API,例如来自带有main方法的类,那么我们可以在进行API调用之前直接设置用户上下文。注意。 这样做只是为了学习API而不是真正的生产实现......

我们将创建自己的小ReST API,因此我们希望使用Basic Auth通过Web浏览器进行身份验证。 Activiti使用Spring Security,因此我们可以很容易地做到这一点。在项目包中创建名为Activiti7ApplicationConfiguration的Spring配置类

@Configuration
@EnableWebSecurity
public class Activiti7ApplicationConfiguration extends WebSecurityConfigurerAdapter {

 private Logger logger = LoggerFactory.getLogger(Activiti7ApplicationConfiguration.class);

 @Override
 @Autowired
 public void configure(AuthenticationManagerBuilder auth) throws Exception {
 auth.userDetailsService(myUserDetailsService());
 }

 @Bean
 public UserDetailsService myUserDetailsService() {

 InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();

 String[][] usersGroupsAndRoles = {
 {"mbergljung", "1234", "ROLE_ACTIVITI_USER", "GROUP_activitiTraining"},
 {"testuser", "1234", "ROLE_ACTIVITI_USER", "GROUP_activitiTraining"},
 {"system", "1234", "ROLE_ACTIVITI_USER"},
 {"admin", "1234", "ROLE_ACTIVITI_ADMIN"},
 };

 for (String[] user : usersGroupsAndRoles) {
 List<String> authoritiesStrings = Arrays.asList(Arrays.copyOfRange(user, 2, user.length));
 logger.info("> Registering new user: " + user[0] + " with the following Authorities[" + authoritiesStrings + "]");
 inMemoryUserDetailsManager.createUser(new User(user[0], passwordEncoder().encode(user[1]),
 authoritiesStrings.stream().map(s -> new SimpleGrantedAuthority(s)).collect(Collectors.toList())));
 }


 return inMemoryUserDetailsManager;
 }

 @Override
 protected void configure(HttpSecurity http) throws Exception {
 http
 .csrf().disable()
 .authorizeRequests()
 .anyRequest()
 .authenticated()
 .and()
 .httpBasic();
 }

 @Bean
 public PasswordEncoder passwordEncoder() {
 return new BCryptPasswordEncoder();
 }
}

设置我们在与流程引擎API交互时可以使用的一些用户和组。 我们在流程定义中使用testuser(分配给用户任务1),因此我们需要包含此用户。 我们还启用了Web安全性,因此我们可以构建一个使用Process Engine Java API的简单ReST API。

添加ReST调用以列出流程定义

首先,让我们添加一个列出已部署流程定义的ReST调用。 在项目包中创建一个名为rest的新子包。 然后在这个新包中添加一个名为ProcessDefinitionsController的新Spring MVC Controller,如下所示:

/**
 * ReST controller to interact with deployed process definitions
 *
 */
@RestController
public class ProcessDefinitionsController {
 private Logger logger = LoggerFactory.getLogger(ProcessDefinitionsController.class);

 @Autowired
 private ProcessRuntime processRuntime;

 @GetMapping("/process-definitions")
 public List<ProcessDefinition> getProcessDefinitions() {
 Page<ProcessDefinition> processDefinitionPage = processRuntime.processDefinitions(Pageable.of(0, 10));
 logger.info("> Available Process definitions: " + processDefinitionPage.getTotalItems());

 for (ProcessDefinition pd : processDefinitionPage.getContent()) {
 logger.info("\t > Process definition: " + pd);
 }

 return processDefinitionPage.getContent();
 }
}

首先注入ProcessRuntime Spring bean,以便我们可以使用Process Runtime API,它具有以下方法:

 

在这种情况下,我们只需要processDefinitions()方法。 / process-definitions URL路径用于此ReST GET调用。

现在,按照前面的描述打包并运行应用程序。 启动应用程序时,您应该看到指示所有内容都已正确实现的日志:

 转到浏览器并点击http:// localhost:8080 / process-definitions,您将看到一个登录对话框。 输入具有ROLE_ACTIVITI_USER角色的用户的用户名和密码,例如testuser / 1234。 然后你应该得到一个像这样的响应:

 在日志中,你能看到以下内容:

 

添加ReST调用以启动流程实例

我们现在知道我们可以使用一个流程定义来启动流程实例。 让我们创建一个可用于执行此操作的ReST调用。 它将使用流程定义键的一个参数。

在项目rest包中添加一个名为ProcessStartController的新Spring MVC Controller,如下所示:

@RestController
public class ProcessStartController {
 private Logger logger = LoggerFactory.getLogger(ProcessStartController.class);

 @Autowired
 private ProcessRuntime processRuntime;

 @RequestMapping("/start-process")
 public ProcessInstance startProcess(
 @RequestParam(value="processDefinitionKey", defaultValue="SampleProcess") String processDefinitionKey) {
 ProcessInstance processInstance = processRuntime.start(ProcessPayloadBuilder
 .start()
 .withProcessDefinitionKey(processDefinitionKey)
 .withProcessInstanceName("Sample Process: " + new Date())
 .withVariable("someProcessVar", "someProcVarValue")
 .build());
 logger.info(">>> Created Process Instance: " + processInstance);

 return processInstance;
 }
}

 首先注入ProcessRuntime Spring bean,以便我们可以使用Process Runtime API,它具有我们需要的start()方法。 / start-process?processDefinitionKey = {processDefinitionKey} URL用于此ReST调用。

现在,按照前面的描述打包并运行应用程序。 启动应用程序时,您应该看到指示所有内容都已正确实现的日志:

转到浏览器并键入以下URL(使用与已部署的流程定义匹配的processDefinitionKey,你可以通过调用/ process-definitions来查看以前执行的内容):http:// localhost:8080 / start-process?processDefinitionKey = sampleproc-e9b76ff9-6f70-42c9-8dee-f6116c533a6d,您应该看到一个登录对话框(如果缓存了凭据,则为NOT)。 为具有ROLE_ACTIVITI_USER角色的用户键入用户名和密码。 然后你应该得到一个像这样的响应:

在日志中,您应该看到以下内容: 

 

添加ReST调用以列出流程实例

能够列出活动流程实例很有用。 并且还能够获得有关流程实例的更多元数据,例如执行流程中的位置。 让我们为此创建几个ReST调用,这些调用可以派上用场。

在项目rest包中添加一个名为ProcessInstanceController的新Spring MVC Controller,如下所示:

/**
 * ReST controller to get some info about a process instance
 *
 */
@RestController
public class ProcessInstanceController {
 private Logger logger = LoggerFactory.getLogger(ProcessInstanceController.class);

 @Autowired
 private ProcessRuntime processRuntime;

 @GetMapping("/process-instances")
 public List<ProcessInstance> getProcessInstances() {
 List<ProcessInstance> processInstances =
 processRuntime.processInstances(Pageable.of(0, 10)).getContent();

 return processInstances;
 }

 @GetMapping("/process-instance-meta")
 public ProcessInstanceMeta getProcessInstanceMeta(@RequestParam(value="processInstanceId") String processInstanceId) {
 ProcessInstanceMeta processInstanceMeta = processRuntime.processInstanceMeta(processInstanceId);

 return processInstanceMeta;
 }
}

首先注入ProcessRuntime Spring bean,以便我们可以使用Process Runtime API,它具有我们需要的processInstances()和processInstanceMeta()方法。 / process-instances URL用于第一个只返回前10个活动流程实例的ReST调用。 第二个URL / process-instance-meta?processInstanceId = {processInstanceId}提供有关流程实例的信息,例如它当前正在等待的活动

现在,按照前面的描述打包并运行应用程序。 启动应用程序时,您应该看到指示所有内容都已正确实现的日志:

当我们运行内存数据库时,我们之前启动的流程实例将在重新启动后消失,创建一个新的http:// localhost:8080 / start-process?processDefinitionKey = sampleproc-e9b76ff9-6f70-42c9-8dee-f6116c533a6d。

现在,要列出流程实例,请访问http:// localhost:8080 / process-instances URL。 您应该收到类似这样的回复:

 然后,我们可以使用其他ReST调用查询此流程实例以获取更多信息。 键入以下URL(使用上面调用中返回的processInstanceId):http:// localhost:8080 / process-instance-meta?processInstanceId = b0a28a43-fa2b-11e8-9c34-acde48001122。 然后你应该得到一个像这样的响应:

 我们的流程定义中的第一个活动是userTask1,因此在流程定义中执行的位置。 进程引擎正在等待以下用户任务完成:

 

当你遇到问题并且不确切知道流程实例在流程定义中等待的位置时,能够列出和检查流程实例非常有用。

添加ReST调用以列出可用的用户任务

随着流程的运行,我们应该能够列出可用任务,然后查看作为流程定义中第一个活动的用户任务。

在项目rest包中添加一个名为TaskManagementController的新Spring MVC Controller,如下所示:

@RestController
public class TaskManagementController {
 private Logger logger = LoggerFactory.getLogger(TaskManagementController.class);

 @Autowired
 private TaskRuntime taskRuntime;

 @GetMapping("/my-tasks")
 public List<Task> getMyTasks() {
 Page<Task> tasks = taskRuntime.tasks(Pageable.of(0, 10));
 logger.info("> My Available Tasks: " + tasks.getTotalItems());

 for (Task task : tasks.getContent()) {
 logger.info("\t> My User Task: " + task);
 }

 return tasks.getContent();
 }
}

 首先注入TaskRuntime Spring bean,以便我们可以使用Task Runtime API。 此API将与当前用户相关的任务一起使用。 因此,当我们调用tasks()方法时,它将返回分配给当前登录用户的任务。 API具有以下方法:

 

在这种情况下,我们只需要tasks()方法。 / my-tasks URL路径用于此ReST GET调用。

当我们想要查看在所有活动流程实例中分配的所有任务时,我们还可以添加ReST调用。 可用于支持和管理目的,例如当您想要代表某人重新分配任务或完成任务时。 此调用将需要管理员凭据(即我们需要以具有ROLE_ACTIVITI_ADMIN的用户身份登录)。 我们还需要使用名为TaskAdminRuntime的不同运行时API。 这是方法:

@Autowired
private TaskAdminRuntime taskAdminRuntime;
...
@GetMapping("/all-tasks")
public List<Task> getAllTasks() {
 Page<Task> tasks = taskAdminRuntime.tasks(Pageable.of(0, 10));
 logger.info("> All Available Tasks: " + tasks.getTotalItems());
 for (Task task : tasks.getContent()) {
 logger.info("\t> User Task: " + task);
 }
 return tasks.getContent();
}

现在,按照前面的描述打包并运行应用程序。 启动应用程序时,您应该看到指示所有内容都已正确实现的日志:

我们需要重新启动之前内存中运行的流程实例,创建一个新的http:// localhost:8080 / start-process?processDefinitionKey = sampleproc-e9b76ff9-6f70-42c9-8dee-f6116c533a6d

我启动了以用户mbergljung身份登录的流程实例。 因为用户任务被分配给用户testuser,所以当我们调用taskRuntime.tasks()时它不会显示。 在继续下面的ReST调用之前,我们必须以testuser身份注销并再次登录(最简单的方法是在下次ReST调用之前清除浏览器缓存)

然后键入以下URL:http:// localhost:8080 / my-tasks,然后您应该得到如下所示的响应:

 在日志中,您应该看到以下内容:

 

 现在,通过清除浏览器缓存注销,然后点击http:// localhost:8080 / all-tasks URL。 当要求登录时使用admin / 1234凭据。 作为响应,您应该看到相同的用户任务。

添加ReST调用以完成用户任务

我们现在处于一个阶段,我们应该能够实现一个ReST调用,该调用可用于把我们刚刚列出的用户任务完成分配给testuser。在我们刚刚使用的同一个控制器中,称为TaskManagementController,实现以下ReST调用:

@RequestMapping("/complete-task")
public String completeTask(@RequestParam(value="taskId") String taskId) {
 taskRuntime.complete(TaskPayloadBuilder.complete()
 .withTaskId(taskId).build());
 logger.info(">>> Completed Task: " + taskId);

 return "Completed Task: " + taskId;
}

这里我们使用TaskRuntime API的complete()方法。 我们需要与分配了任务的用户(所以testuser)一起登录才能完成它。 / complete-task?taskId = {taskId} URL路径用于此ReST GET调用。

如前所述,包装并运行应用程序。 启动应用程序时,您应该看到指示所有内容都已正确实现的日志:

 

我们需要重新启动之前内存中运行的流程实例,创建一个新的http:// localhost:8080 / start-process?processDefinitionKey = sampleproc-e9b76ff9-6f70-42c9-8dee-f6116c533a6d,我启动了以用户mbergljung身份登录的流程实例。 因为用户任务被分配给用户testuser,所以当我们调用taskRuntime.tasks()时它不会显示,我将无法完成它。在继续下面的ReST调用之前,我们必须以testuser身份注销并再次登录(最简单的方法是在下次ReST调用之前清除浏览器缓存)。然后键入以下URL:http:// localhost:8080 / my-tasks,然后您应该得到如下所示的响应:

 记下任务ID,然后在http:// localhost:8080 / complete-task?taskId = b0a60cb6-fa2b-11e8-9c34-acde48001122调用中使用它来完成任务。 这将使流程实例转换到下一个活动,在我们的例子中是一个服务任务。由于我们尚未实现服务任务,因此我们将在日志和浏览器中看到以下异常:

所以让我们修复服务任务实现

实现服务任务和监听器

Activiti 7中的服务任务和侦听器的实现方式与以前的版本不同。

实现服务任务Spring Bean

服务任务是我们流程定义中的最后一个活动。 让我们实现它,以便我们可以完成流程实例。

我们需要做的是创建一个名为serviceTask1Impl的Spring Bean,它将代表服务任务的实现。 Spring bean需要是org.activiti.runtime.api.connector.Connector类型接口。 这个新的Connector接口是Java Delegates的自然演变,Activiti 7 Core将尝试通过将它们包装在Connector实现中来重用Java Delegates

在项目包中创建一个名为connectors的新子包。 然后在这个新包中添加一个名为ServiceTask1Connector的新Spring bean,如下所示:

@Service(value = "serviceTask1Impl")
public class ServiceTask1Connector implements Connector {
 private Logger logger = LoggerFactory.getLogger(ServiceTask1Connector.class);

 public IntegrationContext execute(IntegrationContext integrationContext) {
 logger.info("Some service task logic... [processInstanceId=" + integrationContext.getProcessInstanceId() + "]");

 return integrationContext;
 }
}

连接器将一个Bean名称自动注入到ProcessRuntime,在此示例中为“serviceTask1Impl”。 这个bean名称是从我们的流程定义中的serviceTask元素的implementation属性中获取的:

<bpmn2:serviceTask id="ServiceTask_1wg38me" name="Service Task 1" implementation="serviceTask1Impl">

连接器接收具有流程实例信息和流程变量的IntegrationContext,并返回修改后的IntegrationContext,其中包含需要映射回流程变量的结果。

现在,按照前面的描述打包并运行应用程序。我们需要重新启动之前内存中运行的流程实例,创建一个新的http:// localhost:8080 / start-process?processDefinitionKey = sampleproc-e9b76ff9-6f70-42c9-8dee-f6116c533a6d。 以用户testuser身份登录,以便我们可以始终保持相同的用户。

然后键入以下URL:http:// localhost:8080 / my-tasks,然后您应该得到如下所示的响应:

 记下在http:// localhost:8080 / complete-task?taskId = 79ce7445-fc4b-11e8-95e6-acde48001122调用中使用它来完成任务的ID。 这将使流程实例转换到下一个活动,在我们的例子中是一个服务任务。 您应该在日志中看到以下内容:

 

现在检查流程实例是否已完成。 我们可以使用之前开发的http:// localhost:8080 / process-instances调用来实现。 它应该返回一个空列表。

实现进程监听器和任务监听器

进程监听器和任务监听器传统上是Activiti中专门实现扩展。 这些扩展还意味着代码将与流程执行同步运行。这在云部署环境中是不利的。在Activiti 7中流程引擎会以异步方式监听和订阅的发出事件。

通过实现org.activiti.api.process.runtime.events.listener.ProcessRuntimeEventListener接口创建进程监听器。

在项目包中创建一个名为listeners的新子包。然后在这个新包中添加一个名为MyProcessEventListener的新Spring bean,如下所示:

@Service
public class MyProcessEventListener implements ProcessRuntimeEventListener {
 private Logger logger = LoggerFactory.getLogger(MyProcessEventListener.class);

 @Override
 public void onEvent(RuntimeEvent runtimeEvent) {

 if (runtimeEvent instanceof ProcessStartedEvent)
 logger.info("Do something, process is started: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof ProcessCompletedEvent)
 logger.info("Do something, process is completed: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof ProcessCancelledEvent)
 logger.info("Do something, process is cancelled: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof ProcessSuspendedEvent)
 logger.info("Do something, process is suspended: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof ProcessResumedEvent)
 logger.info("Do something, process is resumed: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof ProcessCreatedEvent)
 logger.info("Do something, process is created: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof SequenceFlowTakenEvent)
 logger.info("Do something, sequence flow is taken: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof VariableCreatedEvent)
 logger.info("Do something, variable was created: " + runtimeEvent.toString());
 else
 logger.info("Unknown event: " + runtimeEvent.toString());
 }
}

通过实现ProcessRuntimeEventListener接口,进程侦听器自动连接到ProcessRuntime。 监听器收到一个RuntimeEvent,其中包含有关该事件的所有信息。 我们可以查看子类来确定事件的内容,例如ProcessCompletedEvent。

以同样的方式,我们可以通过实现org.activiti.api.task.runtime.events.listener.TaskRuntimeEventListener接口来创建任务侦听器。在这个新包中添加一个名为MyTaskEventListener的新Spring bean,如下所示:

@Service
public class MyTaskEventListener implements TaskRuntimeEventListener {
 private Logger logger = LoggerFactory.getLogger(MyTaskEventListener.class);

 @Override
 public void onEvent(RuntimeEvent runtimeEvent) {

 if (runtimeEvent instanceof TaskActivatedEvent)
 logger.info("Do something, task is activated: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof TaskAssignedEvent) {
 TaskAssignedEvent taskEvent = (TaskAssignedEvent)runtimeEvent;
 Task task = taskEvent.getEntity();
 logger.info("Do something, task is assigned: " + task.toString());
 } else if (runtimeEvent instanceof TaskCancelledEvent)
 logger.info("Do something, task is cancelled: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof TaskCompletedEvent)
 logger.info("Do something, task is completed: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof TaskCreatedEvent)
 logger.info("Do something, task is created: " + runtimeEvent.toString());
 else if (runtimeEvent instanceof TaskSuspendedEvent)
 logger.info("Do something, task is suspended: " + runtimeEvent.toString());
 else
 logger.info("Unknown event: " + runtimeEvent.toString());
 }
}

现在,按照前面的描述打包并运行应用程序。 使用http:// localhost:8080 / start-process? processDefinitionKey = sampleproc-e9b76ff9-6f70-42c9-8dee-f6116c533a6d创建一个新的实例,这时候我们可以看到很多事件记录:

 

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值