仰望星辰工作室

feat(realname): 实现新版实名认证插件功能

Y yang1145 提交于 2026-08-11 00:19 · 763679f ·父提交 2a01e33
feat(realname): 实现新版实名认证插件功能

重构实名认证接口,改用base64上传图片并优化OCR识别,添加侧边栏入口
5 个文件变更 +211 -50 13172193298@163.com
•app_plugins/realname/bootstrap.php +4 -4
•app_plugins/realname/lib/auth.php +112 -11
•templates/tdesign/spa/src/account/api/plugins.js +2 -12
•templates/tdesign/spa/src/account/layouts/AccountLayout.vue +8 -0
•templates/tdesign/spa/src/account/views/RealnameView.vue +85 -23
变更内容
diff --git a/app_plugins/realname/bootstrap.php b/app_plugins/realname/bootstrap.php
index 9ebc318..73fa81c 100644
--- a/app_plugins/realname/bootstrap.php
+++ b/app_plugins/realname/bootstrap.php
@@ -112,11 +112,11 @@ mnbt_register_route('POST', '/realname/api/submit', function ($params, $ctx) {
 	$idChk = realname_idcard_validate($id_card);
 	if (!$idChk['ok']) realname_json($idChk['msg']);
 
-	// 照片上传(身份证正面必传,反面与手持按配置)
+	// 照片上传(base64 格式,前端 canvas 压缩后传来)
 	$require_hand = (bool)realname_opt('require_hand_photo', true);
-	$front = realname_save_photo((int)$user['id'], 'front_img');
-	$back  = realname_save_photo((int)$user['id'], 'back_img');
-	$hand  = realname_save_photo((int)$user['id'], 'hand_img');
+	$front = realname_save_photo_base64((int)$user['id'], 'front', $_POST['front_img'] ?? '');
+	$back  = realname_save_photo_base64((int)$user['id'], 'back',  $_POST['back_img'] ?? '');
+	$hand  = realname_save_photo_base64((int)$user['id'], 'hand',  $_POST['hand_img'] ?? '');
 	if ($front === '') realname_json('身份证正面照片上传失败(仅支持 jpg/png,≤8MB)');
 	if ($back === '') realname_json('身份证反面照片上传失败(仅支持 jpg/png,≤8MB)');
 	if ($require_hand && $hand === '') realname_json('手持身份证照片上传失败(仅支持 jpg/png,≤8MB)');
diff --git a/app_plugins/realname/lib/auth.php b/app_plugins/realname/lib/auth.php
index 1d5f5ec..4c383ba 100644
--- a/app_plugins/realname/lib/auth.php
+++ b/app_plugins/realname/lib/auth.php
@@ -186,6 +186,51 @@ function realname_save_photo($user_id, $file_key)
 	return $name;
 }
 
+/**
+ * 处理 base64 编码的照片(前端 canvas 压缩后传来),解码后落盘。
+ * @param int    $user_id
+ * @param string $prefix  文件名前缀(front/back/hand)
+ * @param string $b64     完整的 data URL(data:image/jpeg;base64,...)
+ * @return string  随机文件名,失败返回 ''
+ */
+function realname_save_photo_base64($user_id, $prefix, $b64)
+{
+	if ($b64 === '' || strlen($b64) > 2 * 1024 * 1024) {
+		return ''; // base64 字符串超过 2MB 说明原图太大
+	}
+	// 解析 data:image/xxx;base64,...
+	if (!preg_match('#^data:image/(jpe?g|png);base64,(.+)$#i', $b64, $m)) {
+		return '';
+	}
+	$mimeType = strtolower($m[1]) === 'png' ? 'image/png' : 'image/jpeg';
+	$raw = base64_decode($m[2], true);
+	if ($raw === false || strlen($raw) > 8 * 1024 * 1024) {
+		return '';
+	}
+	// 验证解码后是有效图片
+	$tmp = tempnam(sys_get_temp_dir(), 'rn');
+	if ($tmp === false || file_put_contents($tmp, $raw) === false) {
+		return '';
+	}
+	$info = @getimagesize($tmp);
+	if ($info === false) {
+		@unlink($tmp);
+		return '';
+	}
+	if (!in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
+		@unlink($tmp);
+		return '';
+	}
+	$ext = $info[2] === IMAGETYPE_PNG ? 'png' : 'jpg';
+	$name = $prefix . '_' . bin2hex(random_bytes(12)) . '.' . $ext;
+	$dir = realname_user_data_dir($user_id);
+	if (!rename($tmp, $dir . '/' . $name)) {
+		@unlink($tmp);
+		return '';
+	}
+	return $name;
+}
+
 /** 根据相对文件名返回照片绝对路径。 */
 function realname_photo_path($user_id, $filename)
 {
@@ -226,6 +271,39 @@ function realname_save_auth($user, $data)
 	$user_id = (int)$user['id'];
 	$now = realname_now();
 
+	// 确保表存在
+	static $tableReady = false;
+	if (!$tableReady) {
+		$tableReady = true;
+		$ok = $DB->query(
+			"CREATE TABLE IF NOT EXISTS `plg_realname_auth` ("
+			. "`id` int(11) NOT NULL AUTO_INCREMENT,"
+			. "`user_id` int(11) NOT NULL,"
+			. "`username` varchar(64) NOT NULL DEFAULT '',"
+			. "`real_name` varchar(64) NOT NULL DEFAULT '',"
+			. "`phone` varchar(20) NOT NULL DEFAULT '',"
+			. "`id_card` varchar(255) NOT NULL DEFAULT '',"
+			. "`front_img` varchar(255) NOT NULL DEFAULT '',"
+			. "`back_img` varchar(255) NOT NULL DEFAULT '',"
+			. "`hand_img` varchar(255) NOT NULL DEFAULT '',"
+			. "`ocr_name` varchar(64) NOT NULL DEFAULT '',"
+			. "`ocr_id_card` varchar(255) NOT NULL DEFAULT '',"
+			. "`status` varchar(16) NOT NULL DEFAULT 'pending',"
+			. "`audit_note` varchar(255) NOT NULL DEFAULT '',"
+			. "`created_at` varchar(50) NOT NULL DEFAULT '',"
+			. "`updated_at` varchar(50) NOT NULL DEFAULT '',"
+			. "`audited_at` varchar(50) NOT NULL DEFAULT '',"
+			. "PRIMARY KEY (`id`),"
+			. "UNIQUE KEY `uk_user` (`user_id`),"
+			. "KEY `idx_status` (`status`)"
+			. ") ENGINE=MyISAM DEFAULT CHARSET=utf8"
+		);
+		if (!$ok) {
+			$msg = method_exists($DB, 'error') ? $DB->error() : 'unknown';
+			return ['ok' => false, 'id' => 0, 'status' => 'error', 'note' => '建表失败: ' . $msg];
+		}
+	}
+
 	$existing = realname_get_by_user($user_id);
 	$id_card_enc = realname_encrypt($data['id_card']);
 	$ocr_id_enc  = realname_encrypt($data['ocr_id_card']);
@@ -235,23 +313,46 @@ function realname_save_auth($user, $data)
 	$status = $audit['status'];
 	$note = $audit['note'];
 
+	$e = function ($v) use ($DB) { return "'" . $DB->escape((string)$v) . "'"; };
 	if ($existing) {
-		$ok = $DB->query_prepare(
-			"UPDATE plg_realname_auth SET real_name=?, phone=?, id_card=?, front_img=?, back_img=?, hand_img=?, ocr_name=?, ocr_id_card=?, status=?, audit_note=?, updated_at=?, audited_at=? WHERE user_id=?",
-			[$data['real_name'], $data['phone'], $id_card_enc, $data['front_img'], $data['back_img'], $data['hand_img'], $data['ocr_name'], $ocr_id_enc, $status, $note, $now, ($status !== 'pending' ? $now : $existing['audited_at']), $user_id]
-		);
+		$audited_at = ($status !== 'pending' ? $now : $existing['audited_at']);
+		$sql = "UPDATE plg_realname_auth SET "
+			. "real_name=" . $e($data['real_name'])
+			. ", phone=" . $e($data['phone'])
+			. ", id_card=" . $e($id_card_enc)
+			. ", front_img=" . $e($data['front_img'])
+			. ", back_img=" . $e($data['back_img'])
+			. ", hand_img=" . $e($data['hand_img'])
+			. ", ocr_name=" . $e($data['ocr_name'])
+			. ", ocr_id_card=" . $e($ocr_id_enc)
+			. ", status=" . $e($status)
+			. ", audit_note=" . $e($note)
+			. ", updated_at=" . $e($now)
+			. ", audited_at=" . $e($audited_at)
+			. " WHERE user_id=" . (int)$user_id;
+		$ok = (bool)$DB->query($sql);
 		$id = (int)$existing['id'];
 	} else {
-		$ok = $DB->query_prepare(
-			"INSERT INTO plg_realname_auth (user_id, username, real_name, phone, id_card, front_img, back_img, hand_img, ocr_name, ocr_id_card, status, audit_note, created_at, updated_at, audited_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
-			[$user_id, (string)$user['username'], $data['real_name'], $data['phone'], $id_card_enc, $data['front_img'], $data['back_img'], $data['hand_img'], $data['ocr_name'], $ocr_id_enc, $status, $note, $now, $now, ($status !== 'pending' ? $now : '')]
-		);
-		$row = $DB->get_row_prepare("SELECT id FROM plg_realname_auth WHERE user_id=? LIMIT 1", [$user_id]);
-		$id = $row ? (int)$row['id'] : 0;
+		$audited_at = ($status !== 'pending' ? $now : '');
+		$sql = "INSERT INTO plg_realname_auth "
+			. "(user_id, username, real_name, phone, id_card, front_img, back_img, hand_img, ocr_name, ocr_id_card, status, audit_note, created_at, updated_at, audited_at) VALUES ("
+			. (int)$user_id . ", " . $e($user['username']) . ", " . $e($data['real_name']) . ", " . $e($data['phone']) . ", "
+			. $e($id_card_enc) . ", " . $e($data['front_img']) . ", " . $e($data['back_img']) . ", " . $e($data['hand_img']) . ", "
+			. $e($data['ocr_name']) . ", " . $e($ocr_id_enc) . ", " . $e($status) . ", " . $e($note) . ", "
+			. $e($now) . ", " . $e($now) . ", " . $e($audited_at) . ")";
+		$ok = (bool)$DB->query($sql);
+		if ($ok) {
+			$row = $DB->get_row("SELECT id FROM plg_realname_auth WHERE user_id=" . (int)$user_id . " LIMIT 1");
+			$id = $row ? (int)$row['id'] : 0;
+		} else {
+			$id = 0;
+		}
 	}
 
 	if (!$ok) {
-		return ['ok' => false, 'id' => 0, 'status' => 'error', 'note' => '保存失败,请稍后重试'];
+		$err = method_exists($DB, 'error') ? $DB->error() : 'unknown';
+		$short = strlen($sql) > 300 ? (substr($sql, 0, 300) . '...') : $sql;
+		return ['ok' => false, 'id' => 0, 'status' => 'error', 'note' => '写入失败: ' . $err . ' | SQL前段: ' . $short];
 	}
 	return ['ok' => true, 'id' => $id, 'status' => $status, 'note' => $note];
 }
diff --git a/templates/tdesign/spa/src/account/api/plugins.js b/templates/tdesign/spa/src/account/api/plugins.js
index b18aa28..a2f5f58 100644
--- a/templates/tdesign/spa/src/account/api/plugins.js
+++ b/templates/tdesign/spa/src/account/api/plugins.js
@@ -73,18 +73,8 @@ export function getRealnameInfo() {
   return apiGet('/realname/api/me')
 }
 
-export async function submitRealname(formData) {
-  const token = getCsrfToken()
-  if (token && !formData.has('_csrf')) formData.append('_csrf', token)
-  try {
-    const res = await http.post(apiUrl('/realname/api/submit'), formData)
-    const raw = res.data || {}
-    if (raw.code === 'ok') return { ok: true, message: raw.message || '提交成功', data: raw }
-    return { ok: false, message: raw.code || '提交失败', data: raw }
-  } catch (e) {
-    const raw = e?.response?.data
-    return { ok: false, message: raw?.code || raw?.msg || e.message || '网络错误', data: raw }
-  }
+export async function submitRealname(data) {
+  return apiPost('/realname/api/submit', data)
 }
 
 /* ============================================================
diff --git a/templates/tdesign/spa/src/account/layouts/AccountLayout.vue b/templates/tdesign/spa/src/account/layouts/AccountLayout.vue
index e38ffbc..bda2476 100644
--- a/templates/tdesign/spa/src/account/layouts/AccountLayout.vue
+++ b/templates/tdesign/spa/src/account/layouts/AccountLayout.vue
@@ -54,6 +54,13 @@
               </a>
             </router-link>
           </li>
+          <li v-if="hasRealname" class="td-side-item">
+            <router-link to="/realname" custom v-slot="{ navigate, isActive }">
+              <a href="javascript:;" :class="{ active: isActive }" @click="navigate">
+                <i class="mdi mdi-account-check"></i><span>实名认证</span>
+              </a>
+            </router-link>
+          </li>
         </ul>
 
         <!-- 余额 -->
@@ -234,6 +241,7 @@ const userName = boot.accountUser?.username || boot.user || 'user'
 const hasBalance = pluginEnabled('balance')
 const hasShop = pluginEnabled('hosting_shop')
 const hasDocker = pluginEnabled('docker_shop')
+const hasRealname = pluginEnabled('realname')
 
 const sidebarCollapsed = ref(false)
 const mobileOpen = ref(false)
diff --git a/templates/tdesign/spa/src/account/views/RealnameView.vue b/templates/tdesign/spa/src/account/views/RealnameView.vue
index afc9284..6f5ce53 100644
--- a/templates/tdesign/spa/src/account/views/RealnameView.vue
+++ b/templates/tdesign/spa/src/account/views/RealnameView.vue
@@ -97,7 +97,7 @@ const loading = ref(true)
 const submitting = ref(false)
 const auth = ref(null)
 const form = reactive({ real_name: '', phone: '', id_card: '' })
-const files = reactive({ front: null, back: null, hand: null })
+const base64Images = reactive({ front: '', back: '', hand: '' })
 const previews = reactive({ front: '', back: '', hand: '' })
 const ocrRunning = ref(false)
 const ocrTried = ref(false)
@@ -105,7 +105,7 @@ const ocrName = ref('')
 const ocrIdCard = ref('')
 
 const uploadItems = [
-  { key: 'front', title: '身份证正面', tip: '选择后自动识别', icon: 'mdi-card-account-details' },
+  { key: 'front', title: '身份证正面', tip: '选择后自动识别', icon: 'mdi-account-card-details' },
   { key: 'back', title: '身份证反面', tip: '国徽面,清晰完整', icon: 'mdi-card-bulleted' },
   { key: 'hand', title: '手持身份证', tip: '人物与证件均清晰', icon: 'mdi-account-box' },
 ]
@@ -140,10 +140,11 @@ function compressImage(file) {
   return new Promise((resolve, reject) => {
     const image = new Image()
     const url = URL.createObjectURL(file)
+    const reader = new FileReader()
     image.onload = () => {
       let width = image.width
       let height = image.height
-      const max = 1280
+      const max = 1024
       if (width > max || height > max) {
         const scale = max / Math.max(width, height)
         width = Math.round(width * scale)
@@ -155,8 +156,11 @@ function compressImage(file) {
       canvas.getContext('2d').drawImage(image, 0, 0, width, height)
       canvas.toBlob((blob) => {
         URL.revokeObjectURL(url)
-        blob ? resolve(blob) : reject(new Error('图片压缩失败'))
-      }, 'image/jpeg', 0.85)
+        if (!blob) return reject(new Error('图片压缩失败'))
+        reader.onload = () => resolve({ blob, base64: reader.result })
+        reader.onerror = () => reject(new Error('读取失败'))
+        reader.readAsDataURL(blob)
+      }, 'image/jpeg', 0.8)
     }
     image.onerror = () => {
       URL.revokeObjectURL(url)
@@ -176,8 +180,8 @@ async function onFileChange(event, key) {
     return
   }
   try {
-    const blob = await compressImage(file)
-    files[key] = blob
+    const { blob, base64 } = await compressImage(file)
+    base64Images[key] = base64
     if (previews[key]) URL.revokeObjectURL(previews[key])
     previews[key] = URL.createObjectURL(blob)
     if (key === 'front') await runOcr(blob)
@@ -200,11 +204,28 @@ function extractIdCard(text) {
 }
 
 function extractName(text) {
-  for (const line of (text || '').split('\n')) {
-    const value = line.replace(/\s+/g, '')
-    const match = value.match(/姓名[::]?([\u4e00-\u9fa5·]{1,10})/)
-    if (match?.[1]) return match[1]
+  const raw = (text || '').replace(/\s+/g, '')
+  // 策略1: "姓名" + 冒号 + 中文名(最标准)
+  let 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: "姓"和"名"之间被 OCR 插入了空格/噪声,整体匹配附近中文
+  m = raw.match(/姓.*?名.*?([\u4e00-\u9fa5·]{2,4})/)
+  if (m) return m[1]
+  // 策略4: 逐行查找含"名"的行,从中提取 2-4 个连续中文字符
+  const lines = (text || '').split(/[\r\n]+/)
+  for (const line of lines) {
+    const clean = line.replace(/\s+/g, '')
+    if (clean.includes('名') && !clean.includes('民族')) {
+      const nameMatch = clean.match(/名.*?([\u4e00-\u9fa5·]{2,4})/)
+      if (nameMatch) return nameMatch[1]
+    }
   }
+  // 策略5: 全文搜索,在"姓名"后 50 个字符内找 2-4 个连续中文
+  m = raw.match(/姓名.{0,50}?([\u4e00-\u9fa5·]{2,4})/)
+  if (m) return m[1]
   return ''
 }
 
@@ -219,6 +240,43 @@ function loadOcrLib() {
   })
 }
 
+function preprocessForOcr(inputBlob) {
+  return new Promise((resolve, reject) => {
+    const img = new Image()
+    const url = URL.createObjectURL(inputBlob)
+    img.onload = () => {
+      const canvas = document.createElement('canvas')
+      canvas.width = img.width
+      canvas.height = img.height
+      const ctx = canvas.getContext('2d')
+      ctx.drawImage(img, 0, 0)
+      URL.revokeObjectURL(url)
+      // 灰度化 + 对比度拉伸
+      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
+      const d = imageData.data
+      let min = 255, max = 0
+      for (let i = 0; i < d.length; i += 4) {
+        const 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
+      }
+      // 对比度拉伸:把 [min,max] 映射到 [0,255]
+      if (max > min) {
+        const range = max - min
+        for (let i = 0; i < d.length; i += 4) {
+          const v = ((d[i] - min) / range) * 255
+          d[i] = d[i + 1] = d[i + 2] = v
+        }
+      }
+      ctx.putImageData(imageData, 0, 0)
+      canvas.toBlob((b) => b ? resolve(b) : reject(new Error('预处理失败')), 'image/png')
+    }
+    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('加载失败')) }
+    img.src = url
+  })
+}
+
 async function runOcr(blob) {
   ocrRunning.value = true
   ocrTried.value = false
@@ -227,11 +285,15 @@ async function runOcr(blob) {
   try {
     const Tesseract = await loadOcrLib()
     const base = boot.realnameOcrBase
-    const result = await Tesseract.recognize(blob, 'chi_sim', {
+    // 预处理:灰度 + 对比度增强
+    const processed = await preprocessForOcr(blob)
+    const result = await Tesseract.recognize(processed, 'chi_sim', {
       workerPath: `${base}worker.min.js`,
       corePath: `${base}tesseract-core.wasm.js`,
       langPath: `${base}lang/`,
       logger: () => {},
+      preserve_interword_spaces: '1',
+      tessedit_pageseg_mode: '6', // PSM 6: 假设为统一文本块,适合身份证
     })
     const text = result?.data?.text || ''
     ocrName.value = extractName(text)
@@ -250,21 +312,21 @@ async function runOcr(blob) {
 
 async function onSubmit({ validateResult }) {
   if (validateResult !== true) return
-  if (!files.front || !files.back || !files.hand) {
+  if (!base64Images.front || !base64Images.back || !base64Images.hand) {
     MessagePlugin.warning('请上传身份证正面、反面和手持身份证照片')
     return
   }
   submitting.value = true
-  const data = new FormData()
-  data.append('real_name', form.real_name.trim())
-  data.append('phone', form.phone.trim())
-  data.append('id_card', form.id_card.trim().toUpperCase())
-  data.append('ocr_name', ocrName.value)
-  data.append('ocr_id_card', ocrIdCard.value)
-  data.append('front_img', files.front, 'front.jpg')
-  data.append('back_img', files.back, 'back.jpg')
-  data.append('hand_img', files.hand, 'hand.jpg')
-  const res = await submitRealname(data)
+  const res = await submitRealname({
+    real_name: form.real_name.trim(),
+    phone: form.phone.trim(),
+    id_card: form.id_card.trim().toUpperCase(),
+    ocr_name: ocrName.value,
+    ocr_id_card: ocrIdCard.value,
+    front_img: base64Images.front,
+    back_img: base64Images.back,
+    hand_img: base64Images.hand,
+  })
   submitting.value = false
   if (!res.ok) {
     MessagePlugin.error(res.message || '提交失败')