feat: 接入腾讯云与阿里云实人认证

This commit is contained in:
2026-08-01 13:21:21 +08:00
parent 92e445dbd4
commit 4de67311cc
37 changed files with 2410 additions and 1158 deletions
+17 -6
View File
@@ -34,6 +34,11 @@
<n-form-item label="启用身份认证"><n-switch v-model:value="verifyForm.user_verify_enabled" /></n-form-item>
<n-form-item label="公开页显示认证徽标"><n-switch v-model:value="verifyForm.user_verify_public_display" /></n-form-item>
<n-form-item label="允许用户前台申请"><n-switch v-model:value="verifyForm.user_verify_allow_apply" /></n-form-item>
<n-form-item label="云失败转人工"><n-switch v-model:value="verifyForm.user_verify_allow_manual_fallback" /></n-form-item>
<n-form-item label="认证会话有效期(分钟)"><n-input-number v-model:value="verifyForm.user_verify_session_ttl_minutes" :min="5" :max="1440" /></n-form-item>
<n-form-item label="结果轮询间隔(秒)"><n-input-number v-model:value="verifyForm.user_verify_poll_interval_seconds" :min="3" :max="60" /></n-form-item>
<n-form-item label="个人认证费用"><n-input-number v-model:value="verifyForm.user_verify_personal_fee" :min="0" :step="0.01" /></n-form-item>
<n-form-item label="企业认证费用"><n-input-number v-model:value="verifyForm.user_verify_enterprise_fee" :min="0" :step="0.01" /></n-form-item>
<n-form-item label="个人认证徽标文案">
<n-input v-model:value="verifyForm.user_verify_personal_badge_text" placeholder="个人认证" />
</n-form-item>
@@ -61,7 +66,7 @@
</n-form-item>
<n-form-item label="回调地址">
<n-input-group>
<n-input :value="callbackUrl" readonly />
<n-input :value="callbackUrlForCurrentProvider" readonly />
<n-button @click="copyCallback">复制</n-button>
</n-input-group>
<template #feedback>将此地址填入服务商控制台需要的回调地址配置。</template>
@@ -140,6 +145,11 @@ const verifyDefaults = {
user_verify_enabled: true,
user_verify_public_display: true,
user_verify_allow_apply: true,
user_verify_allow_manual_fallback: true,
user_verify_session_ttl_minutes: 30,
user_verify_poll_interval_seconds: 5,
user_verify_personal_fee: 0,
user_verify_enterprise_fee: 0,
user_verify_personal_badge_text: '个人认证',
user_verify_enterprise_badge_text: '企业认证',
user_verify_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
@@ -150,7 +160,8 @@ const loading = ref(true)
const saving = ref(false)
const testing = ref('')
const activeTab = ref('aliyun')
const callbackUrl = ref('')
const callbackUrls = reactive({ aliyun: '', tencent: '' })
const callbackUrlForCurrentProvider = computed(() => callbackUrls[identityForm.provider] || '')
const initialSnapshot = ref('')
const states = reactive({ aliyun: {}, tencent: {} })
const verifyForm = reactive({ ...verifyDefaults })
@@ -185,7 +196,7 @@ async function load() {
Object.assign(verifyForm, verifyDefaults, extractData(verifyResponse))
const identity = extractData(identityResponse)
identityForm.provider = providerKeys.includes(identity.provider) ? identity.provider : 'aliyun'
callbackUrl.value = identity.callback_url || ''
Object.assign(callbackUrls, identity.callback_url || {})
providerKeys.forEach(key => {
states[key] = identity.providers?.[key] || {}
credentials[key] = {}
@@ -230,7 +241,7 @@ async function persist() {
credentials[key] = {}
clearCredentials[key] = false
})
callbackUrl.value = identity.callback_url || callbackUrl.value
Object.assign(callbackUrls, identity.callback_url || callbackUrls)
initialSnapshot.value = snapshot.value
}
@@ -270,9 +281,9 @@ async function testProvider(provider) {
}
async function copyCallback() {
if (!callbackUrl.value) return message.warning('回调地址暂不可用')
if (!callbackUrlForCurrentProvider.value) return message.warning('回调地址暂不可用')
try {
await navigator.clipboard.writeText(callbackUrl.value)
await navigator.clipboard.writeText(callbackUrlForCurrentProvider.value)
message.success('回调地址已复制')
} catch {
message.error('复制失败,请手动复制')
+133
View File
@@ -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 };
})();
+3 -88
View File
@@ -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;
}
});
});
}
/* ==================== 列表内取消收藏 ==================== */
+3
View File
@@ -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();
}
+1 -1
View File
@@ -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;
}
@@ -1,8 +1,6 @@
<?php
/**
* User center panel: official certification.
*
* Zibll parity: compact center card + in-place application modal.
* User center panel: cloud identity verification.
*
* @package AKSRT
*/
@@ -14,101 +12,92 @@ use AKSRT\User\AuthApply;
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
$user_id = (int) ($context['user_id'] ?? get_current_user_id());
$settings = class_exists(AuthApply::class) ? AuthApply::settings() : ['enabled' => 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']),
];
?>
<div class="aksrt-user-auth-panel uc-auth-panel">
<?php if ($status === 'verified') :
$name = (string) ($info['name'] ?? $apply['name'] ?? __('官方认证', 'aksrt'));
$desc = (string) ($info['desc'] ?? $apply['desc'] ?? '');
$time = (string) ($info['time'] ?? get_user_meta($user_id, 'aksrt_user_verify_reviewed_at', true));
?>
<div class="uc-auth-card uc-auth-card--verified">
<span class="uc-auth-card__art"><i class="fas fa-check-circle" aria-hidden="true"></i></span>
<div class="uc-auth-card__content">
<small><?php esc_html_e('已通过官方认证', 'aksrt'); ?></small>
<h2><?php echo esc_html($name); ?></h2>
<?php if ($desc !== '') : ?><p><?php echo esc_html($desc); ?></p><?php endif; ?>
<?php if ($time !== '') : ?><time><?php printf(esc_html__('认证时间:%s', 'aksrt'), esc_html($time)); ?></time><?php endif; ?>
</div>
</div>
<?php else : ?>
<div class="uc-auth-card">
<span class="uc-auth-card__art"><i class="fas fa-id-badge" aria-hidden="true"></i></span>
<div class="uc-auth-card__content">
<small><?php esc_html_e('身份权益', 'aksrt'); ?></small>
<h2><?php esc_html_e('官方认证', 'aksrt'); ?></h2>
<p><?php echo esc_html((string) ($settings['desc'] ?? __('认证后将展示官方身份标识。', 'aksrt'))); ?></p>
<?php if (empty($settings['allow_apply'])) : ?>
<div class="uc-auth-card__notice"><?php echo wp_kses_post(nl2br(esc_html((string) ($settings['disable_desc'] ?? __('暂时不能申请身份认证', 'aksrt'))))); ?></div>
<?php elseif ($blocked_level) : ?>
<div class="uc-auth-card__notice"><?php printf(esc_html__('达到 %s 后即可申请', 'aksrt'), esc_html(class_exists('\AKSRT\User\Level') ? \AKSRT\User\Level::get_level_name($limit) : ('LV' . $limit))); ?></div>
<?php elseif (in_array($status, ['rejected', 'cancelled'], true) && $reason !== '') : ?>
<div class="uc-auth-card__notice">
<strong><?php echo $status === 'cancelled' ? esc_html__('认证已取消', 'aksrt') : esc_html__('上次申请未通过', 'aksrt'); ?></strong><br>
<?php echo nl2br(esc_html($reason)); ?>
</div>
<?php endif; ?>
<?php if ($can_apply) : ?>
<button type="button" class="btn-primary uc-auth-apply-open" data-auth-modal-open>
<?php echo $status === 'pending' ? esc_html__('查看申请进度', 'aksrt') : esc_html__('申请认证', 'aksrt'); ?>
</button>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="aksrt-user-auth-panel uc-auth-panel" data-identity-root data-identity-config="<?php echo esc_attr(wp_json_encode($config)); ?>">
<?php if ($status === 'verified') : ?>
<div class="uc-auth-card uc-auth-card--verified">
<span class="uc-auth-card__art"><i class="fas fa-check-circle" aria-hidden="true"></i></span>
<div class="uc-auth-card__content">
<small><?php esc_html_e('已通过云端实人认证', 'aksrt'); ?></small>
<h2><?php echo esc_html((string) ($info['name'] ?? __('官方认证', 'aksrt'))); ?></h2>
<?php if (!empty($info['desc'])) : ?><p><?php echo esc_html((string) $info['desc']); ?></p><?php endif; ?>
<?php if (!empty($info['time'])) : ?><time><?php printf(esc_html__('认证时间:%s', 'aksrt'), esc_html((string) $info['time'])); ?></time><?php endif; ?>
</div>
</div>
<?php else : ?>
<div class="uc-auth-card">
<span class="uc-auth-card__art"><i class="fas fa-id-badge" aria-hidden="true"></i></span>
<div class="uc-auth-card__content">
<small><?php esc_html_e('身份权益', 'aksrt'); ?></small>
<h2><?php esc_html_e('官方认证', 'aksrt'); ?></h2>
<p><?php echo esc_html((string) ($settings['desc'] ?? __('个人认证将进行人脸实人核身,企业认证还会核验企业主体与法定代表人关系。', 'aksrt'))); ?></p>
<?php if (!$can_apply) : ?>
<div class="uc-auth-card__notice"><?php echo wp_kses_post(nl2br(esc_html((string) ($settings['disable_desc'] ?? __('暂时不能申请身份认证', 'aksrt'))))); ?></div>
<?php elseif ($reason !== '') : ?>
<div class="uc-auth-card__notice"><?php echo nl2br(esc_html($reason)); ?></div>
<?php endif; ?>
<?php if ($can_apply) : ?><button type="button" class="btn-primary" data-auth-modal-open><?php echo $status === 'pending' ? esc_html__('查看认证进度', 'aksrt') : esc_html__('开始认证', 'aksrt'); ?></button><?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($can_apply) : ?>
<div class="aksrt-modal uc-auth-modal" id="uc-auth-modal" aria-hidden="true">
<div class="aksrt-modal__overlay" data-auth-modal-close></div>
<div class="aksrt-modal__box uc-auth-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="uc-auth-modal-title">
<div class="aksrt-modal__header uc-auth-modal__header">
<div><span><i class="fas fa-id-badge" aria-hidden="true"></i></span><strong id="uc-auth-modal-title"><?php esc_html_e('申请身份认证', 'aksrt'); ?></strong></div>
<button type="button" class="aksrt-modal__close" data-auth-modal-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>"><i class="fas fa-times" aria-hidden="true"></i></button>
</div>
<div class="aksrt-modal__body uc-auth-modal__body">
<?php if ($status === 'pending') : ?>
<div class="uc-auth-pending">
<span><i class="fas fa-hourglass-half" aria-hidden="true"></i></span>
<h3><?php esc_html_e('您的申请正在审核中', 'aksrt'); ?></h3>
<p><?php esc_html_e('请耐心等待,审核结果会通过消息中心通知。', 'aksrt'); ?></p>
</div>
<div class="uc-auth-apply-detail">
<dl><dt><?php esc_html_e('认证身份', 'aksrt'); ?></dt><dd><?php echo esc_html((string) ($apply['name'] ?? '')); ?></dd></dl>
<?php if ($apply_type !== '') : ?><dl><dt><?php esc_html_e('认证类型', 'aksrt'); ?></dt><dd><?php echo esc_html($type_labels[$apply_type] ?? $apply_type); ?></dd></dl><?php endif; ?>
<?php if (!empty($apply['desc'])) : ?><dl><dt><?php esc_html_e('身份简介', 'aksrt'); ?></dt><dd><?php echo esc_html((string) $apply['desc']); ?></dd></dl><?php endif; ?>
<?php if (!empty($apply['msg'])) : ?><dl><dt><?php esc_html_e('申请说明', 'aksrt'); ?></dt><dd><?php echo esc_html((string) $apply['msg']); ?></dd></dl><?php endif; ?>
<?php if (!empty($apply['time'])) : ?><dl><dt><?php esc_html_e('申请时间', 'aksrt'); ?></dt><dd><?php echo esc_html((string) $apply['time']); ?></dd></dl><?php endif; ?>
</div>
<?php if ($evidence !== []) : ?>
<div class="aksrt-user-auth-panel__imgs uc-auth-proof-list"><?php foreach ($evidence as $item) : ?><div class="preview-item"><img src="<?php echo esc_url((string) $item['thumbnail_url']); ?>" alt="" loading="lazy"></div><?php endforeach; ?></div>
<?php endif; ?>
<button type="button" class="btn-primary uc-auth-modal__confirm" data-auth-modal-close><?php esc_html_e('确定', 'aksrt'); ?></button>
<?php else : ?>
<form class="aksrt-user-auth-form uc-auth-form" enctype="multipart/form-data">
<label><span><?php esc_html_e('认证类型', 'aksrt'); ?></span><select class="form-control" name="type" required><?php foreach ((array) ($settings['types'] ?? []) as $type) : ?><option value="<?php echo esc_attr((string) $type['slug']); ?>"><?php echo esc_html((string) $type['label']); ?></option><?php endforeach; ?></select></label>
<label><span><?php esc_html_e('认证身份', 'aksrt'); ?></span><input type="text" class="form-control" name="name" required minlength="4" maxlength="16" placeholder="<?php esc_attr_e('请输入 4-16 个字符', 'aksrt'); ?>"></label>
<label><span>真实姓名</span><input type="text" class="form-control" name="real_name" maxlength="50" placeholder="认证方式要求时必须填写"></label>
<label><span><?php esc_html_e('身份简介', 'aksrt'); ?></span><textarea class="form-control" name="desc" rows="3" maxlength="300" placeholder="<?php esc_attr_e('认证通过后公开展示的身份说明', 'aksrt'); ?>"></textarea></label>
<label><span><?php esc_html_e('申请说明', 'aksrt'); ?></span><textarea class="form-control" name="msg" rows="3" maxlength="500" placeholder="<?php esc_attr_e('说明申请缘由,便于管理员审核', 'aksrt'); ?>"></textarea></label>
<label class="uc-auth-upload"><span><?php esc_html_e('证明材料', 'aksrt'); ?></span><input type="file" name="image[]" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-auth-files><small><?php esc_html_e('最多上传 3 张图片,单张不超过 2MB(可选)。', 'aksrt'); ?></small><div class="uc-auth-upload__previews" data-auth-previews></div></label>
<?php wp_nonce_field('aksrt_user_auth_apply', '_wpnonce', false); ?>
<input type="hidden" name="action" value="aksrt_user_auth_apply">
<button type="submit" class="btn-primary uc-auth-submit"><i class="fas fa-check" aria-hidden="true"></i><?php esc_html_e('确认提交', 'aksrt'); ?></button>
<div class="uc-auth-form__message" data-auth-message role="status"></div>
</form>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php if ($can_apply) : ?>
<div class="aksrt-modal uc-auth-modal" id="uc-auth-modal" aria-hidden="true">
<div class="aksrt-modal__overlay" data-auth-modal-close></div>
<div class="aksrt-modal__box uc-auth-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="uc-auth-modal-title">
<div class="aksrt-modal__header uc-auth-modal__header">
<div><span><i class="fas fa-id-badge" aria-hidden="true"></i></span><strong id="uc-auth-modal-title"><?php esc_html_e('身份认证', 'aksrt'); ?></strong></div>
<button type="button" class="aksrt-modal__close" data-auth-modal-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>"><i class="fas fa-times" aria-hidden="true"></i></button>
</div>
<div class="aksrt-modal__body uc-auth-modal__body">
<div data-auth-status <?php echo $record === [] ? 'hidden' : ''; ?>>
<div class="uc-auth-pending">
<span><i class="fas fa-shield-alt" aria-hidden="true"></i></span>
<h3 data-auth-stage><?php echo esc_html($stage ?: __('准备认证', 'aksrt')); ?></h3>
<p data-auth-status-message><?php esc_html_e('认证结果以服务端向云服务商主动查询为准。', 'aksrt'); ?></p>
</div>
<a class="btn-primary" data-auth-continue target="_blank" rel="noopener noreferrer" hidden><?php esc_html_e('打开云端核身页面', 'aksrt'); ?></a>
<button type="button" class="btn-primary" data-auth-refresh><?php esc_html_e('刷新结果', 'aksrt'); ?></button>
<?php if (!empty($settings['allow_manual_fallback'])) : ?><button type="button" class="button" data-auth-manual-open hidden><?php esc_html_e('上传材料转人工审核', 'aksrt'); ?></button><?php endif; ?>
</div>
<form class="aksrt-user-auth-form uc-auth-form" data-auth-create <?php echo $record !== [] ? 'hidden' : ''; ?>>
<label><span><?php esc_html_e('认证类型', 'aksrt'); ?></span><select class="form-control" name="type" required><option value="personal"><?php esc_html_e('个人认证', 'aksrt'); ?></option><option value="enterprise"><?php esc_html_e('企业认证', 'aksrt'); ?></option></select></label>
<label><span><?php esc_html_e('公开认证名称', 'aksrt'); ?></span><input class="form-control" name="public_name" minlength="2" maxlength="64" required></label>
<label><span><?php esc_html_e('公开身份简介', 'aksrt'); ?></span><textarea class="form-control" name="public_desc" maxlength="300"></textarea></label>
<label><span><?php esc_html_e('真实姓名/法定代表人姓名', 'aksrt'); ?></span><input class="form-control" name="person_name" maxlength="50" required autocomplete="name"></label>
<label><span><?php esc_html_e('身份证号码', 'aksrt'); ?></span><input class="form-control" name="id_number" maxlength="20" required autocomplete="off"></label>
<div data-enterprise-fields hidden>
<label><span><?php esc_html_e('企业名称', 'aksrt'); ?></span><input class="form-control" name="company_name" maxlength="191"></label>
<label><span><?php esc_html_e('统一社会信用代码', 'aksrt'); ?></span><input class="form-control" name="company_code" maxlength="32"></label>
</div>
<label><input type="checkbox" name="consent" value="1" required> <?php esc_html_e('我同意为身份认证目的将必要身份信息提交给站点当前配置的云服务商;站点不保存人脸素材,并仅保留脱敏结果。', 'aksrt'); ?></label>
<input type="hidden" name="return_url" value="<?php echo esc_url(aksrt_get_user_center_tab_url('auth')); ?>">
<button type="submit" class="btn-primary"><?php esc_html_e('创建认证会话', 'aksrt'); ?></button>
<div data-auth-message role="status"></div>
</form>
<form data-auth-manual hidden enctype="multipart/form-data">
<label><span><?php esc_html_e('证明材料', 'aksrt'); ?></span><input type="file" name="evidence[]" accept="image/jpeg,image/png,image/webp" multiple required></label>
<small><?php esc_html_e('最多 3 张,单张不超过 2MB。材料仅本人和身份审核管理员可查看。', 'aksrt'); ?></small>
<button type="submit" class="btn-primary"><?php esc_html_e('提交人工审核', 'aksrt'); ?></button>
</form>
</div>
</div>
</div>
<?php endif; ?>
</div>
+8 -3
View File
@@ -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;
@@ -50,6 +50,8 @@ trait ImportExportControllerPart2 {
}
}
$payload = \AKSRT\Settings\Settings::redact_sensitive_settings($payload);
return [
'id' => time(),
'type' => $type,
+91 -234
View File
@@ -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 '<div class="wrap"><h1>' . esc_html__('身份认证', 'aksrt') . '</h1>';
$this->flash();
echo '<p>' . 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']
)) . '</p>';
echo '<p>' . esc_html(sprintf(__('人工待审 %1$d · 已通过 %2$d · 已驳回 %3$d · 已取消 %4$d', 'aksrt'), $counts['pending'], $counts['verified'], $counts['rejected'], $counts['cancelled'])) . '</p>';
$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<int,array<string,mixed>>,pages:int,total:int}
*/
/** @return array{items:array<int,array<string,mixed>>,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 '<form method="get" style="margin:12px 0;display:flex;gap:8px;align-items:end;flex-wrap:wrap;">';
echo '<input type="hidden" name="page" value="aksrt_user_auth" />';
echo '<label>' . esc_html__('状态', 'aksrt') . '<br><select name="status">';
foreach (['pending' => __('待审核', 'aksrt'), 'verified' => __('已通过', 'aksrt'), 'rejected' => __('已驳回', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), '' => __('全部', 'aksrt')] as $key => $label) {
foreach (['pending' => __('人工待审', 'aksrt'), 'verified' => __('已通过', 'aksrt'), 'rejected' => __('已驳回', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), '' => __('全部', 'aksrt')] as $key => $label) {
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['status'], $key, false), esc_html($label));
}
echo '</select></label>';
echo '<label>' . esc_html__('搜索', 'aksrt') . '<br><input type="search" name="keyword" value="' . esc_attr($filters['keyword']) . '" placeholder="' . esc_attr__('用户、邮箱、认证资料', 'aksrt') . '" /></label>';
echo '<label>' . esc_html__('搜索', 'aksrt') . '<br><input type="search" name="keyword" value="' . esc_attr($filters['keyword']) . '" /></label>';
echo '<label>' . esc_html__('申请起始日期', 'aksrt') . '<br><input type="date" name="date_from" value="' . esc_attr($filters['date_from']) . '" /></label>';
echo '<label>' . esc_html__('申请结束日期', 'aksrt') . '<br><input type="date" name="date_to" value="' . esc_attr($filters['date_to']) . '" /></label>';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
}
/** @param array<int,array<string,mixed>> $items */
private function render_verify_table(array $items): void {
$type_labels = $this->get_verify_type_labels();
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
echo '<th style="width:18%">' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th style="width:9%">' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th style="width:33%">' . esc_html__('申请资料', 'aksrt') . '</th>';
echo '<th style="width:18%">' . esc_html__('审核记录', 'aksrt') . '</th>';
echo '<th style="width:22%">' . esc_html__('操作', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><th>用户</th><th>状态</th><th>申请资料</th><th>审核记录</th><th>操作</th></tr></thead><tbody>';
if ($items === []) {
echo '<tr><td colspan="5">' . esc_html__('暂无认证申请', 'aksrt') . '</td></tr>';
}
foreach ($items as $row) {
$this->render_verify_row($row, $type_labels);
$this->render_verify_row($row);
}
echo '</tbody></table>';
}
/** @return array<string,string> */
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<string,mixed> $row @param array<string,string> $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 '<tr>';
echo '<td><strong><a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html((string) ($row['display_name'] ?? '')) . '</a></strong>';
echo '<div class="description">@' . esc_html((string) ($row['user_login'] ?? '')) . ' · UID ' . esc_html((string) $user_id) . '</div>';
echo '<div><a href="mailto:' . esc_attr((string) ($row['user_email'] ?? '')) . '">' . esc_html((string) ($row['user_email'] ?? '')) . '</a></div></td>';
echo '<td>' . $this->verify_status_badge($status) . '</td>';
echo '<td>';
$type_key = sanitize_key((string) ($apply['type'] ?? ''));
echo '<strong>' . esc_html((string) ($apply['name'] ?? __('未填写认证身份', 'aksrt'))) . '</strong>';
echo '<div>' . esc_html__('类型:', 'aksrt') . esc_html($type_labels[$type_key] ?? $type_key ?: '—') . '</div>';
if (!empty($apply['desc'])) {
echo '<div>' . esc_html((string) $apply['desc']) . '</div>';
}
if (!empty($apply['msg'])) {
echo '<div class="description">' . esc_html__('申请说明:', 'aksrt') . esc_html((string) $apply['msg']) . '</div>';
}
echo '<div class="description">' . esc_html__('申请时间:', 'aksrt') . esc_html((string) ($row['requested_at'] ?: ($apply['time'] ?? '—'))) . '</div>';
$evidence = class_exists('\AKSRT\User\AuthApply') ? \AKSRT\User\AuthApply::get_evidence_items($apply, $user_id, true) : [];
foreach ($evidence as $item) {
echo '<a href="' . esc_url((string) $item['url']) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url((string) $item['thumbnail_url']) . '" alt="' . esc_attr__('认证证明材料', 'aksrt') . '" style="width:64px;height:64px;object-fit:cover;margin:8px 8px 0 0;border-radius:4px;" /></a>';
}
echo '</td>';
echo '<td>';
if (!empty($row['reviewed_at'])) {
echo '<div>' . esc_html((string) $row['reviewed_at']) . '</div>';
}
if ($reviewer) {
echo '<div class="description">' . esc_html__('审核人:', 'aksrt') . esc_html($reviewer->display_name) . '</div>';
}
if (!empty($row['verify_reason'])) {
echo '<div>' . esc_html((string) $row['verify_reason']) . '</div>';
}
if ($history !== []) {
echo '<details><summary>' . esc_html(sprintf(__('历史记录(%d)', 'aksrt'), count($history))) . '</summary><ol style="margin-left:18px;">';
foreach ($history as $entry) {
echo '<li>' . wp_kses_post($this->format_verify_history_entry($entry)) . '</li>';
}
echo '</ol></details>';
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 '<tr><td><strong><a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html((string) $row['display_name']) . '</a></strong>';
echo '<div class="description">@' . esc_html((string) $row['user_login']) . ' · UID ' . $user_id . '</div><div>' . esc_html((string) $row['user_email']) . '</div></td>';
echo '<td>' . wp_kses_post($this->verify_status_badge($status)) . '<div class="description">' . esc_html((string) $row['provider']) . ' / ' . esc_html((string) $row['channel']) . '</div></td>';
echo '<td><strong>' . esc_html((string) $row['public_name']) . '</strong><div>' . esc_html((string) $row['type']) . '</div>';
echo '<div class="description">UUID: ' . esc_html((string) $row['application_uuid']) . '</div>';
if (!empty($row['provider_result_code'])) echo '<div>' . esc_html__('云结果:', 'aksrt') . esc_html((string) $row['provider_result_code']) . '</div>';
foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) {
echo '<a href="' . esc_url($item['url']) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url($item['thumbnail_url']) . '" alt="" style="width:64px;height:64px;object-fit:cover;margin:8px 8px 0 0;border-radius:4px;" /></a>';
}
echo '</td><td>';
$this->render_verify_actions($user_id, $status);
if (!empty($row['review_note'])) echo '<div>' . esc_html((string) $row['review_note']) . '</div>';
if ($history !== []) echo '<details><summary>' . esc_html(sprintf(__('事件记录(%d)', 'aksrt'), count($history))) . '</summary><ol>';
foreach ($history as $event) echo '<li>' . esc_html((string) $event['created_at'] . ' · ' . (string) $event['event_type'] . ' · ' . (string) $event['to_status']) . '</li>';
if ($history !== []) echo '</ol></details>';
echo '</td><td>';
$this->render_verify_actions($row);
echo '</td></tr>';
}
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 '<span style="display:inline-block;padding:2px 8px;border:1px solid currentColor;border-radius:999px;color:'
. esc_attr($colors[$status] ?? '#646970') . '">' . esc_html($labels[$status] ?? $status) . '</span>';
$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 '<span style="display:inline-block;padding:2px 8px;border:1px solid currentColor;border-radius:999px;">' . esc_html($labels[$status] ?? $status) . '</span>';
}
/** @param array<string,mixed> $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 '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:grid;gap:6px;margin-bottom:8px;">';
echo '<input type="hidden" name="action" value="aksrt_users_review_verify" /><input type="hidden" name="user_id" value="' . (int) $row['user_id'] . '" /><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '" /><input type="hidden" name="expected_status" value="manual_pending" /><input type="hidden" name="status" value="' . esc_attr($next) . '" />';
wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']);
echo '<textarea name="note" rows="2" ' . ($next === 'rejected' ? 'required' : '') . '></textarea><button class="button ' . ($next === 'verified' ? 'button-primary' : '') . '">' . esc_html($label) . '</button></form>';
}
} elseif ($status === 'verified') {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '"><input type="hidden" name="action" value="aksrt_users_review_verify" /><input type="hidden" name="user_id" value="' . (int) $row['user_id'] . '" /><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '" /><input type="hidden" name="expected_status" value="verified" /><input type="hidden" name="status" value="cancelled" />';
wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']);
echo '<textarea name="note" required></textarea><button class="button button-link-delete">' . esc_html__('取消认证', 'aksrt') . '</button></form>';
} else {
echo '<span class="description">' . esc_html__('无需人工操作', 'aksrt') . '</span>';
}
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 '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:grid;gap:6px;">';
echo '<input type="hidden" name="action" value="aksrt_users_review_verify" />';
echo '<input type="hidden" name="user_id" value="' . esc_attr((string) $user_id) . '" />';
wp_nonce_field('aksrt_users_review_verify_' . $user_id);
echo '<textarea name="note" rows="2" placeholder="' . esc_attr__('通过时可填备注', 'aksrt') . '"></textarea>';
echo '<button type="submit" name="status" value="verified" class="button button-primary">' . esc_html__('通过认证', 'aksrt') . '</button>';
echo '</form>';
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:grid;gap:6px;margin-top:10px;">';
echo '<input type="hidden" name="action" value="aksrt_users_review_verify" />';
echo '<input type="hidden" name="user_id" value="' . esc_attr((string) $user_id) . '" />';
echo '<input type="hidden" name="status" value="rejected" />';
wp_nonce_field('aksrt_users_review_verify_' . $user_id);
echo '<textarea name="note" rows="2" required placeholder="' . esc_attr__('填写驳回原因', 'aksrt') . '"></textarea>';
echo '<button type="submit" class="button">' . esc_html__('驳回申请', 'aksrt') . '</button>';
echo '</form>';
return;
}
if ($status === 'verified') {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:grid;gap:6px;">';
echo '<input type="hidden" name="action" value="aksrt_users_review_verify" />';
echo '<input type="hidden" name="user_id" value="' . esc_attr((string) $user_id) . '" />';
echo '<input type="hidden" name="status" value="cancelled" />';
wp_nonce_field('aksrt_users_review_verify_' . $user_id);
echo '<textarea name="note" rows="2" required placeholder="' . esc_attr__('填写取消认证原因', 'aksrt') . '"></textarea>';
echo '<button type="submit" class="button button-link-delete" onclick="return confirm(\'' . esc_js(__('确认取消该用户的官方认证?', 'aksrt')) . '\');">' . esc_html__('取消认证', 'aksrt') . '</button>';
echo '</form>';
return;
}
echo '<span class="description">' . esc_html__('用户可在前台重新提交申请', 'aksrt') . '</span>';
}
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<int,array<string,mixed>> */
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);
}
}
+26 -5
View File
@@ -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' => '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'],
]],
],
@@ -43,7 +43,10 @@ trait UserControllerIdentitySettings {
}
private function identity_settings_response(array $settings): array {
$settings['callback_url'] = rest_url(self::API_NAMESPACE . '/identity/callback');
$settings['callback_url'] = [
'aliyun' => rest_url(self::API_NAMESPACE . '/identity/callback/aliyun'),
'tencent' => rest_url(self::API_NAMESPACE . '/identity/callback/tencent'),
];
return $settings;
}
}
+2 -1
View File
@@ -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';
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* Identity verification tables.
*
* @package AKSRT
*/
namespace AKSRT\Core;
defined('ABSPATH') || exit;
trait DBMigratorIdentity {
private static function migrate_identity_tables(string $prefix, string $charset_collate): void {
$application = "CREATE TABLE {$prefix}aksrt_identity_application (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
application_uuid CHAR(36) NOT NULL,
client_request_id CHAR(36) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(20) NOT NULL,
channel VARCHAR(20) NOT NULL DEFAULT 'cloud',
provider VARCHAR(20) NOT NULL DEFAULT '',
provider_flow VARCHAR(80) NOT NULL DEFAULT '',
provider_session_id VARCHAR(191) NOT NULL DEFAULT '',
provider_request_id VARCHAR(191) NOT NULL DEFAULT '',
redirect_url TEXT DEFAULT NULL,
qr_payload TEXT DEFAULT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'created',
public_name VARCHAR(64) NOT NULL DEFAULT '',
public_desc VARCHAR(300) NOT NULL DEFAULT '',
person_name_masked VARCHAR(64) NOT NULL DEFAULT '',
id_number_masked VARCHAR(32) NOT NULL DEFAULT '',
id_number_hash CHAR(64) NOT NULL DEFAULT '',
company_name VARCHAR(191) NOT NULL DEFAULT '',
company_code_masked VARCHAR(32) NOT NULL DEFAULT '',
company_code_hash CHAR(64) NOT NULL DEFAULT '',
sensitive_blob LONGTEXT DEFAULT NULL,
provider_result_code VARCHAR(128) NOT NULL DEFAULT '',
provider_result_summary TEXT DEFAULT NULL,
manual_reason TEXT DEFAULT NULL,
review_note TEXT DEFAULT NULL,
reviewer_id BIGINT UNSIGNED DEFAULT NULL,
evidence_ids TEXT DEFAULT NULL,
fee_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
fee_status VARCHAR(20) NOT NULL DEFAULT 'not_required',
attempt_ordinal INT UNSIGNED NOT NULL DEFAULT 0,
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
provider_started_at DATETIME DEFAULT NULL,
completed_at DATETIME DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_application_uuid (application_uuid),
UNIQUE KEY uniq_user_client_request (user_id, client_request_id),
KEY idx_user_created (user_id, created_at),
KEY idx_user_type_status (user_id, type, status),
KEY idx_provider_session (provider, provider_session_id),
KEY idx_status_updated (status, updated_at),
KEY idx_id_number_hash (id_number_hash),
KEY idx_company_code_hash (company_code_hash)
) {$charset_collate};";
dbDelta($application);
$event = "CREATE TABLE {$prefix}aksrt_identity_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
application_id BIGINT UNSIGNED NOT NULL,
event_uuid CHAR(36) NOT NULL,
source VARCHAR(20) NOT NULL,
event_type VARCHAR(64) NOT NULL,
external_event_id VARCHAR(191) NOT NULL DEFAULT '',
dedupe_key CHAR(64) NOT NULL,
from_status VARCHAR(32) NOT NULL DEFAULT '',
to_status VARCHAR(32) NOT NULL DEFAULT '',
result_code VARCHAR(128) NOT NULL DEFAULT '',
summary_json LONGTEXT DEFAULT NULL,
actor_user_id BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_event_uuid (event_uuid),
UNIQUE KEY uniq_dedupe_key (dedupe_key),
KEY idx_application_created (application_id, created_at),
KEY idx_source_external (source, external_event_id)
) {$charset_collate};";
dbDelta($event);
}
}
+2
View File
@@ -479,5 +479,7 @@ trait DBMigratorPart2 {
KEY idx_order (order_number)
) {$charset_collate};";
dbDelta($sql_card_key);
self::migrate_identity_tables($prefix, $charset_collate);
}
}
+6 -2
View File
@@ -35,8 +35,12 @@ trait DBMigratorPart3 {
'order_shipping' => '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 '';
+11 -1
View File
@@ -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,
@@ -0,0 +1,167 @@
<?php
/**
* Identity verification application persistence.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class ApplicationRepository {
public const ACTIVE_STATUSES = [
'created', 'provider_pending', 'provider_processing', 'provider_failed',
'provider_unsupported', 'provider_expired', 'manual_pending',
];
private string $table;
public function __construct() {
$this->table = \AKSRT\Core\DBMigrator::table('identity_application');
}
/** @param array<string,mixed> $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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed> $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<string,mixed> $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<string,mixed> */
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;
}
}
+286
View File
@@ -0,0 +1,286 @@
<?php
/**
* Identity verification application orchestration.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
use AKSRT\Identity\Provider\ProviderFactory;
use AKSRT\Identity\Provider\ProviderResult;
defined('ABSPATH') || exit;
final class ApplicationService {
public function __construct(
private ?ApplicationRepository $applications = null,
private ?TransitionService $transitions = null,
private ?EvidenceService $evidence = null
) {
$this->applications ??= new ApplicationRepository();
$this->transitions ??= new TransitionService($this->applications);
$this->evidence ??= new EvidenceService();
}
/** @param array<string,mixed> $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<string,mixed> */
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);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* Identity provider callback endpoints.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
use AKSRT\Identity\Provider\ProviderFactory;
defined('ABSPATH') || exit;
final class CallbackController {
private const NS = 'aksrt/v1';
public function register_routes(): void {
foreach (['aliyun', 'tencent'] as $provider) {
register_rest_route(self::NS, '/identity/callback/' . $provider, [
'methods' => $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]);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* Identity verification event persistence.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class EventRepository {
private string $table;
public function __construct() {
$this->table = \AKSRT\Core\DBMigrator::table('identity_event');
}
/** @param array<string,mixed> $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<int,array<string,mixed>> */
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 : [];
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
/**
* Private identity evidence handling.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class EvidenceService {
public const OWNER_META = '_aksrt_verify_evidence_owner';
public const APPLICATION_META = '_aksrt_identity_application_uuid';
public function init(): void {
add_action('admin_post_aksrt_verify_evidence', [$this, 'serve']);
}
/** @return array<int,int>|\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<int,array{id:int,url:string,thumbnail_url:string}> */
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<int,array<string,mixed>>|\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<int,int> $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);
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* Identity verification lifecycle.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class Identity {
private static ?self $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
public function init(): void {
add_action('rest_api_init', [new RestController(), 'register_routes']);
add_action('rest_api_init', [new CallbackController(), 'register_routes']);
(new EvidenceService())->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']);
}
}
@@ -0,0 +1,195 @@
<?php
/**
* Alibaba Cloud Cloudauth provider.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
use AKSRT\Identity\SettingsRuntime;
defined('ABSPATH') || exit;
final class AliyunProvider implements ProviderInterface {
public function key(): string { return 'aliyun'; }
public function capabilities(): array { return ['personal' => 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<string,mixed>|\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';
}
}
@@ -0,0 +1,21 @@
<?php
/**
* Identity provider factory.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
defined('ABSPATH') || exit;
final class ProviderFactory {
public static function make(string $provider): ProviderInterface {
$provider = sanitize_key($provider);
if ($provider === 'tencent') {
return new TencentProvider();
}
return new AliyunProvider();
}
}
@@ -0,0 +1,34 @@
<?php
/**
* Identity provider contract.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
defined('ABSPATH') || exit;
interface ProviderInterface {
public function key(): string;
/** @return array{personal:bool,enterprise:bool} */
public function capabilities(): array;
/** @return true|\WP_Error */
public function validate_config();
/** @param array<string,mixed> $request */
public function start_personal(array $request): ProviderResult;
/** @param array<string,mixed> $request */
public function start_enterprise(array $request): ProviderResult;
/** @param array<string,mixed> $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;
}
@@ -0,0 +1,41 @@
<?php
/**
* Normalized identity provider result.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
defined('ABSPATH') || exit;
final class ProviderResult {
public const PENDING = 'pending';
public const PASSED = 'passed';
public const FAILED = 'failed';
public const UNSUPPORTED = 'unsupported';
public const EXPIRED = 'expired';
/** @param array<string,mixed> $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]);
}
}
@@ -0,0 +1,202 @@
<?php
/**
* Tencent Cloud FaceId provider.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
use AKSRT\Identity\SettingsRuntime;
defined('ABSPATH') || exit;
final class TencentProvider implements ProviderInterface {
private const VERSION = '2018-03-01';
private const SERVICE = 'faceid';
private const ENDPOINT = 'faceid.tencentcloudapi.com';
public function key(): string { return 'tencent'; }
public function capabilities(): array { return ['personal' => 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<string,mixed>|\WP_Error */
private function request(string $action, array $payload) {
return $this->request_service(self::SERVICE, self::ENDPOINT, self::VERSION, $action, $payload);
}
/** @return array<string,mixed>|\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';
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
/**
* Public identity verification REST API.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class RestController {
private const NS = 'aksrt/v1';
public function __construct(
private ?ApplicationRepository $applications = null,
private ?ApplicationService $service = null
) {
$this->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<uuid>[a-f0-9-]{36})', [
'methods' => 'GET',
'callback' => [$this, 'get'],
'permission_callback' => [$this, 'owns_application'],
]);
register_rest_route(self::NS, '/identity/applications/(?P<uuid>[a-f0-9-]{36})/refresh', [
'methods' => 'POST',
'callback' => [$this, 'refresh'],
'permission_callback' => [$this, 'owns_application'],
]);
register_rest_route(self::NS, '/identity/applications/(?P<uuid>[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);
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
/**
* Encrypted identity provider credential storage.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class SecretStore {
public const OPTION_KEY = 'aksrt_identity_secrets';
private const CONTEXT = 'aksrt-identity-secrets-v1';
/** @return array<string,string> */
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<string,string|null> $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<string,array{configured:bool,fingerprint:string}> */
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<string,string>|\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<string,mixed> $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);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Identity verification settings facade.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class SettingsRuntime {
/** @return array<string,mixed> */
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));
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* Identity verification state transitions.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class TransitionService {
private const ALLOWED = [
'created' => ['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<string,mixed> $fields @param array<string,mixed> $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'),
]);
}
}
+1 -2
View File
@@ -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;
}
}
+45 -97
View File
@@ -1,158 +1,106 @@
<?php
/**
* Identity provider configuration storage for the admin REST surface.
* Identity provider configuration for the admin REST surface.
*
* @package AKSRT
*/
namespace AKSRT\Settings;
use AKSRT\Identity\Provider\ProviderFactory;
use AKSRT\Identity\SecretStore;
defined('ABSPATH') || exit;
final class IdentityAdmin {
private const OPTION_KEY = 'aksrt_identity_settings';
private const PROVIDER_FIELDS = [
'aliyun' => ['access_key_id', 'access_key_secret'],
'tencent' => ['secret_id', 'secret_key'],
];
/** @return array<string,mixed> */
public static function read(): array {
$stored = get_option(self::OPTION_KEY, []);
$stored = is_array($stored) ? $stored : [];
$provider = sanitize_key((string) ($stored['provider'] ?? 'aliyun'));
$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', $stored),
'tencent' => self::provider_state('tencent', $stored),
'aliyun' => self::provider_state('aliyun'),
'tencent' => self::provider_state('tencent'),
],
];
}
/** @param array<string,mixed> $input @return array<string,mixed> */
public static function update(array $input): array {
$stored = get_option(self::OPTION_KEY, []);
$stored = is_array($stored) ? $stored : [];
if (array_key_exists('provider', $input)) {
$provider = sanitize_key((string) $input['provider']);
if (isset(self::PROVIDER_FIELDS[$provider])) {
$stored['provider'] = $provider;
Settings::write_domain('users', 'verify', ['user_verify_provider' => $provider]);
}
}
$provider_input = isset($input['providers']) && is_array($input['providers'])
? $input['providers']
: [];
$providers = isset($input['providers']) && is_array($input['providers']) ? $input['providers'] : [];
foreach (self::PROVIDER_FIELDS as $provider => $fields) {
$changes = isset($provider_input[$provider]) && is_array($provider_input[$provider])
? $provider_input[$provider]
: [];
$changes = isset($providers[$provider]) && is_array($providers[$provider]) ? $providers[$provider] : [];
$updates = [];
if (!empty($changes['clear_credentials'])) {
unset($stored['providers'][$provider]);
continue;
}
$credentials = isset($changes['credentials']) && is_array($changes['credentials'])
? $changes['credentials']
: [];
foreach ($fields as $field) {
if (!isset($credentials[$field]) || trim((string) $credentials[$field]) === '') {
continue;
foreach ($fields as $field) {
$updates[$field] = null;
}
$stored['providers'][$provider][$field] = self::encrypt(
sanitize_text_field((string) $credentials[$field])
);
} 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);
}
}
update_option(self::OPTION_KEY, $stored, false);
return self::read();
}
/** @return array{success:bool,message:string} */
public static function test(string $provider): array {
$settings = self::read();
if (!isset(self::PROVIDER_FIELDS[$provider])) {
return ['success' => false, 'message' => '不支持的身份认证服务商'];
return ['success' => false, 'message' => __('不支持的身份认证服务商', 'aksrt')];
}
$state = $settings['providers'][$provider];
if (!$state['configured']) {
return [
'success' => false,
'message' => sprintf('%s凭证不完整,请补充后保存', $state['label']),
];
$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配置检查通过,凭证字段完整', $state['label']),
];
return ['success' => true, 'message' => sprintf(__('%s配置检查通过', 'aksrt'), self::label($provider))];
}
private static function encrypt(string $value): string {
if ($value === '' || !function_exists('openssl_encrypt')) {
return '';
}
$key = hash('sha256', wp_salt('auth'), true);
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt($value, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($ciphertext === false) {
return '';
}
return base64_encode($iv . $tag . $ciphertext);
}
private static function decrypt(string $encoded): string {
if ($encoded === '' || !function_exists('openssl_decrypt')) {
return '';
}
$payload = base64_decode($encoded, true);
if ($payload === false || strlen($payload) <= 28) {
return '';
}
$iv = substr($payload, 0, 12);
$tag = substr($payload, 12, 16);
$ciphertext = substr($payload, 28);
$key = hash('sha256', wp_salt('auth'), true);
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
return is_string($plaintext) ? $plaintext : '';
}
private static function provider_state(string $provider, array $stored): array {
$credentials = isset($stored['providers'][$provider]) && is_array($stored['providers'][$provider])
? $stored['providers'][$provider]
: [];
$missing = [];
/** @return array<string,mixed> */
private static function provider_state(string $provider): array {
$secrets = SecretStore::get($provider);
$configured_fields = [];
$missing = [];
foreach (self::PROVIDER_FIELDS[$provider] as $field) {
$has_value = self::decrypt((string) ($credentials[$field] ?? '')) !== '';
$configured_fields[$field] = $has_value;
if (!$has_value) {
$configured = !empty($secrets[$field]);
$configured_fields[$field] = $configured;
if (!$configured) {
$missing[] = $field;
}
}
return [
'label' => $provider === 'aliyun' ? '阿里云' : '腾讯云',
'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');
}
}
+50
View File
@@ -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<string,mixed> */
public static function get_identity_verification_settings(): array {
return \AKSRT\Identity\SettingsRuntime::get();
}
/** @return array<string,string> */
public static function get_identity_provider_secrets(string $provider): array {
return \AKSRT\Identity\SecretStore::get($provider);
}
/** @param array<string,string|null> $changes */
public static function update_identity_provider_secrets(string $provider, array $changes): bool {
return \AKSRT\Identity\SecretStore::update($provider, $changes);
}
/** @return array<string,array{configured:bool,fingerprint:string}> */
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<string,mixed> $payload
* @return array<string,mixed>
*/
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<string,mixed> $standalone
* @return array<string,mixed>
*/
public static function filter_sensitive_import(array $standalone): array {
foreach (self::SECRET_OPTION_KEYS as $option_key) {
unset($standalone[$option_key]);
}
return $standalone;
}
/**
+5 -2
View File
@@ -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' => [
+149 -616
View File
@@ -1,645 +1,178 @@
<?php
/**
* Frontend identity verification application.
* Identity verification facade for existing presentation call sites.
*
* @package AKSRT
*/
namespace AKSRT\User;
use AKSRT\Identity\ApplicationRepository;
use AKSRT\Identity\EvidenceService;
use AKSRT\Identity\EventRepository;
use AKSRT\Identity\TransitionService;
defined('ABSPATH') || exit;
class AuthApply {
public const STATUS_META = 'aksrt_user_verify_status';
public const REASON_META = 'aksrt_user_verify_reason';
public const APPLY_META = 'aksrt_user_verify_apply';
public const INFO_META = 'aksrt_user_verify_info';
public const REQUESTED_AT_META = 'aksrt_user_verify_requested_at';
public const REVIEWED_AT_META = 'aksrt_user_verify_reviewed_at';
public const REVIEWER_META = 'aksrt_user_verify_reviewer';
public const HISTORY_META = 'aksrt_user_verify_history';
public const APPLICATION_COUNTS_META = 'aksrt_user_verify_application_counts';
private static ?self $instance = null;
private static $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (is_admin()) {
add_filter('manage_users_columns', [$this, 'add_admin_user_column']);
add_filter('manage_users_custom_column', [$this, 'render_admin_user_column'], 10, 3);
add_action('show_user_profile', [$this, 'render_admin_profile_status']);
add_action('edit_user_profile', [$this, 'render_admin_profile_status']);
}
}
public function init(): void {
add_action('wp_ajax_aksrt_user_auth_apply', [$this, 'ajax_apply']);
add_action('admin_post_aksrt_verify_evidence', [$this, 'serve_evidence']);
if (is_admin()) {
add_filter('manage_users_columns', [$this, 'add_admin_user_column']);
add_filter('manage_users_custom_column', [$this, 'render_admin_user_column'], 10, 3);
add_action('show_user_profile', [$this, 'render_admin_profile_status']);
add_action('edit_user_profile', [$this, 'render_admin_profile_status']);
}
}
/** @return array<string,mixed> */
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<string,array{slug:string,label:string,badge_text:string,fee:float,free_applications:int,require_real_name:bool}>} */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<int,array{id:int,url:string,thumbnail_url:string}> */
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 '<span style="color:#646970;">' . esc_html__('未认证', 'aksrt') . '</span>';
}
$colors = ['pending' => '#996800', 'verified' => '#008a20', 'rejected' => '#b32d2e', 'cancelled' => '#646970'];
$html = '<span style="display:inline-block;padding:2px 8px;border:1px solid currentColor;border-radius:999px;color:'
. esc_attr($colors[$status] ?? '#646970') . '">' . esc_html($labels[$status]) . '</span>';
$info = self::get_auth_info($user_id);
if ($status === 'verified' && !empty($info['name'])) {
$html .= '<div class="description">' . esc_html((string) $info['name']) . '</div>';
}
if ($status === 'pending') {
$html .= '<div><a href="' . esc_url(admin_url('users.php?page=aksrt_user_auth&status=pending&keyword=' . $user_id)) . '">' . esc_html__('查看申请', 'aksrt') . '</a></div>';
}
return $html;
}
/** @return array<string,mixed> */
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'] ?? '');
?>
<h2><?php esc_html_e('身份认证(AKSRT)', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><?php esc_html_e('认证状态', 'aksrt'); ?></th>
<td>
<strong><?php echo esc_html($display); ?></strong>
<?php if ($status === 'pending') : ?>
<a class="button button-small" href="<?php echo esc_url(admin_url('users.php?page=aksrt_user_auth&status=pending&keyword=' . (int) $user->ID)); ?>"><?php esc_html_e('前往审核', 'aksrt'); ?></a>
<?php elseif ($status === 'verified') : ?>
<a class="button button-small" href="<?php echo esc_url(admin_url('users.php?page=aksrt_user_auth&status=verified&keyword=' . (int) $user->ID)); ?>"><?php esc_html_e('查看并管理认证', 'aksrt'); ?></a>
<?php endif; ?>
</td>
</tr>
<?php if ($identity_name !== '') : ?>
<tr><th><?php esc_html_e('认证身份', 'aksrt'); ?></th><td><?php echo esc_html($identity_name); ?></td></tr>
<?php endif; ?>
<?php if ($reviewed_at !== '') : ?>
<tr><th><?php esc_html_e('最近处理', 'aksrt'); ?></th><td><?php echo esc_html($reviewed_at); ?><?php if ($reviewer) : ?> · <?php echo esc_html($reviewer->display_name); ?><?php endif; ?></td></tr>
<?php endif; ?>
<?php if ($reason !== '') : ?>
<tr><th><?php esc_html_e('审核说明', 'aksrt'); ?></th><td><?php echo nl2br(esc_html($reason)); ?></td></tr>
<?php endif; ?>
</table>
<?php
}
/** @return array<int,array<string,mixed>> */
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<string,string> */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> $application
* @return array<int,array{id:int,url:string,thumbnail_url:string}>
*/
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<string,mixed> $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<int,array<string,mixed>> */
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<string,mixed>|\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<int,int>|\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<int,array<string,mixed>>|\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<int,int|string> $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);
?>
<h2><?php esc_html_e('身份认证(AKSRT)', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr><th><?php esc_html_e('认证状态', 'aksrt'); ?></th><td><?php echo esc_html($status ?: __('未认证', 'aksrt')); ?></td></tr>
<?php if (!empty($info['name'])) : ?><tr><th><?php esc_html_e('认证身份', 'aksrt'); ?></th><td><?php echo esc_html((string) $info['name']); ?></td></tr><?php endif; ?>
</table>
<?php
}
}
+1 -2
View File
@@ -281,8 +281,7 @@ class Home {
* @param array{accept_email_verified:bool} $verify_settings
*/
private function is_user_verified(int $user_id, array $verify_settings): bool {
$manual = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_verify_status', true));
if ($manual === 'verified') {
if (class_exists('\\AKSRT\\User\\AuthApply') && AuthApply::is_verified($user_id)) {
return true;
}