news 2026/9/26 5:12:09

SpringBoot集成Flowable工作流引擎:从版本选型到生产落地的完整实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot集成Flowable工作流引擎:从版本选型到生产落地的完整实战指南

在企业后端项目里,工作流引擎几乎是绕不开的话题,请假审批、报销审批、合同会签,如果靠硬编码状态机去维护,撑过两三个流程就会让人想砸键盘。我在这类场景里试过好几个方案,最后长期留下来的组合是SpringBoot配合Flowable。这篇文章我会从零开始,把SpringBoot集成Flowable的完整过程、版本选择、数据库初始化、BPMN流程定义、业务侧的核心API操作,以及实际项目中踩过的坑和沉淀下来的配置,一次讲清楚。如果你会SpringBoot但还没碰过工作流引擎,按着这篇的节奏一步步操作,基本都能顺利跑起来。

1. 对比完Activiti和Camunda,我为什么把Flowable放进SpringBoot

1.1 工作流引擎选型这件事,不能只看名气

网上搜工作流引擎,最先跳出来的永远是Activiti、Flowable、Camunda这三家。很多新人会直接选名气最大的Activiti,但等真正开工才发现版本分裂和社区维护状态的问题很闹心。

Activiti 5的时代确实很辉煌,BPMN 2.0规范落地得早,中文教程一抓一大把。但后来项目主体拆分,Flowable团队从Activiti 5分支出来独立发展,Activiti 6/7则走上了另一条路。如果你现在开新项目,再用老一套Activiti 5的思路,会遇到两个直接问题:一是和SpringBoot 2.7+版本的适配需要自己折腾,二是遇到问题去搜资料,搜出来的解决方案大多是针对Flowable和Activiti 5的,版本对不上,踩坑成本很高。

Camunda功能确实强,特别是它的Cockpit监控界面很漂亮,对复杂编排场景支持也细。但它本身更偏重企业级平台,组件体积大,和SpringBoot集成时需要引入的模块也多。对大部分中小规模项目的审批场景来说,有点杀鸡用牛刀的意味。

1.2 Flowable的定位和优势

Flowable从Activiti分叉之后,主打的路线就是把流程引擎做成一个可以嵌入SpringBoot的轻量级组件。它保留了BPMN 2.0的完整支持,CMMN Case模型、DMN决策表这些后续也都做了。最让我满意的是它的Spring Boot Starter体系很完整,引入一个依赖就能拿到ProcessEngine以及所有的Service Bean,不用自己写引擎初始化代码。

另外Flowable的中文社区内容密度比Camunda高不少,遇到资源表达式写法、多实例加签这种问题,搜索引擎里能翻到不少真实案例。这对新手来说太重要了,因为工作流引擎的调试难度比普通CRUD接口高得多,我之前就为了一个排他网关的条件不生效问题折腾了一个下午,最后还是靠社区帖子解决的。

2. 版本搭配与数据库初始化:这个环节最容易翻车

2.1 SpringBoot和Flowable的版本对应关系

很多人第一次集成就把版本搞错了,导致启动直接报ClassNotFoundException或者引擎里一堆方法找不到。Flowable和SpringBoot的适配是有明确对应关系的,我整理了一份实际验证过的搭配:

SpringBoot版本Flowable版本备注
2.3.x6.4.x老项目常见,建议升级
2.7.x6.6.0 ~ 6.8.x国内目前的主流组合
3.0.x及以上7.0.0及以上新项目建议,JDK 17+

我自己目前主力项目用的是SpringBoot 2.7.18 + Flowable 6.7.2,这个组合跑了一年多没有遇到引擎层的问题。如果你是新项目且JDK环境允许,直接上SpringBoot 3 + Flowable 7,毕竟Flowable 7的底层持久层换成了MyBatis Plus体系,对现代开发更友好。但别随便把旧项目的Flowable 6升到7,两个版本之间的一些配置项和API行为有差异,升级成本不像依赖替换那么简单。

2.2 数据库选型与MySQL连接串里的隐形坑

Flowable跑起来需要一系列ACT_开头的表来维护流程定义、流程实例、任务、历史数据。首次启动时你可以让它自动建表,但数据库得先建好。生产环境建议用MySQL或PostgreSQL,本地练手可以用H2内存库,配置简单但重启后数据全丢。

如果用MySQL 8,连接串里有一个参数特别关键:

spring: datasource: url: jdbc:mysql://localhost:3306/flowable?useUnicode=true&characterEncoding=utf8&nullCatalogMeansCurrent=true&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver

nullCatalogMeansCurrent=true这个参数不加,Flowable在某些MySQL版本下查询ACT_ID_GROUP等表时会直接报“Table doesn't exist”之类的异常。原因是JDBC驱动对catalog的处理方式不一致,Flowable在获取表元数据时用到了catalog信息,默认驱动行为会把当前catalog置为null,结果误判了表是否存在。这个问题表面看是缺表,实际是数据库连接参数的问题,排查方向很容易跑偏。

2.3 首次启动建表后的验证方法

Flowable的表不是一次性全部建好,是根据引擎初始化的深度分模块创建的。启动成功后,你至少能看到ACT_GE_PROPERTY、ACT_GE_BYTEARRAY、ACT_RE_PROCDEF等几十张表。可以在数据库客户端执行:

SHOW TABLES LIKE 'ACT_%';

如果一张表都没有,基本可以确定引擎没有真正初始化。最常见的三个原因:flowable.database-schema-update没配置、数据源没被Flowable识别、或者启动日志里有异常被logback吞了。建议第一轮就直接在SpringBoot启动日志里搜Flowable关键字,看到类似Flowable 6.7.2 starting和database schema update successful的输出,才算初始化成功。

3. 依赖、配置与引擎服务:跑通最小可用的集成环境

3.1 pom.xml里到底要引入什么

Flowable 6.x和7.x的Spring Boot Starter坐标是一样的,直接在pom.xml里加:

<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter-process</artifactId> <version>6.7.2</version> </dependency>

这一个依赖会把引擎核心、Spring Boot自动配置、BPMN解析等全部带进来。如果你后面想用DMN决策表,再加flowable-spring-boot-starter-dmn;想做流程监控页面,可以引入flowable-spring-boot-starter-ui,但这个UI实际生产用得少,更多是自己开发。

千万别同时引入flowable-engine和flowable-spring-boot-starter-process两套,那会造成引擎初始化路径混乱,Spring上下文里出现两个ProcessEngine的Bean,各种诡异问题都会冒出来。

3.2 最关键的五个配置项

application.yml里除了数据源,还需要把Flowable自身的开关配好。我实际项目里用的最小配置是这样:

flowable: database-schema-update: true async-executor-activate: true history: full db-history-used: true check-process-definitions: false
  • database-schema-update: true:启动时自动创建或更新表结构。生产环境想更稳妥,可以改为false,用SQL脚本手工管理表结构变更。
  • async-executor-activate: true:激活异步执行器,定时器事件、异步任务都需要它。这个选项我第一次集成时没太在意,后来做超时自动提醒功能时才发现没开。
  • history: full:历史记录级别。full会保存流程变量的所有快照,方便追踪,但数据量也最大。如果只是简单审批,audit级别就够,能拿到任务历史但不会记录变量变更细节。
  • check-process-definitions: false:默认情况下Flowable会去扫描并部署classpath下的流程定义文件,如果还没准备BPMN文件,先把它关掉,免得启动报找不到文件的错。

3.3 自动配置背后发生了什么

Flowable的自动配置核心类会创建ProcessEngine,然后把它管理的一系列Service注册到Spring容器里。你在业务代码里直接注入这些Bean就能用:

  • RepositoryService:部署流程定义、查询流程定义。
  • RuntimeService:启动流程实例、触发流程推进。
  • TaskService:查询任务、认领任务、审批完成任务。
  • HistoryService:查询历史流程实例和活动记录。
  • IdentityService:设置流程发起人、管理用户组关系。
  • ManagementService:引擎管理和Job操作,用得少。

刚接触Flowable时,很多人分不清RepositoryService和RuntimeService的区别。我自己的记忆方式是:RepositoryService管“模板”,流程定义是静态的模板;RuntimeService管“实例”,流程实例是模板跑起来的一次具体过程。定义相当于类,实例相当于对象,这样理解就顺了。

4. 部署请假审批流程:BPMN文件的写法与校验

4.1 一个最简单的BPMN 2.0 XML长什么样

Flowable用BPMN 2.0 XML来描述流程。你可以用Flowable官方Modeler画图然后导出XML,也可以直接在IDEA里用插件画。但我觉得入门阶段最好手动写一遍XML,这对理解流程结构很有帮助。一个经典的请假审批流,包含开始事件、两个用户任务、一个排他网关和结束事件,核心XML如下:

<?xml version="1.0" encoding="UTF-8"?> <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="http://www.flowable.org/processdef"> <process id="leaveProcess" name="请假审批流程" isExecutable="true"> <startEvent id="startEvent" name="发起申请"/> <userTask id="applyTask" name="填写请假单" flowable:assignee="${applyUser}"/> <userTask id="managerTask" name="经理审批" flowable:assignee="${managerUser}"/> <exclusiveGateway id="gateway" name="是否通过"/> <sequenceFlow id="flow1" sourceRef="startEvent" targetRef="applyTask"/> <sequenceFlow id="flow2" sourceRef="applyTask" targetRef="managerTask"/> <sequenceFlow id="flow3" sourceRef="managerTask" targetRef="gateway"/> <sequenceFlow id="flowPass" sourceRef="gateway" targetRef="endEvent"> <conditionExpression xsi:type="tFormalExpression">${approved == true}</conditionExpression> </sequenceFlow> <sequenceFlow id="flowReject" sourceRef="gateway" targetRef="applyTask"> <conditionExpression xsi:type="tFormalExpression">${approved == false}</conditionExpression> </sequenceFlow> <endEvent id="endEvent" name="结束"/> </process> </definitions>

这里用到了两个流程变量:applyUser和managerUser决定任务分配给谁,approved决定审批通过还是驳回重填。排他网关的条件是按顺序解析的,先匹配到的生效,所以条件顺序很重要。我见过有人把两个条件都写成类似${status != 0},结果永远走进第一个分支的情况。

4.2 部署代码和校验步骤

把上面的XML放到src/main/resources/processes/leave.bpmn20.xml,然后通过RepositoryService部署:

@Autowired private RepositoryService repositoryService; public void deployLeaveProcess() { Deployment deployment = repositoryService.createDeployment() .name("请假审批流程") .addClasspathResource("processes/leave.bpmn20.xml") .deploy(); System.out.println("部署ID:" + deployment.getId()); }

部署完成后,用查询接口确认流程定义是否成功发布:

List<ProcessDefinition> definitions = repositoryService.createProcessDefinitionQuery() .processDefinitionKey("leaveProcess") .orderByProcessDefinitionVersion().desc() .list();

这里要注意,流程定义key相同的情况下,每部署一次就会生成一个新版本。如果在开发阶段频繁部署,ACT_RE_PROCDEF里会堆出一串历史版本,启动流程时默认使用最新版本。这个设计是合理的,但如果你在测试环境反复改了又部署,查问题时会发现旧流程实例还在运行,新流程实例却已经用了新版本逻辑,对不上版本容易糊涂。

4.3 中文乱码是部署时最容易踩的坑

BPMN文件里写了中文,部署后在流程定义名称里看到一串乱码,这种问题很常见。根本原因是XML文件读取时的编码和引擎解析时的编码不一致。我遇到过的三种情况和对应解法:

  • 文件保存为GBK编码,而XML声明了UTF-8。这种情况直接改IDE默认编码,把文件统一成UTF-8。
  • 使用addClasspathResource部署时,Spring Boot读取classpath资源默认用了平台编码。在部署代码里显式指定UTF-8读字节流,再用addInputStream部署,通常能解决。
  • Linux服务器环境变量LANG没设置,导致JVM默认文件编码不是UTF-8。在启动参数里加上-Dfile.encoding=UTF-8最稳妥。

部署这块的经验是:流程图可以先画个极简版本,先把部署和启动链路走通,再回过来丰富细节。我一开始就研究复杂的会签、子流程,结果排错时既要排查XML问题又要看网关逻辑,自己把自己绕晕了。

5. 业务代码里的核心操作:发起、查询、审批与条件流转

5.1 发起流程实例的完整姿势

Flowable里发起一个流程实例,核心调用是RuntimeService.startProcessInstanceByKey。业务侧需要把流程变量一起传进去,这样流程里的表达式才能解析:

@Autowired private RuntimeService runtimeService; @Autowired private IdentityService identityService; public void startLeave(String applyUser, String managerUser, Integer days) { identityService.setAuthenticatedUserId(applyUser); Map<String, Object> variables = new HashMap<>(); variables.put("applyUser", applyUser); variables.put("managerUser", managerUser); variables.put("days", days); variables.put("approved", null); ProcessInstance processInstance = runtimeService .startProcessInstanceByKey("leaveProcess", variables); System.out.println("流程实例ID:" + processInstance.getId()); }

identityService.setAuthenticatedUserId(applyUser)这个设置很关键,它会在历史数据里记下发起人信息。如果不设置,ACT_HI_PROCINST里的START_USER_ID_字段就是空的,后续做流程追溯时无从查起。这段代码我建议放在业务事务的最外层,保证发起人和流程实例在同一事务上下文中。

5.2 查询待办任务和审批操作

引擎会为每个userTask生成一条任务记录。业务系统里最常见的操作就是按人查待办:

@Autowired private TaskService taskService; public List<Task> findTodoTasks(String assignee) { return taskService.createTaskQuery() .taskAssignee(assignee) .orderByTaskCreateTime().desc() .list(); }

任务查询可以组合很多条件,比如.processInstanceId()按实例查、.taskDefinitionKey()按节点查、.active()只看未结束任务。实际项目里列表页还需要返回流程名称、发起人、发起时间这些业务字段,那就需要拿task.getProcessInstanceId()再查一遍ACT_HI_PROCINST,或者直接写自定义Mapper连表查。这里我更推荐后者,因为引擎自带查询在复杂条件组合和分页性能上没那么理想,业务侧用自定义SQL更灵活。

审批完成的操作是:

public void completeTask(String taskId, boolean approved, String comment) { Map<String, Object> taskVariables = new HashMap<>(); taskVariables.put("approved", approved); taskVariables.put("comment", comment); taskService.complete(taskId, taskVariables); }

taskService.complete()会触发流程向前推进。如果走到了排他网关,引擎会解析后续连线的条件表达式。特别注意:表达式里变量为null时,排他网关的校验会直接判false,进入不了任何分支。我在5.1里特意把approved初始化为null,就是防止在发起环节的表达式解析阶段出意外。

5.3 网关条件里的表达式规则

Flowable的条件表达式基于Spring的EL表达式,最常用的三种写法:

  • ${approved == true}:布尔变量比较。
  • ${days > 3}:数值比较,这里days是Integer或Long。
  • ${managerUser == 'lisi'}:字符串比较,注意用单引号。

表达式里方法调用也支持,比如${businessService.isApproved(execution)},但这类写法会把业务逻辑耦合进流程定义,除非特别必要,否则我建议还是用纯变量表达式。一个额外的教训:条件变量一定要在到达网关之前就放进变量表里,否则网关在完全不知道变量存在的情况下不会报错,而是直接抛异常或者走向默认分支,这个问题排查起来特别容易懵。

6. Spring事务与Flowable的配合:自调用陷阱和异步执行器

6.1 Flowable在Spring环境下的事务行为

如果你用了flowable-spring-boot-starter-process,Flowable会自动使用Spring的事务管理器。这意味着什么?意味着Flowable的引擎操作会和你的业务操作在同一个事务里,要么一起成功,要么一起回滚。

我举个例子,业务侧在审批通过之后要更新业务单据状态:

@Service public class LeaveService { @Autowired private TaskService taskService; @Autowired private LeaveOrderMapper leaveOrderMapper; @Transactional(rollbackFor = Exception.class) public void approveTask(String taskId, String orderId, boolean approved) { taskService.complete(taskId, Collections.singletonMap("approved", approved)); leaveOrderMapper.updateStatus(orderId, approved ? 2 : 3); } }

由于两个操作在同一个@Transactional事务里,任务完成和单据状态的更新就是原子的。如果后面那步操作抛了RuntimeException,任务流转也会回滚。这是Flowable整合Spring后最好的地方之一,也是我建议把引擎操作包在Service方法里而不是直接丢给Controller的原因。

6.2 自调用导致事务失效,流程推进一半出了问题

Spring事务基于代理实现,而代理在通过this调用同类方法时不会被触发。这是一个老生常谈的坑,但放在Flowable场景下风险更大:

@Service public class LeaveService { // 错误写法:内部调用导致@Transactional不生效 public void handleBusiness(String taskId) { completeTaskInternal(taskId); } @Transactional(rollbackFor = Exception.class) public void completeTaskInternal(String taskId) { taskService.complete(taskId, Collections.singletonMap("approved", true)); } }

上面的例子中,completeTaskInternal的事务注解完全没用。流程任务一旦推进成功,但后续业务代码异常,引擎这步已经提交了,数据就出现一边完成一边没更新的情况。解决方法是换一个Service类来调用,或者在当前类里注入自己的代理对象。这个坑我印象特别深,因为我们线上出现过一次审批通过但业务状态没更新的问题,排查了一整个下午,最后发现就是自调用导致事务边界失效。

6.3 异步执行器的理解和配置

flowable.async-executor-activate这个开关,初学者容易直接复制配置没弄明白。它的作用是启动Flowable的异步执行器线程池,用来处理定时器事件、异步延续等逻辑。如果你在流程里用了边界超时事件,比如请假超过两天自动提醒经理,就需要开启它。

我建议从第一天就设成true,不然以后加了定时器相关节点,流程会卡在等待事件上不肯往下走。同时它支持线程池参数调整:

flowable: async-executor-activate: true async-executor-core-pool-size: 10 async-executor-max-pool-size: 20 async-executor-queue-capacity: 100

默认值对中小项目够用,流量上来再按实际情况调。线上调优时别只盯着线程池大小,还要看数据库连接池,因为异步执行器的任务最终都要落库,连接池太小会出现等待连接的日志。

7. 高频报错排雷与生产环境值得调的几个参数

7.1 我整理了一份真实的报错排查表

下面这些异常是我在实际项目和社区里见过频率最高的,后面附上了根因和处理方式:

报错现象根因处理方案
no processes deployed with key 'xxx'流程未部署或key写错检查部署代码,用repositoryService.createProcessDefinitionQuery()确认key
Table 'flowable.act_ge_property' doesn't exist数据库schema未初始化检查flowable.database-schema-update配置,确认账号有建表权限
部署后流程名称中文乱码XML或JDBC读取编码不一致统一UTF-8编码,启动参数加-Dfile.encoding=UTF-8
NullPointerException出现在表达式解析流程变量在网关判断前未赋值在启动流程或completeTask时显式初始化变量
OptimisticLockingException同一流程实例被并发操作业务层做幂等控制,或对任务加锁后再操作
ClassNotFoundException: org.flowable.spring.boot.ProcessEngineAutoConfigurationFlowable和SpringBoot版本不匹配先核对版本对应表

7.2 生产环境下值得调优的参数

除了异步执行器线程池,还有几个参数在流量上来后一定要关注。

history级别建议按业务诉求控制。我之前有个项目对流程变量的历史不敏感,但因为没改默认值,ACT_HI_VARINST表长到了上千万行。后来把history调成audit,彻底避免记录变量表的增量,查询速度快了很多。代价是不能再追踪单个流程变量在每一步的值,这个取舍看业务。

数据库连接池参数也要跟着调。Flowable的异步执行器、定时任务Job都会占用连接,而业务代码本身也要访问数据库。如果项目使用的是Druid或HikariCP,建议最大连接数至少比引擎线程池多出一倍,避免互相争抢连接池资源。

历史表清理策略在Flowable里没有内置的老数据自动清理机制(企业版除外),生产上我一般自己写个定时任务,按时间清理ACT_HI_PROCINST、ACT_HI_TASKINST、ACT_HI_ACTINST等表。写清理SQL时有一个特别注意点:不要直接DELETE大表全量数据,很可能把活跃流程引用的历史数据删了导致关联查询异常,一定要先确认要清理的流程实例确实已经结束。

7.3 实际项目里的扩展思路

Flowable用顺手之后,可以在它上面做不少扩展。比如我后来的项目在完成审批时用事件监听器同步ES索引,用RuntimeService.addEventListener监听任务创建和流程结束事件。也做过动态加签:通过RuntimeService.createChangeActivityStateBuilder()把流程实例移动到指定节点,实现驳回或跳转。

这些高级玩法都需要对Flowable的内部命令体系有所了解,但前提仍然是先把基础的部署、发起、审批、条件流转跑通。我给新人的建议是:从小流程开始,建立完整的主线认知,再去碰高级特性。工作流的调试成本天然比普通接口高,如果连主线都没跑顺就上复杂特性,出问题时很容易到处怀疑,最后连问题出在“流程定义”还是“业务代码”都分不清。

最后分享一个我自己的习惯:每套流程定义我都会在本地写一个JUnit测试,覆盖“正常通过”和“驳回重填”两条主线。不要小看这个步骤,Flowable的流程定义改动很难发现副作用,回归测试能帮你把大部分低级问题挡在开发阶段。你如果也在做SpringBoot项目且准备引入工作流,希望这篇东西能帮你少走几个我走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 5:11:46

Linux文本处理利器:awk实战详解,从日志分析到运维统计

在Linux下做文本处理&#xff0c;绕不开三剑客。我是从写运维脚本开始碰awk的&#xff0c;最开始觉得这家伙就是个格式化输出工具&#xff0c;直到后来用它在线上日志里几分钟揪出慢接口、统计完几万行访问记录&#xff0c;才意识到一个事实&#xff1a;grep负责“筛”&#xf…

作者头像 李华
网站建设 2026/9/26 5:11:27

ArchivePasswordTestTool:压缩包密码强度验证与审计体系

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 5:10:32

Qwen3.5-9B社区版实测:GGUF量化、MTP加速与部署指南

打开HuggingFace页面看到这一长串名字的时候&#xff0c;我第一反应是“这是模型名还是中二病群名”&#xff1f;Qwen3.5-9B-The-Defiant-Fable-Uncensored-Heretic-NEO-IMATRIX-MAX-MTP-GGUF&#xff0c;一口气念完差点喘不上气。但把这串字符拆开之后&#xff0c;其实每个部件…

作者头像 李华
网站建设 2026/9/26 5:10:06

2026实测百度网盘网页端脚本加速:免装客户端满速解析直链

面对网盘下载界面上迟迟不肯前进的进度条&#xff0c;等待的过程确实非常考验人的耐心。很多人习惯把速度慢归咎于各种不可控的客观因素&#xff0c;却很少回过头来仔细梳理自己手头的使用场景是否存在问题。 在大家日常讨论各种网络优化方案时&#xff0c;PanDown作为一个绕不…

作者头像 李华
网站建设 2026/9/26 5:09:41

饥荒联机版三种“慢”:匹配、加载、卡顿,对症下药才有效

先说结论&#xff1a;网上关于饥荒联机版匹配慢、加载慢、网传卡顿的办法&#xff0c;我基本都试过&#xff0c;绝大多数时候确实没用。这句话不是抬杠&#xff0c;而是我从单机转联机、前后折腾了两年多、换过三台电脑之后得到的真实体会。更讽刺的是&#xff0c;后来我把网上…

作者头像 李华
网站建设 2026/9/26 5:08:16

Word粘贴到富文本编辑器,图片和超链接丢失的完整解决方案

1. 从Word复制出来的内容&#xff0c;比你想的更复杂1.1 剪贴板里的数据不止是“文字”最近做后台内容管理系统&#xff0c;被一个听起来很简单的需求卡了两天&#xff1a;用户从Word复制一段图文&#xff0c;图片上还带着超链接&#xff0c;粘贴到富文本编辑器里&#xff0c;图…

作者头像 李华