SFT/docs/superpowers/specs/2026-08-06-agreement-pdf-fi...

17 KiB
Raw Blame History

个人开户H5"相关协议"步骤:PDF自动填充+上传 设计文档

背景

docs/superpowers/specs/2026-07-27-personal-open-mobile-h5-design.md(2026-08-05更新)已实现 "相关协议"步骤展示协议原文(PDF预览)+勾选。本次产品要求进一步:用户勾选两个协议 checkbox 后, 前端自动在《用户支付服务协议》PDF模板末页的空白签名区域(模板原文"用户:/证件号码:/ 时间: 年 月 日")填入客户姓名/证件号码/当前日期,调用通用文件上传接口拿到文件编号,提交开户 时随 mobile/submit 一起传给后端(payAgreementFileNo 字段)。《用户支付服务隐私政策》不需要 走这套流程。

接口契约相关记录(payAgreementFileNo 新字段来源、upload-file 鉴权要求变更等)在 《缺失的后端接口.md》Part 19,本文档只记录前端实现方案本身。

模板文件与签名区块坐标

银行提供的两份正式模板原件(用户在对话中提供,已落地为 mock-server 的协议文件,详见下文):

  • 《浙江稠州商业银行用户支付服务协议》:3页,签名区块在第3页(末页)
  • 《浙江稠州商业银行用户支付服务隐私政策》:4页,签名区块在第4页(末页),格式与协议一致, 但本次不需要填充/上传这份文件。

两份文件末页都有相同格式的空白区块:

用户:
证件号码:
时间:  年  月  日

坐标测量方法(后续模板换版需要重新测量时参照此方法)

不要用 pypdfextract_text(visitor_text=...) 回调坐标——实测对这两份 PDF(Type0 CID字体 KaiTi/SimSun,Identity-H 编码)返回的 tm 值不可靠,与视觉呈现的实际位置不一致(曾 因此错误估算出错误坐标,详见本次调试记录:第一次用 pypdf 测得的坐标直接用于填充,渲染后发现 填充内容出现在页面最下方,与标签完全错位)。

正确做法:用 PyMuPDF(fitz)的 page.get_text('dict'),读取每个 spanorigin 字段 (即字形基线原点,坐标系:左上角为原点,y向下增大):

import fitz
doc = fitz.open('模板文件.pdf')
page = doc[-1]  # 签名区块在末页
d = page.get_text('dict')
for block in d['blocks']:
    for line in block.get('lines', []):
        for span in line['spans']:
            print(span['text'], span['font'], span['size'], span['origin'])

实测《用户支付服务协议》末页(页面尺寸 595.3×841.9pt,字体 KaiTi 12pt)结果:

文本 origin (x, y_从顶部往下量)
用户: (111.0, 99.77)
证件号码: (111.0, 115.37)
时间: (111.0, 130.97)
(183.0, 130.97)
(219.0, 130.97)
(255.0, 130.97)

pdf-lib 使用 PDF 标准坐标系(原点左下角,y向上增大),换算公式: pdf_y = pageHeight - fitz_y(x 坐标两套体系含义一致,不需要换算)。

标签结束位置(供填值起点参考,已在 agreementPdfFill.js 里写成常量):"用户:"标签宽度36pt (x=111~147),"证件号码:"标签宽度60pt(x=111~171),"时间:"标签宽度36pt(x=111~147); "年"/"月"/"日" 之间的空白区间分别是 [147,183](年)/[195,219](月)/[231,255](日)。

验证方法

本次实现全程用 PyMuPDF 渲染成 PNG 图片肉眼核对(而不是只看坐标数字/文本提取是否正确)—— page.get_pixmap(matrix=fitz.Matrix(3,3), clip=...) 放大截取签名区块渲染,反复调整坐标/字号/ 居中算法直到视觉对齐。这一步不能省略,单靠坐标数值推算容易出现"数字对但视觉不对"的情况 (本次调试就先后踩过"pypdf坐标本身就是错的"这一个坑)。

姓名(中文)渲染方案:从"嵌入中文字体"推翻改为"Canvas渲染成图片"(务必保留本节,避免后续改动回归)

当前最终方案:客户姓名不走 pdf-lib 的字体嵌入/drawText,而是用浏览器 <canvas> 把姓名 栅格化成一张很小的透明背景PNG图片,再用 pdfDoc.embedPng() + page.drawImage() 贴到签名区块 对应位置(证件号码/日期数字仍是纯数字,继续用标准 Helvetica 直接 drawText,未受影响)。

这不是最初方案,中间经历了一次完整推翻,记录如下,避免后续有人"优化"回退到字体嵌入路线:

第一版方案(已废弃):嵌入中文字体

最初用 pdf-lib + @pdf-lib/fontkit 把 Google Fonts "Noto Sans SC" 嵌入PDF直接 drawText 姓名。过程中先后踩了两个 bug(均已用 PyMuPDF 渲染成图片实测确认,不是猜测):

  1. embedFont(bytes, { subset: true }) 会损坏中文字形(@pdf-lib/fontkit 的字体子集化逻辑 对由部件组合而成的汉字存在bug,比如"张三丰"渲染出来只剩"丰"能显示)。规避方式是改用 subset: false 完整嵌入,但这样每份生成的PDF会多出约1.6MB。
  2. 字体资源不能用 .woff2,必须转成未压缩的 .ttf——即使 subset: false,woff2 输入下 @pdf-lib/fontkit 依然渲染不出中文(只有西文正常),是另一个独立的bug。

为什么推翻:真实后端联调时 upload-file 上传失败

按第一版方案生成的PDF体积约1.7MB,转base64后约2.4MB。真实后端环境联调时,带着这个体积调用 upload-file 接口在浏览器 Network 面板直接显示请求失败(红色叉号,没有正常的HTTP状态码), 且 request.js 的响应拦截器弹出"网络异常,请检查网络连接后重试"——这是请求在网络层就没有正常 完成(不是后端返回了业务错误码),推断是网关/Nginx/CDN对请求体大小有限制(常见默认值如 Nginx client_max_body_size 1m),具体阈值未与运维确认,但现象和"体积太大"高度吻合。这暴露了 第一版方案的根本问题:为了绕开字体子集化bug而放弃子集化、完整嵌入2.5MB字体文件,代价是每次 生成的协议PDF都要背负远超实际需要的体积——姓名通常只有2~4个汉字,却要为此多传1.6MB完全不 相关的字形数据。

第二版方案(当前采用):Canvas 渲染姓名为图片

彻底不再嵌入任何自定义中文字体,renderNameToPng()document.createElement('canvas')+ ctx.fillText() 把姓名渲染成刚好包住文字的一张PNG(4倍超采样保证栈格化后依然清晰),再用 pdfDoc.embedPng()+page.drawImage() 贴到PDF页面上:

  • 字体走浏览器/系统自带的中文字体("PingFang SC","Heiti SC","Microsoft YaHei","SimSun",sans-serif 优先级链,最终兜底到浏览器默认 sans-serif——任何能正常显示中文页面的设备,其默认无衬线字体 必然带CJK字形,不存在"装不上字体"的风险),不需要打包/分发任何字体文件。
  • 用 Canvas 的 TextMetrics.actualBoundingBoxAscent/Descent/Left/Right(现代浏览器标准API, iOS Safari 11.1+/Android Chrome 均支持)精确测量实际墨迹边界,据此裁出刚好包住文字的最小图片 尺寸,而不是按整行字体行高留白。
  • 基线对齐:ctx.textBaseline = 'alphabetic'fillText 的 y 坐标就是基线位置,图片内基线 偏移量(baselineFromTopPt)记录下来,贴图时用 targetBaselineY - (heightPt - baselineFromTopPt) 换算出 drawImage 需要的图片左下角y坐标,和其它字段(证件号码/日期)对齐到同一条基线上。

效果:3个汉字姓名生成的PNG只有几KB,整份PDF从约1.7MB降到约130KB量级(实测:135KB左右), base64后远低于之前失败时的~2.4MB,彻底绕开"嵌入字体bug"和"上传体积过大"两个问题,不需要再 关心 @pdf-lib/fontkit 的任何限制(该依赖已从 package.json 移除),也不需要分发/懒加载任何 字体静态资源(fryl_h5/public/fonts/ 已删除)。

验证方式

先用 Python(PIL.ImageDraw.text(..., anchor='ls'),与 Canvas textBaseline='alphabetic' 语义等价)+ PyMuPDF 验证了整套"测量墨迹边界 → 裁图 → 按基线贴图"的几何计算逻辑可行,再用 Puppeteer 跑真实浏览器(headless Chrome)加载 fryl_h5 的 Vite dev server,在页面上下文里 import() 真实的 agreementPdfFill.js 源码执行(不是重写的等价逻辑,是应用运行时会加载的 同一份代码),确认浏览器 Canvas 实际产出的图片尺寸/基线偏移与 Python 原型一致,PyMuPDF 渲染 结果视觉对齐正确。

证件号码/日期数字仍用标准 Helvetica(StandardFonts.Helvetica)绘制,不受本次调整影响—— 纯数字不需要中文字体支持,更窄更整齐。

前端实现方案

新增文件

  • fryl_h5/src/utils/agreementPdfFill.js:核心填充逻辑,导出 fillPayAgreementPdf(templateBase64, { customerName, certificateNumber }),内部:
    1. PDFDocument.load() 加载模板 base64,并行调用 renderNameToPng() 把姓名渲染成PNG (见上一节,不再有任何字体加载步骤)
    2. embedPng() 姓名图片 + 内嵌标准 Helvetica
    3. 定位模板末页(不硬编码具体页码,避免模板改版增删前面页数后坐标失配)
    4. 姓名用 drawImage 按基线对齐贴图,证件号码/日期用 drawText(日期居中于对应空白区间, font.widthOfTextAtSize 动态测量后居中,不是固定x)
    5. pdfDoc.save() 后转 base64 返回

打包体积说明:pdf-lib 压缩后约180KB(gzip),AgreementStep.vueawait import('@/utils/agreementPdfFill') 动态导入(而不是静态 import),让 Vite 把它单独 分包,只有用户真正勾选两个协议触发生成逻辑时才下载,不拖慢H5首屏——实测这个改动把首屏主包 稳定在144KB gzip,不随这个功能的依赖体积增长。

改造文件

  • fryl_h5/src/api/personalOpen.js:新增 uploadFileApi({fileBase64, channelNo})(通用文件 上传,与主项目 src/api/customer.jsuploadCustomerFileApi 对接同一个后端接口); submitApplicationApi 新增第三个参数 payAgreementFileNo
  • fryl_h5/src/views/steps/AgreementStep.vue:
    • 新增 props customerName/certificateNumber/channelNo(父组件从 draftDetail 传入)。
    • watch(bothAgreed):两个协议都勾选后自动触发 generateAndUpload()(拉取协议模板 → fillPayAgreementPdfuploadFileApi),不需要等用户点"下一步"才开始生成,减少等待感。
    • 生成/上传前置校验:customerName/certificateNumber/channelNo 任一缺失直接提示"请联系 柜员核实",不发起必然失败的请求(与 SmsVerifyStep.vue 现有的发码前置校验同一思路)。
    • 统一等待态:van-overlay 全屏遮罩("正在生成协议文件,请稍候...")+ "下一步"按钮 :loading/:disabled 双重防重复点击,符合项目"耗时操作统一等待态"约定。
    • 失败重试:生成失败时展示错误文案 + "点击重试"链接,不清空已勾选的checkbox状态。
    • "下一步"(canProceed)必须同时满足:两协议都勾选 + payAgreementFileNo 非空 + 不在生成 中,才允许点击,避免带着空文件编号进入下一步。
    • next 事件携带 payAgreementFileNo(而不是无参数的 emit('next'))。
  • fryl_h5/src/views/PersonalOpenMobile.vue:向 AgreementStep 传入 draftDetail.customerName/certificateNumber/channelNo;新增 payAgreementFileNo ref, 在 AgreementStepnext 事件里接收并存下,再作为 prop 传给 SmsVerifyStep
  • fryl_h5/src/views/steps/SmsVerifyStep.vue:新增 prop payAgreementFileNo,提交时传给 submitApplicationApi 的第三个参数。

新增依赖

fryl_h5/package.json 新增 pdf-lib(^1.17.1)。仅在 fryl_h5 独立工程内新增,不影响根目录 管理后台依赖。(@pdf-lib/fontkit 曾短暂引入用于第一版字体嵌入方案,推翻后已移除,不要再加回来。)

mock-server 配套改造

  • 新增 mock-server/routes/uploadFile.js:customer-info/personal/upload-file 的匿名版本 (不挂 requireAuth),与 faceRecognize.js 同理——该接口同时被管理后台(已登录)与 fryl_h5(匿名)调用。原 mock-server/routes/customer.js 里带 requireAuth 的同名路由已 删除(避免重复定义),mock-server/index.js 里新路由挂载在 personalOpenMobileRouter/ faceRecognizeRouter 同一批"必须在 customerRouter 之前"的位置。
  • mock-server/assets/bank-agreements/pay_service_agreement.pdf/pay_service_privacy.pdf 已从"纯占位文本PDF"替换为银行提供的正式模板原件——填充逻辑依赖模板末页真实存在的签名区块 及其精确坐标,占位文本PDF没有这个区块,无法用于本地验证该功能。
  • mock-server/routes/personalOpenMobile.jsmobile/submit 路由新增接收 payAgreementFileNo,原样存到申请单记录(仅用于本地调试排查,不做真实归档校验)。

本地端到端验证记录

本次改动已跑通完整链路验证(非仅代码审查),分两轮(第二轮是推翻字体嵌入方案后的重新验证):

  1. 启动 npm run mock(8888)+ fryl_h5vite dev server,确认 GET /api/customer-info/bank-agreement/base64?fileType=PAY_SERVICE_AGREEMENT 返回真实模板 base64。
  2. Puppeteer 启动真实的headless Chrome,加载 fryl_h5 的 Vite dev server 页面,在浏览器 页面上下文里 import() 真实的 agreementPdfFill.js 源码并执行(不是重写的等价逻辑,是 应用运行时会加载的同一份代码),验证 Canvas 姓名渲染 + PDF 生成全流程在真实浏览器环境下 可正常跑通,产出的PDF体积约136KB(对比第一版字体嵌入方案的1.7MB,验证了"推翻方案"确实 解决了体积问题)。
  3. 用 PyMuPDF 把生成结果渲染成图片人工核对签名区块视觉效果(姓名/证件号码/日期均正确显示、 与标签对齐在同一基线、无重叠)。
  4. 用 Node 直接调用 uploadFileApi 对应的 mock 接口,确认拿到 fileNo 全流程闭环。

2026-08-06 二次更新:修复真实后端环境下协议PDF上传后被存成 jpg 打不开的问题

上面记录的"体积过大导致上传失败"问题解决后,真实后端环境联调又发现新问题:协议PDF上传成功 (拿到了 fileNo),但管理后台里查看这个文件时发现被存成了 .jpg,打不开。

根因:upload-file 接口(POST /api/customer-info/personal/upload-file)的请求体 schema 里没有任何 fileName/fileExt/contentType 字段(swagger 已核实),后端判断文件 真实类型/存储扩展名的唯一依据,是 fileBase64 字符串本身是否带 data:<mime>;base64, 前缀fillPayAgreementPdf()(agreementPdfFill.js)出于内部 pdf-lib 处理需要,返回值是不含任何 前缀的"纯净" base64(函数注释里写明了这个约定,base64ToUint8Array 内部用 atob() 直接解码, 带前缀反而会报错)。但项目里其它所有已跑通的 upload-file/face-recognize 调用 (IdCardUpload.vue/LicenseUpload.vue/BeneficiaryProofUpload.vue/FacePhotoUpload.vue) 全部是 FileReader.readAsDataURL() 产出的带 data:image/xxx;base64, 前缀的完整 Data URI——这是一个从未被写进文档、只体现在"历史上所有调用方都恰好这么做"里的隐性契约,AgreementStep.vue 最初直接把 fillPayAgreementPdf() 的纯 base64 返回值原样传给 uploadFileApi,打破了这个隐性 约定,后端拿不到 MIME 信号,回退成了它历史上唯一见过的场景——图片,存成了 .jpg

修复:AgreementStep.vue 调用 uploadFileApi 前,把 filledBase64 包一层 `data:application/pdf;base64,${filledBase64}` 再传给 fileBase64 字段,与其它上传场景 保持一致的约定,agreementPdfFill.js 本身不改动(内部纯 base64 的约定继续保留,只在最终交给 uploadFileApi 之前补前缀)。

验证:mock-server 完全不落盘、不做任何类型判断,不会重现这个问题(见 mock-server/routes/uploadFile.js),只能在真实后端环境验证。本次用 Puppeteer 起真实 headless Chrome + npm run mock,直接拦截浏览器发出的 upload-file 请求,确认修复后请求体 fileBase64 字段以 data:application/pdf;base64,JVBERi0x...(%PDF 魔数)开头。

给后续维护者的提醒:如果以后有新的"非图片文件通过 upload-file 上传"的场景,务必记得手动 拼上正确的 data:<mime>;base64, 前缀,不要照抄"直接传纯 base64"的写法——这个接口的契约完全 靠调用方自觉,后端不会主动提示缺前缀导致的类型误判。

待确认(重要):上面"靠 base64 前缀嗅探 MIME"是根据其它成功场景的行为归纳推断出来的, 并未拿到后端源码或后端团队确认。前端从始至终都没有任何字段可以传"文件名"(swagger 的 upload-file 请求体只有 channelNo/fileBase64 两个字段),所以如果后台看到的文件名是固定的 image.jpg 字面值,这个名字一定是后端自己生成的。不能排除的另一种可能是:后端存储层本来就只 为图片场景写的,不管 fileBase64 内容/前缀是什么都硬编码用 image.jpg 存储——如果是这样, 补前缀不会起作用。需要用本次修复后的版本在真实后端环境重新测一次并确认结果,若问题依旧,需要 后端加真正的 fileName/fileExt 参数或解析 base64 内容魔数,前端没有其它办法。