feat(realname): 实现新版实名认证插件功能
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 || '提交失败')