SFT/docs/superpowers/specs/2026-07-31-personal-open-mo...

276 lines
16 KiB
Markdown

# 个人开户移动端H5 对接真实字段与人脸识别接口 设计文档
## 背景
`docs/superpowers/specs/2026-07-27-personal-open-mobile-h5-design.md`(以下简称"07-27文档")与
`docs/superpowers/specs/2026-07-28-personal-open-mobile-h5-send-verify-code-api-change-design.md`
(以下简称"07-28文档")实现并调整了 `fryl_h5` 独立工程的个人开户移动端H5确认页,但当时
`mobile/draft-detail` 的响应字段、`mobile/submit` 是否需要人脸文件、"人脸对比"环节的后端接口均是
前端单方假设或完全无接口(见《缺失的后端接口.md》Part 12 第93-95条)。
最新 `swagger_project_scfs_2026-07-31_09-25-57.json` 显示后端已经:
1. 正式补充了 `MobileDraftDetailVo` 的完整字段(不再是5个字段的占位契约)。
2. 新增了真实的人脸识别接口 `POST /api/customer-info/personal/face-recognize`(凡荣e链222005),
与主项目管理后台"个人开户"表单的 `src/components/FacePhotoUpload.vue`/`src/api/customer.js`
调用的是同一个接口。
3. `mobile/submit` 的接口描述明确"影像资料由后端从本地查询",请求体仍只有
`{applicationNo, verifyCode}`
本次设计即按新契约重新对接 H5 确认页的"信息核对"与"人脸对比"两个环节,补齐真实的人脸识别调用,
并修复一个顺带发现的 mock-server 路由顺序缺陷(否则本次新增/改造的功能无法在本地 `npm run mock`
环境下验证)。
## 范围
- 仅改动 `fryl_h5` 工程内"信息核对"页字段来源、"人脸对比"步骤的接口对接、"手机验证"步骤的手机号
展示来源。
- 不改动 `mobile/submit` 的请求参数结构(仍是 `{applicationNo, verifyCode}`,人脸识别结果不透传)。
- 不改动 `send-verify-code/general` 的接口地址,仅调整请求体字段取值来源(见下)。
- 顺带修复 `mock-server/index.js` 的路由挂载顺序缺陷 + 新增 `face-recognize` 匿名 mock 路由,便于
本地联调;不改动其余 mock 业务逻辑。
- 不涉及企业开户、贷款申请确认扫码等其它移动端确认场景。
- 不改动管理后台侧任何页面代码(仅读取其 `FacePhotoUpload.vue`/`customer.js` 作为接口调用参照)。
## 接口契约变化(依据新 swagger,不再是假设)
### `POST /api/wallet-management/personal-opening/mobile/draft-detail`
响应 `MobileDraftDetailVo` 真实字段:
| 字段 | 说明 | 备注 |
| --- | --- | --- |
| `customerName` | 客户名称 | 不变 |
| `certificateType` | 证件类型(`CertificateTypeEnum`) | 不变 |
| `certificateNumber` | 证件号码 | 不变 |
| `certificateEffectiveDate` | 证件生效日 | 不变 |
| `certificateExpiryDate` | 证件到期日 | 不变 |
| `issuingAuthority` | 签发机关 | 不变 |
| `gender` | 性别(`GenderEnum`,含 `UNKNOWN`) | 不变 |
| `certificateAddress` | 证件地址 | 不变 |
| `occupation` | 职业(`OccupationTypeEnum` 编码) | 不变 |
| `occupationNote` | 职业备注 | **字段名变化**:此前假设为 `jobNote` |
| `mobilePhone` | 手机号(明文) | **字段名变化**:此前假设为 `mobile` |
| `mobilePhoneMask` | 掩码手机号(后端直接算好,如 `138****1234`) | **新增**:此前(07-28)以为后端不再提供掩码字段,现confirmed后端仍提供,前端不再需要自行计算 |
| `bankCardNumber` | 绑定银行卡号 | 不变 |
| `ethnic` | 民族 | 不变 |
**仍缺失(遗留缺口,沿用 Part12 第93条的假设模式)**:`channelNo`(渠道编号)、`bankCardName`
(银行卡户名)两个字段依然不存在。这两个字段原本用于 `send-verify-code/general` 请求体,现按以下
方案处理(已与用户确认):
- `channelNo`:固定传空字符串 `''`(既有假设模式的延续,不阻塞流程,等后端补充字段后再联调修正)。
- `cardName`:固定传空字符串 `''`(不复用 `customerName`,避免语义臆造)。
### `POST /api/customer-info/personal/face-recognize`(新增,真实接口)
请求体:
```json
{
"fileData": "base64编码的人脸照片(字段描述写"zip"但已由主项目对接确认实际直传图片base64即可)",
"idNo": "证件号码",
"name": "姓名",
"channelNo": "渠道编号(必填,H5场景无来源,固定传空字符串,见下方风险登记)"
}
```
响应 `FaceRecognizeResultVo`:`fileNo`(识别通过后的文件编号)/`recode`/`recodeInfo`/`sysSerialNo`/
`sysDate`。前端沿用主项目 `FacePhotoUpload.vue` 已验证的判断方式:**只按 `data.fileNo` 是否非空
判断识别是否通过**,不对 `recode` 做枚举分支(枚举未文档化,与主项目现状一致)。
`idNo`/`name` 取自 `draft-detail` 已返回的 `certificateNumber`/`customerName`(而不是身份证 OCR
结果,H5 场景本身也没有 OCR 环节)。
### `POST /api/wallet-management/personal-opening/mobile/submit`
不变,`{applicationNo, verifyCode}`。接口描述"影像资料由后端从本地查询",说明后端会按证件号等信息
自行关联此前 `face-recognize` 调用留存的影像,前端**不需要**、也**没有字段**可以把 `fileNo` 传给
`submit`。据此,"人脸对比"步骤在 H5 侧的作用被明确为:**识别通过才允许进入下一步的前置校验关卡**,
识别结果本身不参与提交请求体。
## 页面改造
### 步骤1:信息核对(`InfoConfirmStep.vue`)
- 手机号展示:改为直接使用 `detail.mobilePhoneMask`(后端已算好),不再调用任何前端掩码工具。
- "职业备注"展示字段:`detail.jobNote` → `detail.occupationNote`
- 其余字段展示不变。
### 步骤2:人脸对比(`FaceCompareStep.vue`,本次重点改造)
由"仅拍照留档展示,不比对"的 UI 占位,改为真实调用人脸识别接口:
1. 通过 `van-uploader``:after-read` 钩子拿到新选中的文件(拍照或从相册选择,`capture="camera"`
保留)。
2.`FileReader.readAsDataURL` 转 base64(与主项目 `FacePhotoUpload.vue``readAsBase64` 完全
一致的实现),调用 `faceRecognizeApi({ fileData, idNo, name })`(`idNo`/`name` 由父组件传入,
分别取 `draftDetail.certificateNumber`/`draftDetail.customerName`;`channelNo` 由 api 函数内部
固定补充为 `''`)。
3. 识别中:整页 `van-overlay` + `van-loading`("人脸识别中,请稍候..."),`van-uploader` 与"上一步"
/"下一步"按钮均禁用,防止重复操作(与 `SmsVerifyStep.vue` 提交时的等待态规范保持一致)。
4. 识别通过(`data.fileNo` 非空):`showToast(data.recodeInfo || '人脸识别通过')`,"下一步"按钮启用。
5. 识别未通过(`code!==200` 或 `data.fileNo` 为空):清空已选照片预览(强制重新拍摄,不允许提交
"识别失败的照片"进入下一步),`showErrorModal(data?.recodeInfo || message || '人脸识别失败,请
重新拍摄', '人脸识别未通过')`,"下一步"保持禁用。
6. 用户删除已选照片、或重新选择新照片:识别状态重置为未通过,需重新走一次识别流程。
状态提升到父组件 `PersonalOpenMobile.vue`(新增 `faceVerified` ref,通过 `v-model:verified` 双向
绑定,与既有的 `agreed`/`facePhoto` 提升模式一致):用户从步骤3"上一步"退回步骤2、再次"下一步"
前进时,如果照片与识别通过状态都还保留着,不需要重新识别(组件销毁重建后用 props 里的
`photo`+`verified` 恢复本地状态,不重新触发接口调用)。
原提示文案"当前版本仅采集照片供留档展示,不进行自动人脸比对"删除,替换为真实的操作提示("请正对
手机摄像头拍摄一张清晰的人脸照片,系统将自动识别是否为本人办理")及识别结果状态提示。
### 步骤3:手机验证(`SmsVerifyStep.vue`)
- 删除 `channelNo`/`cardName` 两个 props(不再需要从外部传入,`sendVerifyCodeApi` 内部固定补
`''`)。
- 手机号展示改为直接使用父组件传入的 `mobilePhoneMask`(新 prop,替换原 `mobile` prop 的展示用途;
是否仍需要保留明文 `mobile` prop 用于调用发码接口——**需要**,发码接口请求体的 `mobile` 字段
仍必须是明文,`mobilePhoneMask` 只用于本步骤 UI 展示)。
### 父组件 `PersonalOpenMobile.vue`
- `draftDetail` 字段读取全部对齐新字段名:`mobile` → `mobilePhone`,新增 `mobilePhoneMask` 透传。
- 新增 `faceVerified` ref,传给 `FaceCompareStep``v-model:verified`
-`FaceCompareStep` 新增 `id-no`/`name` props(分别取 `draftDetail.certificateNumber`/
`draftDetail.customerName`)。
-`SmsVerifyStep` 传参调整为 `mobile`(明文,`draftDetail.mobilePhone`)+`mobile-phone-mask`
(`draftDetail.mobilePhoneMask`)+`card-no`(`draftDetail.bankCardNumber`)+`id-no`
(`draftDetail.certificateNumber`),不再传 `channel-no`/`card-name`。
### `src/utils/maskPhone.js`
删除该文件(后端已直接提供掩码字段,前端不再需要自行计算),同时移除 `InfoConfirmStep.vue`/
`SmsVerifyStep.vue` 中对它的 import。
### `src/constants/enums.js`
按新 swagger 的枚举描述校正/补全(不再是前端临时占位):
- `GENDER_LABELS` 补充 `UNKNOWN: '未知'`
- `CERTIFICATE_TYPE_LABELS` 补全 `CertificateTypeEnum` 全部5个值(`ID_CARD`/`PASSPORT`/`OTHER`/
`BUSINESS_LICENSE`/`INDIVIDUAL_BUSINESS`),避免个人客户理论上出现非身份证证件类型时展示裸编码。
- `OCCUPATION_TYPE_LABELS``OccupationTypeEnum` 的官方描述校正文案:`SELF_EMPLOYED`
"个体经营人员"→"个体工商户"、`FARMER` "务农人员"→"农/林/牧/渔"、`RETIRED` "退休人员"→
"离退休人员"(其余值编码与文案不变)。
### `src/api/personalOpen.js`
```js
export const sendVerifyCodeApi = ({ mobile, cardNo, idNo }) =>
request.post('/api/wallet-management/personal-opening/mobile/send-verify-code/general', {
channelNo: '', // 契约缺口:MobileDraftDetailVo 无 channelNo,固定传空,待后端补充字段
tradeNo: '222101',
tradeType: '221506',
mobile,
cardNo,
cardName: '', // 契约缺口:MobileDraftDetailVo 无 bankCardName,固定传空
idNo
})
export const faceRecognizeApi = ({ fileData, idNo, name }) =>
request.post('/api/customer-info/personal/face-recognize', { fileData, idNo, name, channelNo: '' })
```
## mock-server 配套修复与改造
### 路由挂载顺序缺陷修复(阻塞性 bug,顺带修复)
`mock-server/routes/customer.js`/`wallet.js` 均在文件顶部用 `router.use(requireAuth)`(不带路径)
挂鉴权中间件。Express 的这种写法会拦截**该 router 匹配到的任意路径**,而不仅是该文件里定义的具体
路由。`mock-server/index.js` 里这两个 router 都以 `app.use('/api', xxxRouter)` 挂载,且顺序早于
`personalOpenMobileRouter`(同样挂载在 `/api` 前缀下)。结果是:任何 `/api/*` 请求都会先进入
`customerRouter`,被其内部的 `requireAuth` 拦截返回401(除非携带合法 token),H5 匿名接口永远
拿不到执行机会。已实测复现:本地 `npm run mock` 环境下访问
`/api/wallet-management/personal-opening/mobile/draft-detail` 直接返回401。
修复方式:调整 `index.js` 挂载顺序,把不需要鉴权的 router(`personalOpenMobileRouter` + 新增的
`faceRecognizeRouter`)挪到 `customerRouter`/`walletRouter` 之前挂载。
### 新增 `mock-server/routes/faceRecognize.js`(不带 `requireAuth`)
```
POST /customer-info/personal/face-recognize
```
- 校验 `fileData`/`idNo`/`name` 非空(`channelNo` 允许为空字符串,不强制校验,呼应契约缺口),
缺失则 `sendFail`
- 校验通过则返回 `{ fileNo, recode: '0000', recodeInfo: '识别通过', sysSerialNo, sysDate }`,
`fileNo``FACE${nextId('file')}` 生成,足够支撑前端"按 `fileNo` 是否非空判断通过"的逻辑。
- 不模拟"人脸不符"等业务失败场景(与主项目现状一致,无可参照的真实失败样例)。
放在独立、不挂 `requireAuth` 的文件里,同时服务于管理后台(已登录,不受影响)和 H5(匿名)两种
调用方,弥补此前主项目 `FacePhotoUpload.vue` 一直没有对应 mock、无法本地联调的缺口。
### `mock-server/routes/personalOpenMobile.js` 字段同步
`draft-detail` mock 响应同步新字段名:
- `mobile``mobilePhone`
- 新增 `mobilePhoneMask`(mock 内自行按"前3后4"算法计算)
- `jobNote``occupationNote`
- 移除 `channelNo`/`bankCardName`(不再从 `application` 对象读取,契约缺口不在 mock 层面"填平",
与真实后端行为保持一致,便于本地测试提前暴露前端对这两个字段留空的兼容性)
`send-verify-code/general``submit` 两个路由内部逻辑不变(仍以 `mobilePhone` 明文作为验证码
key)。
## 数据流(更新后)
```
PersonalOpenMobile.vue (onMounted)
→ fetchDraftDetailApi(applicationNo)
→ draftDetail = { customerName, certificateType, certificateNumber, certificateEffectiveDate,
certificateExpiryDate, issuingAuthority, gender, certificateAddress,
occupation, occupationNote, mobilePhone, mobilePhoneMask, bankCardNumber, ethnic }
步骤1 InfoConfirmStep: 展示 mobilePhoneMask(不再自算);职业备注读 occupationNote
步骤2 FaceCompareStep(props: idNo=certificateNumber, name=customerName):
拍照/选图 → readAsBase64 → faceRecognizeApi({fileData, idNo, name})
→ 成功(fileNo非空) → emit update:verified=true → "下一步"可点
→ 失败 → 清空预览,emit update:verified=false → 强制重拍
步骤3 SmsVerifyStep(props: mobile=mobilePhone, mobilePhoneMask, cardNo=bankCardNumber, idNo=certificateNumber):
展示 mobilePhoneMask
"获取验证码" → sendVerifyCodeApi({ mobile, cardNo, idNo })
(channelNo=''/cardName''/tradeNo/tradeType 由 api 函数内部固定补充)
"确认提交" → submitApplicationApi(applicationNo, verifyCode) // 不变,不含人脸文件编号
```
## 错误处理
与现有全局规则一致:
- `code !== 200`:`request.js` 响应拦截器统一 `showErrorModal` 提示,不新增特殊分支。
- 人脸识别的"`code===200` 但 `data.fileNo` 为空"这种业务软失败,全局拦截器不会弹窗(因为
`code===200`),`FaceCompareStep.vue` 内部单独调用 `showErrorModal` 兜底提示,与主项目
`FacePhotoUpload.vue` 的既有处理方式保持一致(即便存在与全局拦截器"双弹窗"的可能,也是本仓库
已有的既定模式,本次不额外处理去重,不属于本次任务范围)。
- 网络异常:同现有拦截器统一提示。
## 已知风险 / 遗留缺口(需后端后续确认)
1. **`channelNo` 缺口**:`face-recognize` 接口把 `channelNo` 标记为必填,但 H5 场景(`draft-detail`
无此字段、二维码 URL 也不携带渠道信息)只能固定传空字符串。若后端 `@Valid` 校验真的强制拒绝空
`channelNo`,人脸识别会始终失败,阻塞整个 H5 流程。**需要后端确认该接口对 H5 匿名场景下
`channelNo` 为空的容忍度,或在 `MobileDraftDetailVo` 补充该字段**。
2. **`bankCardName` 缺口**:`send-verify-code/general` 的 `cardName` 固定传空字符串,若凡荣e链短信
接口对该字段有强校验,可能导致发送验证码失败。需要后端确认该字段是否允许为空。
3. **匿名访问 `face-recognize` 的可行性未经真实后端验证**:该接口目前仅由已登录的管理后台调用,
本次 H5 侧调用不带 `Authorization` 头(与其余 H5 匿名接口一致的约定)。由于本地无法连接真实
后端环境验证,无法确认 Spring Security 是否已将该接口路径纳入匿名白名单;若未纳入,H5 场景下
该接口会返回401,需要后端配合调整安全配置。
以上3点已同步登记到《缺失的后端接口.md》Part 12。
## 范围边界(本次不做)
- 不新增"人脸识别失败原因"的细分枚举分支处理(`recode` 取值未文档化,与主项目现状一致)。
- 不做人脸照片压缩/裁剪等图像处理。
- 不修改管理后台侧 `FacePhotoUpload.vue`/`src/api/customer.js` 任何代码(仅作为接口调用参照)。
- 不解决"全局拦截器 + 组件内部"双弹窗的既有小问题(超出本次范围)。