一个简单功能背后需要付出的大量工作

自从我于2021年4月创办这个网站后,我就猜到用户会上传受密码保护的PDF文件。我想如果要做支持这类PDF的功能太麻烦了,所以我推迟了这个功能的研发。最近一个用户请求我,让网站支持密码保护的PDF,所以我就完成了这个功能。

这是一个简单的功能,跑起来也没什么问题。我把昨天所有的时间都花在了它身上,并在今天凌晨2:32把它推送进生产环境。从我过去几天收集的少量数据来看,大约有5%的用户上传带有密码的PDF文件。在这以前,我通常会拒绝用户上传,甚至没有显示错误消息。这样就流失了很多这样的用户,他们大概会认为这个网站不能用。

我花了很长时间才完成这个功能,这让我惊讶的同时也很恼火,所以我想与您分享这段经历,这样您就可以感谢我为您所做的很多工作。[^1]

星期六 晚上11:46 - 向file_mapping表添加字段

file_mapping表存储关于用户上传的每个PDF的信息。我添加了两个字段passwordrequires_password。我的想法是将密码与其他文档元数据一起存储在数据库中。

周日 上午11:10 - 当API收到一个受保护的PDF时

我编辑了API,以便当它创建一个file_mapping记录时,requires_password设置为true,而pdf_type设置为UNKNOWN.在之前pdf_type可能是基于文字基于图像。由于我们不能在没有密码的情况下读取文件,所以我们不能分析它是基于文本的还是基于图像的。

我们这样做是因为,文本PDF和图像PDF使用的代码路径不同

周日下午 1:14 - 编写SQL,更新file_mapping记录

UPDATE file_mapping SET password = ? WHERE uuid = ?;

周日下午 2:55 - 更新了SQL语句并创建了一个API来设置文档的密码

UPDATE file_mapping SET password = ? pdf_type = ? page_count = ? WHERE uuid = ?;

当我编写API时,我意识到在用户成功为文档设置密码后,我希望在file_mapping表中修改更多字段。一旦读取了文档,就可以对其pdf_type进行分类并计算文档的页数。

val fileMappings = repository.getFileMappings(body.passwords.map { it.uuid })
val updates = mutableMapOf<String, UpdateFileMapping>()

val results = fileMappings.map { mapping ->
    mapping.validateOwnership(userId, ipAddress)
    val password = body.passwords.first { it.uuid == mapping.uuid}.password
    val file = File(mapping.filename)
    val result = uploadAction.analysePdf(file, password, userId, ipAddress, mapping.uuid, mapping.originalFilename)

    if (result.state != UploadResponse.State.REQUIRES_PASSWORD) {
        updates[mapping.uuid] = UpdateFileMapping(password, result.pdfType.toString(), result.numberOfPages)
    }

    result
}

// 设置密码
repository.updateFileMappings(updates)

call.respondText(contentType = ContentType.Application.Json) {
    stringify(results)
}

API接受文档标识符和密码。它之后会尝试使用提供的密码打开文档并将正确的密码写入数据库。它的返回值会显示有哪些文档用提供的密码成功打开

星期日下午 3:21 - 更改文档读取代码,当有可用的密码时,用密码打开

val document = if (password == null) Loader.loadPDF(statement) else Loader.loadPDF(statement, password)

如果我们有密码,就用它来打开。这实际上可以更简单。Loader.LoadPDF可以接受空值密码。

周日晚上 11:57 - 更改所有Loader.LoadPDF调用,选择需要提供密码

这很容易修改,我也写了一堆测试用例来验证受保护的PDF可以通过所有转换路径。在这中间有很大一个空当,因为我去我老爸家打了个盹,吃了晚饭,然后看了香港电影金像奖。

星期一上午 12:30 - 修正了开发环境中文件夹错误删除的BUG

我定期会运行一个清理程序,用来删除用户数据,如PDF、渲染图像和OCR识别结果。

for (directory in directories) {
    for (file in directory.walk()) {
        // 删除文件
    }
}

如果目录为空,则上面的代码将删除该目录。这不会发生在生产服务器上,因为目录从来不会是空的,人们经常使用该应用程序。但在开发服务器上这会发生!我用下面的代码修复了这个bug。

fun start(): AccountingProApp {
    tempFileDirectories.forEach {
        if (!it.exists()) {
            it.mkdirs()
        }
    }
    startServerWithRetries()
    cleanup.scheduleExecution()
    imagePdfWorker.scheduleExecution()
    return this
}

在应用程序启动时,如果目录不存在,代码将创建该目录。在我写这篇文章时,我意识到这个修复不是很好。空文件夹仍然会被删除,如果要重新获得它们,我需要重启服务器。由于每次提交代码时它都会重新部署,所以开发服务器会重启很多次。我认为更好的解决办法是在清理工作中不删除文件夹。

周一上午 2:24 - 设计UI,允许文档设置为有密码类型的

<Input onChange={onChange} value={value} placeholder="Password"/>
<Button
  onClick={onClick}
  css={{ marginLeft: '0.25em' }}
  size="compact"
  emphasis="primary">
  Submit
</Button>

当上传受保护的PDF时,提供一个输入元素。当提交按钮被按下时,调用API。协调API响应与浏览器中的本地状态。这都非常复杂,尤其是因为我不太擅长编写前端代码。

这不大好看,输入框太大了。我不喜欢写CSS和功能函数,所以我做到这就停止了。

周一凌晨 2:32 - 修复了API中的一个逻辑错误

我注意到基于文本的PDF正在被预览和OCR识别。这不应该发生,所以我把这个bug修好了。我将代码推送入生产环境,然后睡觉去了。

周一上午 11:13 - UI收尾

我走进我的办公室,检查我的Grafana仪表板,看到有人成功地使用了设置密码功能。不错!

我添加了加载指示器并使输入框元素变小。

<Input
  size="compact"
  onChange={onChange}
  value={value}
  placeholder="Password"
  disabled={disabled}
/>
<Button
  onClick={onClick}
  css={{ marginLeft: '0.25em' }}
  size="compact"
  emphasis="primary"
  disabled={disabled}
  decoratorLeft={decoratorLeft}
>
  Submit
</Button>

周一上午 11:38 - 修复了另一个API bug

// 之前
val state = if (it.uuid in inProgressUuids) UploadResponse.State.PROCESSING else UploadResponse.State.READY

// 之后
val state = when {
    it.uuid in inProgressUuids -> UploadResponse.State.PROCESSING
    it.pdfType == PDFType.UNKNOWN -> UploadResponse.State.REQUIRES_PASSWORD
    else -> UploadResponse.State.READY
}

旧代码只假定两种状态,READY和PROCESSING。这个新特性添加于REQUIRES_PASSWORD状态下。

结论

下面是该功能的一个演示。很难相信这样一个简单的功能,需要在睡两次觉,看完香港电影金像奖之后才能解决

这些工作值得吗?只有时间才能证明一切。如果你在2032年读到这篇博客,请联系我,我会做一些分析,以计算出这个功能赚了多少钱。“从第一个上传受到保护的PDF的用户开始统计获得的收入总和”。如果是2033年,那我就不做分析了。

Join The Mailing List