news 2026/11/2 5:20:21

Rails附件上传实战:从Paperclip迁移到Active Storage的经验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rails附件上传实战:从Paperclip迁移到Active Storage的经验

提到 paperclip,很多从 Rails 4、Rails 5 时代过来的后端开发都会会心一笑。那时候一个项目要支持图片上传,没有太多花哨的方案:要么用 CarrierWave 自己组织文件逻辑,要么直接用 paperclip 这种"把附件当成模型字段一部分"的插件。我在几个实际的业务项目里整整用了三四年 paperclip,从最开始的简单头像,到后来的商品主图、合同 PDF、音视频附件,都靠它撑过来。这篇文章不打算做广告,也不打算劝谁重新用回一个已经停更的库,而是把我在生产环境里和 paperclip 打交道的经验完整记录下来:它怎么做配置,为什么会踩坑,以及现在我们怎么安全地处理遗留数据。

1. 为什么在 Rails 5 时代选 paperclip,它解决的核心问题

1.1 选型背景:当年 Rails 里处理附件只有两条主流路线

大约在 2015 年到 2018 年那段时间,Rails 服务端需要"接收上传的文件"时,大家的选择非常固定:要么 CarrierWave,要么 paperclip。那时候 Active Storage 还没进框架,Rails 官方对附件这块基本是"自己想办法"的态度。我参与维护的第一个电商后台,刚好赶上这个时间窗口,商品图片、品牌 Logo、活动海报全部要支持后端上传,前端还要能剪裁预览。把这些需求摆在一起,团队里争论了好几轮,最后拍板用 paperclip。

现在回头看,当时选择 paperclip 的逻辑其实挺朴素:它跟 ActiveRecord 的绑定足够紧,能把附件元数据(文件名、类型、大小、更新时间)直接落在业务表里,不需要为每个附件类型单独建表。对于一个货架商品、SKU 数量很多的电商项目来说,这种"附一张图就像加一个字段"的心智模型非常省事,数据库结构和业务逻辑几乎不用额外绕。

下表是我当时在选型文档里列的对比。

维度paperclipCarrierWave
元数据存储存在业务表的固定列中需自建字段或序列化对象
上手难度配置几个行内选项即可需要定义 Uploader 类并覆盖不少方法
文件路径风格有默认路径模板,约定大于配置路径由 Uploader 内部定义,自由度高
处理多尺寸图内置 styles 与 ImageMagick 结合提供 version 机制,需要逐个配置
维护状态官方早已宣布停止维护社区仍在维护,但热度同样在下降

这不是说 CarrierWave 不好,它在需要高度自定义文件名规则、多存储后端切换的项目里确实更灵活。但大多数业务系统的附件需求没那么复杂,"能传、能预览、能按尺寸缩放、不丢文件"就够了。paperclip 更像是把"附件的 80% 常规场景"一次性打包好,剩下的 20% 特殊情况再想办法。

1.2 paperclip 把"文件上传"变成了"字段更新"

paperclip 的核心思想是:你声明模型有一个附件,它就自动为这个模型补出一组"伪列"。所谓伪列,就是数据库里真实存在的四个字段:avatar_file_name、avatar_content_type、avatar_file_size、avatar_updated_at。这四个字段分别记住原始文件名、MIME 类型、字节大小和最近更新时间。

因为元数据存储在业务表里,paperclip 天然享受 ActiveRecord 的事务和回调机制。比如一个商品记录在保存时,如果图片尺寸校验不过,整个保存操作会回滚,不会出现"商品数据已经写进去,图片却丢失"的中间状态。这一点在多步表单里非常有用,我做活动页编辑器时,就靠这套机制避免了无数次"库存改了但图没传上去"的尴尬。

class Product < ApplicationRecord has_attached_file :cover, styles: { thumb: '200x200#', detail: '800x600>' }, default_url: '/images/:style/missing.png' validates_attachment_content_type :cover, content_type: /\Aimage\/.*\z/ end

上面的default_url也是一个很有用的设计。图片尚未上传或者生成失败时,系统会自动渲染占位图,而不是抛 404。生产环境里我见过太多因为默认图没配导致的活动页裂图,paperclip 的这套约定至少从配置层面逼着开发者把兜底方案想好。

1.3 为什么不是 CarrierWave:一次真实项目的取舍

严格来说,CarrierWave 的"岗位空间"和 paperclip 是重叠的,但两者对开发者脑力的占用方式完全不同。我在一个客服工单系统里试过 CarrierWave:为了达到"上传后立即生成小图并在页面上显示"的效果,我需要写一个 Uploader,声明 version,再处理文件名合并规则,还要在 model 里接入mount_uploader。看似也不复杂,但当版本多了之后,Uploader 内部的process顺序、不同版本之间的依赖关系就变得很难维护。

paperclip 的处理模型更直观:一个附件定义多个 styles,每个 style 对应一段 ImageMagick 参数,系统按顺序渲染。遇到需要裁剪的,直接在参数里写200x200#;遇到需要等比缩放的,写800x600>;遇到只需要原图的,不定义 styles。项目的认知负担小了很多。

选型建议其实只有一条:如果团队没有特殊的文件命名需求,也没有必须在多后端动态切换的诉求,直接用 paperclip 是非常稳妥的选择。如果已经能预见到未来有复杂的处理管线,那从一开始就选 CarrierWave 或更晚的 Shrine 会更合适。核心问题不是谁更好,而是谁能和你的业务节奏合拍。

2. 最小可复现工程:从 Gemfile 到上传成功的完整链路

2.1 准备 ImageMagick:整个依赖链里最容易先崩的环节

paperclip 本身只是个 Ruby gem,但它处理图片 styles 依赖外部程序 ImageMagick。开发机是 macOS 就brew install imagemagick,Linux 服务器上apt install imagemagick。这一步看起来简单,实际上是很多新手项目最早栽跟头的地方:Gemfile 里写好了gem 'paperclip',代码也照着文档抄了,一上传图片就报ImageMagick is not installed,但明明机器上装了 ImageMagick 7。

如果遇到这种诡异情况,先检查 PATH 里能不能直接执行convert命令。ImageMagick 7 默认把可执行文件改成magick,某些旧版本 paperclip 还在找convert。我在企业服务器上处理过不止一次这个问题,最终解决方案很无趣:把 ImageMagick 降回 6.x,或者做一个convert的软链接指向magick。别笑,这个软链接救过生产环境。

Gemfile 片段:

source 'https://rubygems.org' gem 'rails', '~> 5.2.0' gem 'paperclip', '~> 5.2.0'

安装完 gem 后重启 Rails,确保 paperclip 的 Railtie 正确加载。

2.2 模型声明与数据库迁移

这一步用生成器快速创建附件字段。以 User 的 avatar 为例,命令行执行:

rails generate paperclip user avatar rails db:migrate

生成器会添加一个迁移,内容是在users表里增加刚才说的四个字段。迁移执行完,你在users的 schema 里能看到avatar_file_name、avatar_content_type、avatar_file_size、avatar_updated_at四个字段。手工建表的话,迁移长这样:

class AddAttachmentAvatarToUsers < ActiveRecord::Migration[5.2] def self.up change_table :users do |t| t.attachment :avatar end end def self.down drop_attached_file :users, :avatar end end

然后把模型加上has_attached_file声明。注意,不要漏掉内容类型校验。很多网上教程只写has_attached_file,不写后面的validates_attachment_content_type,结果一上传就报spoofing警告;因为 paperclip 会把"内容类型校验"当成必须项,缺少它时会用一条默认的严格规则兜底。我把这条规则理解为一种"防御性设计":宁可怀疑敌人,也不能放错文件进来。

2.3 视图与控制器:multipart 才是关键

Rails 的form_with和form_for在不指定 multipart 时,默认不会带上文件上传所需的enctype。老版本里form_for会自动根据表单里有没有file_field判断,很多人没踩过这个坑;但如果你用了form_with,并且没有明确声明 multipart,文件字段会变成普通字符串参数,后台 params 里只能拿到文件名,根本读不到文件对象。

推荐的表单位置:

<%= form_for @user, html: { multipart: true } do |f| %> <%= f.file_field :avatar %> <%= f.submit %> <% end %>

控制器侧几乎不需要额外逻辑,因为附件字段和普通 attributes 一样,会被params[:user]一起带到strong_parameters里。

def create @user = User.new(user_params) if @user.save redirect_to @user else render :new end end private def user_params params.require(:user).permit(:name, :avatar) end

2.4 跑通一次上传后要检查的几件事

第一次上传成功后,别急着高兴,我建议按下面的清单检查。

  • 在rails console里执行user.avatar.url(:thumb),确认能输出完整 URL。
  • 刷新页面,如果显示的是默认图,检查public/images/thumb/missing.png文件是否存在。
  • 用ls public/system/users/avatars/000/000/xxx/thumb/查看物理文件路径,确认 styles 真的生成出来了。
  • 再上传一个超过 10MB 的大文件,看有没有超时。开发模式下如果用的是默认 WEBrick,大文件很容易因请求体超时被断开。

这套检查不是形式主义。项目进入联调阶段之后,最消耗时间的问题往往不是逻辑 bug,而是"文件到底存在哪""为什么 URL 和物理路径对不上"这类基础问题。先把基建链条走通,后面加业务规则会顺很多。

3. 图片处理、文件验证与目录结构的深入配置

3.1 styles 的裁剪逻辑:不只是写几个数字

has_attached_file的styles选项看起来很直白,但每个数字后缀的含义值得说清楚。100x100#表示强制缩放到恰好 100x100,多余部分从中间裁剪;100x100>表示最大宽度或高度不超过 100,保持原始比例;100x100<比较少见,表示最小超过一个阈值才缩放,通常用于"放大缩略图"的场景。

这几个符号直接透传给 ImageMagick,所以你知道 ImageMagick 支持的所有高级玩法,比如blur、quality、strip,都可以写进 styles 的 value 里,paperclip 会拼接到 convert 命令上去。

has_attached_file :avatar, styles: { thumb: '100x100#', medium: '300x300>', large: { geometry: '1000x1000>', convert_options: '-quality 80 -strip' } }

真实项目里,我强烈建议给原图也保留一个自定义 style,例如original: '',否则后面做水印、反色、异地 CDN 回源时,没有原始尺寸的存档版本会很被动。

3.2 内容类型校验与 spoof 检测的运作方式

paperclip 对文件类型的判断不是只看浏览器传来的 MIME,它还会用file命令解析文件头,两份结论不一致时就会抛spoofed警告。这个机制能拦掉一部分改名伪装的脚本文件,但也会误伤正常文件,例如某些 office 文档,浏览器报application/octet-stream,而 file 命令识别出真正的application/zip。

遇到这种情况,可以通过validates_attachment_content_type同时放行两端的类型,或者显式关闭对单一文件的 spoof 检测。后者我不推荐全局关闭,毕竟安全细节能保留还是留着。

validates_attachment_content_type :avatar, content_type: ['image/jpeg', 'image/png', 'image/gif'], message: '只支持图片文件'

网上搜索 paperclip spoof 解决方案时,最常见的建议是去掉校验。我的经验是:先搞清楚你的业务到底接受哪些真实类型,再决定白名单怎么写。白名单越短,后面误伤的概率越低。

3.3 不需要生成 styles 时的性能优化

不是所有附件都需要裁剪。像 PDF 合同、Excel 导出文件,用 paperclip 纯粹是为了"存起来 + 给一个可下载 URL"。这时候最好不要声明 styles,不然会自动走 ImageMagick 处理流程。处理 PDF 生成缩略图很耗时,还会拖慢整个上传接口。

如果明确不需要处理,直接关闭 process:

class Contract < ApplicationRecord has_attached_file :pdf, storage: :filesystem, styles: {}, process: false end

另外,paperclip 默认生成的样式文件都会落到同一目录,文件名带后缀。日常使用中,磁盘上的文件名并不是原始文件名,而是original、thumb、medium加上扩展名;这一点会影响 CDN 缓存 Key 的设计。后端换图时,本地路径变了,但文件名结构没变,CDN 可能继续返回旧图。处理方式是给 URL 加版本参数,或者让 paperclip 的updated_at参与 URL 组装,这我在下一节详细讲。

4. 本地与 S3 存储适配:URL 策略、防盗链与 CDN 场景

4.1 本地存储的路径规则与 default_url

paperclip 默认将文件存在public/system目录下,路径规则是/:class/:attachment/:id_partition/:style/:filename。:id_partition是把整数 id 拼成000/000/123这样的多级目录,避免一个目录下文件数量过多。这个规则对小中型项目是够用的。

映射成真实例子:public/system/users/avatars/000/000/123/thumb/my_avatar.jpg。默认 URL 会带上一个:style占位符,因此不同 style 共用同一套 URL 规则,代码里只需要写user.avatar.url(:medium)就能生成对应地址。

本地模式下,如果项目部署在多个实例,文件系统共享成了大问题。那时候我通常直接引入 NFS 或者把 storage 切到 S3,让应用实例专注处理业务逻辑。filesystem 方案更推荐用在对单机性能敏感的微型项目或本地开发阶段。

4.2 S3 接入:尽量规避旧版 SDK 的依赖问题

把 paperclip 接到 S3 的关键是配置paperclip_defaults,并保证 gem 里装有对应版本的 SDK。paperclip 5.x 推荐aws-sdk-s3,旧教程里可见的aws-sdk大包经常引发依赖冲突。

# config/environments/production.rb config.paperclip_defaults = { storage: :s3, s3_region: ENV['AWS_REGION'], s3_credentials: { bucket: ENV['S3_BUCKET_NAME'], access_key_id: ENV['AWS_ACCESS_KEY_ID'], secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] }, url: ':s3_alias_url', path: '/:class/:attachment/:id_partition/:style/:filename' }

S3 模式最重要的坑是:直传外网时,如果 bucket 默认拒绝public-read,生成的 URL 会返回 403。我的习惯是开发阶段用私有 bucket 配合签名 URL,线上需要长期可访问的对象则通过 CDN 回源。CDN 背后的 origin 指向 S3,最终对外只暴露 CDN 域名,还能顺带解决跨域和防盗链问题。

4.3 签名 URL 与受保护附件的实践

商品图、头像这类资源适合公开读。但合同、订单导出文件等场景,绝不能暴露永久链接。paperclip 在 S3 模式下支持通过expiring_url生成带时效的签名 URL:

@contract.pdf.expiring_url(600) # 10 分钟内有效

这个能力让我在账单系统里省了大量写权限校验的功夫。下载接口要做的只是确认来访用户有权限,然后给他一个短期有效的链接,链接到期后自动失效,别人绕过业务系统直接访问文件地址时拿不到内容。

CDN 接入以后,要注意签名 URL 和 CDN 缓存的冲突。公开图片走 CDN 没问题,但私有文件千万别让 CDN 拿到缓存,否则签名过期后 CDN 边缘节点还能继续吐文件。我的方案是私有文件单独走一个不启用 CDN 的 host,或者让 CDN 遵循源站的Cache-Control: private响应头。

5. 实际项目中最常出现的 5 个 paperclip 故障及排查链路

5.1 症状一:上传后一直显示占位图,物理文件却没生成

这个问题几乎都出在validates_attachment_content_type的规则执行时机上。先执行校验,再执行 post processing,如果校验不通过,paperclip 会静默丢弃文件,只在模型 errors 里加一条记录。这种"静默失败"非常恶心,因为页面不会报错,只是不显示用户刚上传的图。

排查链路是:先看user.errors是否为空,再看数据库里的avatar_file_name有没有值;如果文件名字段非空但物理目录没有文件,说明队列里的 delayed paperclip 或后台任务接管了处理,而 worker 没有真正执行。打开 sidekiq 的日志能看到任务是否在报错。很多时候是 worker 环境缺少 ImageMagick,或者 worker 和 web server 挂载的不是同一个磁盘。

5.2 症状二:Content Type Spoof 警告满屏刷

这里我再重复一个容易混淆的事实:spoof 检测不等于内容类型校验,它是 paperclip 自己加的多层判断。它用 ImageMagick(或file命令)探测文件真实类型,再拿探测结果和前端上报的 content_type 比较,两边不一致就刷警告。

线上排查时,用file --mime-type命令直接读取该文件类型,和数据库里存的avatar_content_type对比。如果只是个别文件不匹配,把对应类型加进白名单即可;如果是某类浏览器批量把 JPEG 上报成image/jpg,那就是上传端规范没做好,赶紧让前端统一 MIME 映射,而不是在模型里疯狂放宽限制。

5.3 症状三:Processing Failed,ImageMagick 转换超时

水印、压缩、大图缩放这三个操作都可能让 ImageMagick 处理时间飙升。超过默认的转换限制会抛ProcessingFailed。我在商品图批量导入时遇到过几百张图整体失败的情况,原因不是单张转换失败,而是导入任务里每张图都重新调用reprocess!,串行排队导致整体超时。

解决方案有两个思路:一是调高后台任务超时时间并限制并发;二是把reprocess!改成分批跑,一次最多几十张。立刻有效的是第二个,因为 ImageMagick 碰到内存不足时会大量调 swap,慢的根源往往不是 CPU,而是磁盘 IO。

5.4 症状四:模型查询时附件字段全部为 nil

如果你中途删掉某个附件字段的迁移,又没有更新 schema,会出现avatar_file_name为 nil 但其他业务逻辑还在尝试读取的情况。先别怀疑是 paperclip 坏了,用column_names查一遍表结构,再看has_attached_file是否写到了正确的模型上。

还有一种隐蔽原因:你把has_attached_file写在了一个 STI(单表继承)子类里,而父类没有。ActiveRecord 的继承体系会让部分方法在子类可用,但在父类的查询结果上调用时找不到关联定义。这种问题在 paperclip issue 里出现过很多次,解决办法很简单:统一写在父类或者 shared module 里。

5.5 症状五:URL 与真实文件名不一致

paperclip 默认会把原始文件名做 sanitize,去掉空格和特殊字符。你上传"我的头像(1).jpg",落库很可能变成my_avatar_1_.jpg。如果外部系统需要按原始文件名做校验,反查文件时就会对不上。我的办法是业务系统设计时只以id加style定位文件,绝不用avatar_file_name做唯一键;文件展示层面的名字单独存在业务字段里,避免被 sanitize 规则坑到。

6. paperclip 停更后如何迁移到 Active Storage:我的数据迁移笔记

6.1 官方停更的信号与遗留系统风险

paperclip 后来被官方标记为停更维护,社区的活跃度也明显下降。对一个长期项目来说,"依赖一个没有任何人继续修 bug 的上传组件"本身就是风险:Rails 升级时会遇到兼容性问题,安全漏洞也没人及时补。于是我决定在新项目里转用 Active Storage,老项目则在版本稳定后分批迁移。

迁移不是简单换 gem,需要先理解两个框架的文件定位方式。paperclip 用类名加附件名列生成路径,Active Storage 用随机的key定位文件,并额外用一张active_storage_attachments关联表把业务记录和附件绑定。换个角度说,Active Storage 是一个更正式的多媒体管理子系统,而 paperclip 更像一个"增强版字段"。

6.2 数据迁移脚本的思路:照着路径规则搬一遍

迁移方案我不推荐用脚本一把梭,折中做法是先建立映射表。给每个 paperclip 附件找到对应的物理路径,然后把文件按 Active Storage 的 key 规则搬到新存储,最后在 blob 表中建记录,关联原业务记录。

如果你的附件本身就托管在 S3,可以不用把 S3 对象复制走,只要迁移blob和attachments关联记录,把 paperclip 的path映射成ActiveStorage::Blob的key,并保持旧对象名不变。需要注意的是,两者命名规则不同,迁移前需要为每个文件生成新的blob.key,再决定是重命名对象还是通过key指向原对象。

我个人的建议是:迁移前先冻结附件写入,把存量跑完,再切流量。两步之间不能有新上传,否则会产生"旧 key + 新文件"在同一目录的混乱状态。我通常用下面的任务骨架做存量扫描:

namespace :attachment_migration do desc '迁移 User.avatar 到 Active Storage' task users_avatar: :environment do User.where.not(avatar_file_name: nil).find_each do |user| next if user.avatar.blank? file_path = user.avatar.path(:original) filename = user.avatar_file_name content_type = user.avatar_content_type # 用 Active Storage 挂载一个新附件,并把物理文件复制过去 user.avatar_attachment&.purge user.avatar.attach(io: File.open(file_path), filename: filename, content_type: content_type) end end end

这里代码只是示意,真正跑起来还要考虑校验和、缩略图迁移、回调触发顺序等问题。完整方案我建议在小范围数据上先演练一遍,再逐步扩大。

6.3 迁移后的回归检查清单

迁移完成不代表可以立刻切换,复制一段我自己的回归清单:

  • 检查 20 张老图片的 URL,按 thumb、medium、original 三个样式逐一点。
  • 确认rails active_storage:install生成的表里有数据,特别是active_storage_blobs的key和实际文件路径是否一致。
  • 在测试环境删掉一张物理文件,看 Active Storage 是否按设计返回错误提示,而不是无限 404。
  • 验证大文件下载不会被 web server 超时中断,因为 Active Storage 默认走流式传输,行为可能和 paperclip 不太一样。

所有这些检查通过后,再考虑把线上的默认存储服务切过去。切换过程我会建议留在低峰时段,并在监控里盯紧 5xx 率和上传接口的耗时指标。

最后想提醒一句:paperclip 的 URL 设计和字段式心智模型确实优雅,但它背后的核心价值其实很简单——让文件像字段一样可靠地跟随业务数据。迁移到 Active Storage 之后,这个价值仍然保留,只是实现方式换了一层。遇到系统升级阻力时,可以从最小范围的附件类型做起,比如先迁移头像,再迁移商品图;一次全量迁移的风险,往往是被低估的。

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

DeepSeek深度思考模式实战指南:从配置到提示词工程

简介&#xff1a;本资源是一份面向AI初学者与实践者的《DeepSeek从入门到精通》系统化学习指南&#xff0c;聚焦提示词工程与真实场景落地&#xff0c;解决用户“不会用、用不深、效率低”的核心痛点。全书以PDF格式呈现&#xff0c;共1个文件&#xff08;11.48MB&#xff09;&…

作者头像 李华
网站建设 2026/11/2 5:19:11

MongoDB EF Core提供程序的可查询加密与向量搜索实战

1. 项目概述&#xff1a;MongoDB EF Core提供程序的安全智能特性在.NET生态系统中&#xff0c;Entity Framework Core(EF Core)作为主流的对象关系映射(ORM)框架&#xff0c;其与MongoDB的集成一直备受开发者关注。2024年5月正式发布的MongoDB EF Core提供程序&#xff0c;通过…

作者头像 李华
网站建设 2026/11/2 5:18:25

大数据技术演进:从存储计算到实时智能分析

1. 大数据技术发展脉络与核心架构演进2008年&#xff0c;《Nature》杂志首次提出"大数据"概念时&#xff0c;全球数据总量还不到1ZB。而到2023年&#xff0c;这个数字已经突破120ZB&#xff0c;相当于地球上每粒沙子都存储了1.3MB数据。这种爆炸式增长直接催生了大数…

作者头像 李华
网站建设 2026/11/2 5:17:50

WinForm与Socket TCP通信开发实战指南

1. 项目概述&#xff1a;WinForm下的Socket TCP通信核心价值十年前我第一次用C#写TCP通信程序时&#xff0c;被Socket的复杂性折磨得焦头烂额。如今回头看&#xff0c;WinForm与Socket的结合其实是.NET生态中最优雅的通信解决方案之一。这种组合既能享受可视化开发的便利&#…

作者头像 李华
网站建设 2026/11/2 5:17:02

OpenClaw多智能体协作平台架构与性能优化解析

1. OpenClaw技术架构解析OpenClaw作为新一代多智能体协作平台&#xff0c;其核心架构设计主要围绕三个关键技术点展开&#xff1a;1.1 分布式KV Cache优化机制在传统Transformer架构中&#xff0c;KV Cache&#xff08;键值缓存&#xff09;的内存占用会随着上下文长度平方级增…

作者头像 李华
网站建设 2026/11/2 5:16:28

基于QTcpSocket重建Qt QFtp:兼容Qt5/Qt6的FTP客户端

简介&#xff1a;这份面向 Qt 桌面开发者的 QFtp 移植示例&#xff0c;针对 Qt 5.0 以后移除 QFtp 类、旧项目难以继续使用 FTP 模块的问题&#xff0c;将 Qt 4.8.7 中的 QFtp 源码整理为 QFtp.h 与 QFtp.cpp 两个文件&#xff0c;并配套完整的测试工程。压缩包共 17 个文件&am…

作者头像 李华