diff --git a/assets/admin/src/api/modules/users.js b/assets/admin/src/api/modules/users.js index 897e9b96..b9aed5e6 100644 --- a/assets/admin/src/api/modules/users.js +++ b/assets/admin/src/api/modules/users.js @@ -20,6 +20,14 @@ export const usersApiMethods = { deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) }, runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) }, + getIdentitySettings() { return this.request('/identity/settings') }, + saveIdentitySettings(data) { + return this.request('/identity/settings', { method: 'POST', body: JSON.stringify(data) }) + }, + testIdentitySettings(data = {}) { + return this.request('/identity/settings/test', { method: 'POST', body: JSON.stringify(data) }) + }, + getCreditSettings() { return this.request('/credits/settings') }, saveCreditSettings(data) { return this.request('/credits/settings', { method: 'POST', body: JSON.stringify(data) }) }, listCreditUsers(params = {}) { diff --git a/assets/admin/src/constants/sections/users.js b/assets/admin/src/constants/sections/users.js index 5496a38e..638d4ee8 100644 --- a/assets/admin/src/constants/sections/users.js +++ b/assets/admin/src/constants/sections/users.js @@ -63,11 +63,12 @@ export const usersSection = { canonicalDomain: 'users', section: 'credits', }), - schemaPage({ + customPage({ key: 'users-verify', name: 'UsersVerify', label: '身份认证', route: '/users/verify', + component: 'UsersVerify', title: '身份认证', desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。', canonicalDomain: 'users', diff --git a/assets/admin/src/pages/users/UsersVerify.vue b/assets/admin/src/pages/users/UsersVerify.vue new file mode 100644 index 00000000..9dd7e226 --- /dev/null +++ b/assets/admin/src/pages/users/UsersVerify.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/assets/admin/src/router/component-loaders/users.js b/assets/admin/src/router/component-loaders/users.js index 9f850d99..f5e91037 100644 --- a/assets/admin/src/router/component-loaders/users.js +++ b/assets/admin/src/router/component-loaders/users.js @@ -1,6 +1,7 @@ export const usersPageLoaders = { UsersFeatures: () => import('@/pages/users/UsersFeatures.vue'), UsersSignin: () => import('@/pages/users/UsersSignin.vue'), + UsersVerify: () => import('@/pages/users/UsersVerify.vue'), UsersLevel: () => import('@/pages/users/UsersLevel.vue'), UsersBadge: () => import('@/pages/users/UsersBadge.vue'), UsersWechatTemplate: () => import('@/pages/users/UsersWechatTemplate.vue'), diff --git a/assets/js/routes/user-center-auth.js b/assets/js/routes/user-center-auth.js new file mode 100644 index 00000000..c2757c14 --- /dev/null +++ b/assets/js/routes/user-center-auth.js @@ -0,0 +1,133 @@ +(function () { + 'use strict'; + + function parseConfig(root) { + try { return JSON.parse(root.getAttribute('data-identity-config') || '{}'); } catch (_) { return {}; } + } + + function uuid() { + if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0; + return (c === 'x' ? r : (r & 3 | 8)).toString(16); + }); + } + + function request(url, config, options) { + options = options || {}; + options.credentials = 'same-origin'; + options.headers = Object.assign({ 'X-WP-Nonce': config.nonce || '' }, options.headers || {}); + return fetch(url, options).then(function (response) { + return response.json().then(function (payload) { + if (!response.ok || !payload || payload.success === false) { + throw new Error((payload && payload.message) || '请求失败'); + } + return payload.data; + }); + }); + } + + function init(root) { + if (!root || root.dataset.identityBound === '1') return; + root.dataset.identityBound = '1'; + var config = parseConfig(root); + var modal = root.querySelector('#uc-auth-modal'); + if (!modal) return; + var currentUuid = config.currentUuid || ''; + var pollTimer = null; + var shouldPoll = false; + + function open() { modal.classList.add('is-open'); modal.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; } + function close() { modal.classList.remove('is-open'); modal.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; } + root.querySelectorAll('[data-auth-modal-open]').forEach(function (button) { button.addEventListener('click', open); }); + modal.querySelectorAll('[data-auth-modal-close]').forEach(function (button) { button.addEventListener('click', close); }); + + var statusBox = modal.querySelector('[data-auth-status]'); + var createForm = modal.querySelector('[data-auth-create]'); + var manualForm = modal.querySelector('[data-auth-manual]'); + var stageNode = modal.querySelector('[data-auth-stage]'); + var messageNode = modal.querySelector('[data-auth-status-message]'); + var continueLink = modal.querySelector('[data-auth-continue]'); + var refreshButton = modal.querySelector('[data-auth-refresh]'); + var manualButton = modal.querySelector('[data-auth-manual-open]'); + + function render(data) { + if (!data) return; + currentUuid = data.uuid || currentUuid; + if (statusBox) statusBox.hidden = false; + if (createForm) createForm.hidden = true; + if (stageNode) stageNode.textContent = data.stage || data.status || '认证处理中'; + if (messageNode) messageNode.textContent = data.message || '认证结果以站点服务端主动查询为准。'; + if (continueLink) { + continueLink.hidden = !data.redirect_url; + if (data.redirect_url) continueLink.href = data.redirect_url; + } + if (manualButton) manualButton.hidden = data.next_action !== 'manual_or_retry'; + shouldPoll = data.next_action === 'poll'; + if (data.status === 'verified') { + shouldPoll = false; + stopPolling(); + window.setTimeout(function () { window.location.reload(); }, 500); + } else if (shouldPoll) { + schedulePoll(data.poll_interval || 5); + } else { + stopPolling(); + } + } + + function refresh() { + if (!currentUuid) return Promise.resolve(); + return request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/refresh', config, { method: 'POST' }).then(render); + } + function schedulePoll(seconds) { stopPolling(); shouldPoll = true; pollTimer = window.setTimeout(function () { refresh().catch(function () {}).finally(function () { if (shouldPoll && currentUuid) schedulePoll(seconds); }); }, Math.max(3, seconds) * 1000); } + function stopPolling() { shouldPoll = false; if (pollTimer) window.clearTimeout(pollTimer); pollTimer = null; } + if (refreshButton) refreshButton.addEventListener('click', function () { refresh().catch(function (error) { if (messageNode) messageNode.textContent = error.message; }); }); + document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && currentUuid) refresh().catch(function () {}); }); + + if (createForm) { + var typeSelect = createForm.querySelector('[name="type"]'); + var enterprise = createForm.querySelector('[data-enterprise-fields]'); + function syncEnterprise() { + var enabled = typeSelect && typeSelect.value === 'enterprise'; + if (enterprise) enterprise.hidden = !enabled; + if (enterprise) enterprise.querySelectorAll('input').forEach(function (input) { input.required = enabled; }); + } + if (typeSelect) typeSelect.addEventListener('change', syncEnterprise); + syncEnterprise(); + createForm.addEventListener('submit', function (event) { + event.preventDefault(); + var form = new FormData(createForm); + var payload = {}; + form.forEach(function (value, key) { payload[key] = value; }); + payload.consent = payload.consent === '1'; + payload.client_request_id = uuid(); + var button = createForm.querySelector('[type="submit"]'); + if (button) button.disabled = true; + request(config.restBase, config, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).then(render).catch(function (error) { + var node = createForm.querySelector('[data-auth-message]'); + if (node) node.textContent = error.message; + if (button) button.disabled = false; + }); + }); + } + + if (manualButton) manualButton.addEventListener('click', function () { if (manualForm) manualForm.hidden = false; }); + if (manualForm) manualForm.addEventListener('submit', function (event) { + event.preventDefault(); + if (!currentUuid) return; + var files = manualForm.querySelector('[name="evidence[]"]'); + if (files && files.files.length > 3) return window.alert('证明材料最多上传 3 张'); + var body = new FormData(); + Array.prototype.forEach.call((files && files.files) || [], function (file) { body.append('evidence[]', file); }); + request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/manual', config, { method: 'POST', body: body }).then(render).catch(function (error) { window.alert(error.message); }); + }); + + if (currentUuid && ['provider_pending', 'provider_processing'].indexOf(config.currentStage) !== -1) schedulePoll(5); + } + + window.AKSRTUserCenterAuth = { init: init }; +})(); diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index 465f3c00..bc0f34eb 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -194,95 +194,10 @@ } function initAuthForm() { - var modal = document.getElementById('uc-auth-modal'); - if (!modal || modal.dataset.authModalBound === '1') return; - modal.dataset.authModalBound = '1'; - - function openModal() { - modal.classList.add('is-open'); - modal.setAttribute('aria-hidden', 'false'); - document.body.style.overflow = 'hidden'; - var first = modal.querySelector('input:not([type="hidden"]), select, textarea, button'); - if (first) window.setTimeout(function() { first.focus(); }, 80); + var root = document.querySelector('[data-identity-root]'); + if (root && window.AKSRTUserCenterAuth && typeof window.AKSRTUserCenterAuth.init === 'function') { + window.AKSRTUserCenterAuth.init(root); } - - function closeModal() { - modal.classList.remove('is-open'); - modal.setAttribute('aria-hidden', 'true'); - document.body.style.overflow = ''; - } - - document.querySelectorAll('[data-auth-modal-open]').forEach(function(button) { - button.addEventListener('click', openModal); - }); - modal.querySelectorAll('[data-auth-modal-close]').forEach(function(button) { - button.addEventListener('click', closeModal); - }); - document.addEventListener('keydown', function(event) { - if (event.key === 'Escape' && modal.classList.contains('is-open')) closeModal(); - }); - - var fileInput = modal.querySelector('[data-auth-files]'); - var previews = modal.querySelector('[data-auth-previews]'); - if (fileInput && previews) { - fileInput.addEventListener('change', function() { - previews.innerHTML = ''; - var files = Array.prototype.slice.call(fileInput.files || []); - if (files.length > 3) { - showToast('证明材料最多上传 3 张'); - fileInput.value = ''; - return; - } - for (var index = 0; index < files.length; index++) { - if (files[index].size > 2 * 1024 * 1024) { - showToast('单张图片不能超过 2MB'); - fileInput.value = ''; - previews.innerHTML = ''; - return; - } - var image = document.createElement('img'); - image.src = URL.createObjectURL(files[index]); - image.alt = ''; - image.onload = function() { URL.revokeObjectURL(this.src); }; - previews.appendChild(image); - } - }); - } - - var form = modal.querySelector('.aksrt-user-auth-form'); - if (!form) return; - form.addEventListener('submit', function(e) { - e.preventDefault(); - var button = form.querySelector('[type="submit"]'); - var message = form.querySelector('[data-auth-message]'); - var original = button ? button.textContent : ''; - if (button) { - button.disabled = true; - button.textContent = i18n.submitting || '提交中…'; - } - if (message) message.textContent = ''; - fetch(ajaxUrl, { method: 'POST', body: new FormData(form), credentials: 'same-origin' }) - .then(function(response) { - return response.json().then(function(payload) { - if (!response.ok || !payload || !payload.success) { - throw new Error((payload && payload.data && payload.data.message) || '提交失败'); - } - return payload.data || {}; - }); - }) - .then(function(data) { - showToast(data.message || '提交成功'); - window.setTimeout(function() { window.location.assign(tabUrls.auth || '?tab=auth'); }, 500); - }) - .catch(function(error) { - if (message) message.textContent = error.message || '提交失败'; - showToast(error.message || '提交失败'); - if (button) { - button.disabled = false; - button.textContent = original; - } - }); - }); } /* ==================== 列表内取消收藏 ==================== */ diff --git a/functions.php b/functions.php index 0194194b..c897bb0b 100644 --- a/functions.php +++ b/functions.php @@ -280,6 +280,9 @@ if (class_exists('\AKSRT\Core\LinksPageMeta')) { if (class_exists('\AKSRT\Core\PostsNavs')) { \AKSRT\Core\PostsNavs::get_instance()->init(); } +if (class_exists('\AKSRT\Identity\Identity')) { + \AKSRT\Identity\Identity::get_instance()->init(); +} if (class_exists('\AKSRT\User\AuthApply')) { \AKSRT\User\AuthApply::get_instance()->init(); } diff --git a/inc/helpers/aksrt-single-components.php b/inc/helpers/aksrt-single-components.php index 0b8c06fc..e8b20205 100644 --- a/inc/helpers/aksrt-single-components.php +++ b/inc/helpers/aksrt-single-components.php @@ -415,7 +415,7 @@ if (!function_exists('aksrt_resolve_allow_view_rule')) : } } if (!$ok && $need_auth) { - $verify = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_verify_status', true)); + $verify = class_exists('\\AKSRT\\User\\AuthApply') && \AKSRT\User\AuthApply::is_verified($user_id) ? 'verified' : ''; if (in_array($verify, ['verified', '1', 'yes'], true)) { $ok = true; } diff --git a/inc/helpers/user-center-renderers/profile-auth.php b/inc/helpers/user-center-renderers/profile-auth.php index f69e0395..8f1a427a 100644 --- a/inc/helpers/user-center-renderers/profile-auth.php +++ b/inc/helpers/user-center-renderers/profile-auth.php @@ -1,8 +1,6 @@ false, 'allow_apply' => false]; -$status = $user_id > 0 && class_exists(AuthApply::class) ? AuthApply::get_status($user_id) : ''; -$info = $user_id > 0 && class_exists(AuthApply::class) ? AuthApply::get_auth_info($user_id) : []; -$apply = $user_id > 0 && class_exists(AuthApply::class) ? AuthApply::get_apply_payload($user_id) : []; -$reason = $user_id > 0 && class_exists(AuthApply::class) ? (string) get_user_meta($user_id, AuthApply::REASON_META, true) : ''; -$evidence = class_exists(AuthApply::class) ? AuthApply::get_evidence_items($apply, $user_id) : []; -$type_labels = []; -foreach ((array) ($settings['types'] ?? []) as $type) { - $type_labels[(string) ($type['slug'] ?? '')] = (string) ($type['label'] ?? ''); -} -$apply_type = sanitize_key((string) ($apply['type'] ?? '')); +$status = class_exists(AuthApply::class) ? AuthApply::get_status($user_id) : ''; +$info = class_exists(AuthApply::class) ? AuthApply::get_auth_info($user_id) : []; +$apply = class_exists(AuthApply::class) ? AuthApply::get_apply_payload($user_id) : []; +$record = is_array($apply['_application'] ?? null) ? $apply['_application'] : []; +$stage = sanitize_key((string) ($record['status'] ?? '')); +$reason = (string) ($record['review_note'] ?? $record['provider_result_summary'] ?? ''); $can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) && $status !== 'verified'; +$config = [ + 'restBase' => untrailingslashit(rest_url('aksrt/v1/identity/applications')), + 'nonce' => wp_create_nonce('wp_rest'), + 'currentUuid' => (string) ($record['application_uuid'] ?? ''), + 'currentStage' => $stage, + 'allowManual' => !empty($settings['allow_manual_fallback']), +]; ?> -
- -
- -
- -

-

- -
-
- -
- -
- -

-

- -
- -
- -
-
- -
- - - - -
-
- +
+ +
+ +
+ +

+

+ +
+
+ +
+ +
+ +

+

+ +
+ +
+ + +
+
+ - - - + + +
diff --git a/src/AKSRT/Admin/ImportExportController.php b/src/AKSRT/Admin/ImportExportController.php index 9b9c0212..cd850008 100644 --- a/src/AKSRT/Admin/ImportExportController.php +++ b/src/AKSRT/Admin/ImportExportController.php @@ -192,6 +192,8 @@ class ImportExportController extends ControllerBase { } } + $export = \AKSRT\Settings\Settings::redact_sensitive_settings($export); + return $this->success(['data' => $export]); } @@ -243,9 +245,12 @@ class ImportExportController extends ControllerBase { } } - // Import standalone options - if (!empty($params['standalone']) && is_array($params['standalone'])) { - foreach ($params['standalone'] as $key => $value) { + // Import standalone options. Secret-bearing options are never accepted from exports. + $standalone = !empty($params['standalone']) && is_array($params['standalone']) + ? \AKSRT\Settings\Settings::filter_sensitive_import($params['standalone']) + : []; + if ($standalone !== []) { + foreach ($standalone as $key => $value) { if (!preg_match('/^aksrt_/', $key)) { $results[$key] = ['status' => 'skipped', 'message' => 'Not an AKSRT option']; continue; diff --git a/src/AKSRT/Admin/ImportExportControllerPart2.php b/src/AKSRT/Admin/ImportExportControllerPart2.php index 2b8ea2d9..73211357 100644 --- a/src/AKSRT/Admin/ImportExportControllerPart2.php +++ b/src/AKSRT/Admin/ImportExportControllerPart2.php @@ -50,6 +50,8 @@ trait ImportExportControllerPart2 { } } + $payload = \AKSRT\Settings\Settings::redact_sensitive_settings($payload); + return [ 'id' => time(), 'type' => $type, diff --git a/src/AKSRT/Admin/Native/UsersMenusVerify.php b/src/AKSRT/Admin/Native/UsersMenusVerify.php index 4fad04e0..22ceb709 100644 --- a/src/AKSRT/Admin/Native/UsersMenusVerify.php +++ b/src/AKSRT/Admin/Native/UsersMenusVerify.php @@ -13,30 +13,19 @@ trait UsersMenusVerify { private function count_verify_status(string $status): int { global $wpdb; - return (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_user_verify_status' AND meta_value = %s", - $status - )); + $table = \AKSRT\Core\DBMigrator::table('identity_application'); + return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE status = %s", $status)); } public function render_verify_page(): void { $this->ensure_cap(); - $filters = $this->get_verify_filters(); + $filters = $this->get_verify_filters(); $page_num = max(1, (int) ($_GET['paged'] ?? 1)); - $per_page = 20; - $result = $this->query_verify_requests($filters, $page_num, $per_page); - $counts = $this->get_verify_status_counts(); - + $result = $this->query_verify_requests($filters, $page_num, 20); + $counts = $this->get_verify_status_counts(); echo '

' . esc_html__('身份认证', 'aksrt') . '

'; $this->flash(); - echo '

' . esc_html(sprintf( - /* translators: 1 pending, 2 verified, 3 rejected, 4 cancelled */ - __('待审核 %1$d · 已通过 %2$d · 已驳回 %3$d · 已取消 %4$d', 'aksrt'), - $counts['pending'], - $counts['verified'], - $counts['rejected'], - $counts['cancelled'] - )) . '

'; + echo '

' . esc_html(sprintf(__('人工待审 %1$d · 已通过 %2$d · 已驳回 %3$d · 已取消 %4$d', 'aksrt'), $counts['pending'], $counts['verified'], $counts['rejected'], $counts['cancelled'])) . '

'; $this->render_verify_filters($filters); $this->render_verify_table($result['items']); $this->pagination($result['pages'], $page_num); @@ -49,12 +38,11 @@ trait UsersMenusVerify { if (!in_array($status, ['pending', 'verified', 'rejected', 'cancelled', ''], true)) { $status = 'pending'; } - return [ - 'status' => $status, - 'keyword' => isset($_GET['keyword']) ? sanitize_text_field((string) wp_unslash($_GET['keyword'])) : '', + 'status' => $status, + 'keyword' => isset($_GET['keyword']) ? sanitize_text_field((string) wp_unslash($_GET['keyword'])) : '', 'date_from' => $this->sanitize_verify_date($_GET['date_from'] ?? ''), - 'date_to' => $this->sanitize_verify_date($_GET['date_to'] ?? ''), + 'date_to' => $this->sanitize_verify_date($_GET['date_to'] ?? ''), ]; } @@ -66,281 +54,150 @@ trait UsersMenusVerify { /** @return array{pending:int,verified:int,rejected:int,cancelled:int,total:int} */ private function get_verify_status_counts(): array { global $wpdb; + $table = \AKSRT\Core\DBMigrator::table('identity_application'); $counts = ['pending' => 0, 'verified' => 0, 'rejected' => 0, 'cancelled' => 0, 'total' => 0]; - $rows = $wpdb->get_results( - "SELECT meta_value AS status, COUNT(*) AS total - FROM {$wpdb->usermeta} - WHERE meta_key = 'aksrt_user_verify_status' - GROUP BY meta_value", - ARRAY_A - ); + $rows = $wpdb->get_results("SELECT status, COUNT(*) total FROM {$table} GROUP BY status", ARRAY_A); foreach ((array) $rows as $row) { - $status = sanitize_key((string) ($row['status'] ?? '')); - $total = (int) ($row['total'] ?? 0); - if (isset($counts[$status])) { - $counts[$status] = $total; + $status = (string) ($row['status'] ?? ''); + $total = (int) ($row['total'] ?? 0); + $public = \AKSRT\Identity\TransitionService::public_status($status); + if ($status === 'manual_pending') { + $counts['pending'] += $total; + } elseif (isset($counts[$public])) { + $counts[$public] += $total; } $counts['total'] += $total; } return $counts; } - /** - * @param array{status:string,keyword:string,date_from:string,date_to:string} $filters - * @return array{items:array>,pages:int,total:int} - */ + /** @return array{items:array>,pages:int,total:int} */ private function query_verify_requests(array $filters, int $page_num, int $per_page): array { global $wpdb; - $where = ["status.meta_value IN ('pending','verified','rejected','cancelled')"]; + $table = \AKSRT\Core\DBMigrator::table('identity_application'); + $where = ['1=1']; $params = []; - - if ($filters['status'] !== '') { - $where[] = 'status.meta_value = %s'; + if ($filters['status'] === 'pending') { + $where[] = "a.status = 'manual_pending'"; + } elseif ($filters['status'] !== '') { + $where[] = 'a.status = %s'; $params[] = $filters['status']; } if ($filters['keyword'] !== '') { - $like = '%' . $wpdb->esc_like($filters['keyword']) . '%'; - $where[] = '(CAST(u.ID AS CHAR) LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s OR u.user_email LIKE %s OR apply.meta_value LIKE %s)'; - array_push($params, $like, $like, $like, $like, $like); + $like = '%' . $wpdb->esc_like($filters['keyword']) . '%'; + $where[] = '(CAST(u.ID AS CHAR) LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s OR u.user_email LIKE %s OR a.public_name LIKE %s OR a.application_uuid LIKE %s)'; + array_push($params, $like, $like, $like, $like, $like, $like); } if ($filters['date_from'] !== '') { - $where[] = 'COALESCE(requested_at.meta_value, u.user_registered) >= %s'; - $params[] = $filters['date_from'] . ' 00:00'; + $where[] = 'a.created_at >= %s'; + $params[] = $filters['date_from'] . ' 00:00:00'; } if ($filters['date_to'] !== '') { - $where[] = 'COALESCE(requested_at.meta_value, u.user_registered) <= %s'; - $params[] = $filters['date_to'] . ' 23:59'; + $where[] = 'a.created_at <= %s'; + $params[] = $filters['date_to'] . ' 23:59:59'; } - - $joins = "INNER JOIN {$wpdb->usermeta} status - ON status.user_id = u.ID AND status.meta_key = 'aksrt_user_verify_status' - INNER JOIN {$wpdb->usermeta} apply - ON apply.user_id = u.ID AND apply.meta_key = 'aksrt_user_verify_apply' - LEFT JOIN {$wpdb->usermeta} requested_at - ON requested_at.user_id = u.ID AND requested_at.meta_key = 'aksrt_user_verify_requested_at' - LEFT JOIN {$wpdb->usermeta} reason - ON reason.user_id = u.ID AND reason.meta_key = 'aksrt_user_verify_reason' - LEFT JOIN {$wpdb->usermeta} reviewed_at - ON reviewed_at.user_id = u.ID AND reviewed_at.meta_key = 'aksrt_user_verify_reviewed_at' - LEFT JOIN {$wpdb->usermeta} reviewer - ON reviewer.user_id = u.ID AND reviewer.meta_key = 'aksrt_user_verify_reviewer'"; - $where_sql = ' WHERE ' . implode(' AND ', $where); - $count_sql = "SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u {$joins}{$where_sql}"; - $total = $params - ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) - : (int) $wpdb->get_var($count_sql); - - $query_params = $params; - $query_params[] = $per_page; - $query_params[] = ($page_num - 1) * $per_page; - $sql = "SELECT DISTINCT u.ID, u.display_name, u.user_login, u.user_email, - status.meta_value AS verify_status, apply.meta_value AS verify_apply, - requested_at.meta_value AS requested_at, reason.meta_value AS verify_reason, - reviewed_at.meta_value AS reviewed_at, reviewer.meta_value AS reviewer_id - FROM {$wpdb->users} u {$joins}{$where_sql} - ORDER BY COALESCE(requested_at.meta_value, u.user_registered) DESC, u.ID DESC - LIMIT %d OFFSET %d"; + $where_sql = implode(' AND ', $where); + $total_sql = "SELECT COUNT(*) FROM {$table} a INNER JOIN {$wpdb->users} u ON u.ID = a.user_id WHERE {$where_sql}"; + $total = $params ? (int) $wpdb->get_var($wpdb->prepare($total_sql, $params)) : (int) $wpdb->get_var($total_sql); + $query_params = array_merge($params, [$per_page, ($page_num - 1) * $per_page]); + $sql = "SELECT a.*, u.display_name, u.user_login, u.user_email FROM {$table} a INNER JOIN {$wpdb->users} u ON u.ID = a.user_id WHERE {$where_sql} ORDER BY a.created_at DESC, a.id DESC LIMIT %d OFFSET %d"; $items = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A); - - return [ - 'items' => is_array($items) ? $items : [], - 'pages' => max(1, (int) ceil(max(1, $total) / $per_page)), - 'total' => $total, - ]; + return ['items' => is_array($items) ? $items : [], 'pages' => max(1, (int) ceil(max(1, $total) / $per_page)), 'total' => $total]; } - /** @param array{status:string,keyword:string,date_from:string,date_to:string} $filters */ private function render_verify_filters(array $filters): void { echo '
'; echo ''; echo ''; - echo ''; + echo ''; echo ''; echo ''; submit_button(__('筛选', 'aksrt'), 'secondary', '', false); echo '
'; } - /** @param array> $items */ private function render_verify_table(array $items): void { - $type_labels = $this->get_verify_type_labels(); - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; + echo '
' . esc_html__('用户', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('申请资料', 'aksrt') . '' . esc_html__('审核记录', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
'; if ($items === []) { echo ''; } foreach ($items as $row) { - $this->render_verify_row($row, $type_labels); + $this->render_verify_row($row); } echo '
用户状态申请资料审核记录操作
' . esc_html__('暂无认证申请', 'aksrt') . '
'; } - /** @return array */ - private function get_verify_type_labels(): array { - $labels = []; - if (class_exists('\AKSRT\User\AuthApply')) { - foreach ((array) \AKSRT\User\AuthApply::settings()['types'] as $type) { - $labels[(string) ($type['slug'] ?? '')] = (string) ($type['label'] ?? ''); - } - } - return $labels; - } - - /** @param array $row @param array $type_labels */ - private function render_verify_row(array $row, array $type_labels): void { - $user_id = (int) ($row['ID'] ?? 0); - $status = sanitize_key((string) ($row['verify_status'] ?? '')); - $apply = maybe_unserialize($row['verify_apply'] ?? []); - $apply = is_array($apply) ? $apply : []; - $history = $this->get_verify_history($user_id); - $reviewer_id = (int) ($row['reviewer_id'] ?? 0); - $reviewer = $reviewer_id > 0 ? get_userdata($reviewer_id) : null; - - echo ''; - echo '' . esc_html((string) ($row['display_name'] ?? '')) . ''; - echo '
@' . esc_html((string) ($row['user_login'] ?? '')) . ' · UID ' . esc_html((string) $user_id) . '
'; - echo ''; - echo '' . $this->verify_status_badge($status) . ''; - echo ''; - $type_key = sanitize_key((string) ($apply['type'] ?? '')); - echo '' . esc_html((string) ($apply['name'] ?? __('未填写认证身份', 'aksrt'))) . ''; - echo '
' . esc_html__('类型:', 'aksrt') . esc_html($type_labels[$type_key] ?? $type_key ?: '—') . '
'; - if (!empty($apply['desc'])) { - echo '
' . esc_html((string) $apply['desc']) . '
'; - } - if (!empty($apply['msg'])) { - echo '
' . esc_html__('申请说明:', 'aksrt') . esc_html((string) $apply['msg']) . '
'; - } - echo '
' . esc_html__('申请时间:', 'aksrt') . esc_html((string) ($row['requested_at'] ?: ($apply['time'] ?? '—'))) . '
'; - $evidence = class_exists('\AKSRT\User\AuthApply') ? \AKSRT\User\AuthApply::get_evidence_items($apply, $user_id, true) : []; - foreach ($evidence as $item) { - echo '' . esc_attr__('认证证明材料', 'aksrt') . ''; - } - echo ''; - echo ''; - if (!empty($row['reviewed_at'])) { - echo '
' . esc_html((string) $row['reviewed_at']) . '
'; - } - if ($reviewer) { - echo '
' . esc_html__('审核人:', 'aksrt') . esc_html($reviewer->display_name) . '
'; - } - if (!empty($row['verify_reason'])) { - echo '
' . esc_html((string) $row['verify_reason']) . '
'; - } - if ($history !== []) { - echo '
' . esc_html(sprintf(__('历史记录(%d)', 'aksrt'), count($history))) . '
    '; - foreach ($history as $entry) { - echo '
  1. ' . wp_kses_post($this->format_verify_history_entry($entry)) . '
  2. '; - } - echo '
'; + private function render_verify_row(array $row): void { + $user_id = (int) $row['user_id']; + $status = (string) $row['status']; + $application = (new \AKSRT\Identity\ApplicationRepository())->find_by_id((int) $row['id']) ?: $row; + $history = (new \AKSRT\Identity\EventRepository())->for_application((int) $row['id']); + echo '' . esc_html((string) $row['display_name']) . ''; + echo '
@' . esc_html((string) $row['user_login']) . ' · UID ' . $user_id . '
' . esc_html((string) $row['user_email']) . '
'; + echo '' . wp_kses_post($this->verify_status_badge($status)) . '
' . esc_html((string) $row['provider']) . ' / ' . esc_html((string) $row['channel']) . '
'; + echo '' . esc_html((string) $row['public_name']) . '
' . esc_html((string) $row['type']) . '
'; + echo '
UUID: ' . esc_html((string) $row['application_uuid']) . '
'; + if (!empty($row['provider_result_code'])) echo '
' . esc_html__('云结果:', 'aksrt') . esc_html((string) $row['provider_result_code']) . '
'; + foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) { + echo ''; } echo ''; - $this->render_verify_actions($user_id, $status); + if (!empty($row['review_note'])) echo '
' . esc_html((string) $row['review_note']) . '
'; + if ($history !== []) echo '
' . esc_html(sprintf(__('事件记录(%d)', 'aksrt'), count($history))) . '
    '; + foreach ($history as $event) echo '
  1. ' . esc_html((string) $event['created_at'] . ' · ' . (string) $event['event_type'] . ' · ' . (string) $event['to_status']) . '
  2. '; + if ($history !== []) echo '
'; + echo ''; + $this->render_verify_actions($row); echo ''; } private function verify_status_badge(string $status): string { - $labels = [ - 'pending' => __('待审核', 'aksrt'), - 'verified' => __('已通过', 'aksrt'), - 'rejected' => __('已驳回', 'aksrt'), - 'cancelled' => __('已取消', 'aksrt'), - ]; - $colors = ['pending' => '#996800', 'verified' => '#008a20', 'rejected' => '#b32d2e', 'cancelled' => '#646970']; - return '' . esc_html($labels[$status] ?? $status) . ''; + $labels = ['manual_pending' => __('人工待审', 'aksrt'), 'verified' => __('已通过', 'aksrt'), 'rejected' => __('已驳回', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), 'provider_pending' => __('等待核身', 'aksrt'), 'provider_processing' => __('云端处理中', 'aksrt'), 'provider_failed' => __('云认证失败', 'aksrt'), 'provider_unsupported' => __('云端不支持', 'aksrt'), 'provider_expired' => __('会话已过期', 'aksrt')]; + return '' . esc_html($labels[$status] ?? $status) . ''; } - /** @param array $entry */ - private function format_verify_history_entry(array $entry): string { - $reviewer_id = (int) ($entry['reviewer'] ?? 0); - $reviewer = $reviewer_id > 0 ? get_userdata($reviewer_id) : null; - $parts = [ - esc_html((string) ($entry['time'] ?? '')), - $this->verify_status_badge(sanitize_key((string) ($entry['status'] ?? ''))), - ]; - if ($reviewer) { - $parts[] = esc_html($reviewer->display_name); + private function render_verify_actions(array $row): void { + $status = (string) $row['status']; + if ($status === 'manual_pending') { + foreach (['verified' => __('通过认证', 'aksrt'), 'rejected' => __('驳回申请', 'aksrt')] as $next => $label) { + echo '
'; + echo ''; + wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']); + echo '
'; + } + } elseif ($status === 'verified') { + echo '
'; + wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']); + echo '
'; + } else { + echo '' . esc_html__('无需人工操作', 'aksrt') . ''; } - if (!empty($entry['reason'])) { - $parts[] = esc_html((string) $entry['reason']); - } - return implode(' · ', $parts); - } - - private function render_verify_actions(int $user_id, string $status): void { - if ($status === 'pending') { - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_users_review_verify_' . $user_id); - echo ''; - echo ''; - echo '
'; - echo '
'; - echo ''; - echo ''; - echo ''; - wp_nonce_field('aksrt_users_review_verify_' . $user_id); - echo ''; - echo ''; - echo '
'; - return; - } - - if ($status === 'verified') { - echo '
'; - echo ''; - echo ''; - echo ''; - wp_nonce_field('aksrt_users_review_verify_' . $user_id); - echo ''; - echo ''; - echo '
'; - return; - } - - echo '' . esc_html__('用户可在前台重新提交申请', 'aksrt') . ''; } public function handle_review_verify(): void { - if (!current_user_can(self::CAPABILITY)) { + if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); } - $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; - $status = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : ''; - $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; + $user_id = (int) ($_POST['user_id'] ?? 0); check_admin_referer('aksrt_users_review_verify_' . $user_id); - - $result = class_exists('\AKSRT\User\AuthApply') - ? \AKSRT\User\AuthApply::review($user_id, $status, $note, get_current_user_id()) - : new \WP_Error('aksrt_verify_unavailable', __('认证服务不可用', 'aksrt')); - $redirect_status = $status === 'cancelled' ? 'cancelled' : 'pending'; - $extra = ['status' => $redirect_status]; - if (is_wp_error($result)) { - $extra['aksrt_verify_error'] = $result->get_error_message(); - } - $this->redirect( - 'aksrt_user_auth', - is_wp_error($result) ? 'verify_failed' : 'verify_updated', - $extra - ); + $uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? '')); + $expected = sanitize_key((string) wp_unslash($_POST['expected_status'] ?? '')); + $status = sanitize_key((string) wp_unslash($_POST['status'] ?? '')); + $note = sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')); + $application = (new \AKSRT\Identity\ApplicationRepository())->find_by_uuid($uuid); + $result = (!$application || (int) $application['user_id'] !== $user_id || (string) $application['status'] !== $expected) + ? new \WP_Error('aksrt_verify_conflict', __('认证状态已变化,请刷新后重试', 'aksrt')) + : \AKSRT\User\AuthApply::review($user_id, $status, $note, get_current_user_id()); + $extra = ['status' => $status === 'cancelled' ? 'cancelled' : 'pending']; + if (is_wp_error($result)) $extra['aksrt_verify_error'] = $result->get_error_message(); + $this->redirect('aksrt_user_auth', is_wp_error($result) ? 'verify_failed' : 'verify_updated', $extra); } - /** @return array> */ private function get_verify_history(int $user_id): array { - if (!class_exists('\AKSRT\User\AuthApply')) { - return []; - } return \AKSRT\User\AuthApply::get_review_history($user_id); } } diff --git a/src/AKSRT/Admin/Schema/SchemaUsers.php b/src/AKSRT/Admin/Schema/SchemaUsers.php index 4e5387ce..2705a9da 100644 --- a/src/AKSRT/Admin/Schema/SchemaUsers.php +++ b/src/AKSRT/Admin/Schema/SchemaUsers.php @@ -619,17 +619,38 @@ class SchemaUsers { ['key' => 'user_verify_enterprise_badge_text', 'label' => '企业认证徽标文案', 'type' => 'input', 'default' => '企业认证'], ]], ['key' => 'workflow', 'title' => '认证方式', 'fields' => [ - ['type' => 'alert', 'content' => '认证方式固定为个人认证和企业认证,申请人不可自行新增或修改类型。'], + ['type' => 'alert', 'content' => '个人认证使用人脸实人认证;企业认证需先通过企业要素与法人关系核验,再由法定代表人完成人脸核身。云端失败可按设置转人工审核。'], + ['key' => 'user_verify_provider', 'label' => '当前认证服务商', 'type' => 'select', 'default' => 'aliyun', 'options' => [ + ['label' => '阿里云', 'value' => 'aliyun'], + ['label' => '腾讯云', 'value' => 'tencent'], + ]], + ['key' => 'user_verify_allow_manual_fallback', 'label' => '云认证失败后允许转人工', 'type' => 'switch', 'default' => true], + ['key' => 'user_verify_session_ttl_minutes', 'label' => '云认证会话有效期(分钟)', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 1440], + ['key' => 'user_verify_poll_interval_seconds', 'label' => '结果轮询间隔(秒)', 'type' => 'number', 'default' => 5, 'min' => 3, 'max' => 60], + ['key' => 'user_verify_max_retries', 'label' => '技术故障允许重试次数', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 3], + ]], + ['key' => 'aliyun', 'title' => '阿里云流程参数', 'fields' => [ + ['key' => 'user_verify_aliyun_region', 'label' => 'Region', 'type' => 'input', 'default' => 'cn-hangzhou'], + ['key' => 'user_verify_aliyun_personal_flow', 'label' => '个人认证 SceneId', 'type' => 'input', 'default' => ''], + ['key' => 'user_verify_aliyun_product_code', 'label' => '个人认证 ProductCode', 'type' => 'select', 'default' => 'ID_PRO', 'options' => [ + ['label' => '实人认证(ID_PRO)', 'value' => 'ID_PRO'], + ['label' => '活体人脸验证(PV_FV)', 'value' => 'PV_FV'], + ['label' => '活体检测(LR_FR)', 'value' => 'LR_FR'], + ]], + ['key' => 'user_verify_aliyun_enterprise_flow', 'label' => '企业认证场景/业务标识', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'tencent', 'title' => '腾讯云流程参数', 'fields' => [ + ['key' => 'user_verify_tencent_region', 'label' => 'Region', 'type' => 'input', 'default' => 'ap-guangzhou'], + ['key' => 'user_verify_tencent_personal_flow', 'label' => '个人认证 RuleId/流程标识', 'type' => 'input', 'default' => ''], + ['key' => 'user_verify_tencent_enterprise_flow', 'label' => '企业认证流程标识', 'type' => 'input', 'default' => ''], ]], ['key' => 'apply', 'title' => '申请设置', 'fields' => [ ['key' => 'user_verify_allow_apply', 'label' => '允许用户前台申请', 'type' => 'switch', 'default' => true], ['key' => 'user_verify_disable_desc', 'label' => '停用申请说明', 'type' => 'textarea', 'default' => "由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员"], - ['key' => 'user_verify_personal_fee', 'label' => '个人认证每次扣余额(元)', 'type' => 'number', 'default' => 0, 'min' => 0, 'step' => 0.01], + ['key' => 'user_verify_personal_fee', 'label' => '个人认证每次扣余额(元)', 'type' => 'number', 'sanitize_type' => 'float', 'default' => 0, 'min' => 0, 'step' => 0.01], ['key' => 'user_verify_personal_free_applications', 'label' => '个人认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。'], - ['key' => 'user_verify_personal_require_real_name', 'label' => '个人认证强制填写真实姓名', 'type' => 'switch', 'default' => false], - ['key' => 'user_verify_enterprise_fee', 'label' => '企业认证每次扣余额(元)', 'type' => 'number', 'default' => 0, 'min' => 0, 'step' => 0.01], + ['key' => 'user_verify_enterprise_fee', 'label' => '企业认证每次扣余额(元)', 'type' => 'number', 'sanitize_type' => 'float', 'default' => 0, 'min' => 0, 'step' => 0.01], ['key' => 'user_verify_enterprise_free_applications', 'label' => '企业认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。'], - ['key' => 'user_verify_enterprise_require_real_name', 'label' => '企业认证强制填写真实姓名', 'type' => 'switch', 'default' => false], ['key' => 'user_verify_desc', 'label' => '申请介绍', 'type' => 'textarea', 'default' => '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'], ]], ], diff --git a/src/AKSRT/Admin/UserController.php b/src/AKSRT/Admin/UserController.php index 06201886..07e02238 100644 --- a/src/AKSRT/Admin/UserController.php +++ b/src/AKSRT/Admin/UserController.php @@ -11,6 +11,7 @@ class UserController extends ControllerBase { use UserControllerReportOperations; use UserControllerInviteOperations; use UserControllerVerifyIntegrations; + use UserControllerIdentitySettings; use UserControllerAuthPhoneOperations; use UserControllerRestrictionOperations; use UserControllerFollowOperations; @@ -260,6 +261,25 @@ class UserController extends ControllerBase { 'permission_callback' => function () { return is_user_logged_in(); }, ]); + // Identity verification provider settings (admin only) + register_rest_route(self::API_NAMESPACE, '/identity/settings', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'get_identity_settings'], + 'permission_callback' => [$this, 'check_permission'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'update_identity_settings'], + 'permission_callback' => [$this, 'check_permission'], + ], + ]); + register_rest_route(self::API_NAMESPACE, '/identity/settings/test', [ + 'methods' => 'POST', + 'callback' => [$this, 'test_identity_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + // SMS provider status register_rest_route(self::API_NAMESPACE, '/sms/status', [ 'methods' => 'GET', diff --git a/src/AKSRT/Admin/UserControllerIdentitySettings.php b/src/AKSRT/Admin/UserControllerIdentitySettings.php new file mode 100644 index 00000000..a631c631 --- /dev/null +++ b/src/AKSRT/Admin/UserControllerIdentitySettings.php @@ -0,0 +1,52 @@ +success([ + 'data' => $this->identity_settings_response(IdentityAdmin::read()), + ]); + } + + public function update_identity_settings(\WP_REST_Request $request): \WP_REST_Response { + $settings = IdentityAdmin::update($this->get_json_params($request)); + + return $this->success([ + 'message' => __('身份认证服务商配置已保存', 'aksrt'), + 'data' => $this->identity_settings_response($settings), + ]); + } + + public function test_identity_settings(\WP_REST_Request $request): \WP_REST_Response { + $params = $this->get_json_params($request); + $provider = sanitize_key((string) ($params['provider'] ?? '')); + $result = IdentityAdmin::test($provider); + + if (empty($result['success'])) { + return $this->error((string) ($result['message'] ?? __('配置检查失败', 'aksrt')), 422); + } + + return $this->success([ + 'message' => (string) $result['message'], + 'data' => ['provider' => $provider, 'configured' => true], + ]); + } + + private function identity_settings_response(array $settings): array { + $settings['callback_url'] = [ + 'aliyun' => rest_url(self::API_NAMESPACE . '/identity/callback/aliyun'), + 'tencent' => rest_url(self::API_NAMESPACE . '/identity/callback/tencent'), + ]; + return $settings; + } +} diff --git a/src/AKSRT/Core/DBMigrator.php b/src/AKSRT/Core/DBMigrator.php index 79048be9..cda7aff3 100644 --- a/src/AKSRT/Core/DBMigrator.php +++ b/src/AKSRT/Core/DBMigrator.php @@ -10,9 +10,10 @@ defined('ABSPATH') || exit; class DBMigrator { use DBMigratorPart2; use DBMigratorPart3; + use DBMigratorIdentity; /** Current schema version; changing it re-runs dbDelta. */ - const SCHEMA_VERSION = '1.16.0'; + const SCHEMA_VERSION = '1.17.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/AKSRT/Core/DBMigratorIdentity.php b/src/AKSRT/Core/DBMigratorIdentity.php new file mode 100644 index 00000000..2a1df549 --- /dev/null +++ b/src/AKSRT/Core/DBMigratorIdentity.php @@ -0,0 +1,87 @@ + 'aksrt_order_shipping', 'charge_card' => 'aksrt_charge_card', 'rebate_log' => 'aksrt_rebate_log', - 'coupon_usage' => 'aksrt_coupon_usage', - 'card_key' => 'aksrt_card_key', + 'coupon_usage' => 'aksrt_coupon_usage', + 'card_key' => 'aksrt_card_key', + 'user_restriction' => 'aksrt_user_restriction', + 'product_variant' => 'aksrt_product_variant', + 'identity_application' => 'aksrt_identity_application', + 'identity_event' => 'aksrt_identity_event', ]; if (!isset($allowed[$name])) { return ''; diff --git a/src/AKSRT/Core/ScriptsPart1b.php b/src/AKSRT/Core/ScriptsPart1b.php index 8f09d24f..9f59d5ff 100644 --- a/src/AKSRT/Core/ScriptsPart1b.php +++ b/src/AKSRT/Core/ScriptsPart1b.php @@ -444,10 +444,20 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; ['aksrt-route-profile'], file_exists($user_center_css_path) ? (string) filemtime($user_center_css_path) : $version ); + $identity_js = get_template_directory() . '/assets/js/routes/user-center-auth.js'; + if (file_exists($identity_js)) { + wp_enqueue_script( + 'aksrt-route-user-center-auth', + $base_uri . '/assets/js/routes/user-center-auth.js', + ['aksrt-script'], + (string) filemtime($identity_js), + true + ); + } wp_enqueue_script( 'aksrt-route-user-center', $base_uri . '/assets/js/routes/user-center.js', - ['aksrt-script'], + file_exists($identity_js) ? ['aksrt-script', 'aksrt-route-user-center-auth'] : ['aksrt-script'], file_exists(get_template_directory() . '/assets/js/routes/user-center.js') ? (string) filemtime(get_template_directory() . '/assets/js/routes/user-center.js') : $version, diff --git a/src/AKSRT/Identity/ApplicationRepository.php b/src/AKSRT/Identity/ApplicationRepository.php new file mode 100644 index 00000000..e0f3a7b2 --- /dev/null +++ b/src/AKSRT/Identity/ApplicationRepository.php @@ -0,0 +1,167 @@ +table = \AKSRT\Core\DBMigrator::table('identity_application'); + } + + /** @param array $data */ + public function create(array $data) { + global $wpdb; + if ($this->table === '') { + return new \WP_Error('aksrt_identity_table', __('身份认证数据表不可用', 'aksrt')); + } + $now = current_time('mysql', true); + $row = wp_parse_args($data, [ + 'application_uuid' => wp_generate_uuid4(), + 'client_request_id' => wp_generate_uuid4(), + 'user_id' => 0, + 'type' => 'personal', + 'channel' => 'cloud', + 'provider' => '', + 'status' => 'created', + 'public_name' => '', + 'public_desc' => '', + 'fee_amount' => 0, + 'fee_status' => 'not_required', + 'created_at' => $now, + 'updated_at' => $now, + ]); + $inserted = $wpdb->insert($this->table, $row); + if ($inserted !== 1) { + return new \WP_Error('aksrt_identity_create', __('认证申请创建失败', 'aksrt')); + } + return $this->find_by_id((int) $wpdb->insert_id); + } + + /** @return array|null */ + public function find_by_id(int $id): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + /** @return array|null */ + public function find_by_uuid(string $uuid): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE application_uuid = %s", $uuid), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + /** @return array|null */ + public function find_by_client_request(int $user_id, string $request_id): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table} WHERE user_id = %d AND client_request_id = %s LIMIT 1", + $user_id, + $request_id + ), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + /** @return array|null */ + public function latest_for_user(int $user_id): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table} WHERE user_id = %d ORDER BY id DESC LIMIT 1", + $user_id + ), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + /** @return array|null */ + public function active_for_user(int $user_id): ?array { + global $wpdb; + $placeholders = implode(',', array_fill(0, count(self::ACTIVE_STATUSES), '%s')); + $params = array_merge([$user_id], self::ACTIVE_STATUSES); + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table} WHERE user_id = %d AND status IN ({$placeholders}) ORDER BY id DESC LIMIT 1", + $params + ), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + /** @return array|null */ + public function verified_for_user(int $user_id): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table} WHERE user_id = %d AND status = 'verified' ORDER BY completed_at DESC, id DESC LIMIT 1", + $user_id + ), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + /** @param array $fields */ + public function transition(int $id, string $expected, string $next, array $fields = []): bool { + global $wpdb; + $fields['status'] = $next; + $fields['updated_at'] = current_time('mysql', true); + $formats = array_fill(0, count($fields), '%s'); + foreach (['reviewer_id', 'retry_count', 'attempt_ordinal'] as $int_key) { + $index = array_search($int_key, array_keys($fields), true); + if ($index !== false) { + $formats[$index] = '%d'; + } + } + foreach (['fee_amount'] as $float_key) { + $index = array_search($float_key, array_keys($fields), true); + if ($index !== false) { + $formats[$index] = '%f'; + } + } + return $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1; + } + + /** @param array $fields */ + public function update(int $id, array $fields): bool { + global $wpdb; + if ($fields === []) { + return true; + } + $fields['updated_at'] = current_time('mysql', true); + $updated = $wpdb->update($this->table, $fields, ['id' => $id]); + return $updated !== false; + } + + public function count_user_type(int $user_id, string $type): int { + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table} WHERE user_id = %d AND type = %s AND fee_status IN ('charged','not_required')", + $user_id, + $type + )); + } + + /** @return array */ + private function normalize(array $row): array { + foreach (['id', 'user_id', 'reviewer_id', 'attempt_ordinal', 'retry_count'] as $key) { + $row[$key] = (int) ($row[$key] ?? 0); + } + foreach (['provider_started_at', 'completed_at', 'expires_at'] as $nullable_key) { + if (($row[$nullable_key] ?? null) === null) { + $row[$nullable_key] = ''; + } + } + $row['fee_amount'] = round((float) ($row['fee_amount'] ?? 0), 2); + $evidence = json_decode((string) ($row['evidence_ids'] ?? ''), true); + $row['evidence_ids'] = is_array($evidence) ? array_values(array_map('intval', $evidence)) : []; + return $row; + } +} diff --git a/src/AKSRT/Identity/ApplicationService.php b/src/AKSRT/Identity/ApplicationService.php new file mode 100644 index 00000000..3e0e97e0 --- /dev/null +++ b/src/AKSRT/Identity/ApplicationService.php @@ -0,0 +1,286 @@ +applications ??= new ApplicationRepository(); + $this->transitions ??= new TransitionService($this->applications); + $this->evidence ??= new EvidenceService(); + } + + /** @param array $input */ + public function create(int $user_id, array $input) { + $settings = SettingsRuntime::get(); + if (empty($settings['enabled']) || empty($settings['allow_apply'])) { + return new \WP_Error('aksrt_identity_disabled', __('暂不能申请身份认证', 'aksrt')); + } + if (!is_ssl()) { + return new \WP_Error('aksrt_identity_https', __('身份认证必须通过 HTTPS 发起', 'aksrt')); + } + if ($this->applications->verified_for_user($user_id)) { + return new \WP_Error('aksrt_identity_verified', __('您已是认证用户', 'aksrt')); + } + if ($this->applications->active_for_user($user_id)) { + return new \WP_Error('aksrt_identity_active', __('您已有进行中的认证申请', 'aksrt')); + } + + $type = sanitize_key((string) ($input['type'] ?? '')); + if (!in_array($type, ['personal', 'enterprise'], true)) { + return new \WP_Error('aksrt_identity_type', __('请选择有效的认证类型', 'aksrt')); + } + $request_id = sanitize_text_field((string) ($input['client_request_id'] ?? '')); + if (!wp_is_uuid($request_id)) { + return new \WP_Error('aksrt_identity_request_id', __('请求标识无效', 'aksrt')); + } + $existing = $this->applications->find_by_client_request($user_id, $request_id); + if ($existing) { + return $this->view($existing); + } + + $public_name = sanitize_text_field((string) ($input['public_name'] ?? '')); + $public_desc = sanitize_textarea_field((string) ($input['public_desc'] ?? '')); + if ($this->length($public_name) < 2 || $this->length($public_name) > 64 || $this->length($public_desc) > 300) { + return new \WP_Error('aksrt_identity_public_info', __('认证展示信息长度无效', 'aksrt')); + } + if (empty($input['consent'])) { + return new \WP_Error('aksrt_identity_consent', __('请先同意身份信息处理说明', 'aksrt')); + } + + $type_settings = \AKSRT\User\AuthApply::type_settings($type); + $attempt = $this->applications->count_user_type($user_id, $type); + $free = max(0, (int) ($type_settings['free_applications'] ?? 0)); + $fee = $attempt < $free ? 0.0 : max(0, (float) ($type_settings['fee'] ?? 0)); + $application = $this->applications->create([ + 'application_uuid' => wp_generate_uuid4(), + 'client_request_id' => $request_id, + 'user_id' => $user_id, + 'type' => $type, + 'provider' => $settings['provider'], + 'provider_flow' => (string) $settings['personal_flow'], + 'public_name' => $public_name, + 'public_desc' => $public_desc, + 'person_name_masked' => SettingsRuntime::mask_name((string) ($input['person_name'] ?? '')), + 'id_number_masked' => SettingsRuntime::mask_id_number((string) ($input['id_number'] ?? '')), + 'id_number_hash' => !empty($input['id_number']) ? SettingsRuntime::hmac((string) $input['id_number']) : '', + 'company_name' => $type === 'enterprise' ? sanitize_text_field((string) ($input['company_name'] ?? '')) : '', + 'company_code_masked' => $type === 'enterprise' ? SettingsRuntime::mask_id_number((string) ($input['company_code'] ?? '')) : '', + 'company_code_hash' => $type === 'enterprise' && !empty($input['company_code']) ? SettingsRuntime::hmac((string) $input['company_code']) : '', + 'fee_amount' => $fee, + 'fee_status' => $fee > 0 ? 'pending' : 'not_required', + 'attempt_ordinal' => $attempt + 1, + ]); + if (is_wp_error($application)) { + return $application; + } + + $provider = ProviderFactory::make((string) $settings['provider']); + $valid = $provider->validate_config(); + if (is_wp_error($valid)) { + $next = !empty($settings['allow_manual_fallback']) ? 'provider_unsupported' : 'cancelled'; + $failed = $this->transitions->transition($application, $next, [ + 'provider_result_code' => $valid->get_error_code(), + 'provider_result_summary' => $valid->get_error_message(), + ], ['source' => 'system', 'event_type' => 'provider_preflight_failed']); + return is_wp_error($failed) ? $failed : $this->view($failed); + } + + $provider_request = $input; + $provider_request['application_uuid'] = $application['application_uuid']; + $provider_request['user_id'] = $user_id; + $result = $type === 'personal' + ? $provider->start_personal($provider_request) + : $provider->start_enterprise($provider_request); + return $this->apply_provider_result($application, $result, true); + } + + public function refresh(array $application) { + if (!in_array((string) $application['status'], ['provider_pending', 'provider_processing'], true)) { + return $this->view($application); + } + if (!empty($application['expires_at']) && strtotime((string) $application['expires_at'] . ' UTC') <= time()) { + $settings = SettingsRuntime::get(); + $next = !empty($settings['allow_manual_fallback']) ? 'provider_expired' : 'cancelled'; + $expired = $this->transitions->transition($application, $next, [ + 'provider_result_code' => 'session_expired', + 'provider_result_summary' => __('云认证会话已过期', 'aksrt'), + ], ['source' => 'system', 'event_type' => 'session_expired']); + return is_wp_error($expired) ? $expired : $this->view($expired); + } + $provider = ProviderFactory::make((string) $application['provider']); + return $this->apply_provider_result($application, $provider->query($application), false); + } + + public function move_to_manual(array $application, $files) { + if (!in_array((string) $application['status'], ['created', 'provider_failed', 'provider_unsupported', 'provider_expired'], true)) { + return new \WP_Error('aksrt_identity_manual_state', __('该申请当前不能转人工审核', 'aksrt')); + } + if (empty(SettingsRuntime::get()['allow_manual_fallback'])) { + return new \WP_Error('aksrt_identity_manual_disabled', __('站点未开放人工审核回退', 'aksrt')); + } + $ids = $this->evidence->upload((int) $application['user_id'], (string) $application['application_uuid'], $files); + if (is_wp_error($ids)) { + return $ids; + } + $charge = $this->charge($application); + if (is_wp_error($charge)) { + $this->evidence->delete($ids); + return $charge; + } + $updated = $this->transitions->transition($application, 'manual_pending', [ + 'channel' => 'manual', + 'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'), + 'evidence_ids' => wp_json_encode($ids), + 'fee_status' => $charge, + ], ['source' => 'user', 'event_type' => 'manual_fallback', 'actor_user_id' => (int) $application['user_id']]); + if (is_wp_error($updated)) { + $this->evidence->delete($ids); + $this->refund($application); + return $updated; + } + return $this->view($updated); + } + + /** @return array */ + public function view(array $application): array { + $stage = (string) $application['status']; + $next = 'wait'; + if (in_array($stage, ['provider_pending', 'provider_processing'], true)) { + $next = 'poll'; + } elseif (in_array($stage, ['provider_failed', 'provider_unsupported', 'provider_expired'], true)) { + $next = 'manual_or_retry'; + } elseif ($stage === 'verified') { + $next = 'complete'; + } + return [ + 'uuid' => (string) $application['application_uuid'], + 'type' => (string) $application['type'], + 'status' => TransitionService::public_status($stage), + 'stage' => $stage, + 'next_action' => $next, + 'public_name' => (string) $application['public_name'], + 'public_desc' => (string) $application['public_desc'], + 'result_code' => (string) $application['provider_result_code'], + 'message' => (string) $application['provider_result_summary'], + 'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')), + 'qr_payload' => sanitize_text_field((string) ($application['qr_payload'] ?? '')), + 'expires_at' => (string) ($application['expires_at'] ?? ''), + 'created_at' => (string) $application['created_at'], + 'poll_interval' => (int) SettingsRuntime::get()['poll_interval_seconds'], + ]; + } + + private function apply_provider_result(array $application, ProviderResult $result, bool $starting) { + $data = $result->data; + if ($result->decision === ProviderResult::PENDING) { + $fee_status = (string) $application['fee_status']; + if ($starting) { + $charged = $this->charge($application); + if (is_wp_error($charged)) { + return $charged; + } + $fee_status = $charged; + } + $next = $starting ? 'provider_pending' : 'provider_processing'; + $fields = [ + 'provider_session_id' => sanitize_text_field((string) ($data['provider_session_id'] ?? '')), + 'provider_request_id' => sanitize_text_field((string) ($data['request_id'] ?? '')), + 'redirect_url' => esc_url_raw((string) ($data['redirect_url'] ?? ($application['redirect_url'] ?? ''))), + 'qr_payload' => sanitize_text_field((string) ($data['qr_payload'] ?? ($application['qr_payload'] ?? ''))), + 'provider_started_at' => $starting ? current_time('mysql', true) : ($application['provider_started_at'] ?? ''), + 'expires_at' => (string) ($data['expires_at'] ?? ($application['expires_at'] ?? '')), + 'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? 'pending')), + 'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')), + 'fee_status' => $fee_status, + ]; + $updated = $this->transitions->transition($application, $next, $fields, [ + 'source' => (string) $application['provider'], + 'event_type' => $starting ? 'session_started' : 'query_pending', + 'external_event_id' => (string) ($data['external_event_id'] ?? ''), + ]); + if (is_wp_error($updated)) { + if ($starting) { + $this->refund(array_merge($application, ['fee_status' => $fee_status])); + } + return $updated; + } + $updated['redirect_url'] = (string) ($updated['redirect_url'] ?? ''); + $updated['qr_payload'] = (string) ($updated['qr_payload'] ?? ''); + return $this->view($updated); + } + + $map = [ + ProviderResult::PASSED => 'verified', + ProviderResult::FAILED => 'provider_failed', + ProviderResult::UNSUPPORTED => 'provider_unsupported', + ProviderResult::EXPIRED => 'provider_expired', + ]; + $next = $map[$result->decision] ?? 'provider_failed'; + $settings = SettingsRuntime::get(); + if (in_array($next, ['provider_failed', 'provider_unsupported', 'provider_expired'], true) && empty($settings['allow_manual_fallback'])) { + $next = 'cancelled'; + } + $updated = $this->transitions->transition($application, $next, [ + 'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)), + 'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')), + ], [ + 'source' => (string) $application['provider'], + 'event_type' => 'provider_result', + 'external_event_id' => (string) ($data['external_event_id'] ?? ''), + 'result_code' => (string) ($data['result_code'] ?? $result->decision), + ]); + return is_wp_error($updated) ? $updated : $this->view($updated); + } + + /** @return string|\WP_Error */ + private function charge(array $application) { + if (in_array((string) $application['fee_status'], ['charged', 'not_required'], true)) { + return (string) $application['fee_status']; + } + $fee = (float) $application['fee_amount']; + if ($fee <= 0) { + return 'not_required'; + } + $result = \AKSRT\Credits\Balance::deduct( + (int) $application['user_id'], + $fee, + 'verify_application', + ['type' => 'identity', 'id' => (int) $application['id']], + sprintf(__('提交%s认证申请', 'aksrt'), (string) $application['type']) + ); + return is_wp_error($result) ? $result : 'charged'; + } + + private function refund(array $application): void { + if ((float) $application['fee_amount'] <= 0 || !in_array((string) $application['fee_status'], ['charged', 'pending'], true)) { + return; + } + \AKSRT\Credits\Balance::add( + (int) $application['user_id'], + (float) $application['fee_amount'], + 'verify_application_refund', + ['type' => 'identity', 'id' => (int) $application['id']], + __('认证会话创建失败退款', 'aksrt') + ); + $this->applications->update((int) $application['id'], ['fee_status' => 'refunded']); + } + + private function length(string $value): int { + return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + } +} diff --git a/src/AKSRT/Identity/CallbackController.php b/src/AKSRT/Identity/CallbackController.php new file mode 100644 index 00000000..ee235cea --- /dev/null +++ b/src/AKSRT/Identity/CallbackController.php @@ -0,0 +1,74 @@ + $provider === 'aliyun' ? ['GET', 'POST'] : 'POST', + 'callback' => function (\WP_REST_Request $request) use ($provider): \WP_REST_Response { + return $this->handle($provider, $request); + }, + 'permission_callback' => '__return_true', + ]); + } + } + + private function handle(string $provider_key, \WP_REST_Request $request): \WP_REST_Response { + $body = $request->get_body(); + if (strlen($body) > 1024 * 1024) { + return new \WP_REST_Response(['success' => false], 413); + } + $provider = ProviderFactory::make($provider_key); + if (!$provider->verify_callback($request)) { + return new \WP_REST_Response(['success' => false], 403); + } + $result = $provider->parse_callback($request); + $session = sanitize_text_field((string) ($result->data['provider_session_id'] ?? '')); + $outer_order = sanitize_text_field((string) ($result->data['outer_order_no'] ?? '')); + if ($session === '' && $outer_order === '') { + return new \WP_REST_Response(['success' => false], 422); + } + + global $wpdb; + $table = \AKSRT\Core\DBMigrator::table('identity_application'); + if ($session !== '') { + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$table} WHERE provider = %s AND provider_session_id = %s ORDER BY id DESC LIMIT 1", + $provider_key, + $session + ), ARRAY_A); + } else { + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$table} WHERE provider = %s AND application_uuid = %s ORDER BY id DESC LIMIT 1", + $provider_key, + $outer_order + ), ARRAY_A); + } + if (!is_array($row)) { + return new \WP_REST_Response(['success' => false], 404); + } + + // The callback only wakes server-side reconciliation. A provider query + // remains the source of truth for a successful identity decision. + $service = new ApplicationService(); + $response = $service->refresh($row); + if (is_wp_error($response)) { + return new \WP_REST_Response(['success' => false], 409); + } + return new \WP_REST_Response(['success' => true]); + } +} diff --git a/src/AKSRT/Identity/EventRepository.php b/src/AKSRT/Identity/EventRepository.php new file mode 100644 index 00000000..cc397a99 --- /dev/null +++ b/src/AKSRT/Identity/EventRepository.php @@ -0,0 +1,54 @@ +table = \AKSRT\Core\DBMigrator::table('identity_event'); + } + + /** @param array $event */ + public function record(array $event): bool { + global $wpdb; + $summary = is_array($event['summary'] ?? null) ? $event['summary'] : []; + $row = [ + 'application_id' => (int) ($event['application_id'] ?? 0), + 'event_uuid' => wp_generate_uuid4(), + 'source' => sanitize_key((string) ($event['source'] ?? 'system')), + 'event_type' => sanitize_key((string) ($event['event_type'] ?? 'status_changed')), + 'external_event_id' => sanitize_text_field((string) ($event['external_event_id'] ?? '')), + 'dedupe_key' => (string) ($event['dedupe_key'] ?? hash('sha256', wp_generate_uuid4())), + 'from_status' => sanitize_key((string) ($event['from_status'] ?? '')), + 'to_status' => sanitize_key((string) ($event['to_status'] ?? '')), + 'result_code' => sanitize_text_field((string) ($event['result_code'] ?? '')), + 'summary_json' => $summary !== [] ? wp_json_encode($summary) : null, + 'actor_user_id' => !empty($event['actor_user_id']) ? (int) $event['actor_user_id'] : null, + 'created_at' => current_time('mysql', true), + ]; + $inserted = $wpdb->insert($this->table, $row); + if ($inserted === 1) { + return true; + } + return str_contains(strtolower((string) $wpdb->last_error), 'duplicate'); + } + + /** @return array> */ + public function for_application(int $application_id): array { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table} WHERE application_id = %d ORDER BY id DESC", + $application_id + ), ARRAY_A); + return is_array($rows) ? $rows : []; + } +} diff --git a/src/AKSRT/Identity/EvidenceService.php b/src/AKSRT/Identity/EvidenceService.php new file mode 100644 index 00000000..9e3d144e --- /dev/null +++ b/src/AKSRT/Identity/EvidenceService.php @@ -0,0 +1,150 @@ +|\WP_Error */ + public function upload(int $user_id, string $application_uuid, $input) { + $files = $this->normalize($input); + if (is_wp_error($files) || $files === []) { + return $files; + } + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $ids = []; + foreach ($files as $file) { + $tmp = (string) ($file['tmp_name'] ?? ''); + $size = (int) ($file['size'] ?? 0); + if ((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || $size <= 0 || $size > 2 * MB_IN_BYTES || !is_uploaded_file($tmp)) { + $this->delete($ids); + return new \WP_Error('aksrt_identity_upload', __('证明材料上传失败,单张图片不能超过 2MB', 'aksrt')); + } + $checked = wp_check_filetype_and_ext($tmp, sanitize_file_name((string) ($file['name'] ?? 'evidence.jpg'))); + $mime = (string) ($checked['type'] ?? ''); + $ext = (string) ($checked['ext'] ?? ''); + if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true) || $ext === '') { + $this->delete($ids); + return new \WP_Error('aksrt_identity_upload_type', __('证明材料仅支持 JPG、PNG 或 WebP 图片', 'aksrt')); + } + $id = media_handle_sideload([ + 'name' => wp_generate_password(24, false, false) . '.' . $ext, + 'tmp_name' => $tmp, + 'type' => $mime, + 'error' => UPLOAD_ERR_OK, + 'size' => $size, + ], 0, __('身份认证证明材料', 'aksrt')); + if (is_wp_error($id)) { + $this->delete($ids); + return new \WP_Error('aksrt_identity_upload_write', __('证明材料写入失败', 'aksrt')); + } + $id = (int) $id; + wp_update_post(['ID' => $id, 'post_author' => $user_id, 'post_status' => 'private']); + update_post_meta($id, self::OWNER_META, $user_id); + update_post_meta($id, self::APPLICATION_META, $application_uuid); + $ids[] = $id; + } + return $ids; + } + + /** @return array */ + public function items(array $application, bool $admin = false): array { + $owner_id = (int) ($application['user_id'] ?? 0); + if ($owner_id <= 0 || ($admin ? !current_user_can('aksrt_review_identity') : get_current_user_id() !== $owner_id)) { + return []; + } + $items = []; + foreach ((array) ($application['evidence_ids'] ?? []) as $id) { + $id = (int) $id; + if ((int) get_post_meta($id, self::OWNER_META, true) !== $owner_id || (string) get_post_meta($id, self::APPLICATION_META, true) !== (string) $application['application_uuid']) { + continue; + } + $url = add_query_arg([ + 'action' => 'aksrt_verify_evidence', + 'attachment' => $id, + '_wpnonce' => wp_create_nonce('aksrt_verify_evidence_' . $id), + ], admin_url('admin-post.php')); + $items[] = ['id' => $id, 'url' => $url, 'thumbnail_url' => $url]; + } + return $items; + } + + public function serve(): void { + if (!is_user_logged_in()) { + wp_die(esc_html__('请先登录', 'aksrt'), '', ['response' => 401]); + } + $id = isset($_GET['attachment']) ? (int) $_GET['attachment'] : 0; + if ($id <= 0 || !check_admin_referer('aksrt_verify_evidence_' . $id)) { + wp_die(esc_html__('安全校验失败', 'aksrt'), '', ['response' => 403]); + } + $owner = (int) get_post_meta($id, self::OWNER_META, true); + if ($owner <= 0 || (get_current_user_id() !== $owner && !current_user_can('aksrt_review_identity'))) { + wp_die(esc_html__('您没有权限查看该证明材料', 'aksrt'), '', ['response' => 403]); + } + $path = get_attached_file($id); + $mime = (string) get_post_mime_type($id); + if (!is_string($path) || !is_file($path) || !is_readable($path)) { + wp_die(esc_html__('证明材料不存在', 'aksrt'), '', ['response' => 404]); + } + if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { + wp_die(esc_html__('证明材料类型无效', 'aksrt'), '', ['response' => 415]); + } + nocache_headers(); + header('Cache-Control: private, no-store, max-age=0'); + header('X-Content-Type-Options: nosniff'); + header('Content-Type: ' . $mime); + header('Content-Disposition: inline; filename="evidence-' . $id . '"'); + header('Content-Length: ' . (string) filesize($path)); + readfile($path); + exit; + } + + /** @return array>|\WP_Error */ + private function normalize($input) { + if (!is_array($input) || empty($input['name'])) { + return []; + } + $names = is_array($input['name']) ? $input['name'] : [$input['name']]; + if (count($names) > 3) { + return new \WP_Error('aksrt_identity_upload_limit', __('证明材料最多上传 3 张', 'aksrt')); + } + $files = []; + foreach ($names as $index => $name) { + if ((string) $name === '') { + continue; + } + $files[] = [ + 'name' => $name, + 'tmp_name' => is_array($input['tmp_name'] ?? null) ? ($input['tmp_name'][$index] ?? '') : ($input['tmp_name'] ?? ''), + 'error' => is_array($input['error'] ?? null) ? ($input['error'][$index] ?? UPLOAD_ERR_NO_FILE) : ($input['error'] ?? UPLOAD_ERR_NO_FILE), + 'size' => is_array($input['size'] ?? null) ? ($input['size'][$index] ?? 0) : ($input['size'] ?? 0), + ]; + } + return $files; + } + + /** @param array $ids */ + public function delete(array $ids): void { + foreach ($ids as $id) { + if ((int) $id > 0 && get_post_type((int) $id) === 'attachment') { + wp_delete_attachment((int) $id, true); + } + } + } +} diff --git a/src/AKSRT/Identity/Identity.php b/src/AKSRT/Identity/Identity.php new file mode 100644 index 00000000..dd3652ff --- /dev/null +++ b/src/AKSRT/Identity/Identity.php @@ -0,0 +1,48 @@ +init(); + add_action('init', [$this, 'ensure_reviewer_capability']); + add_action('delete_user', [$this, 'delete_user_data']); + } + + public function ensure_reviewer_capability(): void { + $role = get_role('administrator'); + if ($role && !$role->has_cap('aksrt_review_identity')) { + $role->add_cap('aksrt_review_identity'); + } + } + + public function delete_user_data(int $user_id): void { + global $wpdb; + $applications = \AKSRT\Core\DBMigrator::table('identity_application'); + $events = \AKSRT\Core\DBMigrator::table('identity_event'); + if ($applications === '' || $events === '') { + return; + } + $ids = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$applications} WHERE user_id = %d", $user_id)); + foreach ((array) $ids as $id) { + $wpdb->delete($events, ['application_id' => (int) $id], ['%d']); + } + $wpdb->delete($applications, ['user_id' => $user_id], ['%d']); + } +} diff --git a/src/AKSRT/Identity/Provider/AliyunProvider.php b/src/AKSRT/Identity/Provider/AliyunProvider.php new file mode 100644 index 00000000..ea82e677 --- /dev/null +++ b/src/AKSRT/Identity/Provider/AliyunProvider.php @@ -0,0 +1,195 @@ + true, 'enterprise' => true]; } + + public function validate_config() { + $cfg = SettingsRuntime::get(); + foreach (['access_key_id', 'access_key_secret'] as $key) { + if (empty($cfg['credentials'][$key])) { + return new \WP_Error('aksrt_identity_aliyun_credentials', __('阿里云实人认证凭证不完整', 'aksrt')); + } + } + if (empty($cfg['personal_flow'])) { + return new \WP_Error('aksrt_identity_aliyun_scene', __('请配置阿里云实人认证 SceneId', 'aksrt')); + } + if (!empty($cfg['enterprise_flow']) && preg_match('/[^A-Za-z0-9_-]/', (string) $cfg['enterprise_flow'])) { + return new \WP_Error('aksrt_identity_aliyun_enterprise_scene', __('阿里云企业认证 SceneCode 格式无效', 'aksrt')); + } + return true; + } + + public function start_personal(array $request): ProviderResult { + return $this->init_face_verify($request); + } + + public function start_enterprise(array $request): ProviderResult { + $cfg = SettingsRuntime::get(); + // Ref: https://help.aliyun.com/zh/id-verification/enterprise-factor-validation-v2 + foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) { + if (empty($request[$key])) { + return ProviderResult::failed('aliyun_enterprise_fields', __('企业认证资料不完整', 'aksrt')); + } + } + $response = $this->rpc('cloudauth.aliyuncs.com', '2022-11-25', 'EntElementVerifyV2', [ + 'SceneCode' => (string) ($cfg['enterprise_flow'] ?: $cfg['personal_flow']), + 'MerchantBizId' => preg_replace('/[^A-Za-z0-9]/', '', (string) $request['application_uuid']), + 'MerchantUserId' => preg_replace('/[^A-Za-z0-9]/', '', 'U' . (string) ($request['user_id'] ?? '0')), + 'UserAuthorization' => '1', + 'InfoVerifyType' => 'ENT_4META', + 'LicenseNo' => preg_replace('/\s+/', '', (string) $request['company_code']), + 'EntName' => (string) $request['company_name'], + 'LegalPersonName' => (string) $request['person_name'], + 'LegalPersonCertNo' => preg_replace('/\s+/', '', (string) $request['id_number']), + ]); + if (is_wp_error($response)) { + return ProviderResult::failed($response->get_error_code(), $response->get_error_message()); + } + if (!$this->enterprise_passed($response)) { + return ProviderResult::failed( + sanitize_text_field((string) ($response['Code'] ?? $response['ResultCode'] ?? 'enterprise_failed')), + sanitize_text_field((string) ($response['Message'] ?? __('企业要素或法人关系核验未通过', 'aksrt'))) + ); + } + return $this->init_face_verify($request); + } + + public function query(array $application): ProviderResult { + // Ref: https://api.aliyun.com/document/Cloudauth/2019-03-07/DescribeFaceVerify + $response = $this->rpc('cloudauth.aliyuncs.com', '2019-03-07', 'DescribeFaceVerify', [ + 'SceneId' => (string) ($application['provider_flow'] ?? ''), + 'CertifyId' => (string) ($application['provider_session_id'] ?? ''), + ]); + if (is_wp_error($response)) { + return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]); + } + $code = sanitize_text_field((string) ($response['Code'] ?? '')); + if ($code !== '' && $code !== '200') { + return ProviderResult::failed($code, sanitize_text_field((string) ($response['Message'] ?? ''))); + } + $passed = $response['ResultObject']['Passed'] ?? $response['Passed'] ?? null; + $success = $response['ResultObject']['Success'] ?? $response['Success'] ?? null; + if ((string) ($response['Code'] ?? '') === '200' && ($success === true || $success === 'True') && $passed === 'T') { + return ProviderResult::passed(['request_id' => (string) ($response['RequestId'] ?? ''), 'result_code' => 'passed']); + } + $status = strtoupper((string) ($response['ResultObject']['SubCode'] ?? $response['ResultObject']['Status'] ?? '')); + if ($status === '' || in_array($status, ['PROCESSING', 'INIT', 'PENDING'], true)) { + return ProviderResult::pending(['result_code' => $status ?: 'processing', 'message' => __('阿里云仍在处理认证结果', 'aksrt')]); + } + return ProviderResult::failed($status, sanitize_text_field((string) ($response['ResultObject']['MaterialInfo'] ?? __('实人认证未通过', 'aksrt')))); + } + + public function verify_callback(\WP_REST_Request $request): bool { + $token = sanitize_text_field((string) ($request->get_param('CallbackToken') ?: $request->get_param('callbackToken'))); + $session = sanitize_text_field((string) ($request->get_param('certifyId') ?: $request->get_param('CertifyId'))); + if ($token === '' || $session === '') { + return false; + } + global $wpdb; + $table = \AKSRT\Core\DBMigrator::table('identity_application'); + $uuid = (string) $wpdb->get_var($wpdb->prepare( + "SELECT application_uuid FROM {$table} WHERE provider = 'aliyun' AND provider_session_id = %s LIMIT 1", + $session + )); + return $uuid !== '' && hash_equals(hash_hmac('sha256', $uuid, wp_salt('nonce')), $token); + } + + public function parse_callback(\WP_REST_Request $request): ProviderResult { + $session = sanitize_text_field((string) ($request->get_param('certifyId') ?: $request->get_param('CertifyId'))); + $outer_order = sanitize_text_field((string) ($request->get_param('outerOrderNo') ?: $request->get_param('OuterOrderNo'))); + return ProviderResult::pending(['provider_session_id' => $session, 'outer_order_no' => $outer_order, 'result_code' => 'callback_received']); + } + + private function init_face_verify(array $request): ProviderResult { + // Ref: https://api.aliyun.com/document/Cloudauth/2019-03-07/InitFaceVerify + $cfg = SettingsRuntime::get(); + $callback_token = hash_hmac('sha256', (string) ($request['application_uuid'] ?? ''), wp_salt('nonce')); + $params = [ + 'SceneId' => (string) $cfg['personal_flow'], + 'OuterOrderNo' => (string) ($request['application_uuid'] ?? wp_generate_uuid4()), + 'ProductCode' => (string) $cfg['product_code'], + 'CertifyUrlType' => 'H5', + 'CertType' => 'IDENTITY_CARD', + 'CertName' => (string) ($request['person_name'] ?? ''), + 'CertNo' => preg_replace('/\s+/', '', (string) ($request['id_number'] ?? '')), + 'ReturnUrl' => esc_url_raw((string) ($request['return_url'] ?? '')), + 'CallbackUrl' => rest_url('aksrt/v1/identity/callback/aliyun'), + 'CallbackToken' => $callback_token, + ]; + $response = $this->rpc('cloudauth.aliyuncs.com', '2019-03-07', 'InitFaceVerify', $params); + if (is_wp_error($response)) { + return ProviderResult::failed($response->get_error_code(), $response->get_error_message()); + } + $code = sanitize_text_field((string) ($response['Code'] ?? '')); + if ($code !== '' && $code !== '200') { + return ProviderResult::failed($code, sanitize_text_field((string) ($response['Message'] ?? ''))); + } + $object = is_array($response['ResultObject'] ?? null) ? $response['ResultObject'] : []; + $certify_id = sanitize_text_field((string) ($object['CertifyId'] ?? $response['CertifyId'] ?? '')); + $url = esc_url_raw((string) ($object['CertifyUrl'] ?? $object['Url'] ?? $response['CertifyUrl'] ?? '')); + if ($certify_id === '' || $url === '') { + return ProviderResult::failed('aliyun_invalid_response', __('阿里云未返回有效认证会话', 'aksrt')); + } + return ProviderResult::pending([ + 'provider_session_id' => $certify_id, + 'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')), + 'redirect_url' => $url, + 'result_code' => 'session_created', + 'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)), + ]); + } + + /** @return array|\WP_Error */ + private function rpc(string $host, string $version, string $action, array $business) { + $cfg = SettingsRuntime::get(); + $endpoint = 'https://' . $host . '/'; + // The official PHP SDK uses ACS3-HMAC-SHA256 by default. This zero-dependency + // fallback deliberately uses the documented RPC v2 signature until the SDK + // packages can be installed and locked in this deployment. + $params = array_merge([ + 'Format' => 'JSON', + 'Version' => $version, + 'AccessKeyId' => (string) $cfg['credentials']['access_key_id'], + 'SignatureMethod' => 'HMAC-SHA1', + 'SignatureNonce' => wp_generate_uuid4(), + 'SignatureVersion' => '1.0', + 'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'), + 'Action' => $action, + ], $business); + ksort($params); + $canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986); + $string_to_sign = 'POST&%2F&' . rawurlencode($canonical); + $params['Signature'] = base64_encode(hash_hmac('sha1', $string_to_sign, (string) $cfg['credentials']['access_key_secret'] . '&', true)); + $response = wp_remote_post($endpoint, [ + 'timeout' => 20, + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'body' => $params, + ]); + if (is_wp_error($response)) { + return new \WP_Error('aliyun_network_error', __('阿里云接口请求失败', 'aksrt')); + } + $decoded = json_decode((string) wp_remote_retrieve_body($response), true); + return is_array($decoded) ? $decoded : new \WP_Error('aliyun_invalid_json', __('阿里云返回格式无效', 'aksrt')); + } + + private function enterprise_passed(array $response): bool { + $result = is_array($response['Result'] ?? null) ? $response['Result'] : []; + return (string) ($response['Code'] ?? '') === 'Success' + && (string) ($result['BizCode'] ?? '') === '1' + && (string) ($result['Status'] ?? '') === '1'; + } +} diff --git a/src/AKSRT/Identity/Provider/ProviderFactory.php b/src/AKSRT/Identity/Provider/ProviderFactory.php new file mode 100644 index 00000000..897870a3 --- /dev/null +++ b/src/AKSRT/Identity/Provider/ProviderFactory.php @@ -0,0 +1,21 @@ + $request */ + public function start_personal(array $request): ProviderResult; + + /** @param array $request */ + public function start_enterprise(array $request): ProviderResult; + + /** @param array $application */ + public function query(array $application): ProviderResult; + + public function verify_callback(\WP_REST_Request $request): bool; + + public function parse_callback(\WP_REST_Request $request): ProviderResult; +} diff --git a/src/AKSRT/Identity/Provider/ProviderResult.php b/src/AKSRT/Identity/Provider/ProviderResult.php new file mode 100644 index 00000000..f153915d --- /dev/null +++ b/src/AKSRT/Identity/Provider/ProviderResult.php @@ -0,0 +1,41 @@ + $data */ + public function __construct( + public readonly string $decision, + public readonly array $data = [] + ) {} + + public static function pending(array $data = []): self { + return new self(self::PENDING, $data); + } + + public static function passed(array $data = []): self { + return new self(self::PASSED, $data); + } + + public static function failed(string $code, string $message = ''): self { + return new self(self::FAILED, ['result_code' => $code, 'message' => $message]); + } + + public static function unsupported(string $message): self { + return new self(self::UNSUPPORTED, ['result_code' => 'unsupported', 'message' => $message]); + } +} diff --git a/src/AKSRT/Identity/Provider/TencentProvider.php b/src/AKSRT/Identity/Provider/TencentProvider.php new file mode 100644 index 00000000..8097a1bf --- /dev/null +++ b/src/AKSRT/Identity/Provider/TencentProvider.php @@ -0,0 +1,202 @@ + true, 'enterprise' => true]; } + + public function validate_config() { + $cfg = SettingsRuntime::get(); + foreach (['secret_id', 'secret_key'] as $key) { + if (empty($cfg['credentials'][$key])) { + return new \WP_Error('aksrt_identity_tencent_credentials', __('腾讯云人脸核身凭证不完整', 'aksrt')); + } + } + if (empty($cfg['personal_flow'])) { + return new \WP_Error('aksrt_identity_tencent_rule', __('请配置腾讯云人脸核身 RuleId', 'aksrt')); + } + return true; + } + + public function start_personal(array $request): ProviderResult { + // Ref: https://cloud.tencent.com/document/product/1007/31816 + $cfg = SettingsRuntime::get(); + $payload = [ + 'RuleId' => (string) $cfg['personal_flow'], + 'TerminalType' => $this->terminal_type(), + 'RedirectUrl' => esc_url_raw((string) ($request['return_url'] ?? '')), + 'Extra' => (string) ($request['application_uuid'] ?? ''), + ]; + if (!empty($request['person_name'])) { + $payload['Name'] = (string) $request['person_name']; + } + if (!empty($request['id_number'])) { + $payload['IdCard'] = preg_replace('/\s+/', '', (string) $request['id_number']); + } + $response = $this->request('DetectAuth', $payload); + if (is_wp_error($response)) { + return ProviderResult::failed($response->get_error_code(), $response->get_error_message()); + } + $data = is_array($response['Response'] ?? null) ? $response['Response'] : []; + if (!empty($data['Error'])) { + return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? '')); + } + $biz_token = sanitize_text_field((string) ($data['BizToken'] ?? '')); + $url = esc_url_raw((string) ($data['Url'] ?? '')); + if ($biz_token === '' || $url === '') { + return ProviderResult::failed('tencent_invalid_response', __('腾讯云未返回有效认证会话', 'aksrt')); + } + return ProviderResult::pending([ + 'provider_session_id' => $biz_token, + 'request_id' => sanitize_text_field((string) ($data['RequestId'] ?? '')), + 'redirect_url' => $url, + 'result_code' => 'session_created', + 'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)), + ]); + } + + public function start_enterprise(array $request): ProviderResult { + // Ref: https://cloud.tencent.com/document/product/866/129072 + foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) { + if (empty($request[$key])) { + return ProviderResult::failed('tencent_enterprise_fields', __('企业认证资料不完整', 'aksrt')); + } + } + $response = $this->request_service('ocr', 'ocr.tencentcloudapi.com', '2018-11-19', 'VerifyBizLicenseEnterprise4', [ + 'CreditCode' => preg_replace('/\s+/', '', (string) $request['company_code']), + 'EntName' => (string) $request['company_name'], + 'LrName' => (string) $request['person_name'], + 'IdNum' => preg_replace('/\s+/', '', (string) $request['id_number']), + ]); + if (is_wp_error($response)) { + return ProviderResult::failed($response->get_error_code(), $response->get_error_message()); + } + $data = is_array($response['Response'] ?? null) ? $response['Response'] : []; + if (!empty($data['Error'])) { + return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_ocr_error'), (string) ($data['Error']['Message'] ?? '')); + } + if (!$this->enterprise_passed($data)) { + return ProviderResult::failed('tencent_enterprise_mismatch', __('企业工商四要素或法人关系核验未通过', 'aksrt')); + } + return $this->start_personal($request); + } + + public function query(array $application): ProviderResult { + // Ref: https://cloud.tencent.com/document/product/1007/41957 + $response = $this->request('GetDetectInfoEnhanced', [ + 'BizToken' => (string) ($application['provider_session_id'] ?? ''), + 'RuleId' => (string) ($application['provider_flow'] ?? ''), + 'InfoType' => '0', + ]); + if (is_wp_error($response)) { + return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]); + } + $data = is_array($response['Response'] ?? null) ? $response['Response'] : []; + if (!empty($data['Error'])) { + return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? '')); + } + $text = $data['Text'] ?? ($data['DetectInfo']['Text'] ?? []); + if (is_string($text)) { + $decoded_text = json_decode($text, true); + $text = is_array($decoded_text) ? $decoded_text : []; + } elseif (!is_array($text)) { + $text = []; + } + $result = (string) ($text['ErrCode'] ?? ''); + $live_status = (string) ($text['LiveStatus'] ?? ''); + $compare_status = (string) ($text['Comparestatus'] ?? ''); + if ($result === '0' && ($live_status === '' || $live_status === '0') && ($compare_status === '' || $compare_status === '0')) { + return ProviderResult::passed(['request_id' => (string) ($data['RequestId'] ?? ''), 'result_code' => '0']); + } + $message = sanitize_text_field((string) ($text['ErrMsg'] ?? $text['LiveMsg'] ?? $text['Comparemsg'] ?? '')); + if ($result === '') { + return ProviderResult::pending(['result_code' => 'processing', 'message' => __('腾讯云仍在处理认证结果', 'aksrt')]); + } + return ProviderResult::failed($result, $message); + } + + public function verify_callback(\WP_REST_Request $request): bool { unset($request); return false; } + + public function parse_callback(\WP_REST_Request $request): ProviderResult { unset($request); return ProviderResult::unsupported(__('腾讯云人脸核身使用服务端主动查询,不接收结果回调', 'aksrt')); } + + /** @return array|\WP_Error */ + private function request(string $action, array $payload) { + return $this->request_service(self::SERVICE, self::ENDPOINT, self::VERSION, $action, $payload); + } + + /** @return array|\WP_Error */ + private function request_service(string $service, string $host, string $version, string $action, array $payload) { + $cfg = SettingsRuntime::get(); + $endpoint = 'https://' . $host; + $timestamp = time(); + $date = gmdate('Y-m-d', $timestamp); + $body = (string) wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $canonical_headers = "content-type:application/json; charset=utf-8\nhost:{$host}\nx-tc-action:" . strtolower($action) . "\n"; + $signed_headers = 'content-type;host;x-tc-action'; + $canonical_request = "POST\n/\n\n{$canonical_headers}\n{$signed_headers}\n" . hash('sha256', $body); + $scope = $date . '/' . $service . '/tc3_request'; + $string_to_sign = "TC3-HMAC-SHA256\n{$timestamp}\n{$scope}\n" . hash('sha256', $canonical_request); + $secret_key = (string) $cfg['credentials']['secret_key']; + $secret_date = hash_hmac('sha256', $date, 'TC3' . $secret_key, true); + $secret_service = hash_hmac('sha256', $service, $secret_date, true); + $secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true); + $signature = hash_hmac('sha256', $string_to_sign, $secret_signing); + $authorization = 'TC3-HMAC-SHA256 Credential=' . $cfg['credentials']['secret_id'] . '/' . $scope . ', SignedHeaders=' . $signed_headers . ', Signature=' . $signature; + $headers = [ + 'Authorization' => $authorization, + 'Content-Type' => 'application/json; charset=utf-8', + 'Host' => $host, + 'X-TC-Action' => $action, + 'X-TC-Timestamp' => (string) $timestamp, + 'X-TC-Version' => $version, + ]; + if ($service !== self::SERVICE && !empty($cfg['region'])) { + $headers['X-TC-Region'] = (string) $cfg['region']; + } + $response = wp_remote_post($endpoint, [ + 'timeout' => 20, + 'headers' => $headers, + 'body' => $body, + ]); + if (is_wp_error($response)) { + return new \WP_Error('tencent_network_error', __('腾讯云接口请求失败', 'aksrt')); + } + $decoded = json_decode((string) wp_remote_retrieve_body($response), true); + return is_array($decoded) ? $decoded : new \WP_Error('tencent_invalid_json', __('腾讯云返回格式无效', 'aksrt')); + } + + private function enterprise_passed(array $data): bool { + if ((string) ($data['StatusCode'] ?? '') !== '0' || (string) ($data['VerifyResult'] ?? '') !== '1') { + return false; + } + foreach (['IsCreditCodeConsistent', 'IsEntNameConsistent', 'IsLrNameConsistent', 'IsIdNumConsistent'] as $field) { + $value = $data[$field] ?? null; + if (!in_array($value, [true, 1, '1', 'true'], true)) { + return false; + } + } + $status = strtoupper((string) ($data['OperatingStatus'] ?? $data['RegStatus'] ?? '')); + return !in_array($status, ['吊销', '注销', 'REVOKED', 'CANCELLED'], true); + } + + private function terminal_type(): string { + $ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + return str_contains($ua, 'mobile') ? '1' : '2'; + } +} diff --git a/src/AKSRT/Identity/RestController.php b/src/AKSRT/Identity/RestController.php new file mode 100644 index 00000000..0254c230 --- /dev/null +++ b/src/AKSRT/Identity/RestController.php @@ -0,0 +1,102 @@ +applications ??= new ApplicationRepository(); + $this->service ??= new ApplicationService($this->applications); + } + + public function register_routes(): void { + register_rest_route(self::NS, '/identity/applications', [ + 'methods' => 'POST', + 'callback' => [$this, 'create'], + 'permission_callback' => [$this, 'logged_in'], + ]); + register_rest_route(self::NS, '/identity/applications/current', [ + 'methods' => 'GET', + 'callback' => [$this, 'current'], + 'permission_callback' => [$this, 'logged_in'], + ]); + register_rest_route(self::NS, '/identity/applications/(?P[a-f0-9-]{36})', [ + 'methods' => 'GET', + 'callback' => [$this, 'get'], + 'permission_callback' => [$this, 'owns_application'], + ]); + register_rest_route(self::NS, '/identity/applications/(?P[a-f0-9-]{36})/refresh', [ + 'methods' => 'POST', + 'callback' => [$this, 'refresh'], + 'permission_callback' => [$this, 'owns_application'], + ]); + register_rest_route(self::NS, '/identity/applications/(?P[a-f0-9-]{36})/manual', [ + 'methods' => 'POST', + 'callback' => [$this, 'manual'], + 'permission_callback' => [$this, 'owns_application'], + ]); + } + + public function logged_in(): bool { + return is_user_logged_in(); + } + + public function owns_application(\WP_REST_Request $request): bool { + if (!is_user_logged_in()) { + return false; + } + $application = $this->applications->find_by_uuid((string) $request['uuid']); + return is_array($application) && (int) $application['user_id'] === get_current_user_id(); + } + + public function create(\WP_REST_Request $request): \WP_REST_Response { + $params = $request->get_json_params(); + $params = is_array($params) ? $params : []; + $result = $this->service->create(get_current_user_id(), $params); + return $this->respond($result, 201); + } + + public function current(): \WP_REST_Response { + $application = $this->applications->latest_for_user(get_current_user_id()); + return new \WP_REST_Response(['success' => true, 'data' => $application ? $this->service->view($application) : null]); + } + + public function get(\WP_REST_Request $request): \WP_REST_Response { + $application = $this->applications->find_by_uuid((string) $request['uuid']); + return new \WP_REST_Response(['success' => true, 'data' => $this->service->view($application)]); + } + + public function refresh(\WP_REST_Request $request): \WP_REST_Response { + $application = $this->applications->find_by_uuid((string) $request['uuid']); + return $this->respond($this->service->refresh($application)); + } + + public function manual(\WP_REST_Request $request): \WP_REST_Response { + $application = $this->applications->find_by_uuid((string) $request['uuid']); + return $this->respond($this->service->move_to_manual($application, $_FILES['evidence'] ?? null)); + } + + private function respond($result, int $success_status = 200): \WP_REST_Response { + if (is_wp_error($result)) { + $status = in_array($result->get_error_code(), ['aksrt_identity_active', 'aksrt_identity_verified', 'aksrt_identity_conflict'], true) ? 409 : 422; + return new \WP_REST_Response([ + 'success' => false, + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], $status); + } + return new \WP_REST_Response(['success' => true, 'data' => $result], $success_status); + } +} diff --git a/src/AKSRT/Identity/SecretStore.php b/src/AKSRT/Identity/SecretStore.php new file mode 100644 index 00000000..6fed907b --- /dev/null +++ b/src/AKSRT/Identity/SecretStore.php @@ -0,0 +1,135 @@ + */ + public static function get(string $provider): array { + $stored = get_option(self::OPTION_KEY, []); + $record = is_array($stored) ? ($stored[sanitize_key($provider)] ?? null) : null; + if (!is_array($record)) { + return []; + } + + $plain = self::decrypt($record); + if ($plain === '') { + return []; + } + $data = json_decode($plain, true); + if (!is_array($data)) { + return []; + } + + $clean = []; + foreach ($data as $key => $value) { + if (is_string($key) && is_string($value)) { + $clean[sanitize_key($key)] = $value; + } + } + return $clean; + } + + /** @param array $changes */ + public static function update(string $provider, array $changes): bool { + $provider = sanitize_key($provider); + if (!in_array($provider, ['aliyun', 'tencent'], true)) { + return false; + } + + $current = self::get($provider); + foreach ($changes as $key => $value) { + $key = sanitize_key((string) $key); + if ($key === '') { + continue; + } + if ($value === null) { + unset($current[$key]); + } elseif (is_string($value) && $value !== '') { + $current[$key] = $value; + } + } + + $stored = get_option(self::OPTION_KEY, []); + $stored = is_array($stored) ? $stored : []; + if ($current === []) { + unset($stored[$provider]); + } else { + $record = self::encrypt((string) wp_json_encode($current)); + if (is_wp_error($record)) { + return false; + } + $stored[$provider] = $record; + } + return update_option(self::OPTION_KEY, $stored, false) || get_option(self::OPTION_KEY, []) === $stored; + } + + /** @return array */ + public static function status(string $provider): array { + $status = []; + foreach (self::get($provider) as $key => $value) { + $status[$key] = [ + 'configured' => $value !== '', + 'fingerprint' => $value !== '' ? substr(hash('sha256', $value), -4) : '', + ]; + } + return $status; + } + + /** @return array|\WP_Error */ + private static function encrypt(string $plain) { + $key = self::key(); + if (function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt')) { + $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); + $cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plain, self::CONTEXT, $nonce, $key); + return ['driver' => 'sodium', 'nonce' => base64_encode($nonce), 'cipher' => base64_encode($cipher)]; + } + if (function_exists('openssl_encrypt')) { + $nonce = random_bytes(12); + $tag = ''; + $cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT); + if (!is_string($cipher)) { + return new \WP_Error('aksrt_identity_encrypt', __('身份认证凭证加密失败', 'aksrt')); + } + return ['driver' => 'openssl', 'nonce' => base64_encode($nonce), 'tag' => base64_encode($tag), 'cipher' => base64_encode($cipher)]; + } + return new \WP_Error('aksrt_identity_crypto', __('服务器不支持安全凭证加密', 'aksrt')); + } + + /** @param array $record */ + private static function decrypt(array $record): string { + $nonce = base64_decode((string) ($record['nonce'] ?? ''), true); + $cipher = base64_decode((string) ($record['cipher'] ?? ''), true); + if (!is_string($nonce) || !is_string($cipher)) { + return ''; + } + $key = self::key(); + if (($record['driver'] ?? '') === 'sodium' && function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt')) { + $plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, self::CONTEXT, $nonce, $key); + return is_string($plain) ? $plain : ''; + } + if (($record['driver'] ?? '') === 'openssl' && function_exists('openssl_decrypt')) { + $tag = base64_decode((string) ($record['tag'] ?? ''), true); + if (!is_string($tag)) { + return ''; + } + $plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT); + return is_string($plain) ? $plain : ''; + } + return ''; + } + + private static function key(): string { + return hash_hmac('sha256', self::CONTEXT, wp_salt('auth') . wp_salt('secure_auth'), true); + } +} diff --git a/src/AKSRT/Identity/SettingsRuntime.php b/src/AKSRT/Identity/SettingsRuntime.php new file mode 100644 index 00000000..53965861 --- /dev/null +++ b/src/AKSRT/Identity/SettingsRuntime.php @@ -0,0 +1,62 @@ + */ + public static function get(): array { + $settings = \AKSRT\Settings\Settings::get_all(); + $provider = sanitize_key((string) ($settings['user_verify_provider'] ?? 'aliyun')); + if (!in_array($provider, ['aliyun', 'tencent'], true)) { + $provider = 'aliyun'; + } + + return [ + 'enabled' => !isset($settings['user_verify_enabled']) || !empty($settings['user_verify_enabled']), + 'allow_apply' => !isset($settings['user_verify_allow_apply']) || !empty($settings['user_verify_allow_apply']), + 'provider' => $provider, + 'allow_manual_fallback' => !isset($settings['user_verify_allow_manual_fallback']) || !empty($settings['user_verify_allow_manual_fallback']), + 'session_ttl_minutes' => max(5, min(1440, (int) ($settings['user_verify_session_ttl_minutes'] ?? 30))), + 'poll_interval_seconds' => max(3, min(60, (int) ($settings['user_verify_poll_interval_seconds'] ?? 5))), + 'max_retries' => max(0, min(3, (int) ($settings['user_verify_max_retries'] ?? 1))), + 'personal_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_personal_flow'] ?? '')), + 'product_code' => $provider === 'aliyun' && in_array(($settings['user_verify_aliyun_product_code'] ?? 'ID_PRO'), ['ID_PRO', 'PV_FV', 'LR_FR'], true) + ? (string) $settings['user_verify_aliyun_product_code'] + : 'ID_PRO', + 'enterprise_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_enterprise_flow'] ?? '')), + 'region' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_region'] ?? '')), + 'credentials' => SecretStore::get($provider), + ]; + } + + public static function hmac(string $value): string { + return hash_hmac('sha256', trim($value), wp_salt('secure_auth') . '|aksrt-identity-hmac'); + } + + public static function mask_id_number(string $value): string { + $value = preg_replace('/\s+/', '', $value) ?: ''; + $length = strlen($value); + if ($length <= 8) { + return str_repeat('*', max(0, $length - 2)) . substr($value, -2); + } + return substr($value, 0, 3) . str_repeat('*', $length - 7) . substr($value, -4); + } + + public static function mask_name(string $value): string { + $value = trim($value); + $length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + if ($length <= 1) { + return '*'; + } + $first = function_exists('mb_substr') ? mb_substr($value, 0, 1) : substr($value, 0, 1); + return $first . str_repeat('*', max(1, $length - 1)); + } +} diff --git a/src/AKSRT/Identity/TransitionService.php b/src/AKSRT/Identity/TransitionService.php new file mode 100644 index 00000000..a543a120 --- /dev/null +++ b/src/AKSRT/Identity/TransitionService.php @@ -0,0 +1,106 @@ + ['provider_pending', 'provider_failed', 'provider_unsupported', 'provider_expired', 'manual_pending', 'cancelled'], + 'provider_pending' => ['provider_processing', 'provider_failed', 'provider_unsupported', 'provider_expired', 'verified', 'manual_pending', 'cancelled'], + 'provider_processing' => ['provider_failed', 'provider_unsupported', 'provider_expired', 'verified', 'manual_pending'], + 'provider_failed' => ['provider_pending', 'manual_pending', 'cancelled'], + 'provider_unsupported' => ['manual_pending', 'cancelled'], + 'provider_expired' => ['provider_pending', 'manual_pending', 'cancelled'], + 'manual_pending' => ['verified', 'rejected', 'cancelled'], + 'verified' => ['cancelled'], + ]; + + public function __construct( + private ?ApplicationRepository $applications = null, + private ?EventRepository $events = null + ) { + $this->applications ??= new ApplicationRepository(); + $this->events ??= new EventRepository(); + } + + /** @param array $fields @param array $event */ + public function transition(array $application, string $next, array $fields = [], array $event = []) { + $current = sanitize_key((string) ($application['status'] ?? '')); + $next = sanitize_key($next); + if (!in_array($next, self::ALLOWED[$current] ?? [], true)) { + return new \WP_Error('aksrt_identity_transition', __('该认证记录当前不可执行此操作', 'aksrt')); + } + if (in_array($next, ['verified', 'rejected', 'cancelled'], true)) { + $fields['completed_at'] = current_time('mysql', true); + $fields['sensitive_blob'] = null; + } + if (!$this->applications->transition((int) $application['id'], $current, $next, $fields)) { + return new \WP_Error('aksrt_identity_conflict', __('认证状态已变化,请刷新后重试', 'aksrt')); + } + + $dedupe = (string) ($event['dedupe_key'] ?? hash('sha256', implode('|', [ + (string) $application['application_uuid'], $current, $next, wp_generate_uuid4(), + ]))); + $this->events->record([ + 'application_id' => (int) $application['id'], + 'source' => (string) ($event['source'] ?? 'system'), + 'event_type' => (string) ($event['event_type'] ?? 'status_changed'), + 'external_event_id' => (string) ($event['external_event_id'] ?? ''), + 'dedupe_key' => $dedupe, + 'from_status' => $current, + 'to_status' => $next, + 'result_code' => (string) ($event['result_code'] ?? ''), + 'summary' => is_array($event['summary'] ?? null) ? $event['summary'] : [], + 'actor_user_id' => (int) ($event['actor_user_id'] ?? 0), + ]); + + if ($next === 'verified') { + $this->send_notification((int) $application['user_id'], 'verified', ''); + } elseif ($next === 'rejected' || $next === 'cancelled') { + $this->send_notification((int) $application['user_id'], $next, (string) ($fields['review_note'] ?? '')); + } + do_action('aksrt_user_auth_reviewed', (int) $application['user_id'], self::public_status($next), (string) ($fields['review_note'] ?? ''), (int) ($fields['reviewer_id'] ?? 0)); + + return $this->applications->find_by_id((int) $application['id']); + } + + public static function public_status(string $status): string { + if ($status === 'verified') { + return 'verified'; + } + if ($status === 'cancelled') { + return 'cancelled'; + } + if ($status === 'rejected') { + return 'rejected'; + } + return in_array($status, ApplicationRepository::ACTIVE_STATUSES, true) ? 'pending' : 'rejected'; + } + + private function send_notification(int $user_id, string $status, string $note): void { + if (!class_exists('\\AKSRT\\Messages\\Message')) { + return; + } + if ($status === 'verified') { + $title = __('官方认证结果', 'aksrt'); + $message = __('您的官方认证已通过', 'aksrt'); + } elseif ($status === 'cancelled') { + $title = __('官方认证已取消', 'aksrt'); + $message = sprintf(__('您的官方认证已被取消:%s', 'aksrt'), $note); + } else { + $title = __('官方认证结果', 'aksrt'); + $message = sprintf(__('您的官方认证申请未通过:%s', 'aksrt'), $note); + } + \AKSRT\Messages\Message::get_instance()->send($user_id, 'auth_apply_reply', $message, [ + 'title' => $title, + 'target_url' => aksrt_get_user_center_tab_url('auth'), + ]); + } +} diff --git a/src/AKSRT/Settings/Capabilities.php b/src/AKSRT/Settings/Capabilities.php index b7228a23..4d1a2dcd 100644 --- a/src/AKSRT/Settings/Capabilities.php +++ b/src/AKSRT/Settings/Capabilities.php @@ -147,8 +147,7 @@ class Capabilities { } if (!empty($row['auth'])) { - $verify_status = get_user_meta($user_id, 'aksrt_user_verify_status', true); - if ($verify_status === 'verified') { + if (class_exists('\\AKSRT\\User\\AuthApply') && \AKSRT\User\AuthApply::is_verified($user_id)) { return true; } } diff --git a/src/AKSRT/Settings/IdentityAdmin.php b/src/AKSRT/Settings/IdentityAdmin.php new file mode 100644 index 00000000..06efc57a --- /dev/null +++ b/src/AKSRT/Settings/IdentityAdmin.php @@ -0,0 +1,106 @@ + ['access_key_id', 'access_key_secret'], + 'tencent' => ['secret_id', 'secret_key'], + ]; + + /** @return array */ + public static function read(): array { + $stored = Settings::get_all(); + $provider = sanitize_key((string) ($stored['user_verify_provider'] ?? 'aliyun')); + if (!isset(self::PROVIDER_FIELDS[$provider])) { + $provider = 'aliyun'; + } + return [ + 'provider' => $provider, + 'providers' => [ + 'aliyun' => self::provider_state('aliyun'), + 'tencent' => self::provider_state('tencent'), + ], + ]; + } + + /** @param array $input @return array */ + public static function update(array $input): array { + if (array_key_exists('provider', $input)) { + $provider = sanitize_key((string) $input['provider']); + if (isset(self::PROVIDER_FIELDS[$provider])) { + Settings::write_domain('users', 'verify', ['user_verify_provider' => $provider]); + } + } + + $providers = isset($input['providers']) && is_array($input['providers']) ? $input['providers'] : []; + foreach (self::PROVIDER_FIELDS as $provider => $fields) { + $changes = isset($providers[$provider]) && is_array($providers[$provider]) ? $providers[$provider] : []; + $updates = []; + if (!empty($changes['clear_credentials'])) { + foreach ($fields as $field) { + $updates[$field] = null; + } + } else { + $credentials = isset($changes['credentials']) && is_array($changes['credentials']) ? $changes['credentials'] : []; + foreach ($fields as $field) { + $value = trim((string) ($credentials[$field] ?? '')); + if ($value !== '') { + $updates[$field] = $value; + } + } + } + if ($updates !== []) { + SecretStore::update($provider, $updates); + } + } + return self::read(); + } + + /** @return array{success:bool,message:string} */ + public static function test(string $provider): array { + if (!isset(self::PROVIDER_FIELDS[$provider])) { + return ['success' => false, 'message' => __('不支持的身份认证服务商', 'aksrt')]; + } + $valid = ProviderFactory::make($provider)->validate_config(); + if (is_wp_error($valid)) { + return ['success' => false, 'message' => $valid->get_error_message()]; + } + return ['success' => true, 'message' => sprintf(__('%s配置检查通过', 'aksrt'), self::label($provider))]; + } + + /** @return array */ + private static function provider_state(string $provider): array { + $secrets = SecretStore::get($provider); + $configured_fields = []; + $missing = []; + foreach (self::PROVIDER_FIELDS[$provider] as $field) { + $configured = !empty($secrets[$field]); + $configured_fields[$field] = $configured; + if (!$configured) { + $missing[] = $field; + } + } + return [ + 'label' => self::label($provider), + 'configured' => $missing === [], + 'configured_fields' => $configured_fields, + 'missing_fields' => $missing, + ]; + } + + private static function label(string $provider): string { + return $provider === 'aliyun' ? __('阿里云', 'aksrt') : __('腾讯云', 'aksrt'); + } +} diff --git a/src/AKSRT/Settings/Settings.php b/src/AKSRT/Settings/Settings.php index 43703d37..cd6f755d 100644 --- a/src/AKSRT/Settings/Settings.php +++ b/src/AKSRT/Settings/Settings.php @@ -46,6 +46,7 @@ class Settings { 'aksrt_friend_links', 'aksrt_banners', 'aksrt_category_recommend', + 'aksrt_identity_secrets', ]; /** @@ -54,6 +55,7 @@ class Settings { public const SECRET_OPTION_KEYS = [ 'aksrt_mail_smtp', 'aksrt_oauth', + 'aksrt_identity_secrets', ]; private function __construct() {} @@ -582,6 +584,54 @@ class Settings { return UserRuntime::get_user_identity_settings(); } + /** @return array */ + public static function get_identity_verification_settings(): array { + return \AKSRT\Identity\SettingsRuntime::get(); + } + + /** @return array */ + public static function get_identity_provider_secrets(string $provider): array { + return \AKSRT\Identity\SecretStore::get($provider); + } + + /** @param array $changes */ + public static function update_identity_provider_secrets(string $provider, array $changes): bool { + return \AKSRT\Identity\SecretStore::update($provider, $changes); + } + + /** @return array */ + public static function get_identity_provider_secret_status(string $provider): array { + return \AKSRT\Identity\SecretStore::status($provider); + } + + /** + * Remove secrets from settings export and backup payloads. + * + * @param array $payload + * @return array + */ + public static function redact_sensitive_settings(array $payload): array { + if (isset($payload['standalone']) && is_array($payload['standalone'])) { + foreach (self::SECRET_OPTION_KEYS as $option_key) { + unset($payload['standalone'][$option_key]); + } + } + return $payload; + } + + /** + * Ignore secret options and redacted placeholders during import. + * + * @param array $standalone + * @return array + */ + public static function filter_sensitive_import(array $standalone): array { + foreach (self::SECRET_OPTION_KEYS as $option_key) { + unset($standalone[$option_key]); + } + return $standalone; + } + /** diff --git a/src/AKSRT/Settings/UserRuntime.php b/src/AKSRT/Settings/UserRuntime.php index 6710a67d..c3353c03 100644 --- a/src/AKSRT/Settings/UserRuntime.php +++ b/src/AKSRT/Settings/UserRuntime.php @@ -159,7 +159,7 @@ class UserRuntime { 'badge_text' => trim((string) ($settings['user_verify_personal_badge_text'] ?? '')) ?: __('个人认证', 'aksrt'), 'fee' => max(0, round((float) ($settings['user_verify_personal_fee'] ?? 0), 2)), 'free_applications' => max(0, (int) ($settings['user_verify_personal_free_applications'] ?? 0)), - 'require_real_name' => !empty($settings['user_verify_personal_require_real_name']), + 'require_real_name' => true, ], 'enterprise' => [ 'slug' => 'enterprise', @@ -167,7 +167,7 @@ class UserRuntime { 'badge_text' => trim((string) ($settings['user_verify_enterprise_badge_text'] ?? '')) ?: __('企业认证', 'aksrt'), 'fee' => max(0, round((float) ($settings['user_verify_enterprise_fee'] ?? 0), 2)), 'free_applications' => max(0, (int) ($settings['user_verify_enterprise_free_applications'] ?? 0)), - 'require_real_name' => !empty($settings['user_verify_enterprise_require_real_name']), + 'require_real_name' => true, ], ]; $medal = self::parse_medal_catalog($settings); @@ -179,6 +179,9 @@ class UserRuntime { 'allow_apply' => !isset($settings['user_verify_allow_apply']) || !empty($settings['user_verify_allow_apply']), 'disable_desc' => (string) ($settings['user_verify_disable_desc'] ?? ''), 'desc' => (string) ($settings['user_verify_desc'] ?? ''), + 'provider' => in_array(($settings['user_verify_provider'] ?? 'aliyun'), ['aliyun', 'tencent'], true) ? (string) $settings['user_verify_provider'] : 'aliyun', + 'allow_manual_fallback' => !isset($settings['user_verify_allow_manual_fallback']) || !empty($settings['user_verify_allow_manual_fallback']), + 'poll_interval_seconds' => max(3, min(60, (int) ($settings['user_verify_poll_interval_seconds'] ?? 5))), 'types' => $verify_types, ], 'level' => [ diff --git a/src/AKSRT/User/AuthApply.php b/src/AKSRT/User/AuthApply.php index 22d29ee0..782a5c70 100644 --- a/src/AKSRT/User/AuthApply.php +++ b/src/AKSRT/User/AuthApply.php @@ -1,645 +1,178 @@ */ + public static function settings(): array { + $identity = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_user_identity_settings() : []; + return is_array($identity['verify'] ?? null) ? $identity['verify'] : []; + } - /** @return array{enabled:bool,allow_apply:bool,public_display:bool,limit_level:int,desc:string,disable_desc:string,types:array} */ - public static function settings(): array { - $identity = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_user_identity_settings() : []; - return is_array($identity['verify'] ?? null) ? $identity['verify'] : []; - } + /** @return array */ + public static function type_settings(string $type): array { + $types = self::settings()['types'] ?? []; + $type = sanitize_key($type); + return is_array($types[$type] ?? null) ? $types[$type] : []; + } - /** @return array */ - public static function type_settings(string $type): array { - $types = self::settings()['types'] ?? []; - return is_array($types[sanitize_key($type)] ?? null) ? $types[sanitize_key($type)] : []; - } + public static function application_count(int $user_id, string $type): int { + return (new ApplicationRepository())->count_user_type($user_id, sanitize_key($type)); + } - public static function application_count(int $user_id, string $type): int { - $counts = get_user_meta($user_id, self::APPLICATION_COUNTS_META, true); - $counts = is_array($counts) ? $counts : []; - return max(0, (int) ($counts[sanitize_key($type)] ?? 0)); - } + public static function get_status(int $user_id): string { + $repo = new ApplicationRepository(); + $application = $repo->verified_for_user($user_id) ?: $repo->latest_for_user($user_id); + return $application ? TransitionService::public_status((string) $application['status']) : ''; + } - private static function increment_application_count(int $user_id, string $type): void { - $counts = get_user_meta($user_id, self::APPLICATION_COUNTS_META, true); - $counts = is_array($counts) ? $counts : []; - $type = sanitize_key($type); - $counts[$type] = max(0, (int) ($counts[$type] ?? 0)) + 1; - update_user_meta($user_id, self::APPLICATION_COUNTS_META, $counts); - } + public static function is_verified(int $user_id): bool { + return (new ApplicationRepository())->verified_for_user($user_id) !== null; + } - public static function get_status(int $user_id): string { - if ($user_id <= 0) { - return ''; - } - return sanitize_key((string) get_user_meta($user_id, self::STATUS_META, true)); - } + /** @return array */ + public static function get_auth_info(int $user_id): array { + $application = (new ApplicationRepository())->verified_for_user($user_id); + return $application ? [ + 'name' => (string) $application['public_name'], + 'desc' => (string) $application['public_desc'], + 'type' => (string) $application['type'], + 'time' => (string) ($application['completed_at'] ?: $application['updated_at']), + ] : []; + } - public static function is_verified(int $user_id): bool { - return self::get_status($user_id) === 'verified'; - } + /** @return array */ + public static function get_apply_payload(int $user_id): array { + $application = (new ApplicationRepository())->latest_for_user($user_id); + if (!$application) { + return []; + } + return [ + 'application_uuid' => (string) $application['application_uuid'], + 'user_id' => (int) $application['user_id'], + 'name' => (string) $application['public_name'], + 'real_name' => (string) $application['person_name_masked'], + 'desc' => (string) $application['public_desc'], + 'msg' => (string) ($application['manual_reason'] ?? ''), + 'type' => (string) $application['type'], + 'time' => (string) $application['created_at'], + 'fee' => (float) $application['fee_amount'], + '_application' => $application, + ]; + } - public function add_admin_user_column(array $columns): array { - $columns['aksrt_identity_status'] = __('身份认证', 'aksrt'); - return $columns; - } + /** @return array */ + public static function get_evidence_items(array $application, int $owner_id = 0, bool $admin_context = false): array { + $record = is_array($application['_application'] ?? null) ? $application['_application'] : null; + if (!$record && !empty($application['application_uuid'])) { + $record = (new ApplicationRepository())->find_by_uuid((string) $application['application_uuid']); + } + return $record ? (new EvidenceService())->items($record, $admin_context) : []; + } - public function render_admin_user_column(string $output, string $column_name, int $user_id): string { - if ($column_name !== 'aksrt_identity_status') { - return $output; - } - $status = self::get_status($user_id); - $labels = self::admin_status_labels(); - if ($status === '' || !isset($labels[$status])) { - return '' . esc_html__('未认证', 'aksrt') . ''; - } - $colors = ['pending' => '#996800', 'verified' => '#008a20', 'rejected' => '#b32d2e', 'cancelled' => '#646970']; - $html = '' . esc_html($labels[$status]) . ''; - $info = self::get_auth_info($user_id); - if ($status === 'verified' && !empty($info['name'])) { - $html .= '
' . esc_html((string) $info['name']) . '
'; - } - if ($status === 'pending') { - $html .= ''; - } - return $html; - } + /** @return array */ + public static function application_view(array $application, int $owner_id = 0, bool $admin_context = false): array { + return [ + 'name' => (string) ($application['name'] ?? ''), + 'real_name' => (string) ($application['real_name'] ?? ''), + 'desc' => (string) ($application['desc'] ?? ''), + 'msg' => (string) ($application['msg'] ?? ''), + 'type' => sanitize_key((string) ($application['type'] ?? '')), + 'time' => (string) ($application['time'] ?? ''), + 'evidence' => self::get_evidence_items($application, $owner_id, $admin_context), + ]; + } - public function render_admin_profile_status(\WP_User $user): void { - $status = self::get_status((int) $user->ID); - $labels = self::admin_status_labels(); - $apply = self::get_apply_payload((int) $user->ID); - $info = self::get_auth_info((int) $user->ID); - $reason = (string) get_user_meta((int) $user->ID, self::REASON_META, true); - $reviewed_at = (string) get_user_meta((int) $user->ID, self::REVIEWED_AT_META, true); - $reviewer_id = (int) get_user_meta((int) $user->ID, self::REVIEWER_META, true); - $reviewer = $reviewer_id > 0 ? get_userdata($reviewer_id) : null; - $display = $status !== '' && isset($labels[$status]) ? $labels[$status] : __('未认证', 'aksrt'); - $identity_name = (string) ($info['name'] ?? $apply['name'] ?? ''); - ?> -

- - - - - - - - - - - - - - - - > */ + public static function get_review_history(int $user_id): array { + $application = (new ApplicationRepository())->latest_for_user($user_id); + if (!$application) { + return []; + } + $history = []; + foreach ((new EventRepository())->for_application((int) $application['id']) as $event) { + $history[] = [ + 'status' => TransitionService::public_status((string) ($event['to_status'] ?? '')), + 'reason' => (string) ($event['result_code'] ?? ''), + 'reviewer' => (int) ($event['actor_user_id'] ?? 0), + 'time' => (string) ($event['created_at'] ?? ''), + ]; + } + return $history; + } - /** @return array */ - private static function admin_status_labels(): array { - return [ - 'pending' => __('待审核', 'aksrt'), - 'verified' => __('已认证', 'aksrt'), - 'rejected' => __('已驳回', 'aksrt'), - 'cancelled' => __('已取消', 'aksrt'), - ]; - } + public static function review(int $user_id, string $status, string $note, int $reviewer_id) { + $repo = new ApplicationRepository(); + $application = $repo->latest_for_user($user_id); + if (!$application) { + return new \WP_Error('aksrt_verify_missing', __('认证申请不存在', 'aksrt')); + } + $status = sanitize_key($status); + $note = sanitize_textarea_field($note); + if (!in_array($status, ['verified', 'rejected', 'cancelled'], true)) { + return new \WP_Error('aksrt_verify_status', __('审核状态无效', 'aksrt')); + } + if (in_array($status, ['rejected', 'cancelled'], true) && $note === '') { + return new \WP_Error('aksrt_verify_note', __('请填写处理原因', 'aksrt')); + } + $result = (new TransitionService($repo))->transition($application, $status, [ + 'review_note' => $note, + 'reviewer_id' => $reviewer_id, + ], ['source' => 'admin', 'event_type' => 'manual_review', 'actor_user_id' => $reviewer_id]); + return is_wp_error($result) ? $result : ['status' => $status, 'reviewed_at' => (string) $result['completed_at']]; + } - /** @return array */ - public static function get_auth_info(int $user_id): array { - $info = get_user_meta($user_id, self::INFO_META, true); - return is_array($info) ? $info : []; - } + public function add_admin_user_column(array $columns): array { + $columns['aksrt_identity_status'] = __('身份认证', 'aksrt'); + return $columns; + } - /** @return array */ - public static function get_apply_payload(int $user_id): array { - $apply = get_user_meta($user_id, self::APPLY_META, true); - return is_array($apply) ? $apply : []; - } + public function render_admin_user_column(string $output, string $column_name, int $user_id): string { + if ($column_name !== 'aksrt_identity_status') { + return $output; + } + $status = self::get_status($user_id); + $labels = ['pending' => __('进行中', 'aksrt'), 'verified' => __('已认证', 'aksrt'), 'rejected' => __('未通过', 'aksrt'), 'cancelled' => __('已取消', 'aksrt')]; + return $status === '' ? esc_html__('未认证', 'aksrt') : esc_html($labels[$status] ?? $status); + } - /** - * Return certification evidence only to its owner or an administrator. - * - * @param array $application - * @return array - */ - public static function get_evidence_items(array $application, int $owner_id = 0, bool $admin_context = false): array { - if ($owner_id <= 0) { - return []; - } - $can_view = $admin_context - ? current_user_can('manage_options') - : get_current_user_id() === $owner_id; - if (!$can_view) { - return []; - } - - $items = []; - foreach ((array) ($application['evidence_attachment_ids'] ?? []) as $attachment_id) { - $attachment_id = (int) $attachment_id; - if ( - $attachment_id <= 0 - || get_post_type($attachment_id) !== 'attachment' - || (int) get_post_meta($attachment_id, '_aksrt_verify_evidence_owner', true) !== $owner_id - ) { - continue; - } - $url = add_query_arg([ - 'action' => 'aksrt_verify_evidence', - 'attachment' => $attachment_id, - '_wpnonce' => wp_create_nonce('aksrt_verify_evidence_' . $attachment_id), - ], admin_url('admin-post.php')); - $items[] = [ - 'id' => $attachment_id, - 'url' => esc_url_raw($url), - 'thumbnail_url' => esc_url_raw($url), - ]; - } - return $items; - } - - public function serve_evidence(): void { - if (!is_user_logged_in()) { - wp_die(esc_html__('请先登录', 'aksrt'), '', ['response' => 401]); - } - $attachment_id = isset($_GET['attachment']) ? (int) $_GET['attachment'] : 0; - if ($attachment_id <= 0 || !check_admin_referer('aksrt_verify_evidence_' . $attachment_id)) { - wp_die(esc_html__('安全校验失败', 'aksrt'), '', ['response' => 403]); - } - $owner_id = (int) get_post_meta($attachment_id, '_aksrt_verify_evidence_owner', true); - if ($owner_id <= 0 || (get_current_user_id() !== $owner_id && !current_user_can('manage_options'))) { - wp_die(esc_html__('您没有权限查看该证明材料', 'aksrt'), '', ['response' => 403]); - } - $file_path = get_attached_file($attachment_id); - if (!is_string($file_path) || $file_path === '' || !is_file($file_path) || !is_readable($file_path)) { - wp_die(esc_html__('证明材料不存在', 'aksrt'), '', ['response' => 404]); - } - $mime = (string) get_post_mime_type($attachment_id); - if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) { - wp_die(esc_html__('证明材料类型无效', 'aksrt'), '', ['response' => 415]); - } - - nocache_headers(); - header('Content-Type: ' . $mime); - header('Content-Disposition: inline; filename="' . rawurlencode(basename($file_path)) . '"'); - header('Content-Length: ' . (string) filesize($file_path)); - readfile($file_path); - exit; - } - - /** @param array $application */ - public static function application_view(array $application, int $owner_id = 0, bool $admin_context = false): array { - return [ - 'name' => (string) ($application['name'] ?? ''), - 'real_name' => (string) ($application['real_name'] ?? ''), - 'desc' => (string) ($application['desc'] ?? ''), - 'msg' => (string) ($application['msg'] ?? ''), - 'type' => sanitize_key((string) ($application['type'] ?? '')), - 'time' => (string) ($application['time'] ?? ''), - 'evidence' => self::get_evidence_items($application, $owner_id, $admin_context), - ]; - } - - /** @return array> */ - public static function get_review_history(int $user_id): array { - $history = get_user_meta($user_id, self::HISTORY_META, true); - $history = is_array($history) ? array_values($history) : []; - usort($history, static fn(array $left, array $right): int => strcmp( - (string) ($right['time'] ?? ''), - (string) ($left['time'] ?? '') - )); - return $history; - } - - /** - * Apply one authoritative certification status transition. - * - * @return array|\WP_Error - */ - public static function review(int $user_id, string $status, string $note, int $reviewer_id) { - $status = sanitize_key($status); - $note = sanitize_textarea_field($note); - if (!in_array($status, ['verified', 'rejected', 'cancelled'], true)) { - return new \WP_Error('aksrt_verify_status', __('审核状态无效', 'aksrt')); - } - if ($user_id <= 0 || !get_userdata($user_id) || $reviewer_id <= 0) { - return new \WP_Error('aksrt_verify_user', __('用户不存在', 'aksrt')); - } - if ($status === 'cancelled' && $note === '') { - return new \WP_Error('aksrt_verify_note', __('取消认证时必须填写原因', 'aksrt')); - } - if ($status === 'rejected' && $note === '') { - return new \WP_Error('aksrt_verify_note', __('驳回申请时必须填写原因', 'aksrt')); - } - - $current = self::get_status($user_id); - $allowed = $status === 'cancelled' - ? $current === 'verified' - : $current === 'pending'; - $application = self::get_apply_payload($user_id); - if (!$allowed || $application === [] || empty($application['name']) || empty($application['type'])) { - return new \WP_Error('aksrt_verify_transition', __('该认证记录当前不可执行此操作', 'aksrt')); - } - - global $wpdb; - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT umeta_id, meta_value - FROM {$wpdb->usermeta} - WHERE user_id = %d AND meta_key = %s - ORDER BY umeta_id ASC", - $user_id, - self::STATUS_META - ), ARRAY_A); - if (count((array) $rows) !== 1 || sanitize_key((string) ($rows[0]['meta_value'] ?? '')) !== $current) { - return new \WP_Error('aksrt_verify_state', __('认证状态数据异常,请修复后重试', 'aksrt')); - } - $updated = $wpdb->query($wpdb->prepare( - "UPDATE {$wpdb->usermeta} - SET meta_value = %s - WHERE umeta_id = %d AND meta_value = %s", - $status, - (int) $rows[0]['umeta_id'], - $current - )); - if ($updated !== 1) { - return new \WP_Error('aksrt_verify_conflict', __('认证状态已变化,请刷新后重试', 'aksrt')); - } - - $reviewed_at = current_time('mysql'); - update_user_meta($user_id, self::REVIEWED_AT_META, $reviewed_at); - update_user_meta($user_id, self::REVIEWER_META, $reviewer_id); - if ($note !== '') { - update_user_meta($user_id, self::REASON_META, $note); - } else { - delete_user_meta($user_id, self::REASON_META); - } - - if ($status === 'verified') { - update_user_meta($user_id, self::INFO_META, [ - 'name' => (string) ($application['name'] ?? ''), - 'desc' => (string) ($application['desc'] ?? ''), - 'type' => sanitize_key((string) ($application['type'] ?? '')), - 'time' => current_time('Y-m-d H:i'), - ]); - } elseif ($status === 'cancelled') { - delete_user_meta($user_id, self::INFO_META); - } - - $entry = [ - 'status' => $status, - 'reason' => $note, - 'reviewer' => $reviewer_id, - 'time' => $reviewed_at, - 'application' => self::application_view($application), - ]; - $history = self::get_review_history($user_id); - array_unshift($history, $entry); - update_user_meta($user_id, self::HISTORY_META, array_slice($history, 0, 50)); - - self::send_review_notification($user_id, $status, $note); - do_action('aksrt_user_auth_reviewed', $user_id, $status, $note, $reviewer_id); - - return ['status' => $status, 'reviewed_at' => $reviewed_at]; - } - - private static function send_review_notification(int $user_id, string $status, string $note): void { - if (!class_exists('\\AKSRT\\Messages\\Message')) { - return; - } - if ($status === 'verified') { - $message = __('您的官方认证申请已通过审核', 'aksrt'); - $title = __('官方认证审核结果', 'aksrt'); - } elseif ($status === 'cancelled') { - $message = sprintf(__('您的官方认证已被取消:%s', 'aksrt'), $note); - $title = __('官方认证已取消', 'aksrt'); - } else { - $message = sprintf(__('您的官方认证申请未通过:%s', 'aksrt'), $note); - $title = __('官方认证审核结果', 'aksrt'); - } - \AKSRT\Messages\Message::get_instance()->send($user_id, 'auth_apply_reply', $message, [ - 'title' => $title, - 'target_url' => aksrt_get_user_center_tab_url('auth'), - ]); - } - - public function ajax_apply(): void { - if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') { - wp_send_json_error(['message' => __('请求方式无效', 'aksrt')], 405); - } - if (!is_user_logged_in()) { - wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401); - } - if (!check_ajax_referer('aksrt_user_auth_apply', '_wpnonce', false)) { - wp_send_json_error(['message' => __('安全校验失败', 'aksrt')], 403); - } - - $user_id = get_current_user_id(); - $s = self::settings(); - if (!$s['enabled'] || !$s['allow_apply']) { - wp_send_json_error(['message' => __('暂不能申请身份认证', 'aksrt')], 403); - } - if (self::is_verified($user_id)) { - wp_send_json_error(['message' => __('您已是认证用户', 'aksrt')], 409); - } - if (self::get_status($user_id) === 'pending') { - wp_send_json_error(['message' => __('您的申请正在审核中', 'aksrt')], 409); - } - $name = isset($_POST['name']) ? sanitize_text_field(wp_unslash((string) $_POST['name'])) : ''; - $real_name = isset($_POST['real_name']) ? sanitize_text_field(wp_unslash((string) $_POST['real_name'])) : ''; - $desc = isset($_POST['desc']) ? sanitize_textarea_field(wp_unslash((string) $_POST['desc'])) : ''; - $msg = isset($_POST['msg']) ? sanitize_textarea_field(wp_unslash((string) $_POST['msg'])) : ''; - $type = isset($_POST['type']) ? sanitize_key((string) wp_unslash($_POST['type'])) : ''; - $type_settings = self::type_settings($type); - if ($this->text_length($name) < 4 || $this->text_length($name) > 16) { - wp_send_json_error(['message' => __('认证身份长度需在 4-16 个字符之间', 'aksrt')], 422); - } - if ($this->text_length($desc) > 300 || $this->text_length($msg) > 500) { - wp_send_json_error(['message' => __('认证说明内容过长', 'aksrt')], 422); - } - if ($type_settings === []) { - wp_send_json_error(['message' => __('请选择有效的认证类型', 'aksrt')], 422); - } - if (!empty($type_settings['require_real_name']) && $real_name === '') { - wp_send_json_error(['message' => __('请填写真实姓名', 'aksrt')], 422); - } - if ($this->text_length($real_name) > 50) { - wp_send_json_error(['message' => __('真实姓名不能超过 50 个字符', 'aksrt')], 422); - } - - $lock_key = 'aksrt_auth_apply_lock_' . $user_id; - $lock_token = wp_generate_uuid4(); - $lock_payload = ['token' => $lock_token, 'expires' => time() + 300]; - if (!add_option($lock_key, $lock_payload, '', false)) { - $current_lock = get_option($lock_key, []); - if (is_array($current_lock) && (int) ($current_lock['expires'] ?? 0) < time()) { - delete_option($lock_key); - } - if (!add_option($lock_key, $lock_payload, '', false)) { - wp_send_json_error(['message' => __('认证申请正在提交,请勿重复操作', 'aksrt')], 409); - } - } - - $uploaded = []; - if (self::is_verified($user_id)) { - $this->release_submission_lock($lock_key, $lock_token); - wp_send_json_error(['message' => __('您已是认证用户', 'aksrt')], 409); - } - if (self::get_status($user_id) === 'pending') { - $this->release_submission_lock($lock_key, $lock_token); - wp_send_json_error(['message' => __('您的申请正在审核中', 'aksrt')], 409); - } - - $uploaded = $this->upload_evidence($user_id); - if (is_wp_error($uploaded)) { - $this->release_submission_lock($lock_key, $lock_token); - wp_send_json_error(['message' => $uploaded->get_error_message()], 422); - } - - if (!$this->owns_submission_lock($lock_key, $lock_token)) { - $this->delete_attachments($uploaded); - wp_send_json_error(['message' => __('认证申请提交超时,请重新提交', 'aksrt')], 409); - } - $this->renew_submission_lock($lock_key, $lock_token); - - $free_applications = max(0, (int) ($type_settings['free_applications'] ?? 0)); - $application_count = self::application_count($user_id, $type); - $fee = $application_count < $free_applications ? 0.0 : max(0, (float) ($type_settings['fee'] ?? 0)); - if ($fee > 0) { - $deducted = \AKSRT\Credits\Balance::deduct( - $user_id, - $fee, - 'verify_application', - ['type' => 'verify', 'id' => $user_id], - sprintf(__('提交%s申请', 'aksrt'), (string) $type_settings['label']) - ); - if (is_wp_error($deducted)) { - $this->delete_attachments($uploaded); - $this->release_submission_lock($lock_key, $lock_token); - wp_send_json_error(['message' => $deducted->get_error_message()], 422); - } - } - - $previous_payload = self::get_apply_payload($user_id); - $requested_at = current_time('Y-m-d H:i'); - $payload = [ - 'name' => $name, - 'real_name' => $real_name, - 'desc' => $desc, - 'msg' => $msg, - 'type' => $type, - 'evidence_attachment_ids' => $uploaded, - 'time' => $requested_at, - 'fee' => $fee, - ]; - - $payload_saved = update_user_meta($user_id, self::APPLY_META, $payload); - if ($payload_saved === false && get_user_meta($user_id, self::APPLY_META, true) !== $payload) { - $this->delete_attachments($uploaded); - if ($fee > 0) { - \AKSRT\Credits\Balance::add($user_id, $fee, 'verify_application_refund', ['type' => 'verify', 'id' => $user_id], __('认证申请保存失败退款', 'aksrt')); - } - $this->release_submission_lock($lock_key, $lock_token); - wp_send_json_error(['message' => __('认证申请保存失败,请重试', 'aksrt')], 500); - } - $status_saved = update_user_meta($user_id, self::STATUS_META, 'pending'); - if ($status_saved === false && self::get_status($user_id) !== 'pending') { - $this->delete_attachments($uploaded); - if ($fee > 0) { - \AKSRT\Credits\Balance::add($user_id, $fee, 'verify_application_refund', ['type' => 'verify', 'id' => $user_id], __('认证申请保存失败退款', 'aksrt')); - } - $previous_payload === [] - ? delete_user_meta($user_id, self::APPLY_META) - : update_user_meta($user_id, self::APPLY_META, $previous_payload); - $this->release_submission_lock($lock_key, $lock_token); - wp_send_json_error(['message' => __('认证申请保存失败,请重试', 'aksrt')], 500); - } - update_user_meta($user_id, self::REQUESTED_AT_META, $requested_at); - delete_user_meta($user_id, self::REVIEWED_AT_META); - delete_user_meta($user_id, self::REVIEWER_META); - delete_user_meta($user_id, self::REASON_META); - self::increment_application_count($user_id, $type); - - if (!$this->owns_submission_lock($lock_key, $lock_token)) { - $this->release_submission_lock($lock_key, $lock_token); - wp_send_json_error(['message' => __('认证申请已提交,请刷新查看最新状态', 'aksrt')], 409); - } - $this->renew_submission_lock($lock_key, $lock_token); - $this->delete_attachments(array_column(self::get_evidence_items($previous_payload, $user_id), 'id')); - - /** @param int $user_id @param array $payload */ - do_action('aksrt_user_auth_apply_submitted', $user_id, $payload); - $this->release_submission_lock($lock_key, $lock_token); - - wp_send_json_success([ - 'message' => __('提交成功,请等待管理员审核', 'aksrt'), - 'status' => 'pending', - ], 201); - } - - /** @return array|\WP_Error */ - private function upload_evidence(int $user_id) { - $files = $this->normalize_uploads($_FILES['image'] ?? null); - if (is_wp_error($files)) { - return $files; - } - if ($files === []) { - return []; - } - - require_once ABSPATH . 'wp-admin/includes/file.php'; - require_once ABSPATH . 'wp-admin/includes/media.php'; - require_once ABSPATH . 'wp-admin/includes/image.php'; - - $attachment_ids = []; - foreach ($files as $file) { - $filename = sanitize_file_name((string) ($file['name'] ?? '')); - $tmp_name = (string) ($file['tmp_name'] ?? ''); - $size = (int) ($file['size'] ?? 0); - $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); - if ($error !== UPLOAD_ERR_OK || $filename === '' || $size <= 0 || $size > 2 * MB_IN_BYTES || !is_uploaded_file($tmp_name)) { - $this->delete_attachments($attachment_ids); - return new \WP_Error('aksrt_auth_upload_failed', __('证明材料上传失败,单张图片不能超过 2MB', 'aksrt')); - } - $file_check = wp_check_filetype_and_ext($tmp_name, $filename); - $mime = (string) ($file_check['type'] ?? ''); - if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) { - $this->delete_attachments($attachment_ids); - return new \WP_Error('aksrt_auth_upload_type', __('证明材料仅支持 JPG、PNG、GIF 或 WebP 图片', 'aksrt')); - } - - $attachment_id = media_handle_sideload([ - 'name' => $filename, - 'tmp_name' => $tmp_name, - 'type' => $mime, - 'error' => UPLOAD_ERR_OK, - 'size' => $size, - ], 0, __('官方认证证明材料', 'aksrt')); - if (is_wp_error($attachment_id)) { - $this->delete_attachments($attachment_ids); - return new \WP_Error('aksrt_auth_upload_failed', __('证明材料写入媒体库失败', 'aksrt')); - } - $attachment_id = (int) $attachment_id; - $stored_mime = (string) get_post_mime_type($attachment_id); - if (!in_array($stored_mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) { - wp_delete_attachment($attachment_id, true); - $this->delete_attachments($attachment_ids); - return new \WP_Error('aksrt_auth_upload_type', __('证明材料类型校验失败', 'aksrt')); - } - wp_update_post([ - 'ID' => $attachment_id, - 'post_author' => $user_id, - 'post_status' => 'private', - ]); - update_post_meta($attachment_id, '_aksrt_verify_evidence_owner', $user_id); - $attachment_ids[] = $attachment_id; - } - return $attachment_ids; - } - - /** @return array>|\WP_Error */ - private function normalize_uploads($input) { - if (!is_array($input) || empty($input['name'])) { - return []; - } - $names = is_array($input['name']) ? $input['name'] : [$input['name']]; - if (count($names) > 3) { - return new \WP_Error('aksrt_auth_upload_limit', __('证明材料最多上传 3 张', 'aksrt')); - } - $files = []; - foreach ($names as $index => $name) { - if ((string) $name === '') { - continue; - } - $files[] = [ - 'name' => $name, - 'type' => is_array($input['type'] ?? null) ? ($input['type'][$index] ?? '') : ($input['type'] ?? ''), - 'tmp_name' => is_array($input['tmp_name'] ?? null) ? ($input['tmp_name'][$index] ?? '') : ($input['tmp_name'] ?? ''), - 'error' => is_array($input['error'] ?? null) ? ($input['error'][$index] ?? UPLOAD_ERR_NO_FILE) : ($input['error'] ?? UPLOAD_ERR_NO_FILE), - 'size' => is_array($input['size'] ?? null) ? ($input['size'][$index] ?? 0) : ($input['size'] ?? 0), - ]; - } - return $files; - } - - /** @param array $attachment_ids */ - private function delete_attachments(array $attachment_ids): void { - foreach ($attachment_ids as $attachment_id) { - $attachment_id = (int) $attachment_id; - if ($attachment_id > 0 && get_post_type($attachment_id) === 'attachment') { - wp_delete_attachment($attachment_id, true); - } - } - } - - private function owns_submission_lock(string $lock_key, string $lock_token): bool { - $current_lock = get_option($lock_key, []); - return is_array($current_lock) - && (int) ($current_lock['expires'] ?? 0) >= time() - && hash_equals((string) ($current_lock['token'] ?? ''), $lock_token); - } - - private function renew_submission_lock(string $lock_key, string $lock_token): void { - if ($this->owns_submission_lock($lock_key, $lock_token)) { - update_option($lock_key, ['token' => $lock_token, 'expires' => time() + 300], false); - } - } - - private function release_submission_lock(string $lock_key, string $lock_token): void { - $current_lock = get_option($lock_key, []); - if (is_array($current_lock) && hash_equals((string) ($current_lock['token'] ?? ''), $lock_token)) { - delete_option($lock_key); - } - } - - private function text_length(string $value): int { - return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); - } + public function render_admin_profile_status(\WP_User $user): void { + $status = self::get_status((int) $user->ID); + $info = self::get_auth_info((int) $user->ID); + ?> +

+ + + + +