better-staridc-MNBT
1<?php
2/**
3 * realname 插件 - 主入口
4 *
5 * 功能:三要素实名认证(姓名/手机号/身份证号 + 身份证正反面与手持照片),
6 * 前端 tesseract.js 本地 OCR 识别身份证正面,服务端自动审核;
7 * 未实名插件用户无法发起支付(pay.dispatch.before filter 统一拦截)。
8 * 依赖:user_info 插件(MN_plugin_user 用户体系)
9 */
10
11if (!defined('IN_CRONLITE')) {
12 exit;
13}
14
15require_once __DIR__ . '/lib/auth.php';
16
17mnbt_plugin_register('realname', [
18 'name' => '实名认证',
19 'description' => '三要素实名认证 + 身份证本地 OCR + 自动审核,未实名无法发起支付',
20]);
21
22/* ============================================================
23 * 用户端页面路由
24 * ============================================================ */
25
26$realname_user_auth = function () {
27 return (bool)(function_exists('user_info_auth_current') ? user_info_auth_current() : null);
28};
29
30// 申请页(未认证/被驳回可提交;已通过跳转状态页)
31mnbt_register_route('GET', '/realname/apply', function ($params, $ctx) {
32 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
33 if (!$user) {
34 header('Location: ' . (function_exists('user_info_url') ? user_info_url('account/login') : realname_url('realname/apply')));
35 exit;
36 }
37 $auth = realname_get_by_user((int)$user['id']);
38 if ($auth && $auth['status'] === 'approved') {
39 header('Location: ' . realname_url('realname/status'));
40 exit;
41 }
42 realname_render('apply', [
43 'page_title' => '实名认证',
44 'auth' => $auth,
45 ]);
46}, 10, $realname_user_auth);
47
48// 状态页
49mnbt_register_route('GET', '/realname/status', function ($params, $ctx) {
50 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
51 if (!$user) {
52 header('Location: ' . (function_exists('user_info_url') ? user_info_url('account/login') : realname_url('realname/apply')));
53 exit;
54 }
55 $auth = realname_get_by_user((int)$user['id']);
56 realname_render('status', [
57 'page_title' => '认证状态',
58 'auth' => $auth,
59 ]);
60}, 10, $realname_user_auth);
61
62/* ============================================================
63 * 用户端 API 路由
64 * ============================================================ */
65
66// 当前认证状态
67mnbt_register_route('GET', '/realname/api/me', function ($params, $ctx) {
68 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
69 if (!$user) {
70 realname_json('not_login', ['logged_in' => false]);
71 return;
72 }
73 $auth = realname_get_by_user((int)$user['id']);
74 $data = ['logged_in' => true];
75 if ($auth) {
76 $idcard = realname_decrypt($auth['id_card']);
77 $data['auth'] = [
78 'id' => (int)$auth['id'],
79 'status' => (string)$auth['status'],
80 'real_name' => realname_mask_name($auth['real_name']),
81 'phone' => realname_mask_phone($auth['phone']),
82 'id_card' => realname_mask_idcard($idcard),
83 'audit_note' => (string)$auth['audit_note'],
84 'created_at' => (string)$auth['created_at'],
85 'audited_at' => (string)$auth['audited_at'],
86 ];
87 } else {
88 $data['auth'] = null;
89 }
90 realname_json('ok', $data);
91}, 10, $realname_user_auth);
92
93// 提交认证(multipart:三要素 + 三张照片 + OCR 结果)
94mnbt_register_route('POST', '/realname/api/submit', function ($params, $ctx) {
95 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
96 if (!$user) {
97 realname_json('not_login', ['logged_in' => false]);
98 return;
99 }
100
101 $real_name = trim((string)($_POST['real_name'] ?? ''));
102 $phone = trim((string)($_POST['phone'] ?? ''));
103 $id_card = strtoupper(trim((string)($_POST['id_card'] ?? '')));
104 $ocr_name = trim((string)($_POST['ocr_name'] ?? ''));
105 $ocr_id_card = strtoupper(preg_replace('/\s+/', '', (string)($_POST['ocr_id_card'] ?? '')));
106
107 // 三要素服务端预校验
108 $nameChk = realname_name_validate($real_name);
109 if (!$nameChk['ok']) realname_json($nameChk['msg']);
110 $phoneChk = realname_phone_validate($phone);
111 if (!$phoneChk['ok']) realname_json($phoneChk['msg']);
112 $idChk = realname_idcard_validate($id_card);
113 if (!$idChk['ok']) realname_json($idChk['msg']);
114
115 // 照片上传(base64 格式,前端 canvas 压缩后传来)
116 $require_hand = (bool)realname_opt('require_hand_photo', true);
117 $front = realname_save_photo_base64((int)$user['id'], 'front', $_POST['front_img'] ?? '');
118 $back = realname_save_photo_base64((int)$user['id'], 'back', $_POST['back_img'] ?? '');
119 $hand = realname_save_photo_base64((int)$user['id'], 'hand', $_POST['hand_img'] ?? '');
120 if ($front === '') realname_json('身份证正面照片上传失败(仅支持 jpg/png,≤8MB)');
121 if ($back === '') realname_json('身份证反面照片上传失败(仅支持 jpg/png,≤8MB)');
122 if ($require_hand && $hand === '') realname_json('手持身份证照片上传失败(仅支持 jpg/png,≤8MB)');
123
124 $result = realname_save_auth($user, [
125 'real_name' => $real_name,
126 'phone' => $phone,
127 'id_card' => $id_card,
128 'front_img' => $front,
129 'back_img' => $back,
130 'hand_img' => $hand,
131 'ocr_name' => $ocr_name,
132 'ocr_id_card' => $ocr_id_card,
133 ]);
134 if (!$result['ok']) {
135 realname_json($result['note']);
136 }
137
138 $tips = [
139 'approved' => '实名认证已通过',
140 'rejected' => '认证未通过:' . $result['note'],
141 'pending' => '已提交,等待人工复核:' . $result['note'],
142 ];
143 realname_json('ok', [
144 'message' => $tips[$result['status']] ?? '已提交',
145 'status' => $result['status'],
146 'note' => $result['note'],
147 ]);
148}, 10, $realname_user_auth);
149
150// 照片访问(鉴权:本人或管理员)
151mnbt_register_route('GET', '/realname/api/img', function ($params, $ctx) {
152 $id = (int)($_GET['id'] ?? 0);
153 $type = (string)($_GET['type'] ?? 'front');
154 if ($id <= 0 || !in_array($type, ['front', 'back', 'hand'], true)) {
155 realname_json('参数错误');
156 }
157 $auth = realname_get_by_id($id);
158 if (!$auth) {
159 realname_json('记录不存在');
160 }
161 // 权限:管理员($islogin,由 member.php 解析 admin_token),或本人
162 $isAdmin = isset($GLOBALS['islogin']) && (int)$GLOBALS['islogin'] === 1;
163 if (!$isAdmin) {
164 $user = function_exists('user_info_auth_current') ? user_info_auth_current() : null;
165 if (!$user || (int)$user['id'] !== (int)$auth['user_id']) {
166 realname_json('无权访问');
167 }
168 }
169 $field = $type . '_img';
170 $path = realname_photo_path((int)$auth['user_id'], $auth[$field]);
171 if ($path === '' || !is_file($path)) {
172 realname_json('照片不存在');
173 }
174 $mime = (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'png') ? 'image/png' : 'image/jpeg';
175 @header('Content-Type: ' . $mime);
176 @header('X-Content-Type-Options: nosniff');
177 @header('Cache-Control: private, max-age=300');
178 readfile($path);
179 exit;
180});
181
182// 支付拦截:未实名插件用户禁止发起支付
183mnbt_add_filter('pay.dispatch.before', 'realname_pay_guard', 10);
184
185/* ============================================================
186 * 管理员端页面与菜单
187 * ============================================================ */
188
189mnbt_register_page('admin', 'audits', 'admin/audits.php', '实名认证审核');
190mnbt_register_page('admin', 'audit_detail', 'admin/audit_detail.php', '实名认证详情');
191
192mnbt_register_menu('admin', [
193 'title' => '实名认证',
194 'icon' => 'mdi-account-check',
195 'order' => 80,
196 'children' => [
197 ['title' => '审核列表', 'page' => 'audits', 'icon' => 'mdi-format-list-bulleted', 'multitabs' => true],
198 ],
199]);
200
201// 管理员端 AJAX
202mnbt_register_ajax('admin', 'realname_admin_list', 'realname_admin_list');
203mnbt_register_ajax('admin', 'realname_admin_approve', 'realname_admin_approve');
204mnbt_register_ajax('admin', 'realname_admin_reject', 'realname_admin_reject');
205mnbt_register_ajax('admin', 'realname_admin_decrypt', 'realname_admin_decrypt');