feat(delivery): 优化身份证及凭证图片上传和提交校验

- 在提交前新增必填图片校验,确保身份证正反面及其他凭证全部上传
- 优化图片上传占位样式,新增上传提示文案
- uploadImage函数增加上传中loading提示及成功失败反馈toast
- 格式化后端错误提示,过滤错误码尾巴,提升用户体验
- 明确图片字段标签,标注为“身份证正面(人像面)”等,增强用户指引
This commit is contained in:
2026-07-26 14:09:31 +08:00
parent 96d153ac6d
commit 6abb49c911
6 changed files with 312 additions and 81 deletions

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/yunxinwei-uniapp.iml" filepath="$PROJECT_DIR$/.idea/yunxinwei-uniapp.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

12
.idea/yunxinwei-uniapp.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,45 @@
# 2026-07-26
## 修复 `pages/order/delivery.vue` 身份证上传失败 Bug
**用户报告**:页面提交时弹"请上传身份证正面照片.FAILUREFP03333"。
**根因分析**
- 错误码 `FAILUREFP03333` 和提示文本"请上传身份证正面照片"都是后端 `POST /open/equipment/receipt` 返回的,前端代码无此内容。
- `orderSourceData` 数组下标与图片含义是固定绑定0=身份证正面、1=身份证反面、2=人车合照、3=车子照片、4=安装照片。
- 截图里只看到"其他"模块 3 张图,未见"身份证信息"模块——用户大概率跳过了身份证正反面上传就点了提交。
**修复内容**`pages/order/delivery.vue`
1.`handleSubmit` 提交前增加客户端必传校验5 个图片槽位任一为空就拦截,并按"身份证正面 > 身份证反面 > 其他"优先级提示用户补图。
2. 过滤后端返回的错误码尾巴(如 `.FAILUREFP03333`),新增 `formatErrorMsg()` 工具函数。
3. 优化占位图:未上传时显示"+ 点击上传身份证正面(人像面)"等说明,替代原来的灰色 `not-dealer.png` 占位。
4. `chooseImage``uni.showLoading` 上传中提示,成功/失败分别 toast 明确文案。
5. 字段标签明确化:"正面/反面"→"身份证正面(人像面)/身份证反面(国徽面)"。
**关于 `FAILUREFP03333` 的判断**
- 非前端错误码(前端 grep 无任何匹配)。
- 推测是后端在调用阿里云内容安全 / 第三方 OCRFace++、百度、腾讯云天御等)做身份证内容审核时返回的状态码。
- 真正根因在后端,前端只能加校验和友好提示,无法根本解决"图片本身被识别为非身份证"的场景。
- 建议后端同事确认 `/open/equipment/receipt` 接口的身份证正反面校验逻辑,以及是否依赖第三方图片识别服务。
## 后端 `yunxinwei-api` 同步修复FAILUREFP03333 根因)
**项目路径**`/Users/gxwebsoft/JAVA/yunxinwei-api`
**文件**`src/main/java/com/gxwebsoft/open/controller/OpenEquipmentController.java`
**根因确认**
- `receipt()` 接口(原 402-461 行)调用 `idcardService.verify(front, back)` 做阿里云市场 OCR`miitangs14.market.alicloudapi.com/v1/tools/ocr/idCard`),非 `FP00000` 时把 `verify.getMessage() + verify.getCode()` 直接拼进用户提示 → 用户看到"请上传身份证正面照片.FAILUREFP03333"。
- `idcardService.verify` 在 OCR 接口异常时返回 `null`,原代码紧接着 `verify.getCode()` 会 NPE → 500。
- `images.get(0/1)` 在图片不足 2 张时会 `IndexOutOfBounds`;用户跳过身份证上传只传 3 张其他图时,`images[0]` 被当成身份证人像面去 OCR → 失败但提示语仍是"请上传身份证正面照片"。
**修复内容**
1. 抽出私有方法 `verifyIdCard(orderSourceData, order)`,把身份证识别与实名回填逻辑内聚。
2. JSON 解析加 try-catch失败返回"身份证图片数据格式有误"。
3. 校验 `images` 至少 2 张且 `images[0]`/`images[1]` 非空,否则返回"请上传身份证正面和反面照片"(不再越界)。
4. `verify` 调用加 try-catch + `null` 判断,避免 NPEOCR 异常返回友好提示。
5. **内部错误码 `FAILUREFP03333` 不再回吐给用户**,仅 `log.warn` 记录;用户提示改为"身份证正面照片识别失败,请重新上传清晰的身份证人像面照片" / "身份证反面照片识别失败,请重新上传清晰的身份证国徽面照片"。
6. `userService.getById(...)``null` 防护,避免回填空指针。
**验证**`./mvnw -o -q compile` 离线编译通过EXIT=0依赖已本地缓存
**前后端配合结论**前端delivery.vue做提交前必传校验 + 友好占位后端OpenEquipmentController做空安全 + 内部码隔离 + 分正反面提示,两端共同消除该 Bug。

View File

@@ -6,34 +6,49 @@
<view class="reject" v-else-if="form.apply_status == 30">
<u-alert-tips type="error" title="您的申请已被驳回:" :description="form.reject_reason"></u-alert-tips>
</view>
<u-form :model="form" ref="uForm" label-width="140rpx">
<u-form :model="form" ref="uForm" label-width="180rpx">
<!-- 标题 -->
<view class="page-title">身份证信息</view>
<view class="page-title">身份证信息必传</view>
<view class="form-wrapper">
<u-form-item label="正面" prop="img1">
<image :src="orderSourceData[0]" v-if="orderSourceData[0]" mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(1)"></image>
<image src="../../static/not-dealer.png" v-else mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(1)"></image>
<u-form-item label="身份证正面" prop="img1" required>
<image :src="orderSourceData[0]" v-if="orderSourceData[0]" mode="aspectFill" class="upload-img" @click="chooseImage(1)"></image>
<view v-else class="upload-placeholder" @click="chooseImage(1)">
<text class="placeholder-icon">+</text>
<text class="placeholder-text">点击上传身份证正面人像面</text>
</view>
</u-form-item>
<u-form-item label="反面" prop="img2">
<image :src="orderSourceData[1]" v-if="orderSourceData[1]" mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(2)"></image>
<image src="../../static/not-dealer.png" v-else mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(2)"></image>
<u-form-item label="身份证反面" prop="img2" required>
<image :src="orderSourceData[1]" v-if="orderSourceData[1]" mode="aspectFill" class="upload-img" @click="chooseImage(2)"></image>
<view v-else class="upload-placeholder" @click="chooseImage(2)">
<text class="placeholder-icon">+</text>
<text class="placeholder-text">点击上传身份证反面国徽面</text>
</view>
</u-form-item>
</view>
<!-- 标题 -->
<view class="page-title">其他</view>
<view class="page-title">其他凭证必传</view>
<!-- 表单组件 -->
<view class="form-wrapper">
<u-form-item label="人车合照" prop="img3">
<image :src="orderSourceData[2]" v-if="orderSourceData[2]" mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(3)"></image>
<image src="../../static/not-dealer.png" v-else mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(3)"></image>
<u-form-item label="人车合照" prop="img3" required>
<image :src="orderSourceData[2]" v-if="orderSourceData[2]" mode="aspectFill" class="upload-img" @click="chooseImage(3)"></image>
<view v-else class="upload-placeholder" @click="chooseImage(3)">
<text class="placeholder-icon">+</text>
<text class="placeholder-text">点击上传人车合照</text>
</view>
</u-form-item>
<u-form-item label="车子照片" prop="img4">
<image :src="orderSourceData[3]" v-if="orderSourceData[3]" mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(4)"></image>
<image src="../../static/not-dealer.png" v-else mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(4)"></image>
<u-form-item label="车子照片" prop="img4" required>
<image :src="orderSourceData[3]" v-if="orderSourceData[3]" mode="aspectFill" class="upload-img" @click="chooseImage(4)"></image>
<view v-else class="upload-placeholder" @click="chooseImage(4)">
<text class="placeholder-icon">+</text>
<text class="placeholder-text">点击上传车子照片</text>
</view>
</u-form-item>
<u-form-item label="安装照片" prop="img5">
<image :src="orderSourceData[4]" v-if="orderSourceData[4]" mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(5)"></image>
<image src="../../static/not-dealer.png" v-else mode="aspectFill" style="width: 300rpx;height: 200rpx" @click="chooseImage(5)"></image>
<u-form-item label="安装照片" prop="img5" required>
<image :src="orderSourceData[4]" v-if="orderSourceData[4]" mode="aspectFill" class="upload-img" @click="chooseImage(5)"></image>
<view v-else class="upload-placeholder" @click="chooseImage(5)">
<text class="placeholder-icon">+</text>
<text class="placeholder-text">点击上传安装完成照片</text>
</view>
</u-form-item>
</view>
@@ -119,6 +134,15 @@
}],
}
// 图片槽位说明(与 orderSourceData 下标一一对应)
const imageSlotLabels = ['身份证正面', '身份证反面', '人车合照', '车子照片', '安装照片']
// 过滤后端返回的错误码尾巴(如 ".FAILUREFP03333"),让提示更友好
function formatErrorMsg(msg) {
if (!msg) return '操作失败,请稍后重试'
return String(msg).replace(/\.[A-Z0-9]{4,}\s*$/, '').trim() || '操作失败,请稍后重试'
}
export default {
data() {
return {
@@ -243,13 +267,23 @@
}, */
// 表单提交
handleSubmit() {
// if(orderSourceData.length < 5) {
// return
// }
const that = this
const app = this
const { orderId,orderSourceData,captcha,receiptPhone,emergentUser,officeAddress, homeAddress } = app
const { orderId, orderSourceData, captcha, receiptPhone, emergentUser, officeAddress, homeAddress } = app
// 1) 校验 5 张图是否都已上传
const missingIndex = []
for (let i = 0; i < imageSlotLabels.length; i++) {
if (!orderSourceData[i]) {
missingIndex.push(i)
}
}
if (missingIndex.length > 0) {
// 优先提示最关键的那张(身份证正面)
const firstMissing = missingIndex.includes(0) ? 0 : missingIndex[0]
that.$toast(`请先上传${imageSlotLabels[firstMissing]}`, 2500)
return
}
receiptOrder({
orderId: orderId,
@@ -267,10 +301,10 @@
that.$navTo('pages/order/index',{}, 'redirectTo')
}, 1000)
} else {
that.$toast(result.message)
that.$toast(formatErrorMsg(result.message), 2500)
}
}).catch(err => {
that.$toast(err.message)
that.$toast(formatErrorMsg((err && err.message) || '提交失败,请稍后重试'), 2500)
})
},
@@ -286,6 +320,8 @@
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success(chooseImageRes) {
const tempFilePaths = chooseImageRes.tempFilePaths;
const slotLabel = imageSlotLabels[id - 1] || '图片'
uni.showLoading({ title: '上传中...', mask: true })
uploadFile({
filePath: tempFilePaths[0],
@@ -300,6 +336,11 @@
app.orderSourceData.push(res.data.url)
}
console.log('app.orderSourceData', app.orderSourceData)
app.$success(`${slotLabel}上传成功`)
}).catch(err => {
app.$toast(formatErrorMsg((err && (err.errMsg || err.message)) || `${slotLabel}上传失败,请重试`), 2500)
}).finally(() => {
uni.hideLoading()
})
}
@@ -334,6 +375,39 @@
background: #fff;
}
/* 上传图片(已上传时) */
.upload-img {
width: 300rpx;
height: 200rpx;
border-radius: 8rpx;
}
/* 上传占位符(未上传时) */
.upload-placeholder {
width: 300rpx;
height: 200rpx;
border: 2rpx dashed #c8c8c8;
border-radius: 8rpx;
background: #fafafa;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #999;
.placeholder-icon {
font-size: 56rpx;
line-height: 1;
color: #c0c4cc;
}
.placeholder-text {
font-size: 22rpx;
margin-top: 10rpx;
color: #909399;
}
}
/* 底部操作栏 */
.footer {

86
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,86 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@dcloudio/uni-ui':
specifier: ^1.4.28
version: 1.5.12
async-validator:
specifier: ^4.2.5
version: 4.2.5
dayjs:
specifier: ^1.11.10
version: 1.11.21
luch-request:
specifier: ^3.0.8
version: 3.1.1
devDependencies:
js:
specifier: ^0.1.0
version: 0.1.0
js-md5:
specifier: ^0.7.3
version: 0.7.3
packages:
'@dcloudio/types@2.6.12':
resolution: {integrity: sha512-mrCMwcINy1IFjU9VUqLeWBkj404yWs5paLDttBcA+eqUjanuUQbBcTVPqlrGgkyzLXDcV2oDDZRSNxNpXi4kMQ==}
'@dcloudio/uni-ui@1.5.12':
resolution: {integrity: sha512-mGDl2OZSz7D8xcUAzJegWDHOqB4MEFBSW9Esb/oJiu2/3Gk9+P/Z4bA4JZ9jv9VWBYbMrYwaTfK1Z728kABdYg==}
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
commander@1.1.1:
resolution: {integrity: sha512-71Rod2AhcH3JhkBikVpNd0pA+fWsmAaVoti6OR38T76chA7vE3pSerS0Jor4wDw+tOueD2zLVvFOw5H0Rcj7rA==}
engines: {node: '>= 0.6.x'}
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
js-md5@0.7.3:
resolution: {integrity: sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==}
js@0.1.0:
resolution: {integrity: sha512-ZBbGYOpact8QAH9RprFWL4RAESYwbDodxiuDjOnzwzzk9pBzKycoifGuUrHHcDixE/eLMKPHRaXenTgu1qXBqA==}
hasBin: true
keypress@0.1.0:
resolution: {integrity: sha512-x0yf9PL/nx9Nw9oLL8ZVErFAk85/lslwEP7Vz7s5SI1ODXZIgit3C5qyWjw4DxOuO/3Hb4866SQh28a1V1d+WA==}
luch-request@3.1.1:
resolution: {integrity: sha512-p7+mlcEtgRcd0OfXC4XZbyiwSr1XgCeqNT7LlVUjnk7InYl/8d5Rk7BUqAYNA2WRafI1wRIUQWRWZRpeUwWR0w==}
snapshots:
'@dcloudio/types@2.6.12': {}
'@dcloudio/uni-ui@1.5.12': {}
async-validator@4.2.5: {}
commander@1.1.1:
dependencies:
keypress: 0.1.0
dayjs@1.11.21: {}
js-md5@0.7.3: {}
js@0.1.0:
dependencies:
commander: 1.1.1
keypress@0.1.0: {}
luch-request@3.1.1:
dependencies:
'@dcloudio/types': 2.6.12