金融系统文档导入功能开发实录:从技术焦虑到突破的全过程

2023年8月14日 周一 需求确认

作为公司前端开发组的核心成员,我接到了产品总监的紧急需求:在现有金融业务系统中新增Word/PDF导入功能,要求完整保留文档中的表格样式、公司LOGo和金融图表。当前系统架构为:

  • 前端:Vue2-CLI + TinyMCE4(富文本编辑器)
  • 后端:SpringBoot 2.7 + MySQL 8.0
  • 安全要求:等保三级合规

“这个需求的关键是样式保真度”,我在需求评审会上强调,“特别是那些带条件格式的财务报表和矢量图表”。技术总监敲了敲白板:“给你们三周时间,必须通过金融行业的压力测试——客户要导入的文档可能包含200页的招股说明书”。

8月15日-17日 开源方案探索

Day1:TinyMCE生态调研

  • 测试tinymce-powerpaste插件:虽然能保留样式,但599美元/年的授权费超出预算
  • 发现mammoth.js开源库:能提取Word文档结构,但无法处理复杂表格和图片
  • 尝试docx-preview:生成的HTML与TinyMCE不兼容,嵌套表格全部错位

Day2:PDF处理困境

  • pdf.js渲染的文档在编辑器中出现严重偏移
  • pdf2htmlEX转换后产生大量冗余``标签,导致编辑器卡顿
  • 测试Apache PDFBox:后端解析速度太慢,单页PDF处理耗时3秒

Day3:混合方案尝试

  • 前端用mammoth.js提取文本+图片元数据
  • 后端用Apache POI处理复杂样式
  • 发现图片处理存在跨域问题,上传七牛云时token失效
8月18日 技术方案突破

凌晨两点,我在重看TinyMCE文档时突然意识到:或许可以分层处理文档内容

最终架构设计

  1. 前端预处理层

    • 使用Web Worker解析文档(避免阻塞UI)
    • 图片分片上传至对象存储(阿里云OSS)
    • 生成样式标记(如[table:finance]
  2. 后端处理层

    • SpringBoot接收标记HTML
    • 用Jsoup清理XSS漏洞
    • 金融样式增强引擎(自定义CSS映射)
  3. 编辑器适配层

    • 扩展TinyMCE的paste插件
    • 实现特殊标记转换(如[table:finance] → 预定义样式表)
8月21日-25日 核心代码实现

前端实现(Vue组件)

// DocxImporter.vue
export default {
  methods: {
    async handleFile(file) {
      // 1. 文件校验
      if (!file.name.match(/\.(docx|pdf)$/)) {
        this.$message.error('仅支持docx/pdf格式');
        return;
      }

      // 2. 启动Web Worker解析
      const worker = new Worker('./docx-parser.worker.js');
      worker.postMessage({ file });
      
      // 3. 处理进度反馈
      worker.onmessage = (e) => {
        if (e.data.type === 'progress') {
          this.progress = e.data.value;
        } else if (e.data.type === 'result') {
          this.insertToEditor(e.data.html);
        }
      };
    },

    insertToEditor(html) {
      // 金融样式增强
      const enhancedHtml = html
        .replace(/ {
  const { file } = e.data;
  
  // 1. 读取文件ArrayBuffer
  const buffer = await file.arrayBuffer();
  
  // 2. 使用mammoth提取内容(基础解析)
  const result = await mammoth.extractRawText({ arrayBuffer: buffer });
  
  // 3. 自定义图片处理器
  const images = [];
  result.messages.forEach(msg => {
    if (msg.type === 'warning' && msg.message.includes('image')) {
      const imageId = msg.message.match(/image-(\d+)/)[1];
      images.push(extractImage(buffer, imageId));
    }
  });
  
  // 4. 上传图片到OSS
  const imageUrls = await Promise.all(
    images.map(img => uploadToOSS(img))
  );
  
  // 5. 替换HTML中的图片标记
  let html = result.value;
  imageUrls.forEach((url, idx) => {
    html = html.replace(`[image:${idx}]`, ``);
  });
  
  self.postMessage({ 
    type: 'result', 
    html 
  });
};

后端安全处理(SpringBoot)

@Service
public class DocumentSanitizer {
    // 金融样式白名单
    private static final Set ALLOWED_CLASSES = 
        Set.of("finance-table", "finance-title", "chart-container");
    
    public String sanitize(String html) {
        // 1. 使用Jsoup清理危险标签
        Document doc = Jsoup.parse(html);
        doc.select("script, iframe, object, embed, form, input").remove();
        
        // 2. 过滤非法样式类
        doc.select("*").forEach(element -> {
            String classAttr = element.attr("class");
            if (!classAttr.isEmpty()) {
                String[] classes = classAttr.split("\\s+");
                List validClasses = Arrays.stream(classes)
                    .filter(ALLOWED_CLASSES::contains)
                    .collect(Collectors.toList());
                element.attr("class", String.join(" ", validClasses));
            }
        });
        
        // 3. 添加金融行业必备属性
        doc.select("table").attr("border", "1")
           .attr("cellspacing", "0")
           .attr("cellpadding", "5");
        
        return doc.html();
    }
}
8月26日-28日 性能优化

问题1:大文件上传超时

  • 解决方案:实现分片上传+断点续传
// 前端分片上传
async function uploadInChunks(file, chunkSize = 5 * 1024 * 1024) {
  const chunks = Math.ceil(file.size / chunkSize);
  const uploadPromises = [];
  
  for (let i = 0; i < chunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    
    uploadPromises.push(
      axios.post('/api/upload/chunk', {
        file: chunk,
        chunkIndex: i,
        totalChunks: chunks,
        fileHash: await calculateFileHash(file) // 前端计算文件指纹
      })
    );
  }
  
  await Promise.all(uploadPromises);
  return await mergeChunks(file.name, chunks);
}

问题2:后端解析内存溢出

  • 解决方案:使用流式处理+临时文件
@PostMapping("/import")
public ResponseEntity importDocument(@RequestParam("file") MultipartFile file) {
    try {
        // 1. 保存到临时文件
        Path tempFile = Files.createTempFile("doc-", ".tmp");
        file.transferTo(tempFile.toFile());
        
        // 2. 使用流式解析
        try (InputStream is = Files.newInputStream(tempFile)) {
            String html = documentParser.parse(is); // 流式处理
            String sanitized = sanitizer.sanitize(html);
            return ResponseEntity.ok(sanitized);
        }
    } finally {
        // 清理临时文件
        // ...
    }
}
8月29日 金融级安全加固
  1. 数据校验三重防护

    • 前端:文件类型白名单+大小限制(50MB)
    • 网关层:Nginx限制上传速率(2MB/s)
    • 后端:Magic Number校验文件真实类型
  2. XSS防护增强

// 前端二次转义
function escapeHtml(unsafe) {
  return unsafe
    .replace(/&/g, "&")
    .replace(//g, ">")
    .replace(/"/g, """)
    .replace(/'/g, "'");
}
  1. 审计日志记录
CREATE TABLE document_import_logs (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id VARCHAR(32) NOT NULL,
  file_name VARCHAR(255) NOT NULL,
  file_size BIGINT NOT NULL,
  ip_address VARCHAR(45) NOT NULL,
  import_result TINYINT NOT NULL COMMENT '0:成功 1:失败 2:部分成功',
  error_message TEXT,
  create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
8月30日 验收测试

测试用例1:200页招股说明书

  • 导入时间:3分15秒(原需求≤5分钟)
  • 样式保留率:92%(复杂图表需手动调整)
  • 图片完整率:100%

测试用例2:含宏的Word文档

  • 自动拦截并提示:“检测到宏内容,已自动清除”

测试用例3:PDF表单

  • 成功提取文本内容,但表单控件转为静态图片
9月1日 项目总结

成果

  1. 首次在金融行业实现高保真文档导入
  2. 通过等保三级安全认证
  3. 客户满意度达9.2/10

教训

  1. 开源方案二次开发成本可能高于自主开发
  2. 金融行业对样式精确度的要求远超预期
  3. 大文件处理必须从架构层面设计

后续计划

  1. 2023年Q4支持LaTeX公式导入
  2. 接入AI智能识别,自动调整异常样式
  3. 建立金融文档样式标准库

当看到系统成功处理某券商的300页IPO文件时,测试总监感叹:“这比专业文档转换工具还稳定”。这一刻,所有熬夜调试的疲惫都化作了成就感——我们不仅完成了需求,更重新定义了金融行业文档导入的标准。

复制插件

WordPaster插件文件夹

安装jquery

npm install jquery

在组件中引入

  // 引入tinymce-vue
  import Editor from '@tinymce/tinymce-vue'
  import {WordPaster} from '../../static/WordPaster/js/w'
  import {zyOffice} from '../../static/zyOffice/js/o'
  import {zyCapture} from '../../static/zyCapture/z'

添加工具栏

//添加导入excel工具栏按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor).importExcel()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('excelimport', {
        text: '',
        tooltip: '导入Excel文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('excelimport', {
        text: '',
        tooltip: '导入Excel文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('excelimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加word转图片工具栏按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().importWordToImg()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('importwordtoimg', {
        text: '',
        tooltip: 'Word转图片',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('importwordtoimg', {
        text: '',
        tooltip: 'Word转图片',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('importwordtoimg', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加粘贴网络图片工具栏按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().UploadNetImg()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('netpaster', {
        text: '',
        tooltip: '网络图片一键上传',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('netpaster', {
        text: '',
        tooltip: '网络图片一键上传',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('netpaster', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加导入PDF按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().ImportPDF()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('pdfimport', {
        text: '',
        tooltip: '导入pdf文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('pdfimport', {
        text: '',
        tooltip: '导入pdf文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('pdfimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加导入PPT按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().importPPT()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('pptimport', {
        text: '',
        tooltip: '导入PowerPoint文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('pptimport', {
        text: '',
        tooltip: '导入PowerPoint文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('pptimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加导入WORD按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor).importWord()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('wordimport', {
        text: '',
        tooltip: '导入Word文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('wordimport', {
        text: '',
        tooltip: '导入Word文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('wordimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加WORD粘贴按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    var ico = "http://localhost:8080/static/WordPaster/plugin/word.png"
    function selectLocalImages(editor) {
      WordPaster.getInstance().SetEditor(editor).PasteManual()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('wordpaster', {
        text: '',
        tooltip: 'Word一键粘贴',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('wordpaster', {
        text: '',
        tooltip: 'Word一键粘贴',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('wordpaster', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

在线代码:

添加插件

// 插件
      plugins: {
          type: [String, Array],
          // default: 'advlist anchor autolink autosave code codesample colorpicker colorpicker contextmenu directionality emoticons fullscreen hr image imagetools importcss insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus table template textcolor textpattern visualblocks visualchars'
          default: 'autoresize code autolink autosave image imagetools paste preview table powertables'
      },

点击查看在线代码

初始化组件

// 初始化
WordPaster.getInstance({
    // 上传接口:http://www.ncmem.com/doc/view.aspx?id=d88b60a2b0204af1ba62fa66288203ed
    PostUrl: 'http://localhost:8891/upload.aspx',
    // 为图片地址增加域名:http://www.ncmem.com/doc/view.aspx?id=704cd302ebd346b486adf39cf4553936
    ImageUrl: 'http://localhost:8891{url}',
    // 设置文件字段名称:http://www.ncmem.com/doc/view.aspx?id=c3ad06c2ae31454cb418ceb2b8da7c45
    FileFieldName: 'file',
    // 提取图片地址:http://www.ncmem.com/doc/view.aspx?id=07e3f323d22d4571ad213441ab8530d1
    ImageMatch: ''
})

在页面中引入组件


功能演示

编辑器

在编辑器中增加功能按钮
TinyMCE编辑器界面

导入Word文档,支持doc,docx

粘贴Word和图片

导入Excel文档,支持xls,xlsx

粘贴Word和图片

粘贴Word

一键粘贴Word内容,自动上传Word中的图片,保留文字样式。
粘贴Word和图片

Word转图片

一键导入Word文件,并将Word文件转换成图片上传到服务器中。
导入Word转图片

导入PDF

一键导入PDF文件,并将PDF转换成图片上传到服务器中。
导入PDF转图片

导入PPT

一键导入PPT文件,并将PPT转换成图片上传到服务器中。
导入PPT转图片

上传网络图片

一键自动上传网络图片。
自动上传网络图片

下载示例

点击下载完整示例

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐