254 lines
17 KiB
Markdown
254 lines
17 KiB
Markdown
# 个人开户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向下增大):
|
||
|
||
```python
|
||
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.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')`)。
|
||
- `fryl_h5/src/views/PersonalOpenMobile.vue`:向 `AgreementStep` 传入
|
||
`draftDetail.customerName`/`certificateNumber`/`channelNo`;新增 `payAgreementFileNo` ref,
|
||
在 `AgreementStep` 的 `next` 事件里接收并存下,再作为 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.js` 的 `mobile/submit` 路由新增接收
|
||
`payAgreementFileNo`,原样存到申请单记录(仅用于本地调试排查,不做真实归档校验)。
|
||
|
||
## 本地端到端验证记录
|
||
|
||
本次改动已跑通完整链路验证(非仅代码审查),分两轮(第二轮是推翻字体嵌入方案后的重新验证):
|
||
|
||
1. 启动 `npm run mock`(8888)+ `fryl_h5` 的 `vite` 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 内容魔数,前端没有其它办法。
|