仰望星辰工作室

refactor(realname): 优化实名认证OCR流程与审核逻辑

Y yang1145 提交于 2026-08-11 00:30 · 7aba758 ·父提交 763679f
refactor(realname): 优化实名认证OCR流程与审核逻辑

1. 调整OCR不一致时的审核状态为待人工复核而非直接拒绝
2. 重构前端图片处理流程:
   - 将图片压缩最大边长从1280改为1024,压缩质量调整为0.8
   - 添加灰度拉伸预处理提升OCR识别率
   - 优化压缩后返回结果结构,同时保留blob和base64
3. 升级姓名提取逻辑,使用5级降级策略提升准确率
4. 移除FormData提交,改用x-www-form-urlencoded格式传输base64图片
5. 重构OCR加载、工具函数结构,优化代码可读性
2 个文件变更 +128 -69 13172193298@163.com
•app_plugins/realname/assets/realname.js +126 -66
•app_plugins/realname/lib/auth.php +2 -3
变更内容
diff --git a/app_plugins/realname/assets/realname.js b/app_plugins/realname/assets/realname.js
index 3043a99..3281132 100644
--- a/app_plugins/realname/assets/realname.js
+++ b/app_plugins/realname/assets/realname.js
@@ -1,20 +1,20 @@
 /* ============================================================
  * realname 插件 - 前端逻辑
- * 图片压缩 → 本地 OCR(tesseract.js,同源模型)→ 回填 → 提交
+ * 图片压缩(1024px/0.8) → 灰度对比度预处理 → 本地 OCR(tesseract.js)
+ * → 5级姓名提取 → base64 回传提交(x-www-form-urlencoded,无 FormData)
  * ============================================================ */
 (function () {
   'use strict';
 
   var $ = function (id) { return document.getElementById(id); };
-  var RN = window.RN_OCR_URL || '';
 
-  /* ---------- 图片压缩(canvas,最长边 1280) ---------- */
+  /* ---------- 图片压缩(canvas,返回 { blob, base64 }) ---------- */
   function compressImage(file) {
     return new Promise(function (resolve, reject) {
       var img = new Image();
       var url = URL.createObjectURL(file);
       img.onload = function () {
-        var MAX = 1280;
+        var MAX = 1024;
         var w = img.width, h = img.height;
         if (w > MAX || h > MAX) {
           if (w >= h) { h = Math.round(h * MAX / w); w = MAX; }
@@ -26,26 +26,62 @@
         ctx.drawImage(img, 0, 0, w, h);
         canvas.toBlob(function (blob) {
           URL.revokeObjectURL(url);
-          if (blob) resolve(blob); else reject(new Error('compress failed'));
-        }, 'image/jpeg', 0.85);
+          if (!blob) return reject(new Error('compress failed'));
+          var reader = new FileReader();
+          reader.onload = function () { resolve({ blob: blob, base64: reader.result }); };
+          reader.onerror = function () { reject(new Error('read failed')); };
+          reader.readAsDataURL(blob);
+        }, 'image/jpeg', 0.8);
       };
       img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('image load failed')); };
       img.src = url;
     });
   }
 
+  /* ---------- 灰度化 + 对比度拉伸 ---------- */
+  function preprocessForOcr(inputBlob) {
+    return new Promise(function (resolve, reject) {
+      var img = new Image();
+      var url = URL.createObjectURL(inputBlob);
+      img.onload = function () {
+        var canvas = document.createElement('canvas');
+        canvas.width = img.width; canvas.height = img.height;
+        var ctx = canvas.getContext('2d');
+        ctx.drawImage(img, 0, 0);
+        URL.revokeObjectURL(url);
+        var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        var d = imageData.data;
+        var min = 255, max = 0;
+        for (var i = 0; i < d.length; i += 4) {
+          var gray = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
+          d[i] = d[i + 1] = d[i + 2] = gray;
+          if (gray < min) min = gray;
+          if (gray > max) max = gray;
+        }
+        if (max > min) {
+          var range = max - min;
+          for (var j = 0; j < d.length; j += 4) {
+            var v = ((d[j] - min) / range) * 255;
+            d[j] = d[j + 1] = d[j + 2] = v;
+          }
+        }
+        ctx.putImageData(imageData, 0, 0);
+        canvas.toBlob(function (b) { b ? resolve(b) : reject(new Error('preprocess failed')); }, 'image/png');
+      };
+      img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('load failed')); };
+      img.src = url;
+    });
+  }
+
   /* ---------- 上传框交互 ---------- */
   var boxes = document.querySelectorAll('.rn-upload-box');
   var files = { front: null, back: null, hand: null };
+  var base64Images = { front: '', back: '', hand: '' };
 
-  function setPreview(box, type, blobOrUrl) {
+  function setPreview(box, type, blob) {
     var img = box.querySelector('.rn-upload-preview');
     if (!img) return;
-    if (blobOrUrl instanceof Blob) {
-      img.src = URL.createObjectURL(blobOrUrl);
-    } else {
-      img.src = blobOrUrl;
-    }
+    img.src = URL.createObjectURL(blob);
     box.classList.add('has-file');
   }
 
@@ -58,37 +94,24 @@
       var file = input.files && input.files[0];
       if (!file) return;
       if (file.size > 8 * 1024 * 1024) { alert('图片不能超过 8MB'); input.value = ''; return; }
-      compressImage(file).then(function (blob) {
-        files[type] = blob;
-        setPreview(box, type, blob);
-        // 正面图选择后触发 OCR
-        if (type === 'front') runOcr(blob);
+      compressImage(file).then(function (result) {
+        files[type] = result.blob;
+        base64Images[type] = result.base64;
+        setPreview(box, type, result.blob);
+        if (type === 'front') runOcr(result.blob);
       }).catch(function () { alert('图片处理失败,请更换图片'); });
     });
 
     clear.addEventListener('click', function (e) {
       e.stopPropagation();
       files[type] = null;
+      base64Images[type] = '';
       input.value = '';
       box.classList.remove('has-file');
     });
   });
 
-  /* ---------- 本地 OCR(tesseract.js) ---------- */
-  var ocrRunning = false;
-
-  function loadOcrLib() {
-    return new Promise(function (resolve, reject) {
-      if (window.Tesseract) { resolve(window.Tesseract); return; }
-      if (!RN) { reject(new Error('OCR 库路径未配置')); return; }
-      var s = document.createElement('script');
-      s.src = RN;
-      s.onload = function () { window.Tesseract ? resolve(window.Tesseract) : reject(new Error('OCR 加载失败')); };
-      s.onerror = function () { reject(new Error('OCR 库加载失败')); };
-      document.head.appendChild(s);
-    });
-  }
-
+  /* ---------- 身份证校验 ---------- */
   function idcardCheck(id) {
     if (!/^\d{17}[\dXx]$/.test(id)) return false;
     var w = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
@@ -100,43 +123,75 @@
 
   function extractIdCard(text) {
     var t = (text || '').replace(/\s+/g, '');
-    var m = t.match(/\d{17}[\dXx]/g) || [];
-    for (var i = 0; i < m.length; i++) {
-      if (idcardCheck(m[i])) return m[i].toUpperCase();
+    var matches = t.match(/\d{17}[\dXx]/g) || [];
+    for (var i = 0; i < matches.length; i++) {
+      if (idcardCheck(matches[i])) return matches[i].toUpperCase();
     }
     return '';
   }
 
+  /* ---------- 姓名提取(5级降级策略) ---------- */
   function extractName(text) {
-    var lines = (text || '').split('\n');
+    var raw = (text || '').replace(/\s+/g, '');
+    var m;
+    // 策略1: "姓名" + 冒号 + 中文名
+    m = raw.match(/姓名[::]([\u4e00-\u9fa5·]{2,4})/);
+    if (m) return m[1];
+    // 策略2: "姓名" 后紧跟中文名(无冒号),排除后跟"性"
+    m = raw.match(/姓名(?!性)([\u4e00-\u9fa5·]{2,4})/);
+    if (m) return m[1];
+    // 策略3: "姓"和"名"被噪声分隔
+    m = raw.match(/姓.*?名.*?([\u4e00-\u9fa5·]{2,4})/);
+    if (m) return m[1];
+    // 策略4: 逐行找含"名"的行
+    var lines = (text || '').split(/[\r\n]+/);
     for (var i = 0; i < lines.length; i++) {
-      var t = lines[i].replace(/\s+/g, '');
-      var m = t.match(/姓名[::]?([\u4e00-\u9fa5·]{1,10})/);
-      if (m && m[1]) return m[1];
-      if (t.indexOf('姓名') >= 0) {
-        var rest = t.replace(/姓名[::]?/, '');
-        if (/^[\u4e00-\u9fa5·]{1,10}$/.test(rest)) return rest;
+      var clean = lines[i].replace(/\s+/g, '');
+      if (clean.indexOf('名') >= 0 && clean.indexOf('民族') < 0) {
+        var nm = clean.match(/名.*?([\u4e00-\u9fa5·]{2,4})/);
+        if (nm) return nm[1];
       }
     }
+    // 策略5: 全文 "姓名" 后 50 字符内找
+    m = raw.match(/姓名.{0,50}?([\u4e00-\u9fa5·]{2,4})/);
+    if (m) return m[1];
     return '';
   }
 
+  /* ---------- 本地 OCR ---------- */
+  var ocrRunning = false;
+
+  function loadOcrLib() {
+    return new Promise(function (resolve, reject) {
+      if (window.Tesseract) { resolve(window.Tesseract); return; }
+      var ocrUrl = window.RN_OCR_URL;
+      if (!ocrUrl) { reject(new Error('OCR 库路径未配置')); return; }
+      var s = document.createElement('script');
+      s.src = ocrUrl;
+      s.onload = function () { window.Tesseract ? resolve(window.Tesseract) : reject(new Error('OCR 加载失败')); };
+      s.onerror = function () { reject(new Error('OCR 库加载失败')); };
+      document.head.appendChild(s);
+    });
+  }
+
   function runOcr(blob) {
     if (ocrRunning) return;
     var state = $('rnOcrState');
-    var box = $('rnFrontBox');
     if (state) state.style.display = 'flex';
     ocrRunning = true;
 
     loadOcrLib().then(function (Tesseract) {
-      var options = {
-        logger: function () { /* 静默进度 */ }
-      };
-      if (window.RN_OCR_WORKER_URL) options.workerPath = window.RN_OCR_WORKER_URL;
-      if (window.RN_OCR_CORE_URL) options.corePath = window.RN_OCR_CORE_URL;
-      if (window.RN_OCR_LANG_URL) options.langPath = window.RN_OCR_LANG_URL;
-
-      return Tesseract.recognize(blob, 'chi_sim', options);
+      return preprocessForOcr(blob).then(function (processed) {
+        var options = {
+          logger: function () {},
+          preserve_interword_spaces: '1',
+          tessedit_pageseg_mode: '6'
+        };
+        if (window.RN_OCR_WORKER_URL) options.workerPath = window.RN_OCR_WORKER_URL;
+        if (window.RN_OCR_CORE_URL) options.corePath = window.RN_OCR_CORE_URL;
+        if (window.RN_OCR_LANG_URL) options.langPath = window.RN_OCR_LANG_URL;
+        return Tesseract.recognize(processed, 'chi_sim', options);
+      });
     }).then(function (result) {
       var text = (result && result.data && result.data.text) || '';
       var idcard = extractIdCard(text);
@@ -167,7 +222,7 @@
     });
   }
 
-  /* ---------- 表单提交 ---------- */
+  /* ---------- 表单提交(base64 x-www-form-urlencoded) ---------- */
   var form = $('rnForm');
   var submitBtn = $('rnSubmit');
 
@@ -180,26 +235,31 @@
     if (!/^[\u4e00-\u9fa5·]{2,20}$/.test(realName)) { alert('请填写正确的姓名'); return; }
     if (!/^1[3-9]\d{9}$/.test(phone)) { alert('请填写正确的 11 位手机号'); return; }
     if (!/^\d{17}[\dX]$/.test(idCard)) { alert('请填写正确的 18 位身份证号'); return; }
-    if (!files.front) { alert('请上传身份证正面照片'); return; }
-    if (!files.back) { alert('请上传身份证反面照片'); return; }
-    if (!files.hand) { alert('请上传手持身份证照片'); return; }
+    if (!base64Images.front) { alert('请上传身份证正面照片'); return; }
+    if (!base64Images.back) { alert('请上传身份证反面照片'); return; }
+    if (!base64Images.hand) { alert('请上传手持身份证照片'); return; }
 
     submitBtn.disabled = true;
     submitBtn.textContent = '提交中…';
 
-    var fd = new FormData();
-    fd.append('real_name', realName);
-    fd.append('phone', phone);
-    fd.append('id_card', idCard);
-    fd.append('ocr_name', $('rnOcrNameVal').value || '');
-    fd.append('ocr_id_card', $('rnOcrIdCardVal').value || '');
-    fd.append('front_img', files.front, 'front.jpg');
-    fd.append('back_img', files.back, 'back.jpg');
-    fd.append('hand_img', files.hand, 'hand.jpg');
+    var body = new URLSearchParams();
+    body.append('real_name', realName);
+    body.append('phone', phone);
+    body.append('id_card', idCard);
+    body.append('ocr_name', ($('rnOcrNameVal').value || ''));
+    body.append('ocr_id_card', ($('rnOcrIdCardVal').value || ''));
+    body.append('front_img', base64Images.front);
+    body.append('back_img', base64Images.back);
+    body.append('hand_img', base64Images.hand);
     var tokenEl = $('rnCsrf');
-    if (tokenEl && tokenEl.value) fd.append('_csrf', tokenEl.value);
+    if (tokenEl && tokenEl.value) body.append('_csrf', tokenEl.value);
 
-    fetch(window.RN_SUBMIT_URL, { method: 'POST', body: fd, credentials: 'same-origin' })
+    fetch(window.RN_SUBMIT_URL, {
+      method: 'POST',
+      body: body,
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
+      credentials: 'same-origin'
+    })
       .then(function (r) { return r.json().catch(function () { return { code: '响应解析失败' }; }); })
       .then(function (res) {
         if (res && res.code === 'ok') {
diff --git a/app_plugins/realname/lib/auth.php b/app_plugins/realname/lib/auth.php
index 4c383ba..3695033 100644
--- a/app_plugins/realname/lib/auth.php
+++ b/app_plugins/realname/lib/auth.php
@@ -398,11 +398,10 @@ function realname_auto_audit($data)
 	}
 	// 5. OCR 一致性:身份证号
 	if ($ocr_id_card !== '' && $ocr_id_card !== $id_card) {
-		return ['status' => 'rejected', 'note' => 'OCR 识别的身份证号与填写不一致'];
+		return ['status' => 'pending', 'note' => 'OCR 身份证号与填写不一致,已转人工复核'];
 	}
 	// 6. OCR 一致性:姓名
 	if ($allow_name_diff) {
-		// 允许不一致 → 直接通过
 		if ($ocr_id_card === '') {
 			return ['status' => 'pending', 'note' => 'OCR 身份证号识别失败,转人工复核'];
 		}
@@ -412,7 +411,7 @@ function realname_auto_audit($data)
 		return ['status' => 'pending', 'note' => 'OCR 姓名识别失败,转人工复核'];
 	}
 	if ($ocr_name !== $real_name) {
-		return ['status' => 'rejected', 'note' => 'OCR 识别的姓名与填写不一致'];
+		return ['status' => 'pending', 'note' => 'OCR 姓名与填写不一致,已转人工复核'];
 	}
 	if ($ocr_id_card === '') {
 		return ['status' => 'pending', 'note' => 'OCR 身份证号识别失败,转人工复核'];