17 KiB
个人开户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页(末页),格式与协议一致, 但本次不需要填充/上传这份文件。
两份文件末页都有相同格式的空白区块:
用户:
证件号码:
时间: 年 月 日
坐标测量方法(后续模板换版需要重新测量时参照此方法)
不要用 pypdf 的 extract_text(visitor_text=...) 回调坐标——实测对这两份 PDF(Type0
CID字体 KaiTi/SimSun,Identity-H 编码)返回的 tm 值不可靠,与视觉呈现的实际位置不一致(曾
因此错误估算出错误坐标,详见本次调试记录:第一次用 pypdf 测得的坐标直接用于填充,渲染后发现
填充内容出现在页面最下方,与标签完全错位)。
正确做法:用 PyMuPDF(fitz)的 page.get_text('dict'),读取每个 span 的 origin 字段
(即字形基线原点,坐标系:左上角为原点,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 渲染成图片实测确认,不是猜测):
embedFont(bytes, { subset: true })会损坏中文字形(@pdf-lib/fontkit的字体子集化逻辑 对由部件组合而成的汉字存在bug,比如"张三丰"渲染出来只剩"丰"能显示)。规避方式是改用subset: false完整嵌入,但这样每份生成的PDF会多出约1.6MB。- 字体资源不能用
.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 }),内部:PDFDocument.load()加载模板 base64,并行调用renderNameToPng()把姓名渲染成PNG (见上一节,不再有任何字体加载步骤)embedPng()姓名图片 + 内嵌标准Helvetica- 定位模板末页(不硬编码具体页码,避免模板改版增删前面页数后坐标失配)
- 姓名用
drawImage按基线对齐贴图,证件号码/日期用drawText(日期居中于对应空白区间,font.widthOfTextAtSize动态测量后居中,不是固定x) pdfDoc.save()后转 base64 返回
打包体积说明:pdf-lib 压缩后约180KB(gzip),AgreementStep.vue 里用
await import('@/utils/agreementPdfFill') 动态导入(而不是静态 import),让 Vite 把它单独
分包,只有用户真正勾选两个协议触发生成逻辑时才下载,不拖慢H5首屏——实测这个改动把首屏主包
稳定在144KB gzip,不随这个功能的依赖体积增长。
改造文件
fryl_h5/src/api/personalOpen.js:新增uploadFileApi({fileBase64, channelNo})(通用文件 上传,与主项目src/api/customer.js的uploadCustomerFileApi对接同一个后端接口);submitApplicationApi新增第三个参数payAgreementFileNo。fryl_h5/src/views/steps/AgreementStep.vue:- 新增 props
customerName/certificateNumber/channelNo(父组件从draftDetail传入)。 watch(bothAgreed):两个协议都勾选后自动触发generateAndUpload()(拉取协议模板 →fillPayAgreementPdf→uploadFileApi),不需要等用户点"下一步"才开始生成,减少等待感。- 生成/上传前置校验:
customerName/certificateNumber/channelNo任一缺失直接提示"请联系 柜员核实",不发起必然失败的请求(与SmsVerifyStep.vue现有的发码前置校验同一思路)。 - 统一等待态:
van-overlay全屏遮罩("正在生成协议文件,请稍候...")+ "下一步"按钮:loading/:disabled双重防重复点击,符合项目"耗时操作统一等待态"约定。 - 失败重试:生成失败时展示错误文案 + "点击重试"链接,不清空已勾选的checkbox状态。
- "下一步"(
canProceed)必须同时满足:两协议都勾选 +payAgreementFileNo非空 + 不在生成 中,才允许点击,避免带着空文件编号进入下一步。 next事件携带payAgreementFileNo(而不是无参数的emit('next'))。
- 新增 props
fryl_h5/src/views/PersonalOpenMobile.vue:向AgreementStep传入draftDetail.customerName/certificateNumber/channelNo;新增payAgreementFileNoref, 在AgreementStep的next事件里接收并存下,再作为 prop 传给SmsVerifyStep。fryl_h5/src/views/steps/SmsVerifyStep.vue:新增 proppayAgreementFileNo,提交时传给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.js的mobile/submit路由新增接收payAgreementFileNo,原样存到申请单记录(仅用于本地调试排查,不做真实归档校验)。
本地端到端验证记录
本次改动已跑通完整链路验证(非仅代码审查),分两轮(第二轮是推翻字体嵌入方案后的重新验证):
- 启动
npm run mock(8888)+fryl_h5的vitedev server,确认GET /api/customer-info/bank-agreement/base64?fileType=PAY_SERVICE_AGREEMENT返回真实模板 base64。 - 用 Puppeteer 启动真实的headless Chrome,加载
fryl_h5的 Vite dev server 页面,在浏览器 页面上下文里import()真实的agreementPdfFill.js源码并执行(不是重写的等价逻辑,是 应用运行时会加载的同一份代码),验证 Canvas 姓名渲染 + PDF 生成全流程在真实浏览器环境下 可正常跑通,产出的PDF体积约136KB(对比第一版字体嵌入方案的1.7MB,验证了"推翻方案"确实 解决了体积问题)。 - 用 PyMuPDF 把生成结果渲染成图片人工核对签名区块视觉效果(姓名/证件号码/日期均正确显示、 与标签对齐在同一基线、无重叠)。
- 用 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 内容魔数,前端没有其它办法。