Explorar el Código

fix(uniapp): 扫码链路 4 个关键 bug

1. request.ts: 加 safeStringifyLongIds 全局拦截
   解析前用正则把 long-id 字段(id/taskId/userId/...)从数字转字符串,
   解决雪花 ID (2.08e18) 超过 Number.MAX_SAFE_INTEGER (9.0e15)
   导致的 JS Number 精度丢失(JSON.stringify 末位被 round 掉)

2. api/inspection.ts: verifyQrCode 字段名修正
   data: { taskId, qrContent } → { taskId, qrCode: qrContent }
   之前移动端发的字段名跟后端 VerifyQrBo 不一致,qrCode 收到 null

3. scan/index.vue (巡检 + 品控): taskId 改 string
   防止 onLoad Number(q.taskId) 精度丢失

4. scan/index.vue (巡检 + 品控): 扫码流程改进
   - lastFailedQr 冷却 1.5s,防止 raf 反复命中同 QR 弹错
   - resumeScan: 不匹配/异常时清空结果 + 重启 H5 scanner
     (修 bug: h5StopScanner 把 h5Stream 置 null,resumeScan 不能用 h5Stream 作判断)
   - 位置获取独立 try-catch,失败不阻塞扫码流程
     (H5 端用户没授权位置时不再弹'校验失败')
   - 错误消息细分:二维码校验失败带具体原因

5. .gitignore: 加 cert/(自签 H5 证书,不入库)

参考: api/qualityInspection.ts 之前就是对的(qrCode),证明此次修复对齐
yangjingjing hace 13 horas
padre
commit
7ec70df5ef

+ 1 - 0
.gitignore

@@ -29,3 +29,4 @@ yarn-error.log*
 # 临时文件
 *.tmp
 *.bak
+cert/

+ 9 - 2
src/api/inspection.ts

@@ -98,12 +98,19 @@ export function submitPunch(payload: PunchSubmitPayload): Promise<any> {
 }
 
 // ===== 校验二维码与任务绑定关系 =====
-export function verifyQrCode(taskId: number, qrContent: string): Promise<boolean> {
+/**
+ * 注意:taskId 必须传 string,不要传 number!
+ * 雪花 ID (2.08e18) 超过 JS Number.MAX_SAFE_INTEGER (9.0e15),
+ * number 类型会精度丢失(JSON 序列化末几位被 round 掉,后端查不到任务)
+ *
+ * 字段名 taskId/qrCode 必须与后端 VerifyQrBo 一致,否则 Bo 反序列化收不到
+ */
+export function verifyQrCode(taskId: string, qrContent: string): Promise<boolean> {
   return request<boolean>({
     url: '/risk/inspectionTask/verifyQr',
     method: 'POST',
     header: { 'Content-Type': 'application/json;charset=utf-8' },
-    data: { taskId, qrContent }
+    data: { taskId, qrCode: qrContent }
   })
 }
 

+ 66 - 26
src/pages/inspection/scan/index.vue

@@ -64,7 +64,9 @@ import { verifyQrCode } from '@/api/inspection'
 // jsQR:纯 JS QR 识别库,作为 BarcodeDetector 不可用时的 fallback(iOS Safari < 17 / 老 WebView)
 import jsQR from 'jsqr'
 
-const taskId = ref<number>(0)
+const taskId = ref<string>('')
+// 注意:taskId 必须用 string 存!雪花 ID (2.08e18) 超过 JS Number.MAX_SAFE_INTEGER (9.0e15),
+// 用 number 会精度丢失(JSON 序列化时末几位被 round 掉,后端查不到记录)
 const scanResult = ref<string>('')
 const tipText = ref<string>('请将摄像头对准巡检点位的二维码')
 const scanRetry = ref<boolean>(false)
@@ -78,7 +80,8 @@ let h5RafId: number | null = null
 const slot = ref<string>('')
 
 onLoad((q) => {
-  taskId.value = Number(q?.taskId || 0)
+  // taskId 用 String 存,不要 Number() 转(雪花 ID 精度丢失)
+  taskId.value = String(q?.taskId || '')
   slot.value = String(q?.slot || '')
   // 自动启动扫码(大部分设备直接可用)
   // iOS Safari 若因手势问题失败,会显示"点击重试"按钮
@@ -290,26 +293,61 @@ onUnmounted(() => {
   showPhotoScan.value = false
 })
 
+// ===== 扫码冷却:同一 QR 内容在 1.5s 内不重复弹错,避免 raf 反复命中弹 toast 干扰体验 =====
+let lastFailedQr: { content: string; at: number } | null = null
+const QR_COOLDOWN_MS = 1500
+
+/** 不匹配/位置超界/异常时:清空结果 + 重启 scanner,让用户能直接继续扫下一个 */
+function resumeScan() {
+  scanResult.value = ''
+  // h5StartScanner() 内部会重新 getUserMedia + 启动 raf 循环
+  // (h5Stream 此时已被 h5StopScanner 置 null,所以不要用 h5Stream 作判断)
+  h5StartScanner()
+}
+
 async function onScanSuccess(qrContent: string) {
+  // 同一 QR 在冷却期内不重复处理(raf 高速识别,避免反复 toast)
+  const now = Date.now()
+  if (lastFailedQr && lastFailedQr.content === qrContent && now - lastFailedQr.at < QR_COOLDOWN_MS) {
+    return
+  }
   uni.showLoading({ title: '校验中…' })
+
+  // ========== 1. 二维码校验(独立 try-catch,定位错误不能掩盖) ==========
+  let valid = false
   try {
-    // 1. 校验二维码与任务绑定关系
-    // 走统一 request 后:data 即后端 R.data(这里后端直接返回 boolean)
-    const valid = await verifyQrCode(taskId.value, qrContent)
-    if (!valid) {
-      uni.hideLoading()
-      uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })
-      return
-    }
+    valid = await verifyQrCode(taskId.value, qrContent)
+  } catch (e: any) {
+    uni.hideLoading()
+    uni.showToast({ title: '二维码校验失败:' + (e?.message || '网络异常'), icon: 'none', duration: 2500 })
+    lastFailedQr = { content: qrContent, at: Date.now() }
+    setTimeout(resumeScan, QR_COOLDOWN_MS)
+    return
+  }
+  if (!valid) {
+    uni.hideLoading()
+    uni.showToast({ title: '二维码与任务不匹配,请重试', icon: 'none', duration: 2000 })
+    lastFailedQr = { content: qrContent, at: Date.now() }
+    setTimeout(resumeScan, QR_COOLDOWN_MS)
+    return
+  }
 
-    // 2. 校验位置
+  // ========== 2. 位置校验(独立 try-catch + 失败不阻塞跳转) ==========
+  // 扫码本身已通过,位置错误不应该导致扫码流程卡死;
+  // 实际位置信息可以在 check 页提交时再获取,这里仅做软校验
+  let loc: { latitude: number; longitude: number } | null = null
+  try {
     const auth = await ensureLocationAuth()
-    if (!auth) {
-      uni.hideLoading()
-      return
+    if (auth) {
+      loc = await getCurrentLocation()
     }
-    const loc = await getCurrentLocation()
+  } catch (e: any) {
+    // H5 端用户拒绝位置 / 浏览器不支持 getLocation / 定位超时 —— 不阻塞扫码流程
+    console.warn('[scan] 获取位置失败(非阻塞):', e?.message || e)
+    loc = null
+  }
 
+  if (loc) {
     // 任务点位经纬度(实际项目从任务详情中获取)
     const taskLng = uni.getStorageSync(`task_lng_${taskId.value}`) || 0
     const taskLat = uni.getStorageSync(`task_lat_${taskId.value}`) || 0
@@ -323,21 +361,23 @@ async function onScanSuccess(qrContent: string) {
           icon: 'none',
           duration: 3000
         })
+        lastFailedQr = { content: qrContent, at: Date.now() }
+        setTimeout(resumeScan, 3000)
         return
       }
     }
-
-    uni.hideLoading()
-    // 3. 校验通过:跳转到打卡页
-    //    每日多时段任务:带上当前 slot
-    const slotQuery = slot.value ? `&slot=${encodeURIComponent(slot.value)}` : ''
-    uni.redirectTo({
-      url: `/pages/inspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}${slotQuery}`
-    })
-  } catch (e) {
-    uni.hideLoading()
-    uni.showToast({ title: '校验失败', icon: 'none' })
   }
+
+  // ========== 3. 通过:跳转到打卡页 ==========
+  uni.hideLoading()
+  //    每日多时段任务:带上当前 slot
+  //    没拿到位置也传 0,0;check 页提交时再申请位置
+  const slotQuery = slot.value ? `&slot=${encodeURIComponent(slot.value)}` : ''
+  const lng = loc?.longitude ?? 0
+  const lat = loc?.latitude ?? 0
+  uni.redirectTo({
+    url: `/pages/inspection/check/index?taskId=${taskId.value}&lng=${lng}&lat=${lat}${slotQuery}`
+  })
 }
 
 function onManualInput() {

+ 57 - 23
src/pages/qualityInspection/scan/index.vue

@@ -283,25 +283,59 @@ onUnmounted(() => {
   scanRetry.value = false
 })
 
+// ===== 扫码冷却:同一 QR 内容在 1.5s 内不重复弹错,避免 raf 反复命中弹 toast 干扰体验 =====
+let lastFailedQr: { content: string; at: number } | null = null
+const QR_COOLDOWN_MS = 1500
+
+/** 不匹配/位置超界/异常时:清空结果 + 重启 scanner,让用户能直接继续扫下一个 */
+function resumeScan() {
+  scanResult.value = ''
+  // h5StartBarcodeScanner() 内部会重新 getUserMedia + 启动 raf 循环
+  // (h5Stream 此时已被 h5StopScanner 置 null,所以不要用 h5Stream 作判断)
+  h5StartBarcodeScanner()
+}
+
 async function onScanSuccess(qrContent: string) {
+  // 同一 QR 在冷却期内不重复处理
+  const now = Date.now()
+  if (lastFailedQr && lastFailedQr.content === qrContent && now - lastFailedQr.at < QR_COOLDOWN_MS) {
+    return
+  }
   uni.showLoading({ title: '校验中…' })
+
+  // ========== 1. 二维码校验(独立 try-catch,定位错误不能掩盖) ==========
+  let valid = false
   try {
-    // 1. 校验二维码与任务绑定关系
-    const valid = await verifyQualityQrCode(taskId.value, qrContent)
-    if (!valid) {
-      uni.hideLoading()
-      uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })
-      return
-    }
+    valid = await verifyQualityQrCode(taskId.value, qrContent)
+  } catch (e: any) {
+    uni.hideLoading()
+    uni.showToast({ title: '二维码校验失败:' + (e?.message || '网络异常'), icon: 'none', duration: 2500 })
+    lastFailedQr = { content: qrContent, at: Date.now() }
+    setTimeout(resumeScan, QR_COOLDOWN_MS)
+    return
+  }
+  if (!valid) {
+    uni.hideLoading()
+    uni.showToast({ title: '二维码与任务不匹配,请重试', icon: 'none', duration: 2000 })
+    lastFailedQr = { content: qrContent, at: Date.now() }
+    setTimeout(resumeScan, QR_COOLDOWN_MS)
+    return
+  }
 
-    // 2. 校验位置
+  // ========== 2. 位置校验(独立 try-catch + 失败不阻塞跳转) ==========
+  // H5 端用户拒绝位置 / 浏览器不支持 / 定位超时,都不应该阻塞扫码流程
+  let loc: { latitude: number; longitude: number } | null = null
+  try {
     const auth = await ensureLocationAuth()
-    if (!auth) {
-      uni.hideLoading()
-      return
+    if (auth) {
+      loc = await getCurrentLocation()
     }
-    const loc = await getCurrentLocation()
+  } catch (e: any) {
+    console.warn('[quality-scan] 获取位置失败(非阻塞):', e?.message || e)
+    loc = null
+  }
 
+  if (loc) {
     // 任务点位经纬度(若未设置则跳过距离校验,仅获取位置坐标带到打卡页)
     if (taskLng && taskLat) {
       const limitMeter = await getScanRangeMeter()  // 系统配置
@@ -313,21 +347,21 @@ async function onScanSuccess(qrContent: string) {
           icon: 'none',
           duration: 3000
         })
+        lastFailedQr = { content: qrContent, at: Date.now() }
+        setTimeout(resumeScan, 3000)
         return
       }
     }
-
-    uni.hideLoading()
-    // 3. 校验通过:跳转到品控打卡页
-    //    每日多时段任务:带上当前 slot
-    const slotQuery = slot.value ? `&slot=${encodeURIComponent(slot.value)}` : ''
-    uni.redirectTo({
-      url: `/pages/qualityInspection/check/index?taskId=${taskId.value}&lng=${loc.longitude}&lat=${loc.latitude}${slotQuery}`
-    })
-  } catch (e) {
-    uni.hideLoading()
-    uni.showToast({ title: '校验失败', icon: 'none' })
   }
+
+  // ========== 3. 通过:跳转到品控打卡页 ==========
+  uni.hideLoading()
+  const slotQuery = slot.value ? `&slot=${encodeURIComponent(slot.value)}` : ''
+  const lng = loc?.longitude ?? 0
+  const lat = loc?.latitude ?? 0
+  uni.redirectTo({
+    url: `/pages/qualityInspection/check/index?taskId=${taskId.value}&lng=${lng}&lat=${lat}${slotQuery}`
+  })
 }
 
 function onManualInput() {

+ 43 - 1
src/utils/request.ts

@@ -12,6 +12,30 @@ import { getToken, removeToken } from './auth'
 // 标记正在跳转登录页,避免并发的 401 触发多次 reLaunch
 let redirecting = false
 
+/**
+ * 后端 Java Long 类型 id(雪花 ID) 超过 JS Number.MAX_SAFE_INTEGER (2^53 ≈ 9.0e15),
+ * 标准 JSON.parse 会把它解析为 number 并截断末位 → 后端 selectById 查不到记录 → 各种"不匹配"/404
+ *
+ * 解决方案:解析前先用正则把"长整型 id 字段"的值从数字转字符串,这样后续所有使用都是 string,精度无损
+ *
+ * 字段列表是后端常见主键/外键名,加新字段时在这里追加
+ */
+const LONG_ID_KEYS = [
+  'id', 'taskId', 'userId', 'deptId', 'storeId',
+  'inspectorId', 'auditorId', 'itemId', 'recordId',
+  'parentId', 'templateId', 'formId', 'postId',
+  'roleId', 'menuId', 'fileId', 'certId', 'personnelId',
+  'planId', 'reportId', 'photoId'
+]
+const LONG_ID_REGEX = new RegExp(
+  `"(${LONG_ID_KEYS.join('|')})"\\s*:\\s*(\\d{16,})`,
+  'g'
+)
+function safeStringifyLongIds(jsonText: string): string {
+  // 把超过 16 位的数字(必然超过 2^53)包成字符串
+  return jsonText.replace(LONG_ID_REGEX, (_m, key, val) => `"${key}":"${val}"`)
+}
+
 function redirectToLogin() {
   if (redirecting) return
   redirecting = true
@@ -95,6 +119,17 @@ export function request<T = any>(opts: RequestOptions): Promise<T> {
           reject(new Error(`网络异常(${status})`))
           return
         }
+        // === 防雪花 ID 精度丢失:解析前把长整型 id 字段转字符串 ===
+        // res.data 可能是 string(uniapp H5 + 非自动 JSON 解析)或已解析对象
+        // 字符串场景:先做正则转换再 JSON.parse
+        if (typeof res.data === 'string') {
+          try {
+            const safeText = safeStringifyLongIds(res.data)
+            res.data = JSON.parse(safeText)
+          } catch {
+            // 解析失败,按原文本当字符串返回(上层会自行处理)
+          }
+        }
         // 后端 R<T> 结构
         const body = res.data as BizResponse | undefined
         if (!body || typeof body !== 'object' || typeof body.code === 'undefined') {
@@ -166,7 +201,14 @@ export function uploadFile<T = any>(opts: {
           return
         }
         try {
-          const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+          let body: any
+          if (typeof res.data === 'string') {
+            // 同样做长整型 id 字符串化(防雪花 ID 精度丢失)
+            const safeText = safeStringifyLongIds(res.data)
+            body = JSON.parse(safeText)
+          } else {
+            body = res.data
+          }
           if (body?.code === 200) {
             resolve(body.data as T)
           } else {