上一篇完成了浏览器与核心 API 的可靠联调,本篇加入附件和第三方通知。文件与 webhook 都跨越信任边界:扩展名可以伪造,网络会超时,回调会重复或乱序。实现目标是让大文件不穿过应用服务器,同时保证权限、元数据和业务状态仍由我们控制。
一、痛点:上传不是接收一个表单字段
若浏览器把 100 MB 文件先传给 API,再由 API 转存对象存储,应用实例承担双倍带宽、内存与超时压力。更合适的流程是三步:客户端向 API 请求上传意图;API 校验任务权限、大小和声明类型,创建pending附件并签发短期上传 URL;客户端直传对象存储后调用完成接口,服务端再核对对象大小、摘要和所有者,将状态改为ready。
对象键由服务端生成随机值,不使用原文件名,避免路径穿越、覆盖和隐私泄露。原文件名只做展示且转义。下载同样先授权,再返回短期 URL;存储桶保持私有。限制单文件大小、单任务数量和工作区配额,并在创建上传意图时预留配额,失败或超时后释放。
二、原理:内容、声明与用途要分别验证
Content-Type和扩展名都是客户端声明,不能证明真实内容。服务端至少检查魔数、实际大小和允许用途;高风险文件异步杀毒或内容净化,扫描完成前不可下载。下例识别常见安全白名单格式,并拒绝扩展名与内容不一致。生产中应使用维护良好的文件识别库,而不是无限扩展手写规则。
fromdataclassesimportdataclass SIGNATURES={"pdf":(b"%PDF-","application/pdf"),"png":(b"\x89PNG\r\n\x1a\n","image/png"),"jpg":(b"\xff\xd8\xff","image/jpeg"),}@dataclass(frozen=True)classUpload:filename:strdeclared_type:strcontent:bytesdefvalidate_upload(upload:Upload,max_bytes:int=5_000_000)->str:ifnot0<len(upload.content)<=max_bytes:raiseValueError("invalid_size")extension=upload.filename.rsplit(".",1)[-1].lower()ifextensionnotinSIGNATURES:raiseValueError("extension_not_allowed")signature,detected_type=SIGNATURES[extension]ifnotupload.content.startswith(signature):raiseValueError("signature_mismatch")ifupload.declared_type!=detected_type:raiseValueError("content_type_mismatch")returndetected_type sample=Upload("design.pdf","application/pdf",b"%PDF-1.7\nexample")print("accepted="+validate_upload(sample))try:validate_upload(Upload("avatar.png","image/png",b"not-a-real-png"))exceptValueErroraserror:print("rejected="+str(error))运行输出:
accepted=application/pdf rejected=signature_mismatch对象存储的预签名 URL 是临时能力凭证,泄露后在有效期内可用,所以有效期应短、权限只允许单个键和指定方法,并限制内容长度与类型。上传完成不能只相信客户端说“成功”,应由后端 HEAD 对象或消费存储事件核验。未完成对象通过定时任务和生命周期策略清理。
三、实现:第三方回调先验签再入队
我们用邮件服务发送任务指派通知。写任务事务只写 outbox;worker 读取后调用供应商 API,设置连接和总超时,对 429、502、503 做带抖动的指数退避,对明显 4xx 进入死信并报警。每次调用携带事件 ID 作为幂等键。供应商 webhook 到来时,先读取原始字节、校验时间戳窗口和 HMAC,再解析 JSON;解析后验签会因序列化差异失败。
importhashlibimporthmacimportjsonimporttime SECRET=b"webhook-secret"defsign(timestamp:int,body:bytes)->str:message=str(timestamp).encode()+b"."+bodyreturnhmac.new(SECRET,message,hashlib.sha256).hexdigest()defverify(timestamp:int,body:bytes,signature:str,now:int)->dict:ifabs(now-timestamp)>300:raiseValueError("stale_timestamp")expected=sign(timestamp,body)ifnothmac.compare_digest(expected,signature):raiseValueError("invalid_signature")event=json.loads(body)ifnotisinstance(event.get("id"),str)ornotevent["id"]:raiseValueError("invalid_event")returnevent now=1_786_000_000body=b'{"id":"evt_42","type":"email.delivered"}'signature=sign(now,body)event=verify(now,body,signature,now+10)print(f"verified={event['id']}type={event['type']}")try:verify(now,body+b" ",signature,now+10)exceptValueErroraserror:print("tampered="+str(error))运行输出:
verified=evt_42 type=email.delivered tampered=invalid_signaturewebhook 表以供应商事件 ID 唯一。处理过程先插入收件箱记录;重复事件直接返回 2xx,防止供应商持续重试。不要假定事件顺序,业务更新应比较供应商事件时间或采用单调状态机,例如 delivered 不能被晚到的 queued 覆盖。响应 webhook 要快,耗时业务放队列,供应商通常以非 2xx 或超时判断失败。
把第三方封装在适配器后:领域层只调用send_assignment(),适配器负责供应商字段、认证和错误翻译。保存供应商请求 ID便于支持工单关联,但日志中屏蔽收件地址、签名和 URL 凭证。为每个依赖记录超时、重试、速率限制、降级策略、数据保留和退出迁移方案。
四、踩坑:重试必须有上限、有分类
无差别重试会对无效凭证打满供应商,也会在故障时形成重试风暴。仅重试瞬时错误,指数退避加入随机抖动,总次数与总时长设上限。断路器在持续失败时快速拒绝,让系统保留线程和连接;恢复期少量探测。上传进度条只表示字节发送,不表示扫描通过,UI 要区分 uploading、processing、ready、rejected。
图片解码器、压缩包和文档解析器都有安全历史。限制解压后总大小、文件数、像素尺寸和处理时间,转换任务使用隔离环境。不要开放任意 URL 让服务器代取文件,否则会产生 SSRF;若业务需要远程导入,限制协议、域名、解析后的 IP 和重定向。
五、验证:故障注入比成功截图更重要
测试零字节、超大文件、伪造扩展名、同键覆盖、上传中断、完成接口重复调用、扫描失败和配额竞争。第三方测试超时、429、500、无效签名、过期时间戳、重复与乱序事件。确认日志能用请求 ID、附件 ID、事件 ID 和供应商请求 ID 串起链路,秘密始终不出现。
附件与外部通知已经可靠接入。下一篇将建立测试金字塔、契约测试和统一异常处理,并验证这些失败场景在重构后不会重新出现。
参考来源
- AWS S3:使用预签名 URL 上传对象
- OWASP:文件上传备忘单
- OWASP:SSRF 防护备忘单
- Stripe:Webhook 最佳实践
👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于《全栈项目从 0 到 1 实战》系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。