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

254 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 个人开户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 内容魔数,前端没有其它办法。