Springboot整合Activiti7

前言

今天在springboot项目中完成了对Activiti7的整合,activiti7提供了对springboot的场景启动器(starter),也提供了相应的依赖管理的包,所以整个过程非常的的方便,也比较简单。

但是整合的过程中以为Activiti7默认使用了Spring Security,所以整合的过程中有温习了一遍关于Spring Security的配置信息。Activiti在鉴权方便帮我们做了选择,这有的时候也限制了我们使用其他的库,比如Shiro等,这也可以看出Activiti是提倡我们使用SpringSecurity来做权限认证的。

网上也已经有许多博客讲了如何整合Shiro到Activit中,这个我目前没有这方面的需求所以也没有去做相应的研究,闲暇之时我也回去尝试下如何整合Shiro。

本篇文章主要作为学习笔记记录所用,也希望能帮助希望快速整合Activiti的朋友做个参考,文中不足之处还有望各路大神指出。

鉴权

官方Example中的Util

@Component
public class SecurityUtil {
    // 模拟调用了SpringSecurity 登录鉴权
    private Logger logger = LoggerFactory.getLogger(SecurityUtil.class);

    @Autowired
    private UserDetailsService userDetailsService;

    public void logInAs(String username) {

        UserDetails user = userDetailsService.loadUserByUsername(username);
        if (user == null) {
            throw new IllegalStateException("User " + username + " doesn't exist, please provide a valid user");
        }
        logger.info("> Logged in as: " + username);
        SecurityContextHolder.setContext(new SecurityContextImpl(new Authentication() {
            @Override
            public Collection<? extends GrantedAuthority> getAuthorities() {
                return user.getAuthorities();
            }

            @Override
            public Object getCredentials() {
                return user.getPassword();
            }

            @Override
            public Object getDetails() {
                return user;
            }

            @Override
            public Object getPrincipal() {
                return user;
            }

            @Override
            public boolean isAuthenticated() {
                return true;
            }

            @Override
            public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {

            } 
            @Override
            public String getName() {
                return user.getUsername();
            }
        }));
        org.activiti.engine.impl.identity.Authentication.setAuthenticatedUserId(username);
    }
}

为了方便测试,Activiti的官方示例中提供了一个Util,我们注意到这个仓库进行了模拟用户登录,并将鉴权信息赋值到引擎中:

org.activiti.engine.impl.identity.Authentication.setAuthenticatedUserId(username);

新API中的鉴权原理

  • TaskRuntime
package org.activiti.runtime.api.impl;
@PreAuthorize("hasRole('ACTIVITI_USER')")
public class TaskRuntimeImpl implements TaskRuntime {
}

  • ProcessRuntime
package org.activiti.runtime.api.impl;
@PreAuthorize("hasRole('ACTIVITI_USER')")
public class ProcessRuntimeImpl implements ProcessRuntime {
}

activiti7中对原有的一些接口做了二次封装,从而进一步简化了用户的使用流程。

通过查看这个两个API的实现类源码来看,调用的话需要调用的用户含有ACTIVITI_USER角色权限。所以,如果没有使用SpringSecurity的话,这两个API便不能直接调用。

POM文件

<!--  springboot 依赖      -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.3.3.RELEASE</version>
</dependency>
<!--  mybatis 依赖      -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.4</version>
</dependency>
<!--  mysql驱动 依赖      -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
    <version>8.0.19</version>
</dependency> 
<!--  activiti 依赖      -->
<dependency>
    <groupId>org.activiti</groupId>
    <artifactId>activiti-spring-boot-starter</artifactId>
    <version>7.1.0.M4</version>
</dependency>
<dependency>
    <groupId>org.activiti.dependencies</groupId>
    <artifactId>activiti-dependencies</artifactId>
    <version>7.1.0.M4</version>
    <type>pom</type>
</dependency>

SpringSecurity简易配置

如果仅仅是测试的话,可以直接将用户存在内存中实现。我这里还是使用的数据库方式来保存用户信息。

  • 用户表结构

    +----------+--------------+------+-----+---------+----------------+
    | Field    | Type         | Null | Key | Default | Extra          |
    +----------+--------------+------+-----+---------+----------------+
    | id       | int          | NO   | PRI | NULL    | auto_increment |
    | username | varchar(255) | YES  |     | NULL    |                |
    | password | varchar(255) | YES  |     | NULL    |                |
    | roles    | varchar(255) | YES  |     | NULL    |                |
    +----------+--------------+------+-----+---------+----------------+
    

需要实现的配置

用户查询接口

  • 实现UserDetails的接口类作为鉴权用户实体
@Component
public class LocalUserDetail implements UserDetails {

    private int id;
    private String username;
    private String password;
    private String roles;
    
    // Entity 转 LocalUserDetail
    public static LocalUserDetail of(User user){
        LocalUserDetail localUserDetail = new LocalUserDetail();
        localUserDetail.id = user.getId();
        localUserDetail.username = user.getUsername();
        localUserDetail.password = user.getPassword();
        localUserDetail.roles = user.getRoles();
        return localUserDetail;
    }
    
    // 权限包装
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Arrays.stream(roles.split(",")).map(e->new SimpleGrantedAuthority(e)).collect(Collectors.toSet());
    }


    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • 用户查询接口

    @Component
    public class CustomUserDetailService implements UserDetailsService {
    
        @Autowired
        UserMapper userMapper;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 调用DAO实现用户的查询
            Optional<User> user = userMapper.selectOne(c -> c.where(UserDynamicSqlSupport.username, isEqualTo(username)));
            if (!user.isPresent()){
               throw new UsernameNotFoundException("用户不存在");
            }
            User u = user.get();
            return LocalUserDetail.of(u);
        }
    
        @Bean
        public PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    
    }
    

登录成功的处理(可选)

@Component()
public class LoginSuccessHandle implements AuthenticationSuccessHandler {

  @Autowired
  ObjectMapper objectMapper;

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
      System.out.println("浏览器表单登录");
  }

  @Override
  public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
      System.out.println("AJAX登录");
      // 统一返回json体作为回应
      httpServletResponse.setContentType("application/json;charset=UTF-8");        httpServletResponse.getWriter().write(objectMapper.writeValueAsString(BaseResponse.success(UUID.randomUUID().toString())));
  }
}

登录失败的处理(可选)

@Component
public class LoginFailHandle implements AuthenticationFailureHandler {
    @Autowired
    ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        // 统一返回json体作为回应
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        httpServletResponse.getWriter().write(objectMapper.writeValueAsString(BaseResponse.error("登录失败,请重试!")));
    }
}

鉴权失效的处理(可选)

/**
 * 检测到未登录的时候,这里返回json的应答,而不是跳转到登录页面
 */
public class AuthEntryPoint implements AuthenticationEntryPoint {

    public static ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void commence(HttpServletRequest httpServletRequest, 
                         HttpServletResponse httpServletResponse,
                         AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        BaseResponse<Void> response = new BaseResponse<>(401, "请先登录系统");
        httpServletResponse.getWriter().write(objectMapper.writeValueAsString(response));
    }
}

SpringSecurity 配置

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    LoginSuccessHandle successHandle;

    @Autowired
    LoginFailHandle failHandle;
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin() 
                .loginProcessingUrl("/login")
                // 配置登录成功处理器
                .successHandler(successHandle)
                // 配置登录失败处理器
                .failureHandler(failHandle)
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/v2/**").permitAll()
                .antMatchers("/swagger-resources/**").permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .logout().permitAll().and()
                .headers().frameOptions().disable()//让frame页面可以正常使用
                .and()
                // 配置自定义鉴权失败端点
                .exceptionHandling().authenticationEntryPoint(new AuthEntryPoint())
                .and()
                .csrf().disable();
    }
}

Activiti 使用

activiti工作流程图

运行前提

运行后没有生成数据库表

在数据库访问的JDBC URL上添加配置:nullCatalogMeansCurrent=true

在使用mysql-connect 8.+以上版本的时候需要添加nullCatalogMeansCurrent=true参数,否则在使用mybatis-generator生成表对应的xml等时会扫描整个服务器里面的全部数据库中的表,而不是扫描对应数据库的表。因此mysql会扫描所有的库来找表,如果其他库中有相同名称的表,activiti就以为找到了,本质上这个表在当前数据库中并不存在。

调用接口是报缺少字段

Activiti自身问题。

alter table ACT_RE_DEPLOYMENT add column PROJECT_RELEASE_VERSION_ varchar(255) DEFAULT NULL;
alter table ACT_RE_DEPLOYMENT add column VERSION_ varchar(255) DEFAULT NULL;

流程部署相关

自动加载BPMN文件部署

将bpmn文件放在resource下的processes目录下,activiti启动的时候会自动加载该目录下的bpmn文件

调用接口部署

上传文件部署
@PostMapping("/uploadFileAndDeployment")
public BaseResponse uploadFileAndDeployment(
    @RequestParam("processFile")MultipartFile processFile,
    @RequestParam(value = "processName",required = false) String processName){
    
    String originalFilename = processFile.getOriginalFilename();
    String extension = FilenameUtils.getExtension(originalFilename);
    if (processName != null){
        processName = originalFilename;
    }
    try {
        InputStream inputStream = processFile.getInputStream();
        Deployment deployment = null;
        if ("zip".equals(extension)){
            // 压缩包部署方式
            ZipInputStream zipInputStream = new ZipInputStream(inputStream);
            deployment = repositoryService.createDeployment().addZipInputStream(zipInputStream).name(processName).deploy();
        }else if ("bpmn".equals(extension)){
            // bpmn文件部署方式
            deployment = repositoryService.createDeployment().addInputStream(originalFilename,inputStream).name(processName).deploy();
        }
        return BaseResponse.success(deployment);
    } catch (IOException e) {
        e.printStackTrace();
    }
    return BaseResponse.success();
}
上传BPMN内容字符串部署
@PostMapping("/postBPMNAndDeployment")
public BaseResponse postBPMNAndDeployment(@RequestBody AddXMLRequest addXMLRequest){
    Deployment deploy = repositoryService.createDeployment()
        // .addString 第一次参数的名字如果没有添加.bpmn的话,不会插入到 ACT_RE_DEPLOYMENT 表中
        .addString(addXMLRequest.getProcessName()+".bpmn", addXMLRequest.getBpmnContent())
        .name(addXMLRequest.getProcessName())
        .deploy();
    return BaseResponse.success(deploy);
}
获取流程资源文件
@GetMapping("/getProcessDefineXML")
public void getProcessDefineXML(String deploymentId, String resourceName, HttpServletResponse response){
    try {
        InputStream inputStream = repositoryService.getResourceAsStream(deploymentId,resourceName);
        int count = inputStream.available();
        byte[] bytes = new byte[count];
        response.setContentType("text/xml");
        OutputStream outputStream = response.getOutputStream();
        while (inputStream.read(bytes) != -1) {
            outputStream.write(bytes);
        }
        inputStream.close();
    } catch (Exception e) {
        e.toString();
    }
}

流程实例相关

启动实例

@PostMapping("/startProcess")
public BaseResponse startProcess(
    String processDefinitionKey, 
    String instanceName,
    @AuthenticationPrincipal LocalUserDetail userDetail){
    
    ProcessInstance processInstance = null;
    try{
        StartProcessPayload startProcessPayload = ProcessPayloadBuilder.start().withProcessDefinitionKey(processDefinitionKey)
            .withBusinessKey("businessKey")
            .withVariable("sponsor",userDetail.getUsername())
            .withName(instanceName).build();
        processInstance = processRuntime.start(startProcessPayload);
    }catch (Exception e){
        System.out.println(e);
        return BaseResponse.error("开启失败:"+e.getLocalizedMessage());
    }
    return BaseResponse.success(processInstance);
}

挂起实例

@PostMapping("/suspendInstance/{instanceId}")
public BaseResponse suspendInstance(@PathVariable String instanceId){
    ProcessInstance processInstance = processRuntime.suspend(ProcessPayloadBuilder.suspend().withProcessInstanceId(instanceId).build());
    return BaseResponse.success(processInstance);
}

激活实例

@PostMapping("/resumeInstance/{instanceId}")
public BaseResponse resumeInstance(@PathVariable String instanceId){
    ProcessInstance processInstance = processRuntime
        .resume(ProcessPayloadBuilder.resume().withProcessInstanceId(instanceId).build());
    return BaseResponse.success(processInstance);
}

任务相关接口

完成任务

@PostMapping("/completeTask/{taskId}")
public BaseResponse completeTask(@PathVariable String taskId){
    Task task = taskRuntime.task(taskId);
    if (task.getAssignee()==null){
        // 说明任务需要拾取
        taskRuntime.claim(TaskPayloadBuilder.claim().withTaskId(taskId).build());
    }
    taskRuntime.complete(TaskPayloadBuilder.complete().withTaskId(taskId).build());
    return BaseResponse.success();
}

获取自己的任务

@GetMapping("/getTasks")
public BaseResponse getTasks(){
    Page<Task> taskPage = taskRuntime.tasks(Pageable.of(0, 100));
    List<Task> tasks = taskPage.getContent();
    List<TaskVO> taskVOS = new ArrayList<>();
    for (Task task : tasks) {
        TaskVO taskVO = TaskVO.of(task);
        ProcessInstance instance = processRuntime.processInstance(task.getProcessInstanceId());
        taskVO.setInstanceName(instance.getName());
        taskVOS.add(taskVO);
    }
    return BaseResponse.success(taskVOS);
}

历史数据查询

public List<HistoricActivityInstanceVO> getProcessHistoryByBusinessKey(String businessKey) {
    ProcessInstance instance = runtimeService.createProcessInstanceQuery().processInstanceBusinessKey(businessKey).singleResult();
    List<HistoricActivityInstance> historicActivityInstanceList = historyService.createHistoricActivityInstanceQuery().processInstanceId(instance.getId())
        .orderByHistoricActivityInstanceStartTime().asc().list();
    List<HistoricActivityInstanceVO> historicActivityInstanceVOList = new ArrayList<>();
    historicActivityInstanceList.forEach(historicActivityInstance -> historicActivityInstanceVOList.add(VOConverter.getHistoricActivityInstanceVO(historicActivityInstance)));
    return historicActivityInstanceVOList;
}  

历史详情查询

HistoricDetailQuery historicDetailQuery = historyService.createHistoricDetailQuery();
List<HistoricDetail> historicDetails = historicDetailQuery.processInstanceId(instanceId).orderByTime().list();
for (HistoricDetail hd: historicDetails) {
    System.out.println("流程实例ID:"+hd.getProcessInstanceId());
    System.out.println("活动实例ID:"+hd.getActivityInstanceId());
    System.out.println("执行ID:"+hd.getTaskId());
    System.out.println("记录时间:"+hd.getTime());
}

历史流程实例查询

HistoricProcessInstanceQuery historicProcessInstanceQuery = historyService.createHistoricProcessInstanceQuery();
List<HistoricProcessInstance> processInstances = historicProcessInstanceQuery.processDefinitionId(processDefinitionId).list();
for (HistoricProcessInstance hpi : processInstances) {
    System.out.println("业务ID:"+hpi.getBusinessKey());
    System.out.println("流程定义ID:"+hpi.getProcessDefinitionId());
    System.out.println("流程定义Key:"+hpi.getProcessDefinitionKey());
    System.out.println("流程定义名称:"+hpi.getProcessDefinitionName());
    System.out.println("流程定义版本:"+hpi.getProcessDefinitionVersion());
    System.out.println("流程部署ID:"+hpi.getDeploymentId());
    System.out.println("开始时间:"+hpi.getStartTime());
    System.out.println("结束时间:"+hpi.getEndTime());
}
package org.activiti.engine.history; 
@Internal
public interface HistoricProcessInstance {
    String getId();

    String getBusinessKey();

    String getProcessDefinitionId();

    String getProcessDefinitionName();

    String getProcessDefinitionKey();

    Integer getProcessDefinitionVersion();

    String getDeploymentId();

    Date getStartTime();

    Date getEndTime();

    Long getDurationInMillis();

    String getEndActivityId();

    String getStartUserId();

    String getStartActivityId();

    String getDeleteReason();

    String getSuperProcessInstanceId();

    String getTenantId();

    String getName();

    String getDescription();

    Map<String, Object> getProcessVariables();
}

任务历史查询

某一次流程的执行经历的多少任务

HistoricTaskInstanceQuery historicTaskInstanceQuery = historyService.createHistoricTaskInstanceQuery();
List<HistoricTaskInstance> taskInstances = historicTaskInstanceQuery.taskId(taskId).list();
for (HistoricTaskInstance hti : taskInstances) {
    System.out.println("开始时间:"+hti.getStartTime());
    System.out.println("结束时间:"+hti.getEndTime());
    System.out.println("任务拾取时间:"+hti.getClaimTime());
    System.out.println("删除原因:"+hti.getDeleteReason());
}

活动历史查询

查询某个流程的每个阶段(活动)

HistoricActivityInstanceQuery historicActivityInstanceQuery = historyService.createHistoricActivityInstanceQuery();
List<HistoricActivityInstance> historicActivityInstances = historicActivityInstanceQuery.processInstanceId(instanceId).list();
for (HistoricActivityInstance hai : historicActivityInstances) {
    System.out.println("活动ID:"+hai.getActivityId());
    System.out.println("活动类型:"+hai.getActivityType());
    System.out.println("活动名称:"+hai.getActivityName());
    System.out.println("任务ID:"+hai.getTaskId());
}
package org.activiti.engine.history;  
@Internal
public interface HistoricActivityInstance extends HistoricData {
    String getId();

    String getActivityId();

    String getActivityName();

    String getActivityType();

    String getProcessDefinitionId();

    String getProcessInstanceId();

    String getExecutionId();

    String getTaskId();

    String getCalledProcessInstanceId();

    String getAssignee();

    Date getStartTime();

    Date getEndTime();

    Long getDurationInMillis();

    String getDeleteReason();

    String getTenantId();
}

变量历史信息

某一次流程的执行时设置的流程变量

HistoricVariableInstanceQuery historicVariableInstanceQuery = historyService.createHistoricVariableInstanceQuery();
List<HistoricVariableInstance> variableInstances =historicVariableInstanceQuery
                                                    .processInstanceId(instanceId)
                                                    .list();
for (HistoricVariableInstance hva : variableInstances) {
    System.out.println("变量名称:"+hva.getVariableName());
    System.out.println("变量类型名称:"+hva.getVariableTypeName());
    System.out.println("变量值:"+hva.getValue());
    System.out.println("流程实例ID:"+hva.getProcessInstanceId());
    System.out.println("任务ID:"+hva.getTaskId());
}

附件

Activiti官方示例源码

本章源码码云仓库

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容