diff --git a/assets/admin/src/api/modules/users.js b/assets/admin/src/api/modules/users.js index 1a98f8fb..3fc85192 100644 --- a/assets/admin/src/api/modules/users.js +++ b/assets/admin/src/api/modules/users.js @@ -17,14 +17,6 @@ export const usersApiMethods = { deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) }, runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) }, - getIdentitySettings() { return this.request('/identity/settings') }, - saveIdentitySettings(data) { - return this.request('/identity/settings', { method: 'POST', body: JSON.stringify(data) }) - }, - testIdentitySettings(data = {}) { - return this.request('/identity/settings/test', { method: 'POST', body: JSON.stringify(data) }) - }, - getCreditSettings() { return this.request('/assets/settings') }, saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) }, listCreditUsers(params = {}) { diff --git a/assets/admin/src/constants/sections/users.js b/assets/admin/src/constants/sections/users.js index 6ba9626f..ce1521ce 100644 --- a/assets/admin/src/constants/sections/users.js +++ b/assets/admin/src/constants/sections/users.js @@ -60,7 +60,7 @@ export const usersSection = { route: '/users/verify', component: 'UsersVerify', title: '身份认证', - desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。', + desc: '配置通用认证申请规则;申请审核和认证资料管理位于 WordPress 用户菜单。', canonicalDomain: 'users', section: 'verify', }), diff --git a/assets/admin/src/pages/users/UsersVerify.vue b/assets/admin/src/pages/users/UsersVerify.vue index f25b147d..614e389f 100644 --- a/assets/admin/src/pages/users/UsersVerify.vue +++ b/assets/admin/src/pages/users/UsersVerify.vue @@ -1,220 +1,52 @@ - - diff --git a/assets/admin/src/schema/sections/users/verify.js b/assets/admin/src/schema/sections/users/verify.js index c56c859a..0b1bede5 100644 --- a/assets/admin/src/schema/sections/users/verify.js +++ b/assets/admin/src/schema/sections/users/verify.js @@ -2,83 +2,51 @@ export const verifySchema = { key: 'users.verify', title: '身份认证', icon: 'fa fas fa-id-card', - description: '配置公开认证徽标、申请类型与申请门槛;审核、驳回和取消认证请前往 WordPress 用户 → 身份认证。', + description: '设置通用身份认证的申请条件;用户提交资料后由管理员审核。', defaults: { - user_verify_enabled: true, - user_verify_public_display: true, - user_verify_personal_badge_text: '个人认证', - user_verify_personal_fee: '0.00', - user_verify_personal_free_applications: 0, - user_verify_personal_require_real_name: false, - user_verify_enterprise_badge_text: '企业认证', - user_verify_enterprise_fee: '0.00', - user_verify_enterprise_free_applications: 0, - user_verify_enterprise_require_real_name: false, - user_verify_allow_apply: true, - user_verify_disable_desc: - '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员', - user_verify_desc: - '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。', + user_auth_s: true, + user_auth_apply_s: true, + user_auth_apply_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员', + user_auth_apply_limit_level: 0, + user_auth_apply_desc: '请填写认证身份、简介和申请说明,提交后由管理员审核。', }, groups: [ - { - key: 'display', - title: '公开展示', - icon: 'fa fas fa-eye', - fields: [ - { key: 'user_verify_enabled', label: '启用身份认证', type: 'switch', default: true }, - { - key: 'user_verify_public_display', - label: '公开页显示认证徽标', - type: 'switch', - default: true, - dependsOn: { key: 'user_verify_enabled', value: true }, - }, - { - key: 'user_verify_personal_badge_text', label: '个人认证徽标文案', type: 'input', default: '个人认证', - dependsOn: { key: 'user_verify_public_display', value: true }, - }, - { - key: 'user_verify_enterprise_badge_text', label: '企业认证徽标文案', type: 'input', default: '企业认证', - dependsOn: { key: 'user_verify_public_display', value: true }, - }, - ], - }, - { - key: 'workflow', title: '认证方式', icon: 'fa fas fa-tasks', - fields: [{ type: 'alert', content: '认证方式固定为个人认证和企业认证,申请人不可自行新增或修改类型。' }], - }, { key: 'apply', - title: '申请设置', + title: '认证申请', icon: 'fa fas fa-edit', fields: [ + { key: 'user_auth_s', label: '启用身份认证', type: 'switch', default: true }, { - key: 'user_verify_allow_apply', + key: 'user_auth_apply_s', label: '允许用户前台申请', type: 'switch', default: true, - dependsOn: { key: 'user_verify_enabled', value: true }, + dependsOn: { key: 'user_auth_s', value: true }, }, { - key: 'user_verify_disable_desc', + key: 'user_auth_apply_disable_desc', label: '停用申请说明', type: 'textarea', default: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员', - dependsOn: { key: 'user_verify_allow_apply', value: false }, + dependsOn: { key: 'user_auth_apply_s', value: false }, }, - { key: 'user_verify_personal_fee', label: '个人认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } }, - { key: 'user_verify_personal_free_applications', label: '个人认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } }, - { key: 'user_verify_personal_require_real_name', label: '个人认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } }, - { key: 'user_verify_enterprise_fee', label: '企业认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } }, - { key: 'user_verify_enterprise_free_applications', label: '企业认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } }, - { key: 'user_verify_enterprise_require_real_name', label: '企业认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } }, { - key: 'user_verify_desc', + key: 'user_auth_apply_limit_level', + label: '申请最低用户等级', + type: 'number', + default: 0, + min: 0, + max: 30, + help: '设置为 0 时不限制用户等级。', + dependsOn: { key: 'user_auth_apply_s', value: true }, + }, + { + key: 'user_auth_apply_desc', label: '申请介绍', type: 'textarea', - default: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。', - dependsOn: { key: 'user_verify_allow_apply', value: true }, + default: '请填写认证身份、简介和申请说明,提交后由管理员审核。', + dependsOn: { key: 'user_auth_apply_s', value: true }, }, ], }, diff --git a/assets/css/routes/user-center/settings.css b/assets/css/routes/user-center/settings.css index 9bbe2e69..ecf1c4d4 100644 --- a/assets/css/routes/user-center/settings.css +++ b/assets/css/routes/user-center/settings.css @@ -528,20 +528,11 @@ .uc-auth-form { display: grid; gap: 17px; } .uc-auth-field { display: grid; gap: 7px; color: var(--color-ink-2); font-size: 13px; font-weight: 600; } -.uc-auth-enterprise-fields { display: grid; gap: 17px; } -.uc-auth-enterprise-fields[hidden] { display: none; } .uc-auth-form .form-control { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid var(--color-line-soft, rgba(15, 23, 42, 0.1)); border-radius: 9px; background: var(--surface-1); color: var(--color-ink); font: inherit; font-weight: 400; box-sizing: border-box; } .uc-auth-form textarea.form-control { resize: vertical; } -.uc-auth-consent { display: flex; align-items: flex-start; gap: 8px; color: var(--color-ink-3); font-size: 13px; font-weight: 400; line-height: 1.6; } -.uc-auth-consent input { flex: 0 0 auto; margin-top: 3px; } .uc-auth-submit { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; } .uc-auth-form__message { min-height: 18px; color: var(--color-error, #dc2626); font-size: 12px; text-align: center; } -.uc-auth-pending { text-align: center; padding: 4px 0 18px; } -.uc-auth-pending > span { display: grid; place-items: center; width: 58px; height: 58px; margin: 0 auto 12px; border-radius: 18px; background: rgba(245, 158, 11, 0.14); color: #a66e0b; font-size: 24px; } -.uc-auth-pending h3 { margin: 0 0 7px; } -.uc-auth-pending p { margin: 0; color: var(--color-ink-4); font-size: 13px; } - @media (max-width: 640px) { .uc-auth-modal__dialog { position: fixed; inset: auto 0 0; width: 100%; max-width: none; max-height: 88vh; margin: 0; border-radius: 18px 18px 0 0; } } diff --git a/assets/js/routes/user-center-auth.js b/assets/js/routes/user-center-auth.js index dc78a27f..50084a7c 100644 --- a/assets/js/routes/user-center-auth.js +++ b/assets/js/routes/user-center-auth.js @@ -1,364 +1,113 @@ (function (window, document) { 'use strict'; - function parseConfig(root) { + function configFor(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(path, config, options) { - options = Object.assign({}, options || {}); - options.credentials = 'same-origin'; - options.headers = Object.assign({ 'X-WP-Nonce': config.nonce || '' }, options.headers || {}); - return window.AksrtRequest.request(path, options).then(function (response) { - return response.data !== undefined ? response.data : response.json; - }); + function parseResponse(response) { + var payload = response && (response.data !== undefined ? response.data : response.json); + return payload && payload.data !== undefined ? payload.data : payload; } function errorMessage(error, fallback) { - if (error && error.json) { - return error.json.message || (error.json.data && error.json.data.message) || fallback; - } + if (error && error.json) return error.json.message || fallback; return error && error.message ? error.message : fallback; } - function loadScript(src) { - return new Promise(function (resolve, reject) { - var existing = document.querySelector('script[src="' + src + '"]'); - if (existing) { - if (existing.dataset.loaded === '1') return resolve(); - existing.addEventListener('load', resolve, { once: true }); - existing.addEventListener('error', reject, { once: true }); - return; - } - var script = document.createElement('script'); - script.src = src; - script.async = true; - script.onload = function () { script.dataset.loaded = '1'; resolve(); }; - script.onerror = reject; - document.head.appendChild(script); - }); - } - - function aliyunMetaInfo(config) { - if (config.provider !== 'aliyun') return Promise.resolve(''); - return loadScript('https://o.alicdn.com/yd-cloudauth/cloudauth-cdn/jsvm_all.js').then(function () { - if (typeof window.getMetaInfo !== 'function') throw new Error('阿里云 MetaInfo 组件未加载成功'); - var meta = window.getMetaInfo(); - return typeof meta === 'string' ? meta : JSON.stringify(meta || {}); - }); - } - function init(root) { if (!root || root.dataset.identityBound === '1') return; root.dataset.identityBound = '1'; - var config = parseConfig(root); + var config = configFor(root); var template = root.querySelector('[data-auth-modal-template]'); var runtime = window.AksrtModalRuntime; - if (!template) return; - - var activeShell = null; - var currentUuid = config.currentUuid || ''; - var currentStage = config.currentStage || ''; - var pollTimer = null; - var pollBackoff = 0; - var currentController = null; - var refreshController = null; - var refreshTimeout = null; - var refreshInFlight = false; - var openSequence = 0; - var pendingCreateKey = ''; - var pendingCreateSignature = ''; - var pendingRefreshKey = ''; - var pendingManualKey = ''; - var pendingManualSignature = ''; - - function isOpen(shell) { - return !!shell && shell.classList.contains('is-open') && document.contains(shell); - } - - function stopPolling() { - if (pollTimer) window.clearTimeout(pollTimer); - pollTimer = null; - } - - function abortCurrent() { - if (currentController) { - try { currentController.abort(); } catch (_) {} - } - currentController = null; - } - - function abortRefresh() { - if (refreshTimeout) window.clearTimeout(refreshTimeout); - refreshTimeout = null; - if (refreshController) { - try { refreshController.abort(); } catch (_) {} - } - refreshController = null; - refreshInFlight = false; - } - - function setMessage(shell, message) { - var node = shell && shell.querySelector('[data-auth-status-message]'); - if (node) node.textContent = message || ''; - } - - function updateSummary(data) { - if (!data || data.status !== 'verified') return; - var trigger = root.querySelector('[data-auth-modal-open]'); - if (trigger) { - trigger.disabled = true; - trigger.textContent = '已认证'; - } - runtime.invalidate(['identity']); - document.dispatchEvent(new CustomEvent('aksrt:identity:changed', { detail: { status: data.status } })); - } - - function schedulePoll(delay) { - stopPolling(); - if (!currentUuid || !isOpen(activeShell) || document.visibilityState !== 'visible') return; - var base = Math.max(5, Number(delay || config.pollInterval || 5)); - var seconds = Math.min(60, base * Math.pow(2, Math.min(4, pollBackoff))); - pollTimer = window.setTimeout(function () { - pollTimer = null; - refresh(false); - }, seconds * 1000); - } - - function render(data, shell) { - if (!shell || shell !== activeShell || !isOpen(shell)) return; - data = data || {}; - var previousStage = currentStage; - currentUuid = data.uuid || currentUuid; - currentStage = data.stage || currentStage; - if (data.stage && data.stage !== previousStage) runtime.invalidate(['identity']); - var lock = ['created', 'provider_pending', 'provider_processing', 'manual_pending'].indexOf(currentStage) !== -1; - var retryable = data.next_action === 'manual_or_retry'; - var statusBox = shell.querySelector('[data-auth-status]'); - var createForm = shell.querySelector('[data-auth-create]'); - var stageNode = shell.querySelector('[data-auth-stage]'); - var messageNode = shell.querySelector('[data-auth-status-message]'); - var continueLink = shell.querySelector('[data-auth-continue]'); - var manualButton = shell.querySelector('[data-auth-manual-open]'); - - if (statusBox) statusBox.hidden = !data.uuid && !retryable; - if (createForm) createForm.hidden = lock || data.status === 'verified'; - if (stageNode) stageNode.textContent = data.stage || data.status || '准备认证'; - if (messageNode) messageNode.textContent = data.message || '认证结果以站点服务端主动查询为准。'; - if (continueLink) { - var canContinue = data.next_action === 'poll' && data.redirect_url; - continueLink.hidden = !canContinue; - if (canContinue) continueLink.href = data.redirect_url; - } - if (manualButton) manualButton.hidden = !retryable; - - if (data.status === 'verified' || data.next_action !== 'poll') { - pollBackoff = 0; - stopPolling(); - } else if (isOpen(shell) && document.visibilityState === 'visible') { - schedulePoll(data.poll_interval || config.pollInterval); - } - updateSummary(data); - } - - function refresh(explicit) { - if (!currentUuid || !isOpen(activeShell) || document.visibilityState !== 'visible' || refreshInFlight) { - return Promise.resolve(null); - } - stopPolling(); - abortRefresh(); - refreshInFlight = true; - refreshController = window.AbortController ? new window.AbortController() : null; - var timedOut = false; - var shell = activeShell; - var timeoutMs = Math.max(5000, Number(config.refreshTimeout || 15000)); - if (!pendingRefreshKey) pendingRefreshKey = uuid(); - refreshTimeout = window.setTimeout(function () { - timedOut = true; - if (refreshController) { - try { refreshController.abort(); } catch (_) {} - } - }, timeoutMs); - - return request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/refresh', config, { - method: 'POST', - headers: { 'Idempotency-Key': pendingRefreshKey }, - signal: refreshController ? refreshController.signal : undefined, - }).then(function (data) { - pendingRefreshKey = ''; - if (shell !== activeShell || !isOpen(shell)) return data; - pollBackoff = explicit ? 0 : Math.min(4, pollBackoff + 1); - render(data, shell); - return data; - }).catch(function (error) { - var aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR'); - if (shell !== activeShell || !isOpen(shell) || (aborted && !timedOut)) return null; - setMessage(shell, timedOut ? '云端刷新超时,请稍后重试。' : errorMessage(error, '刷新失败,请稍后重试。')); - pollBackoff = Math.min(4, pollBackoff + 1); - schedulePoll(config.pollInterval || 5); - return null; - }).finally(function () { - if (refreshTimeout) window.clearTimeout(refreshTimeout); - refreshTimeout = null; - refreshController = null; - refreshInFlight = false; - }); - } - - function loadCurrent(shell, sequence) { - abortCurrent(); - currentController = window.AbortController ? new window.AbortController() : null; - var controller = currentController; - return request(config.restBase + '/current', config, { - method: 'GET', - signal: controller ? controller.signal : undefined, - }).then(function (snapshot) { - if (sequence !== openSequence || shell !== activeShell || !isOpen(shell)) return; - var data = snapshot && (snapshot.current || snapshot.active || snapshot.latest); - if (!data) { - currentUuid = ''; - currentStage = ''; - var statusBox = shell.querySelector('[data-auth-status]'); - var createForm = shell.querySelector('[data-auth-create]'); - if (statusBox) statusBox.hidden = true; - if (createForm) createForm.hidden = false; - stopPolling(); - return; - } - render(data, shell); - }).catch(function (error) { - var aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR'); - if (aborted || sequence !== openSequence || shell !== activeShell || !isOpen(shell)) return; - setMessage(shell, errorMessage(error, '状态加载失败,请稍后重试。')); - }).finally(function () { - if (currentController === controller) currentController = null; - }); - } - - function bindForm(shell) { - var createForm = shell.querySelector('[data-auth-create]'); - var manualForm = shell.querySelector('[data-auth-manual]'); - var statusBox = shell.querySelector('[data-auth-status]'); - var refreshButton = shell.querySelector('[data-auth-refresh]'); - var manualButton = shell.querySelector('[data-auth-manual-open]'); - var typeSelect = createForm && createForm.querySelector('[name="type"]'); - var enterprise = createForm && createForm.querySelector('[data-enterprise-fields]'); - var personLabel = createForm && createForm.querySelector('[data-auth-person-label]'); - var idLabel = createForm && createForm.querySelector('[data-auth-id-label]'); - - 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 (personLabel) personLabel.textContent = enabled ? '法定代表人姓名' : '真实姓名'; - if (idLabel) idLabel.textContent = enabled ? '法定代表人身份证号' : '身份证号码'; - } - - if (typeSelect) { - typeSelect.addEventListener('change', syncEnterprise); - syncEnterprise(); - } - if (refreshButton) refreshButton.addEventListener('click', function () { refresh(true); }); - if (manualButton) manualButton.addEventListener('click', function () { if (manualForm) manualForm.hidden = false; }); - - if (createForm) createForm.addEventListener('submit', function (event) { - event.preventDefault(); - var button = createForm.querySelector('[type="submit"]'); - var message = createForm.querySelector('[data-auth-message]'); - var form = new FormData(createForm); - var payload = {}; - form.forEach(function (value, key) { payload[key] = value; }); - payload.consent = payload.consent === '1'; - var signature = JSON.stringify(payload); - if (!pendingCreateKey || pendingCreateSignature !== signature) { - pendingCreateKey = uuid(); - pendingCreateSignature = signature; - } - if (button) button.disabled = true; - if (message) message.textContent = ''; - aliyunMetaInfo(config).then(function (metaInfo) { - if (metaInfo) payload.meta_info = metaInfo; - return request(config.restBase, config, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pendingCreateKey }, - body: JSON.stringify(payload), - }); - }).then(function (data) { - pendingCreateKey = ''; - pendingCreateSignature = ''; - render(data, shell); - }).catch(function (error) { - if (message) message.textContent = errorMessage(error, '创建认证会话失败,请重试。'); - }).finally(function () { - if (button) button.disabled = 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) { - setMessage(shell, '证明材料最多上传 3 张'); - return; - } - var button = manualForm.querySelector('[type="submit"]'); - var body = new FormData(); - Array.prototype.forEach.call((files && files.files) || [], function (file) { body.append('evidence[]', file); }); - var signature = Array.prototype.map.call((files && files.files) || [], function (file) { - return [file.name, file.size, file.lastModified].join(':'); - }).join('|'); - if (!pendingManualKey || pendingManualSignature !== signature) { - pendingManualKey = uuid(); - pendingManualSignature = signature; - } - if (button) button.disabled = true; - request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/manual', config, { - method: 'POST', - headers: { 'Idempotency-Key': pendingManualKey }, - body: body, - }).then(function (data) { - pendingManualKey = ''; - pendingManualSignature = ''; - manualForm.hidden = true; - render(data, shell); - }).catch(function (error) { - setMessage(shell, errorMessage(error, '人工审核提交失败,请重试。')); - }).finally(function () { - if (button) button.disabled = false; - }); - }); - - if (statusBox && statusBox.hidden && createForm) createForm.hidden = false; - } + if (!template || !runtime || !window.AksrtRequest) return; function open() { - stopPolling(); - abortCurrent(); - abortRefresh(); - openSequence += 1; - var sequence = openSequence; - var shell = runtime.open({ + runtime.open({ html: template.innerHTML, kind: 'identity', className: 'aksrt-identity-modal-shell', mobileBottom: true, onContent: function (_, session) { - activeShell = session.shell; - bindForm(session.shell); + var shell = session && session.shell; + var form = shell && shell.querySelector('[data-auth-create]'); + if (!form) return; + form.addEventListener('submit', function (event) { + event.preventDefault(); + var input = form.querySelector('[name="evidence[]"]'); + var message = form.querySelector('[data-auth-message]'); + var button = form.querySelector('[type="submit"]'); + var files = input ? Array.prototype.slice.call(input.files || []) : []; + if (files.length > 3) { + if (message) message.textContent = '证明材料最多上传 3 张'; + return; + } + if (files.some(function (file) { return file.size > 2 * 1024 * 1024; })) { + if (message) message.textContent = '单张证明材料不能超过 2MB'; + return; + } + + if (button) button.disabled = true; + if (message) message.textContent = ''; + window.AksrtRequest.request(config.restBase, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-WP-Nonce': config.nonce || '' }, + body: new FormData(form), + }).then(function (response) { + var result = parseResponse(response); + if (!result || result.success === false) throw new Error((result && result.message) || '申请提交失败,请稍后重试'); + if (message) message.textContent = '申请已提交,请耐心等待管理员审核。'; + var summary = root.querySelector('[data-auth-summary]'); + if (summary) { + summary.replaceChildren(); + var pending = document.createElement('div'); + pending.className = 'c-red mb10'; + pending.textContent = '您的申请正在审核中,请耐心等待'; + var name = document.createElement('div'); + name.textContent = result.name || ''; + summary.appendChild(pending); + summary.appendChild(name); + if (result.description) { + var description = document.createElement('div'); + description.className = 'muted-2-color mt10'; + description.textContent = result.description; + summary.appendChild(description); + } + if (result.message) { + var applicationMessage = document.createElement('div'); + applicationMessage.className = 'muted-2-color mt10'; + applicationMessage.textContent = '申请说明:' + result.message; + summary.appendChild(applicationMessage); + } + (Array.isArray(result.evidence) ? result.evidence : []).forEach(function (item) { + if (!item || !item.url || !item.thumbnail_url) return; + var link = document.createElement('a'); + link.href = item.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + var image = document.createElement('img'); + image.src = item.thumbnail_url; + image.alt = '身份认证证明材料'; + image.width = 64; + image.height = 64; + image.style.objectFit = 'cover'; + image.style.borderRadius = '4px'; + link.appendChild(image); + summary.appendChild(link); + }); + } + if (runtime.getActive && runtime.getActive() === shell) runtime.close('submit'); + }).catch(function (error) { + if (message) message.textContent = errorMessage(error, '申请提交失败,请稍后重试'); + }).finally(function () { + if (button) button.disabled = false; + }); + }); }, }); - activeShell = shell; - loadCurrent(shell, sequence); } root.querySelectorAll('[data-auth-modal-open]').forEach(function (button) { @@ -367,27 +116,6 @@ open(); }); }); - - document.addEventListener('visibilitychange', function () { - if (!isOpen(activeShell)) return; - if (document.visibilityState !== 'visible') { - stopPolling(); - abortCurrent(); - abortRefresh(); - return; - } - if (currentUuid && ['provider_pending', 'provider_processing'].indexOf(currentStage) !== -1) { - schedulePoll(config.pollInterval || 5); - } - }); - - document.addEventListener('aksrt:modal:close', function (event) { - if (!activeShell || !event.detail || event.detail.shell !== activeShell) return; - stopPolling(); - abortCurrent(); - abortRefresh(); - activeShell = null; - }); } window.AKSRTUserCenterAuth = { init: init }; diff --git a/inc/helpers/user-center-renderers/profile-auth.php b/inc/helpers/user-center-renderers/profile-auth.php index 8ed8dcdf..3620ebd5 100644 --- a/inc/helpers/user-center-renderers/profile-auth.php +++ b/inc/helpers/user-center-renderers/profile-auth.php @@ -1,123 +1,73 @@ ` below and is driven by user-center-auth.js. - * - * @package AKSRT - */ +/** User center identity certification panel. @package AKSRT */ defined('ABSPATH') || exit; use AKSRT\User\AuthApply; +use AKSRT\Identity\EvidenceService; $context = is_array($args['context'] ?? null) ? $args['context'] : []; $user_id = (int) ($context['user_id'] ?? get_current_user_id()); -$snapshot = class_exists(AuthApply::class) ? AuthApply::snapshot($user_id) : [ - 'status' => '', - 'stage' => '', - 'can_apply' => false, - 'verified' => false, - 'info' => [], - 'apply' => [], - 'active' => null, - 'latest' => null, - 'poll_interval' => 5, -]; -$settings = class_exists(AuthApply::class) ? AuthApply::settings() : ['enabled' => false, 'allow_apply' => false]; -$status = (string) ($snapshot['status'] ?? ''); +$snapshot = AuthApply::snapshot($user_id); +$settings = AuthApply::settings(); $info = is_array($snapshot['info'] ?? null) ? $snapshot['info'] : []; -$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : []; -$latest = is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : []; -$stage = sanitize_key((string) ($active['status'] ?? '')); -$locks_application = $stage !== ''; -$reason = (string) (($latest['review_note'] ?? '') ?: ($latest['provider_result_summary'] ?? '')); +$application = is_array($snapshot['application'] ?? null) ? $snapshot['application'] : []; +$status = (string) ($snapshot['status'] ?? ''); $can_apply = !empty($snapshot['can_apply']); -$config = [ - 'restBase' => untrailingslashit(rest_url('aksrt/v1/identity/applications')), - 'nonce' => wp_create_nonce('wp_rest'), - 'currentUuid' => $locks_application ? (string) ($active['application_uuid'] ?? '') : '', - 'currentStage' => $locks_application ? $stage : '', - 'provider' => sanitize_key((string) ($settings['provider'] ?? 'aliyun')), - 'allowManual' => !empty($settings['allow_manual_fallback']), - 'pollInterval' => max(5, (int) ($snapshot['poll_interval'] ?? 5)), - 'snapshot' => class_exists(AuthApply::class) ? AuthApply::snapshot_view($user_id) : [], -]; $auth_icon = ''; +$config = [ + 'restBase' => untrailingslashit(rest_url('aksrt/v1/identity/applications')), + 'nonce' => wp_create_nonce('wp_rest'), +]; ?>
-
- - -
+
+
+
- -
- -
- -
+
+
+ +
+
+
+
+
+ items($application); ?> +
<?php esc_attr_e('身份认证证明材料', 'aksrt'); ?>
+
- -
- +
-
- +
- -
- +
-
- + +
diff --git a/src/Admin/Native/UsersMenusVerify.php b/src/Admin/Native/UsersMenusVerify.php index 22d8ecca..4d170e5c 100644 --- a/src/Admin/Native/UsersMenusVerify.php +++ b/src/Admin/Native/UsersMenusVerify.php @@ -1,9 +1,5 @@ get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE status = %s", $status)); - } + private function count_verify_status(string $status): int { + global $wpdb; + $table = \AKSRT\Core\DBMigrator::table('identity_application'); + $stored = $status === 'pending' ? 'manual_pending' : $status; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE type = %s AND channel = %s AND status = %s", + 'general', + 'manual', + $stored + )); + } - public function render_verify_page(): void { - $this->ensure_cap(); - $filters = $this->get_verify_filters(); - $page_num = max(1, (int) ($_GET['paged'] ?? 1)); - $result = $this->query_verify_requests($filters, $page_num, 20); - $counts = $this->get_verify_status_counts(); - echo '

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

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

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

'; - $this->render_verify_filters($filters); - $this->render_verify_table($result['items']); - $this->pagination($result['pages'], $page_num); - echo '
'; - } + public function render_verify_page(): void { + $this->ensure_cap(); + $filters = $this->get_verify_filters(); + $page = max(1, (int) ($_GET['paged'] ?? 1)); + $data = $this->query_verify_requests($filters, $page, 20); + $counts = $this->get_verify_status_counts(); + echo '

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

'; + $this->flash(); + printf('

%s

', esc_html(sprintf(__('待审核 %1$d · 已通过 %2$d · 已拒绝 %3$d', 'aksrt'), $counts['pending'], $counts['approved'], $counts['rejected']))); + $this->render_verify_filters($filters); + $this->render_verify_table($data['items']); + $this->pagination($data['pages'], $page); + echo '
'; + } - /** @return array{status:string,keyword:string,date_from:string,date_to:string} */ - private function get_verify_filters(): array { - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending'; - 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'])) : '', - 'date_from' => $this->sanitize_verify_date($_GET['date_from'] ?? ''), - 'date_to' => $this->sanitize_verify_date($_GET['date_to'] ?? ''), - ]; - } + /** @return array{status:string,keyword:string} */ + private function get_verify_filters(): array { + $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending'; + if (!in_array($status, ['pending', 'approved', 'rejected', ''], true)) $status = 'pending'; + return [ + 'status' => $status, + 'keyword' => isset($_GET['keyword']) ? sanitize_text_field((string) wp_unslash($_GET['keyword'])) : '', + ]; + } - private function sanitize_verify_date($value): string { - $value = sanitize_text_field((string) wp_unslash($value)); - return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : ''; - } + /** @return array{pending:int,approved:int,rejected:int} */ + private function get_verify_status_counts(): array { + return [ + 'pending' => $this->count_verify_status('pending'), + 'approved' => $this->count_verify_status('verified'), + 'rejected' => $this->count_verify_status('rejected'), + ]; + } - /** @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 status, COUNT(*) total FROM {$table} GROUP BY status", ARRAY_A); - foreach ((array) $rows as $row) { - $status = (string) ($row['status'] ?? ''); - $total = (int) ($row['total'] ?? 0); - $public = \AKSRT\Identity\TransitionService::public_status($status); - if (in_array($status, ['manual_pending', 'provider_failed', 'provider_unsupported', 'provider_expired'], true)) { - $counts['pending'] += $total; - } elseif (isset($counts[$public])) { - $counts[$public] += $total; - } - $counts['total'] += $total; - } - return $counts; - } + /** @return array{items:array>,pages:int} */ + private function query_verify_requests(array $filters, int $page, int $per_page): array { + global $wpdb; + $table = \AKSRT\Core\DBMigrator::table('identity_application'); + $where = ["a.type = 'general'", "a.channel = 'manual'"]; + $params = []; + if ($filters['status'] !== '') { + $where[] = 'a.status = %s'; + $params[] = $filters['status'] === 'pending' ? 'manual_pending' : ($filters['status'] === 'approved' ? 'verified' : $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 a.public_name LIKE %s OR a.provider_result_summary LIKE %s)'; + array_push($params, $like, $like, $like, $like, $like, $like); + } + $condition = implode(' AND ', $where); + $base = "FROM {$table} a INNER JOIN {$wpdb->users} u ON u.ID = a.user_id WHERE {$condition}"; + $count_sql = "SELECT COUNT(*) {$base}"; + $total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql); + $limit = max(1, min(100, $per_page)); + $query_params = array_merge($params, [$limit, (max(1, $page) - 1) * $limit]); + $sql = "SELECT a.id, a.application_uuid, a.user_id, a.type, a.channel, a.status, a.public_name, a.provider_result_summary AS description, a.manual_reason, a.review_note, a.evidence_ids, a.created_at, u.display_name, u.user_login, u.user_email {$base} ORDER BY a.created_at DESC, a.id DESC LIMIT %d OFFSET %d"; + $rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A); + return ['items' => is_array($rows) ? $rows : [], 'pages' => max(1, (int) ceil(max(1, $total) / $limit))]; + } - /** @return array{items:array>,pages:int,total:int} */ - private function query_verify_requests(array $filters, int $page_num, int $per_page): array { - global $wpdb; - $table = \AKSRT\Core\DBMigrator::table('identity_application'); - $where = ['1=1']; - $params = []; - if ($filters['status'] === 'pending') { - $where[] = "a.status IN ('manual_pending','provider_failed','provider_unsupported','provider_expired')"; - } 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 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[] = 'a.created_at >= %s'; - $params[] = $filters['date_from'] . ' 00:00:00'; - } - if ($filters['date_to'] !== '') { - $where[] = 'a.created_at <= %s'; - $params[] = $filters['date_to'] . ' 23:59:59'; - } - $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]; - } + private function render_verify_filters(array $filters): void { + echo '
'; + echo ''; + echo ''; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + } - private function render_verify_filters(array $filters): void { - echo '
'; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; - } + private function render_verify_table(array $items): void { + echo ''; + if ($items === []) echo ''; + foreach ($items as $row) $this->render_verify_row($row); + echo '
用户状态认证名称认证简介 / 申请说明证明材料审核
' . esc_html__('暂无认证申请', 'aksrt') . '
'; + } - private function render_verify_table(array $items): void { - echo ''; - if ($items === []) { - echo ''; - } - foreach ($items as $row) { - $this->render_verify_row($row); - } - echo '
用户状态申请资料审核记录操作
' . esc_html__('暂无认证申请', 'aksrt') . '
'; - } + 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; + echo '' . esc_html((string) $row['display_name']) . '
@' . esc_html((string) $row['user_login']) . ' · UID ' . $user_id . '
' . esc_html((string) $row['user_email']) . '
'; + echo '' . esc_html($this->verify_status_label($status)) . '
' . esc_html((string) $row['created_at']) . '
'; + echo '' . esc_html((string) $row['public_name']) . ''; + echo '' . esc_html((string) $row['description']) . '

' . esc_html((string) $row['manual_reason']) . '

'; + if (!empty($row['review_note'])) echo '

' . esc_html__('处理留言:', 'aksrt') . esc_html((string) $row['review_note']) . '

'; + echo ''; + foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) { + echo ''; + } + echo ''; + $this->render_verify_actions($row); + echo ''; + } - private function render_verify_row(array $row): void { - $user_id = (int) $row['user_id']; - $status = (string) $row['status']; - $application = (new \AKSRT\Identity\ApplicationRepository())->find_by_id((int) $row['id']) ?: $row; - $history = (new \AKSRT\Identity\EventRepository())->for_application((int) $row['id']); - echo '' . esc_html((string) $row['display_name']) . ''; - echo '
@' . esc_html((string) $row['user_login']) . ' · UID ' . $user_id . '
' . esc_html((string) $row['user_email']) . '
'; - echo '' . wp_kses_post($this->verify_status_badge($status)) . '
' . esc_html((string) $row['provider']) . ' / ' . esc_html((string) $row['channel']) . '
'; - echo '' . esc_html((string) $row['public_name']) . '
' . esc_html((string) $row['type']) . '
'; - echo '
UUID: ' . esc_html((string) $row['application_uuid']) . '
'; - if (!empty($row['provider_result_code'])) echo '
' . esc_html__('云结果:', 'aksrt') . esc_html((string) $row['provider_result_code']) . '
'; - foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) { - echo ''; - } - echo ''; - if (!empty($row['review_note'])) echo '
' . esc_html((string) $row['review_note']) . '
'; - if ($history !== []) echo '
' . esc_html(sprintf(__('事件记录(%d)', 'aksrt'), count($history))) . '
    '; - foreach ($history as $event) echo '
  1. ' . esc_html((string) $event['created_at'] . ' · ' . (string) $event['event_type'] . ' · ' . (string) $event['to_status']) . '
  2. '; - if ($history !== []) echo '
'; - echo ''; - $this->render_verify_actions($row); - echo ''; - } + private function verify_status_label(string $status): string { + $labels = ['manual_pending' => __('待审核', 'aksrt'), 'verified' => __('已通过', 'aksrt'), 'rejected' => __('已拒绝', 'aksrt')]; + return (string) ($labels[$status] ?? $status); + } - private function verify_status_badge(string $status): string { - $labels = ['manual_pending' => __('人工待审', 'aksrt'), 'verified' => __('已通过', 'aksrt'), 'rejected' => __('已驳回', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), 'provider_pending' => __('等待核身', 'aksrt'), 'provider_processing' => __('云端处理中', 'aksrt'), 'provider_failed' => __('云认证失败', 'aksrt'), 'provider_unsupported' => __('云端不支持', 'aksrt'), 'provider_expired' => __('会话已过期', 'aksrt')]; - return '' . esc_html($labels[$status] ?? $status) . ''; - } + private function render_verify_actions(array $row): void { + $status = (string) $row['status']; + if ($status === 'manual_pending') { + foreach (['verified' => __('通过申请', 'aksrt'), 'rejected' => __('拒绝申请', 'aksrt')] as $next => $label) { + echo '
'; + echo ''; + wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']); + echo ''; + echo ''; + echo ''; + echo '
'; + } + } else { + echo '' . esc_html__('已处理', 'aksrt') . ''; + } + echo '
'; + echo ''; + wp_nonce_field('aksrt_users_delete_verify_' . (string) $row['application_uuid']); + echo '
'; + } - private function render_verify_actions(array $row): void { - $status = (string) $row['status']; - if (in_array($status, ['manual_pending', 'provider_failed', 'provider_unsupported', 'provider_expired'], true)) { - foreach (['verified' => __('通过认证', 'aksrt'), 'rejected' => __('驳回申请', 'aksrt')] as $next => $label) { - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']); - echo '
'; - } - } elseif ($status === 'verified') { - echo '
'; - wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']); - echo '
'; - } else { - echo '' . esc_html__('无需人工操作', 'aksrt') . ''; - } - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_delete_verify_' . (string) $row['application_uuid']); - echo '
'; - } + public function handle_review_verify(): void { + if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt')); + $user_id = absint($_POST['user_id'] ?? 0); + check_admin_referer('aksrt_users_review_verify_' . $user_id); + $uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? '')); + $status = sanitize_key((string) wp_unslash($_POST['status'] ?? '')); + $note = sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')); + $fields = [ + 'name' => sanitize_text_field((string) wp_unslash($_POST['name'] ?? '')), + 'description' => sanitize_textarea_field((string) wp_unslash($_POST['description'] ?? '')), + ]; + $result = \AKSRT\User\AuthApply::review($user_id, $uuid, $status, $note, get_current_user_id(), $fields); + $extra = ['status' => '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); + } - public function handle_review_verify(): void { - if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } - $user_id = (int) ($_POST['user_id'] ?? 0); - check_admin_referer('aksrt_users_review_verify_' . $user_id); - $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, $uuid, $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); - } - - public function handle_delete_verify(): void { - if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } - $uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? '')); - check_admin_referer('aksrt_users_delete_verify_' . $uuid); - $repo = new \AKSRT\Identity\ApplicationRepository(); - $application = $repo->find_by_uuid($uuid); - if (!$application) { - $this->redirect('aksrt_user_auth', 'verify_failed', ['aksrt_verify_error' => __('认证记录不存在', 'aksrt')]); - } - (new \AKSRT\Identity\EvidenceService())->delete((array) ($application['evidence_ids'] ?? [])); - (new \AKSRT\Identity\EventRepository())->delete_for_application((int) $application['id']); - $deleted = $repo->delete((int) $application['id']); - $this->redirect('aksrt_user_auth', $deleted ? 'verify_deleted' : 'verify_failed', $deleted ? [] : ['aksrt_verify_error' => __('认证记录删除失败', 'aksrt')]); - } - - private function get_verify_history(int $user_id): array { - return \AKSRT\User\AuthApply::get_review_history($user_id); - } + public function handle_delete_verify(): void { + if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt')); + $uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? '')); + check_admin_referer('aksrt_users_delete_verify_' . $uuid); + $repo = new \AKSRT\Identity\ApplicationRepository(); + $application = $repo->find_by_uuid($uuid); + if ( + !$application + || (string) ($application['type'] ?? '') !== 'general' + || (string) ($application['channel'] ?? '') !== 'manual' + ) { + $this->redirect('aksrt_user_auth', 'verify_failed', ['aksrt_verify_error' => __('认证记录不存在', 'aksrt')]); + } + $deleted = $repo->delete((int) $application['id']); + if (!$deleted) { + $this->redirect('aksrt_user_auth', 'verify_failed', ['aksrt_verify_error' => __('认证记录删除失败', 'aksrt')]); + } + (new \AKSRT\Identity\EvidenceService())->delete((array) ($application['evidence_ids'] ?? [])); + (new \AKSRT\Identity\EventRepository())->delete_for_application((int) $application['id']); + $this->redirect('aksrt_user_auth', 'verify_deleted'); + } } diff --git a/src/Admin/Schema/SchemaUsers.php b/src/Admin/Schema/SchemaUsers.php index 5216401a..d05fcc93 100644 --- a/src/Admin/Schema/SchemaUsers.php +++ b/src/Admin/Schema/SchemaUsers.php @@ -538,49 +538,15 @@ class SchemaUsers { 'verify' => [ 'key' => 'verify', 'title' => '身份认证', - 'version' => '1.1.0', + 'version' => '2.0.0', 'option_key' => 'aksrt_settings', 'groups' => [ - ['key' => 'display', 'title' => '公开展示', 'fields' => [ - ['key' => 'user_verify_enabled', 'label' => '启用身份认证', 'type' => 'switch', 'default' => true], - ['key' => 'user_verify_public_display', 'label' => '公开页显示认证徽标', 'type' => 'switch', 'default' => true], - ['key' => 'user_verify_personal_badge_text', 'label' => '个人认证徽标文案', 'type' => 'input', 'default' => '个人认证'], - ['key' => 'user_verify_enterprise_badge_text', 'label' => '企业认证徽标文案', 'type' => 'input', 'default' => '企业认证'], - ]], - ['key' => 'workflow', 'title' => '认证方式', 'fields' => [ - ['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' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'desc' => '仅接受最多两位小数的十进制字符串。'], - ['key' => 'user_verify_personal_free_applications', 'label' => '个人认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。'], - ['key' => 'user_verify_enterprise_fee', 'label' => '企业认证每次扣余额(元)', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '0.00', 'desc' => '仅接受最多两位小数的十进制字符串。'], - ['key' => 'user_verify_enterprise_free_applications', 'label' => '企业认证赠送初始次数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'desc' => '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。'], - ['key' => 'user_verify_desc', 'label' => '申请介绍', 'type' => 'textarea', 'default' => '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'], + ['key' => 'user_auth_s', 'label' => '启用身份认证', 'type' => 'switch', 'default' => true], + ['key' => 'user_auth_apply_s', 'label' => '允许用户前台申请', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'user_auth_s', 'value' => true]], + ['key' => 'user_auth_apply_disable_desc', 'label' => '停用申请说明', 'type' => 'textarea', 'default' => "由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员", 'dependsOn' => ['key' => 'user_auth_apply_s', 'value' => false]], + ['key' => 'user_auth_apply_limit_level', 'label' => '申请最低用户等级', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 30, 'desc' => '设置为 0 时不限制用户等级。', 'dependsOn' => ['key' => 'user_auth_apply_s', 'value' => true]], + ['key' => 'user_auth_apply_desc', 'label' => '申请介绍', 'type' => 'textarea', 'default' => '请填写认证身份、简介和申请说明,提交后由管理员审核。', 'dependsOn' => ['key' => 'user_auth_apply_s', 'value' => true]], ]], ], ], diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index 9954d296..ba59a442 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -9,8 +9,6 @@ defined('ABSPATH') || exit; class UserController extends ControllerBase { use UserControllerReportOperations; - use UserControllerVerifyIntegrations; - use UserControllerIdentitySettings; use UserControllerAuthPhoneOperations; use UserControllerRestrictionOperations; use UserControllerFollowOperations; @@ -234,25 +232,6 @@ class UserController extends ControllerBase { 'permission_callback' => '__return_true', ]); - // Identity verification provider settings (admin only) - register_rest_route(self::API_NAMESPACE, '/identity/settings', [ - [ - 'methods' => 'GET', - 'callback' => [$this, 'get_identity_settings'], - 'permission_callback' => [$this, 'check_permission'], - ], - [ - 'methods' => 'POST', - 'callback' => [$this, 'update_identity_settings'], - 'permission_callback' => [$this, 'check_permission'], - ], - ]); - register_rest_route(self::API_NAMESPACE, '/identity/settings/test', [ - 'methods' => 'POST', - 'callback' => [$this, 'test_identity_settings'], - 'permission_callback' => [$this, 'check_permission'], - ]); - // SMS provider status register_rest_route(self::API_NAMESPACE, '/sms/status', [ 'methods' => 'GET', diff --git a/src/Admin/UserControllerIdentitySettings.php b/src/Admin/UserControllerIdentitySettings.php deleted file mode 100644 index a631c631..00000000 --- a/src/Admin/UserControllerIdentitySettings.php +++ /dev/null @@ -1,52 +0,0 @@ -success([ - 'data' => $this->identity_settings_response(IdentityAdmin::read()), - ]); - } - - public function update_identity_settings(\WP_REST_Request $request): \WP_REST_Response { - $settings = IdentityAdmin::update($this->get_json_params($request)); - - return $this->success([ - 'message' => __('身份认证服务商配置已保存', 'aksrt'), - 'data' => $this->identity_settings_response($settings), - ]); - } - - public function test_identity_settings(\WP_REST_Request $request): \WP_REST_Response { - $params = $this->get_json_params($request); - $provider = sanitize_key((string) ($params['provider'] ?? '')); - $result = IdentityAdmin::test($provider); - - if (empty($result['success'])) { - return $this->error((string) ($result['message'] ?? __('配置检查失败', 'aksrt')), 422); - } - - return $this->success([ - 'message' => (string) $result['message'], - 'data' => ['provider' => $provider, 'configured' => true], - ]); - } - - private function identity_settings_response(array $settings): array { - $settings['callback_url'] = [ - 'aliyun' => rest_url(self::API_NAMESPACE . '/identity/callback/aliyun'), - 'tencent' => rest_url(self::API_NAMESPACE . '/identity/callback/tencent'), - ]; - return $settings; - } -} diff --git a/src/Admin/UserControllerVerifyIntegrations.php b/src/Admin/UserControllerVerifyIntegrations.php deleted file mode 100644 index 363f6e92..00000000 --- a/src/Admin/UserControllerVerifyIntegrations.php +++ /dev/null @@ -1,105 +0,0 @@ -success(['data' => \AKSRT\Sms\SMS::get_instance()->get_provider_status()]); - } - - return $this->success([ - 'data' => [ - 'enabled' => false, - 'provider' => 'none', - 'configured' => false, - 'available' => false, - 'verified' => false, - 'transport' => '', - 'missing_fields' => [], - 'code_length' => 6, - 'code_expire' => 300, - 'rate_limit' => 60, - 'daily_limit' => 10, - 'notify_bind' => true, - 'notify_login' => false, - ], - ]); - } - - /** - * POST /sms/test — Send a test SMS to verify provider configuration. - */ - public function test_sms_send(\WP_REST_Request $request): \WP_REST_Response { - if (!class_exists('\AKSRT\Sms\SMS')) { - return $this->error('短信服务不可用'); - } - - $params = $this->get_json_params($request); - $phone = sanitize_text_field((string) ($params['phone'] ?? '')); - - if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { - return $this->error('手机号格式不正确'); - } - - $result = \AKSRT\Sms\SMS::get_instance()->test_provider($phone); - - if (!empty($result['success'])) { - return $this->success(['message' => $result['message'] ?? '发送成功']); - } - - return $this->error($result['message'] ?? '发送失败'); - } - - /** - * GET /wechat/status — WeChat notification service status. - */ - public function get_wechat_status(): \WP_REST_Response { - if (class_exists('\AKSRT\Payment\WeChatNotify')) { - return $this->success(['data' => \AKSRT\Payment\WeChatNotify::get_instance()->get_status()]); - } - - return $this->success([ - 'data' => [ - 'appid' => '', - 'configured' => false, - 'has_token' => false, - 'available' => false, - ], - ]); - } - - /** - * POST /wechat/settings — 更新微信通知设置 - */ - public function update_wechat_settings(\WP_REST_Request $request): \WP_REST_Response { - $params = $this->get_json_params($request); - - $settings = [ - 'enabled' => !empty($params['enabled']), - 'app_id' => sanitize_text_field((string) ($params['app_id'] ?? '')), - 'app_secret' => sanitize_text_field((string) ($params['app_secret'] ?? '')), - 'template_id' => sanitize_text_field((string) ($params['template_id'] ?? '')), - ]; - - $existing = \AKSRT\Settings\Settings::get_wechat_settings(); - if ($settings['app_secret'] === '***') { - $settings['app_secret'] = $existing['app_secret'] ?? ''; - } - - $merged = array_merge($existing, $settings); - \AKSRT\Settings\Settings::update_wechat_settings($merged); - delete_transient('aksrt_wechat_token'); - - return $this->success(['message' => __('微信设置已保存', 'aksrt')]); - } -} diff --git a/src/Core/DBMigratorIdentity.php b/src/Core/DBMigratorIdentity.php index ac390ce4..b93f6d66 100644 --- a/src/Core/DBMigratorIdentity.php +++ b/src/Core/DBMigratorIdentity.php @@ -18,47 +18,24 @@ trait DBMigratorIdentity { client_request_id CHAR(36) NOT NULL, request_payload_hash CHAR(64) 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, + type VARCHAR(20) NOT NULL DEFAULT 'general', + channel VARCHAR(20) NOT NULL DEFAULT 'manual', status VARCHAR(32) NOT NULL DEFAULT 'created', public_name VARCHAR(64) 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) + KEY idx_user_auth_status (user_id, type, channel, status) ) ENGINE=InnoDB {$charset_collate};"; dbDelta($application); @@ -68,7 +45,6 @@ trait DBMigratorIdentity { 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 '', @@ -79,8 +55,7 @@ trait DBMigratorIdentity { 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) + KEY idx_application_created (application_id, created_at) ) ENGINE=InnoDB {$charset_collate};"; dbDelta($event); } diff --git a/src/Identity/ApplicationRepository.php b/src/Identity/ApplicationRepository.php index ffc37ed8..f350338a 100644 --- a/src/Identity/ApplicationRepository.php +++ b/src/Identity/ApplicationRepository.php @@ -11,31 +11,30 @@ defined('ABSPATH') || exit; final class ApplicationRepository { - public const ACTIVE_STATUSES = [ - 'created', 'provider_pending', 'provider_processing', 'manual_pending', - ]; + public const ACTIVE_STATUSES = ['manual_pending']; + private const APPLICATION_COLUMNS = 'id, application_uuid, user_id, type, channel, status, public_name, provider_result_summary, manual_reason, review_note, reviewer_id, evidence_ids, created_at, completed_at, updated_at'; private string $table; - /** @var array|null,active:array|null,verified:array|null}> */ - private static array $snapshot_cache = []; + /** @var array|null> */ + private static array $active_cache = []; public function __construct() { $this->table = \AKSRT\Core\DBMigrator::table('identity_application'); } /** - * Clear the request-local application snapshot cache. + * Clear the request-local active application cache. * - * Writes can change the active/latest/verified selection, so callers must + * Writes can change the active application, so callers must * invalidate before reading the next view in the same request. */ - public static function clear_snapshot_cache(int $user_id = 0): void { + public static function clear_active_cache(int $user_id = 0): void { if ($user_id > 0) { - unset(self::$snapshot_cache[$user_id]); + unset(self::$active_cache[$user_id]); return; } - self::$snapshot_cache = []; + self::$active_cache = []; } /** @param array $data */ @@ -44,19 +43,17 @@ final class ApplicationRepository { if ($this->table === '') { return new \WP_Error('aksrt_identity_table', __('身份认证数据表不可用', 'aksrt')); } + $data = $this->map_description_to_storage($data); $now = current_time('mysql', true); $row = wp_parse_args($data, [ 'application_uuid' => wp_generate_uuid4(), 'client_request_id' => wp_generate_uuid4(), - 'request_payload_hash' => '', + 'request_payload_hash' => '', 'user_id' => 0, - 'type' => 'personal', - 'channel' => 'cloud', - 'provider' => '', + 'type' => 'general', + 'channel' => 'manual', 'status' => 'created', 'public_name' => '', - 'fee_amount' => '0.00', - 'fee_status' => 'not_required', 'created_at' => $now, 'updated_at' => $now, ]); @@ -64,179 +61,124 @@ final class ApplicationRepository { if ($inserted !== 1) { return new \WP_Error('aksrt_identity_create', __('认证申请创建失败', 'aksrt')); } - self::clear_snapshot_cache((int) $row['user_id']); + self::clear_active_cache((int) $row['user_id']); return $this->find_by_id((int) $wpdb->insert_id); } /** @return array|null */ public function find_by_id(int $id): ?array { global $wpdb; - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id), ARRAY_A); + $row = $wpdb->get_row($wpdb->prepare( + "SELECT " . self::APPLICATION_COLUMNS . " FROM {$this->table} WHERE id = %d AND type = %s AND channel = %s", + $id, + 'general', + 'manual' + ), ARRAY_A); return is_array($row) ? $this->normalize($row) : null; } - /** @return array|null */ - public function find_by_id_for_update(int $id): ?array { - global $wpdb; - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d FOR UPDATE", $id), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; - } - /** @return array|null */ public function find_by_uuid(string $uuid): ?array { - global $wpdb; - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE application_uuid = %s", $uuid), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; - } - - /** @return array|null */ - public function find_by_client_request(int $user_id, string $request_id): ?array { global $wpdb; $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$this->table} WHERE user_id = %d AND client_request_id = %s LIMIT 1", - $user_id, - $request_id - ), ARRAY_A); + "SELECT " . self::APPLICATION_COLUMNS . " FROM {$this->table} WHERE application_uuid = %s AND type = %s AND channel = %s", + $uuid, + 'general', + 'manual' + ), ARRAY_A); return is_array($row) ? $this->normalize($row) : null; } - /** - * Load the latest, active, and verified applications in one request-local - * context. The three views are selected from one ordered result set so - * facade callers cannot repeat the same application SELECT. - * - * @return array{latest:array|null,active:array|null,verified:array|null} - */ - public function snapshot_for_user(int $user_id): array { - if ($user_id <= 0) { - return ['latest' => null, 'active' => null, 'verified' => null]; - } - if (array_key_exists($user_id, self::$snapshot_cache)) { - return self::$snapshot_cache[$user_id]; - } - - global $wpdb; - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT * FROM {$this->table} WHERE user_id = %d ORDER BY id DESC", - $user_id - ), ARRAY_A); - $snapshot = ['latest' => null, 'active' => null, 'verified' => null]; - foreach ((array) $rows as $raw) { - if (!is_array($raw)) { - continue; - } - $record = $this->normalize($raw); - if ($snapshot['latest'] === null) { - $snapshot['latest'] = $record; - } - if ($snapshot['active'] === null && in_array((string) $record['status'], self::ACTIVE_STATUSES, true)) { - $snapshot['active'] = $record; - } - if ((string) $record['status'] === 'verified') { - $current = $snapshot['verified']; - if ($current === null || $this->verified_is_newer($record, $current)) { - $snapshot['verified'] = $record; - } - } - } - - self::$snapshot_cache[$user_id] = $snapshot; - return $snapshot; - } - - /** @return array|null */ - public function latest_for_user(int $user_id): ?array { - return $this->snapshot_for_user($user_id)['latest']; - } - /** @return array|null */ public function active_for_user(int $user_id): ?array { - return $this->snapshot_for_user($user_id)['active']; - } + if ($user_id <= 0) { + return null; + } + if (array_key_exists($user_id, self::$active_cache)) { + return self::$active_cache[$user_id]; + } - /** @return array|null */ - public function verified_for_user(int $user_id): ?array { - return $this->snapshot_for_user($user_id)['verified']; + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT " . self::APPLICATION_COLUMNS . " FROM {$this->table} WHERE user_id = %d AND type = %s AND channel = %s AND status = %s ORDER BY id DESC LIMIT 1", + $user_id, + 'general', + 'manual', + self::ACTIVE_STATUSES[0] + ), ARRAY_A); + self::$active_cache[$user_id] = is_array($row) ? $this->normalize($row) : null; + return self::$active_cache[$user_id]; } /** @param array $fields */ public function transition(int $id, string $expected, string $next, array $fields = []): bool { global $wpdb; + $fields = $this->map_description_to_storage($fields); $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'; - } + $index = array_search('reviewer_id', array_keys($fields), true); + if ($index !== false) { + $formats[$index] = '%d'; } - $updated = $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1; + $updated = $wpdb->update( + $this->table, + $fields, + ['id' => $id, 'type' => 'general', 'channel' => 'manual', 'status' => $expected], + $formats, + ['%d', '%s', '%s', '%s'] + ) === 1; if ($updated) { - self::clear_snapshot_cache(); + self::clear_active_cache(); } return $updated; } - /** @param array $fields */ - public function update(int $id, array $fields): bool { - global $wpdb; - if ($fields === []) { - return true; - } - $fields['updated_at'] = current_time('mysql', true); - $updated = $wpdb->update($this->table, $fields, ['id' => $id]); - if ($updated !== false) { - self::clear_snapshot_cache(); - } - 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 - )); - } - public function delete(int $id): bool { global $wpdb; - $deleted = $wpdb->delete($this->table, ['id' => $id], ['%d']) === 1; + $deleted = $wpdb->delete( + $this->table, + ['id' => $id, 'type' => 'general', 'channel' => 'manual'], + ['%d', '%s', '%s'] + ) === 1; if ($deleted) { - self::clear_snapshot_cache(); + self::clear_active_cache(); } return $deleted; } - /** @param array $record @param array $current */ - private function verified_is_newer(array $record, array $current): bool { - $record_time = strtotime((string) ($record['completed_at'] ?? '')) ?: 0; - $current_time = strtotime((string) ($current['completed_at'] ?? '')) ?: 0; - if ($record_time !== $current_time) { - return $record_time > $current_time; + /** @return array> */ + public function delete_user(int $user_id): array { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT id, application_uuid, user_id, evidence_ids FROM {$this->table} WHERE user_id = %d", + $user_id + ), ARRAY_A); + if ($wpdb->delete($this->table, ['user_id' => $user_id], ['%d']) === false) { + return []; } - return (int) ($record['id'] ?? 0) > (int) ($current['id'] ?? 0); + self::clear_active_cache($user_id); + return array_map([$this, 'normalize'], is_array($rows) ? $rows : []); } /** @return array */ private function normalize(array $row): array { - foreach (['id', 'user_id', 'reviewer_id', 'attempt_ordinal', 'retry_count'] as $key) { + foreach (['id', 'user_id', 'reviewer_id'] 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] = ''; - } - } - try { - $row['fee_amount'] = \AKSRT\Ledger\AssetAmount::balance((string) ($row['fee_amount'] ?? ''))->decimal(); - } catch (\InvalidArgumentException $e) { - $row['fee_amount'] = ''; - } + $row['description'] = (string) ($row['provider_result_summary'] ?? ''); + unset($row['provider_result_summary']); $evidence = json_decode((string) ($row['evidence_ids'] ?? ''), true); $row['evidence_ids'] = is_array($evidence) ? array_values(array_map('intval', $evidence)) : []; return $row; } + + /** @param array $fields @return array */ + private function map_description_to_storage(array $fields): array { + if (array_key_exists('description', $fields)) { + $fields['provider_result_summary'] = $fields['description']; + unset($fields['description']); + } + return $fields; + } } diff --git a/src/Identity/ApplicationService.php b/src/Identity/ApplicationService.php index ba6a053c..a2e890d6 100644 --- a/src/Identity/ApplicationService.php +++ b/src/Identity/ApplicationService.php @@ -1,461 +1,111 @@ applications ??= new ApplicationRepository(); - $this->transitions ??= new TransitionService($this->applications); - $this->evidence ??= new EvidenceService(); - } + private ApplicationRepository $applications; + private EvidenceService $evidence; - /** @param array $input */ - public function create(int $user_id, array $input, string $request_id, string $operation_key) { - $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')); - } - $type = sanitize_key((string) ($input['type'] ?? '')); - if (!in_array($type, ['personal', 'enterprise'], true)) { - return new \WP_Error('aksrt_identity_type', __('请选择有效的认证类型', 'aksrt')); - } - $request_id = strtolower(trim($request_id)); - if (!wp_is_uuid($request_id) || $operation_key === '') { - return new \WP_Error('aksrt_identity_request_id', __('请求标识无效', 'aksrt')); - } + public function __construct(?ApplicationRepository $applications = null, ?EvidenceService $evidence = null) { + $this->applications = $applications ?? new ApplicationRepository(); + $this->evidence = $evidence ?? new EvidenceService(); + } - $person_name = sanitize_text_field((string) ($input['person_name'] ?? '')); - $id_number = strtoupper(preg_replace('/\s+/', '', sanitize_text_field((string) ($input['id_number'] ?? ''))) ?: ''); - $company_name = $type === 'enterprise' ? sanitize_text_field((string) ($input['company_name'] ?? '')) : ''; - $company_code = $type === 'enterprise' ? (preg_replace('/\s+/', '', sanitize_text_field((string) ($input['company_code'] ?? ''))) ?: '') : ''; - if ($person_name === '' || $this->length($person_name) > 50 || !$this->is_china_id_number($id_number)) { - return new \WP_Error('aksrt_identity_person_fields', __('请填写有效的姓名和身份证号码', 'aksrt')); - } - if ($type === 'enterprise' && ($company_name === '' || $this->length($company_name) > 191 || $company_code === '' || $this->length($company_code) > 32)) { - return new \WP_Error('aksrt_identity_enterprise_fields', __('请填写有效的企业全称和统一社会信用代码', 'aksrt')); - } - if (empty($input['consent'])) { - return new \WP_Error('aksrt_identity_consent', __('请先同意身份信息处理说明', 'aksrt')); - } - $request_payload_hash = $this->request_payload_hash($type, $person_name, $id_number, $company_name, $company_code); - $existing = $this->applications->find_by_client_request($user_id, $request_id); - if ($existing) { - if (!hash_equals((string) ($existing['request_payload_hash'] ?? ''), $request_payload_hash)) { - return new \WP_Error('aksrt_identity_idempotency_conflict', __('相同操作标识已用于其他认证申请', 'aksrt'), ['status' => 409]); - } - if ((string) ($existing['status'] ?? '') !== 'created') { - return $this->view($existing, LedgerService::get_instance()->result_for_operation($operation_key), true); - } + /** @return array|\WP_Error */ + public function create(int $user_id, array $input, $files = null) { + $settings = \AKSRT\User\AuthApply::settings(); + if (empty($settings['enabled']) || empty($settings['allow_apply'])) { + return new \WP_Error('aksrt_identity_disabled', __('暂不能申请身份认证', 'aksrt'), ['status' => 403]); } - if (!$existing && $this->applications->verified_for_user($user_id)) { + if (\AKSRT\User\AuthApply::is_verified($user_id)) { return new \WP_Error('aksrt_identity_verified', __('您已是认证用户', 'aksrt'), ['status' => 409]); } - if (!$existing && $this->applications->active_for_user($user_id)) { - return new \WP_Error('aksrt_identity_active', __('您已有进行中的认证申请', 'aksrt'), ['status' => 409]); + if ($this->applications->active_for_user($user_id)) { + return new \WP_Error('aksrt_identity_active', __('您的申请已提交,请耐心等待', 'aksrt'), ['status' => 409]); + } + $minimum_level = max(0, (int) ($settings['minimum_level'] ?? 0)); + $identity_settings = class_exists('AKSRT\\Settings\\Settings') + ? \AKSRT\Settings\Settings::get_user_identity_settings() + : []; + $level_enabled = !isset($identity_settings['level']['enabled']) || !empty($identity_settings['level']['enabled']); + if (!\AKSRT\User\AuthApply::meets_minimum_level($user_id, $minimum_level, $level_enabled)) { + return new \WP_Error('aksrt_identity_level', __('当前等级暂不能申请身份认证', 'aksrt'), ['status' => 403]); } - $public_name = $type === 'enterprise' ? $this->truncate($company_name, 64) : __('个人认证', 'aksrt'); - $is_new_application = !$existing; - if ($existing) { - $application = $existing; - } else { - $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)); - try { - $configured_fee = AssetAmount::balance(is_string($type_settings['fee'] ?? null) ? trim($type_settings['fee']) : ''); - } catch (\InvalidArgumentException $e) { - return new \WP_Error('aksrt_identity_fee', __('认证费用配置无效', 'aksrt'), ['status' => 500]); + $name = sanitize_text_field((string) ($input['name'] ?? '')); + $description = sanitize_textarea_field((string) ($input['description'] ?? '')); + $message = sanitize_textarea_field((string) ($input['message'] ?? '')); + if ($this->length($name) < 4 || $this->length($name) > 16) { + return new \WP_Error('aksrt_identity_name', __('认证名称不能少于 4 个或超过 16 个字符', 'aksrt')); + } + + $uuid = wp_generate_uuid4(); + $evidence_ids = []; + if ($files !== null) { + $evidence_ids = $this->evidence->upload($user_id, $uuid, $files); + if (is_wp_error($evidence_ids)) { + return $evidence_ids; } - $fee = $attempt < $free ? AssetAmount::balance('0.00') : $configured_fee; - $application = $this->applications->create([ - 'application_uuid' => wp_generate_uuid4(), - 'client_request_id' => $request_id, - 'request_payload_hash' => $request_payload_hash, - 'user_id' => $user_id, - 'type' => $type, - 'provider' => $settings['provider'], - 'provider_flow' => (string) $settings['personal_flow'], - 'public_name' => $public_name, - 'person_name_masked' => SettingsRuntime::mask_name($person_name), - 'id_number_masked' => SettingsRuntime::mask_id_number($id_number), - 'id_number_hash' => SettingsRuntime::hmac($id_number), - 'company_name' => $company_name, - 'company_code_masked' => $type === 'enterprise' ? SettingsRuntime::mask_id_number($company_code) : '', - 'company_code_hash' => $type === 'enterprise' ? SettingsRuntime::hmac($company_code) : '', - 'fee_amount' => $fee->decimal(), - 'fee_status' => $fee->minor() > 0 ? 'pending' : 'not_required', - 'attempt_ordinal' => $attempt + 1, - ]); } - if (is_wp_error($application)) { - return $application; - } - if ($is_new_application) { - do_action('aksrt_user_auth_submitted', (int) $application['user_id'], $application); - } - - $provider = ProviderFactory::make((string) $application['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', - 'dedupe_key' => $this->event_dedupe_key($application, $operation_key, $next), - ]); - return is_wp_error($failed) ? $failed : $this->view($failed); - } - - $provider_request = $input; - $provider_request['person_name'] = $person_name; - $provider_request['id_number'] = $id_number; - $provider_request['company_name'] = $company_name; - $provider_request['company_code'] = $company_code; - $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, $operation_key); - } - - public function refresh(array $application, string $operation_key = '') { - 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'; - $effective_key = $operation_key !== '' - ? $operation_key - : OperationKey::deterministic('identity-expired', [(int) $application['id'], (string) $application['expires_at']]); - $expired = $this->transitions->transition($application, $next, [ - 'provider_result_code' => 'session_expired', - 'provider_result_summary' => __('云认证会话已过期', 'aksrt'), - ], [ - 'source' => 'system', - 'event_type' => 'session_expired', - 'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next), - ]); - 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, $operation_key); - } - - public function move_to_manual(array $application, $files, string $operation_key) { - $payload_hash = $this->evidence->fingerprint($files); - if (is_wp_error($payload_hash)) { - return $payload_hash; - } - $manual_dedupe = $this->event_dedupe_key($application, $operation_key, 'manual_pending'); - $existing_event = (new EventRepository())->find_by_dedupe($manual_dedupe); - if (is_array($existing_event) && (int) $existing_event['application_id'] === (int) $application['id']) { - $summary = json_decode((string) ($existing_event['summary_json'] ?? ''), true); - if (!is_array($summary) || !hash_equals((string) ($summary['payload_hash'] ?? ''), $payload_hash)) { - return new \WP_Error('aksrt_identity_idempotency_conflict', __('相同操作标识已用于其他认证材料', 'aksrt'), ['status' => 409]); - } - $current = $this->applications->find_by_id((int) $application['id']); - return is_array($current) - ? $this->view($current, LedgerService::get_instance()->result_for_operation($operation_key), true) - : new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 404]); - } - if (!in_array((string) $application['status'], ['created', 'provider_failed', 'provider_unsupported', 'provider_expired'], true)) { - return new \WP_Error('aksrt_identity_manual_state', __('该申请当前不能转人工审核', 'aksrt'), ['status' => 409]); - } - 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; - } - $fields = [ + $created = $this->applications->create([ + 'application_uuid' => $uuid, + 'client_request_id' => wp_generate_uuid4(), + 'request_payload_hash' => hash('sha256', wp_json_encode([$name, $description, $message])), + 'user_id' => $user_id, + 'type' => 'general', 'channel' => 'manual', - 'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'), - 'evidence_ids' => wp_json_encode($ids), - ]; - $event = [ + 'status' => 'manual_pending', + 'public_name' => $name, + 'description' => $description, + 'manual_reason' => $message, + 'evidence_ids' => wp_json_encode(array_map('absint', $evidence_ids)), + ]); + if (is_wp_error($created)) { + $this->evidence->delete($evidence_ids); + return $created; + } + (new EventRepository())->record([ + 'application_id' => (int) $created['id'], 'source' => 'user', - 'event_type' => 'manual_fallback', - 'actor_user_id' => (int) $application['user_id'], - 'dedupe_key' => $manual_dedupe, - 'summary' => ['payload_hash' => $payload_hash], - ]; - $changed = $this->transition_with_charge($application, 'manual_pending', $fields, $event, $operation_key, ['business_payload_hash' => $payload_hash]); - if (is_wp_error($changed)) { - $this->evidence->delete($ids); - return $changed; - } - $updated = $changed['application']; - if ((array) ($updated['evidence_ids'] ?? []) !== array_values(array_map('intval', $ids))) { - $this->evidence->delete($ids); + 'event_type' => 'application_submitted', + 'dedupe_key' => hash('sha256', $uuid . '|submitted'), + 'to_status' => 'manual_pending', + 'actor_user_id' => $user_id, + 'summary' => ['name' => $name], + ]); + if (class_exists('AKSRT\\Settings\\MailNotifications')) { + \AKSRT\Settings\MailNotifications::auth_submitted($user_id, $created); } - return $this->view($updated, $changed['ledger']); - } + return $this->view($created); + } - /** @return array */ - public function view(array $application, ?LedgerResult $ledger_result = null, bool $replayed = false): 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'; - } - $balance_minor = LedgerService::get_instance()->balance((int) $application['user_id'], AssetType::BALANCE); + /** @return array */ + private function view(array $application): array { + $items = []; + foreach ($this->evidence->items($application) as $item) { + $items[] = ['url' => $item['url'], 'thumbnail_url' => $item['thumbnail_url']]; + } 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'], - 'result_code' => (string) $application['provider_result_code'], - 'message' => (string) $application['provider_result_summary'], - 'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')), - 'expires_at' => (string) ($application['expires_at'] ?? ''), - 'created_at' => (string) $application['created_at'], - 'poll_interval' => (int) SettingsRuntime::get()['poll_interval_seconds'], - 'transaction_id' => $ledger_result?->transaction_id ?? 0, - 'replayed' => $ledger_result?->replayed ?? $replayed, - 'wallet_balance' => AssetAmount::from_minor(AssetType::BALANCE, $balance_minor)->decimal(), - ]; - } - - private function apply_provider_result(array $application, ProviderResult $result, bool $starting, string $operation_key = '') { - $data = $result->data; - if ($result->decision === ProviderResult::PENDING) { - $next = $starting ? 'provider_pending' : 'provider_processing'; - if (!$starting && (string) $application['status'] === $next) { - return $this->view($application, null, true); - } - $effective_key = $this->provider_operation_key($application, $operation_key, $next, $data, $result->decision); - $fields = [ - 'provider_session_id' => sanitize_text_field((string) ($data['provider_session_id'] ?? ($application['provider_session_id'] ?? ''))), - 'provider_request_id' => sanitize_text_field((string) ($data['request_id'] ?? ($application['provider_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'] ?? '')), - ]; - $event = [ - 'source' => (string) $application['provider'], - 'event_type' => $starting ? 'session_started' : 'query_pending', - 'external_event_id' => (string) ($data['external_event_id'] ?? ''), - 'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next), - ]; - $changed = $starting - ? $this->transition_with_charge($application, $next, $fields, $event, $effective_key) - : $this->transition_without_charge($application, $next, $fields, $event); - if (is_wp_error($changed)) { - return $changed; - } - $updated = $changed['application']; - $updated['redirect_url'] = (string) ($updated['redirect_url'] ?? ''); - $updated['qr_payload'] = (string) ($updated['qr_payload'] ?? ''); - return $this->view($updated, $changed['ledger']); - } - - $map = [ - ProviderResult::PASSED => 'verified', - ProviderResult::FAILED => 'provider_failed', - ProviderResult::UNSUPPORTED => 'provider_unsupported', - ProviderResult::EXPIRED => 'provider_expired', + 'uuid' => (string) ($application['application_uuid'] ?? ''), + 'status' => TransitionService::public_status((string) ($application['status'] ?? '')), + 'name' => (string) ($application['public_name'] ?? ''), + 'description' => (string) ($application['description'] ?? ''), + 'message' => (string) ($application['manual_reason'] ?? ''), + 'admin_note' => (string) ($application['review_note'] ?? ''), + 'time' => (string) ($application['created_at'] ?? ''), + 'evidence' => $items, ]; - $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'; - } - $effective_key = $this->provider_operation_key($application, $operation_key, $next, $data, $result->decision); - $fields = [ - 'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)), - 'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')), - ]; - $event = [ - 'source' => (string) $application['provider'], - 'event_type' => 'provider_result', - 'external_event_id' => (string) ($data['external_event_id'] ?? ''), - 'result_code' => (string) ($data['result_code'] ?? $result->decision), - 'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next), - ]; - $changed = $starting && $next === 'verified' - ? $this->transition_with_charge($application, $next, $fields, $event, $effective_key) - : $this->transition_without_charge($application, $next, $fields, $event); - return is_wp_error($changed) ? $changed : $this->view($changed['application'], $changed['ledger']); } - /** - * @param array $application - * @param array $fields - * @param array $event - * @return array{application:array,ledger:?LedgerResult}|\WP_Error - */ - private function transition_with_charge(array $application, string $next, array $fields, array $event, string $operation_key, array $ledger_metadata = []) { - $fee_status = (string) ($application['fee_status'] ?? ''); - if (in_array($fee_status, ['charged', 'not_required'], true)) { - $fields['fee_status'] = $fee_status; - return $this->transition_without_charge($application, $next, $fields, $event); + private function length(string $value): int { + if (function_exists('mb_strlen')) { + return mb_strlen($value); } - try { - $fee = AssetAmount::balance((string) ($application['fee_amount'] ?? '')); - } catch (\InvalidArgumentException $e) { - return new \WP_Error('aksrt_identity_fee', __('认证费用配置无效', 'aksrt'), ['status' => 500]); - } - if ($fee->minor() <= 0) { - $fields['fee_status'] = 'not_required'; - return $this->transition_without_charge($application, $next, $fields, $event); - } - - $application_id = (int) $application['id']; - $user_id = (int) $application['user_id']; - $expected_status = (string) $application['status']; - $expected_minor = $fee->minor(); - $locked_application = null; - $guard = function () use ($application_id, $user_id, $expected_status, $expected_minor, &$locked_application) { - $locked_application = $this->applications->find_by_id_for_update($application_id); - try { - $current_minor = is_array($locked_application) - ? AssetAmount::balance((string) ($locked_application['fee_amount'] ?? ''))->minor() - : -1; - } catch (\InvalidArgumentException $e) { - $current_minor = -1; - } - if ( - !is_array($locked_application) - || (int) $locked_application['user_id'] !== $user_id - || (string) $locked_application['status'] !== $expected_status - || (string) $locked_application['fee_status'] !== 'pending' - || $current_minor !== $expected_minor - ) { - return new \WP_Error('aksrt_identity_fee_state', __('认证费用或申请状态已变化', 'aksrt'), ['status' => 409]); - } - return true; - }; - $mutation_fields = $fields; - $mutation_fields['fee_status'] = 'charged'; - $mutation = function () use (&$locked_application, $next, $mutation_fields, $event) { - if (!is_array($locked_application)) { - return new \WP_Error('aksrt_identity_fee_state', __('认证申请未锁定', 'aksrt'), ['status' => 500]); - } - return $this->transitions->transition_locked($locked_application, $next, $mutation_fields, $event); - }; - $ledger_metadata['identity_type'] = (string) $application['type']; - $ledger_metadata['business_payload_hash'] ??= (string) ($application['request_payload_hash'] ?? ''); - $result = LedgerService::get_instance()->debit( - $user_id, - AssetType::BALANCE, - $fee->minor(), - $operation_key !== '' ? $operation_key : OperationKey::deterministic('identity-fee', [$application_id]), - 'verify_application', - ['type' => 'identity', 'id' => $application_id], - sprintf(__('提交%s认证申请', 'aksrt'), (string) $application['type']), - $user_id, - $ledger_metadata, - $mutation, - $guard - ); - if (is_wp_error($result)) { - return $result; - } - $updated = $this->applications->find_by_id($application_id); - if (!is_array($updated)) { - return new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 500]); - } - if (!$result->replayed) { - $this->transitions->after_commit(is_array($locked_application) ? $locked_application : $application, $next, $mutation_fields); - } - return ['application' => $updated, 'ledger' => $result]; + $count = preg_match_all('/./us', $value, $matches); + return $count === false ? strlen($value) : $count; } - - /** - * @param array $application - * @param array $fields - * @param array $event - * @return array{application:array,ledger:null}|\WP_Error - */ - private function transition_without_charge(array $application, string $next, array $fields, array $event) { - $updated = $this->transitions->transition($application, $next, $fields, $event); - return is_wp_error($updated) ? $updated : ['application' => $updated, 'ledger' => null]; - } - - /** @param array $application @param array $data */ - private function provider_operation_key(array $application, string $operation_key, string $next, array $data, string $decision): string { - if ($operation_key !== '') { - return $operation_key; - } - $external = (string) ($data['external_event_id'] ?? $data['request_id'] ?? $data['result_code'] ?? $decision); - return OperationKey::deterministic('identity-provider', [(int) $application['id'], $next, $external]); - } - - /** @param array $application */ - private function event_dedupe_key(array $application, string $operation_key, string $next): string { - return hash('sha256', implode('|', ['identity', (int) $application['id'], $operation_key, sanitize_key($next)])); - } - - private function request_payload_hash(string $type, string $person_name, string $id_number, string $company_name, string $company_code): string { - return hash('sha256', (string) wp_json_encode([ - 'type' => $type, - 'person_name' => $person_name, - 'id_number_hmac' => SettingsRuntime::hmac($id_number), - 'company_name' => $company_name, - 'company_code_hmac' => $company_code !== '' ? SettingsRuntime::hmac($company_code) : '', - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); - } - - private function length(string $value): int { - return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); - } - - private function truncate(string $value, int $length): string { - return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length); - } - - private function is_china_id_number(string $value): bool { - if (!preg_match('/^(\d{17}[0-9X]|\d{15})$/', $value)) { - return false; - } - if (strlen($value) === 15) { - return true; - } - $weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; - $codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; - $sum = 0; - for ($i = 0; $i < 17; $i++) { - $sum += (int) $value[$i] * $weights[$i]; - } - return $codes[$sum % 11] === $value[17]; - } } diff --git a/src/Identity/CallbackController.php b/src/Identity/CallbackController.php deleted file mode 100644 index ee235cea..00000000 --- a/src/Identity/CallbackController.php +++ /dev/null @@ -1,74 +0,0 @@ - $provider === 'aliyun' ? ['GET', 'POST'] : 'POST', - 'callback' => function (\WP_REST_Request $request) use ($provider): \WP_REST_Response { - return $this->handle($provider, $request); - }, - 'permission_callback' => '__return_true', - ]); - } - } - - private function handle(string $provider_key, \WP_REST_Request $request): \WP_REST_Response { - $body = $request->get_body(); - if (strlen($body) > 1024 * 1024) { - return new \WP_REST_Response(['success' => false], 413); - } - $provider = ProviderFactory::make($provider_key); - if (!$provider->verify_callback($request)) { - return new \WP_REST_Response(['success' => false], 403); - } - $result = $provider->parse_callback($request); - $session = sanitize_text_field((string) ($result->data['provider_session_id'] ?? '')); - $outer_order = sanitize_text_field((string) ($result->data['outer_order_no'] ?? '')); - if ($session === '' && $outer_order === '') { - return new \WP_REST_Response(['success' => false], 422); - } - - global $wpdb; - $table = \AKSRT\Core\DBMigrator::table('identity_application'); - if ($session !== '') { - $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$table} WHERE provider = %s AND provider_session_id = %s ORDER BY id DESC LIMIT 1", - $provider_key, - $session - ), ARRAY_A); - } else { - $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$table} WHERE provider = %s AND application_uuid = %s ORDER BY id DESC LIMIT 1", - $provider_key, - $outer_order - ), ARRAY_A); - } - if (!is_array($row)) { - return new \WP_REST_Response(['success' => false], 404); - } - - // The callback only wakes server-side reconciliation. A provider query - // remains the source of truth for a successful identity decision. - $service = new ApplicationService(); - $response = $service->refresh($row); - if (is_wp_error($response)) { - return new \WP_REST_Response(['success' => false], 409); - } - return new \WP_REST_Response(['success' => true]); - } -} diff --git a/src/Identity/EventRepository.php b/src/Identity/EventRepository.php index 32418898..bd99f828 100644 --- a/src/Identity/EventRepository.php +++ b/src/Identity/EventRepository.php @@ -26,7 +26,6 @@ final class EventRepository { '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'] ?? '')), @@ -49,26 +48,6 @@ final class EventRepository { return (int) $existing_application_id === (int) $row['application_id']; } - /** @return array|null */ - public function find_by_dedupe(string $dedupe_key): ?array { - global $wpdb; - $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$this->table} WHERE dedupe_key = %s LIMIT 1", - $dedupe_key - ), ARRAY_A); - return is_array($row) ? $row : null; - } - - /** @return array> */ - public function for_application(int $application_id): array { - global $wpdb; - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT * FROM {$this->table} WHERE application_id = %d ORDER BY id DESC", - $application_id - ), ARRAY_A); - return is_array($rows) ? $rows : []; - } - public function delete_for_application(int $application_id): bool { global $wpdb; return $wpdb->delete($this->table, ['application_id' => $application_id], ['%d']) !== false; diff --git a/src/Identity/EvidenceService.php b/src/Identity/EvidenceService.php index 24f558dc..78a25052 100644 --- a/src/Identity/EvidenceService.php +++ b/src/Identity/EvidenceService.php @@ -18,27 +18,6 @@ final class EvidenceService { add_action('admin_post_aksrt_verify_evidence', [$this, 'serve']); } - /** @return string|\WP_Error */ - public function fingerprint($input) { - $files = $this->normalize($input); - if (is_wp_error($files)) { - return $files; - } - $hashes = []; - foreach ($files as $file) { - $tmp = (string) ($file['tmp_name'] ?? ''); - if ((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || $tmp === '' || !is_uploaded_file($tmp)) { - return new \WP_Error('aksrt_identity_upload', __('证明材料上传失败', 'aksrt'), ['status' => 400]); - } - $digest = hash_file('sha256', $tmp); - if (!is_string($digest)) { - return new \WP_Error('aksrt_identity_upload', __('证明材料读取失败', 'aksrt'), ['status' => 400]); - } - $hashes[] = $digest; - } - return hash('sha256', implode('|', $hashes)); - } - /** @return array|\WP_Error */ public function upload(int $user_id, string $application_uuid, $input) { $files = $this->normalize($input); @@ -60,7 +39,7 @@ final class EvidenceService { $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 === '') { + if (!in_array($mime, ['image/gif', 'image/jpeg', 'image/png'], true) || $ext === '') { $this->delete($ids); return new \WP_Error('aksrt_identity_upload_type', __('证明材料仅支持 JPG、PNG 或 WebP 图片', 'aksrt')); } @@ -123,7 +102,7 @@ final class EvidenceService { 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)) { + if (!in_array($mime, ['image/gif', 'image/jpeg', 'image/png'], true)) { wp_die(esc_html__('证明材料类型无效', 'aksrt'), '', ['response' => 415]); } nocache_headers(); diff --git a/src/Identity/Identity.php b/src/Identity/Identity.php index dd3652ff..bc28f600 100644 --- a/src/Identity/Identity.php +++ b/src/Identity/Identity.php @@ -19,7 +19,6 @@ final class Identity { 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']); @@ -33,16 +32,12 @@ final class 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; + $repository = new ApplicationRepository(); + foreach ($repository->delete_user($user_id) as $application) { + (new EvidenceService())->delete((array) ($application['evidence_ids'] ?? [])); + (new EventRepository())->delete_for_application((int) ($application['id'] ?? 0)); } - $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']); + delete_user_meta($user_id, 'auth'); + delete_user_meta($user_id, 'auth_info'); } } diff --git a/src/Identity/Provider/AliyunProvider.php b/src/Identity/Provider/AliyunProvider.php deleted file mode 100644 index f7d1655d..00000000 --- a/src/Identity/Provider/AliyunProvider.php +++ /dev/null @@ -1,351 +0,0 @@ - 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 === '424') { - return ProviderResult::pending([ - 'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')), - 'result_code' => $code, - 'message' => __('用户尚未在阿里云核身页面提交认证,请完成后再刷新结果', 'aksrt'), - ]); - } - if ($code !== '' && $code !== '200') { - return ProviderResult::failed($code, sanitize_text_field((string) ($response['Message'] ?? ''))); - } - $object = $this->normalize_result_object($response['ResultObject'] ?? []); - $passed = $object['Passed'] ?? $object['passed'] ?? $response['Passed'] ?? null; - $success = $object['Success'] ?? $object['success'] ?? $response['Success'] ?? null; - if ((string) ($response['Code'] ?? '') === '200' && ($success === true || $success === 'True' || $success === 'true') && $passed === 'T') { - return ProviderResult::passed(['request_id' => (string) ($response['RequestId'] ?? ''), 'result_code' => 'passed']); - } - $status = strtoupper((string) ($object['SubCode'] ?? $object['Status'] ?? $object['subCode'] ?? $object['status'] ?? '')); - if ((string) ($response['Code'] ?? '') === '200' && $status === '200') { - return ProviderResult::passed(['request_id' => (string) ($response['RequestId'] ?? ''), 'result_code' => 'passed']); - } - $message = $object['MaterialInfo'] ?? $object['Message'] ?? $object['message'] ?? __('阿里云仍在处理认证结果', 'aksrt'); - if ($passed === 'F') { - $debug = $this->response_debug_summary($response, $object, ['passed' => 'F']); - $this->log_debug('DescribeFaceVerify not passed', $debug); - return ProviderResult::failed($status ?: 'not_passed', sanitize_text_field((string) $message)); - } - if ($object === []) { - $debug = $this->response_debug_summary($response, $object); - $this->log_debug('DescribeFaceVerify empty ResultObject', $debug); - return ProviderResult::pending([ - 'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')), - 'result_code' => 'processing', - 'message' => sprintf(__('阿里云已返回成功状态,认证明细仍在生成中:%s', 'aksrt'), $debug), - ]); - } - if ($status === '' || in_array($status, ['PROCESSING', 'INIT', 'PENDING'], true)) { - return ProviderResult::pending(['result_code' => $status ?: 'processing', 'message' => sanitize_text_field((string) $message)]); - } - return ProviderResult::failed($status, sanitize_text_field((string) $message)); - } - - private function is_public_https_url(string $url): bool { - if ($url === '') { - return false; - } - $parts = wp_parse_url($url); - if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') { - return false; - } - $host = strtolower((string) ($parts['host'] ?? '')); - return $host !== '' && !in_array($host, ['localhost', '127.0.0.1', '::1'], true) && !str_ends_with($host, '.local'); - } - - 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'], - 'CertType' => 'IDENTITY_CARD', - 'CertName' => (string) ($request['person_name'] ?? ''), - 'CertNo' => preg_replace('/\s+/', '', (string) ($request['id_number'] ?? '')), - ]; - $meta_info = trim((string) ($request['meta_info'] ?? '')); - if ($meta_info !== '') { - $params['MetaInfo'] = $meta_info; - } - $return_url = esc_url_raw((string) ($request['return_url'] ?? '')); - if ($this->is_public_https_url($return_url)) { - $params['ReturnUrl'] = $return_url; - } - $callback_url = esc_url_raw(rest_url('aksrt/v1/identity/callback/aliyun')); - if ($this->is_public_https_url($callback_url)) { - $params['CallbackUrl'] = $callback_url; - $params['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 = $this->normalize_result_object($response['ResultObject'] ?? []); - $certify_id = sanitize_text_field((string) $this->first_response_value($response, [ - ['ResultObject', 'CertifyId'], - ['ResultObject', 'certifyId'], - ['CertifyId'], - ['certifyId'], - ['Data', 'CertifyId'], - ['Data', 'certifyId'], - ])); - $url = esc_url_raw((string) $this->first_response_value(array_merge($response, ['ResultObject' => $object]), [ - ['ResultObject', 'CertifyUrl'], - ['ResultObject', 'certifyUrl'], - ['ResultObject', 'Url'], - ['ResultObject', 'url'], - ['CertifyUrl'], - ['certifyUrl'], - ['Url'], - ['url'], - ['Data', 'CertifyUrl'], - ['Data', 'certifyUrl'], - ['Data', 'Url'], - ['Data', 'url'], - ])); - if ($certify_id === '') { - $debug = $this->response_debug_summary($response, $object, [ - 'has_certify_id' => false, - 'has_certify_url' => $url !== '', - ]); - $this->log_debug('InitFaceVerify missing CertifyId', $debug); - return ProviderResult::failed('aliyun_invalid_response', sprintf( - __('阿里云未返回有效认证会话:%s', 'aksrt'), - $debug - )); - } - if ($url === '') { - $debug = $this->response_debug_summary($response, $object, [ - 'has_certify_id' => true, - 'has_certify_url' => false, - ]); - $this->log_debug('InitFaceVerify returned CertifyId without URL', $debug); - return ProviderResult::failed('aliyun_missing_certify_url', sprintf( - __('阿里云只返回了 CertifyId,未返回可打开的核身链接。请确认 SceneId/ProductCode 对应的是支持 H5 链接的实人认证方案;调试信息:%s', 'aksrt'), - $debug - )); - } - return ProviderResult::pending([ - 'provider_session_id' => $certify_id, - 'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')), - 'redirect_url' => $url, - 'result_code' => 'session_created', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)), - ]); - } - - /** @return array|\WP_Error */ - private function rpc(string $host, string $version, string $action, array $business) { - $cfg = SettingsRuntime::get(); - $endpoint = 'https://' . $host . '/'; - // The official PHP SDK uses ACS3-HMAC-SHA256 by default. This zero-dependency - // fallback deliberately uses the documented RPC v2 signature until the SDK - // packages can be installed and locked in this deployment. - $params = array_merge([ - 'Format' => 'JSON', - 'Version' => $version, - 'AccessKeyId' => (string) $cfg['credentials']['access_key_id'], - 'SignatureMethod' => 'HMAC-SHA1', - 'SignatureNonce' => wp_generate_uuid4(), - 'SignatureVersion' => '1.0', - 'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'), - 'Action' => $action, - ], $business); - ksort($params); - $canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986); - $string_to_sign = 'POST&%2F&' . rawurlencode($canonical); - $params['Signature'] = base64_encode(hash_hmac('sha1', $string_to_sign, (string) $cfg['credentials']['access_key_secret'] . '&', true)); - $response = wp_remote_post($endpoint, [ - 'timeout' => 20, - 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], - 'body' => $params, - ]); - if (is_wp_error($response)) { - return new \WP_Error('aliyun_network_error', __('阿里云接口请求失败', 'aksrt')); - } - $status_code = (int) wp_remote_retrieve_response_code($response); - $body = (string) wp_remote_retrieve_body($response); - $decoded = json_decode($body, true); - if (!is_array($decoded)) { - $this->log_debug('Aliyun invalid JSON response', [ - 'http_status' => $status_code, - 'body_preview' => $this->truncate_debug($body, 800), - ]); - return new \WP_Error('aliyun_invalid_json', __('阿里云返回格式无效', 'aksrt')); - } - if (defined('WP_DEBUG') && WP_DEBUG) { - $this->log_debug('Aliyun RPC response', [ - 'action' => $action, - 'http_status' => $status_code, - 'summary' => $this->response_debug_summary($decoded), - ]); - } - return $decoded; - } - - 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'; - } - - /** @param mixed $value @return array */ - private function normalize_result_object($value): array { - if (is_array($value)) { - return $value; - } - if (is_string($value) && $value !== '') { - $decoded = json_decode($value, true); - return is_array($decoded) ? $decoded : []; - } - return []; - } - - /** @param array $response @param array> $paths */ - private function first_response_value(array $response, array $paths): string { - foreach ($paths as $path) { - $value = $response; - foreach ($path as $segment) { - if (!is_array($value) || !array_key_exists($segment, $value)) { - $value = null; - break; - } - $value = $value[$segment]; - } - if (is_scalar($value) && (string) $value !== '') { - return (string) $value; - } - } - return ''; - } - - /** @param array $response @param array $object @param array $extra */ - private function response_debug_summary(array $response, array $object = [], array $extra = []): string { - $summary = array_merge([ - 'code' => (string) ($response['Code'] ?? ''), - 'message' => (string) ($response['Message'] ?? ''), - 'request_id' => (string) ($response['RequestId'] ?? ''), - 'top_keys' => implode(',', array_slice(array_keys($response), 0, 20)), - 'result_keys' => $object !== [] ? implode(',', array_slice(array_keys($object), 0, 20)) : '', - ], $extra); - return wp_json_encode($summary, JSON_UNESCAPED_UNICODE) ?: ''; - } - - /** @param mixed $data */ - private function log_debug(string $message, $data): void { - if (!defined('WP_DEBUG') || !WP_DEBUG) { - return; - } - error_log('[AKSRT Identity Aliyun] ' . $message . ': ' . (is_string($data) ? $data : (wp_json_encode($data, JSON_UNESCAPED_UNICODE) ?: ''))); - } - - private function truncate_debug(string $value, int $length): string { - return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length); - } -} diff --git a/src/Identity/Provider/ProviderFactory.php b/src/Identity/Provider/ProviderFactory.php deleted file mode 100644 index 897870a3..00000000 --- a/src/Identity/Provider/ProviderFactory.php +++ /dev/null @@ -1,21 +0,0 @@ - $request */ - public function start_personal(array $request): ProviderResult; - - /** @param array $request */ - public function start_enterprise(array $request): ProviderResult; - - /** @param array $application */ - public function query(array $application): ProviderResult; - - public function verify_callback(\WP_REST_Request $request): bool; - - public function parse_callback(\WP_REST_Request $request): ProviderResult; -} diff --git a/src/Identity/Provider/ProviderResult.php b/src/Identity/Provider/ProviderResult.php deleted file mode 100644 index f153915d..00000000 --- a/src/Identity/Provider/ProviderResult.php +++ /dev/null @@ -1,41 +0,0 @@ - $data */ - public function __construct( - public readonly string $decision, - public readonly array $data = [] - ) {} - - public static function pending(array $data = []): self { - return new self(self::PENDING, $data); - } - - public static function passed(array $data = []): self { - return new self(self::PASSED, $data); - } - - public static function failed(string $code, string $message = ''): self { - return new self(self::FAILED, ['result_code' => $code, 'message' => $message]); - } - - public static function unsupported(string $message): self { - return new self(self::UNSUPPORTED, ['result_code' => 'unsupported', 'message' => $message]); - } -} diff --git a/src/Identity/Provider/TencentProvider.php b/src/Identity/Provider/TencentProvider.php deleted file mode 100644 index 8097a1bf..00000000 --- a/src/Identity/Provider/TencentProvider.php +++ /dev/null @@ -1,202 +0,0 @@ - true, 'enterprise' => true]; } - - public function validate_config() { - $cfg = SettingsRuntime::get(); - foreach (['secret_id', 'secret_key'] as $key) { - if (empty($cfg['credentials'][$key])) { - return new \WP_Error('aksrt_identity_tencent_credentials', __('腾讯云人脸核身凭证不完整', 'aksrt')); - } - } - if (empty($cfg['personal_flow'])) { - return new \WP_Error('aksrt_identity_tencent_rule', __('请配置腾讯云人脸核身 RuleId', 'aksrt')); - } - return true; - } - - public function start_personal(array $request): ProviderResult { - // Ref: https://cloud.tencent.com/document/product/1007/31816 - $cfg = SettingsRuntime::get(); - $payload = [ - 'RuleId' => (string) $cfg['personal_flow'], - 'TerminalType' => $this->terminal_type(), - 'RedirectUrl' => esc_url_raw((string) ($request['return_url'] ?? '')), - 'Extra' => (string) ($request['application_uuid'] ?? ''), - ]; - if (!empty($request['person_name'])) { - $payload['Name'] = (string) $request['person_name']; - } - if (!empty($request['id_number'])) { - $payload['IdCard'] = preg_replace('/\s+/', '', (string) $request['id_number']); - } - $response = $this->request('DetectAuth', $payload); - if (is_wp_error($response)) { - return ProviderResult::failed($response->get_error_code(), $response->get_error_message()); - } - $data = is_array($response['Response'] ?? null) ? $response['Response'] : []; - if (!empty($data['Error'])) { - return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? '')); - } - $biz_token = sanitize_text_field((string) ($data['BizToken'] ?? '')); - $url = esc_url_raw((string) ($data['Url'] ?? '')); - if ($biz_token === '' || $url === '') { - return ProviderResult::failed('tencent_invalid_response', __('腾讯云未返回有效认证会话', 'aksrt')); - } - return ProviderResult::pending([ - 'provider_session_id' => $biz_token, - 'request_id' => sanitize_text_field((string) ($data['RequestId'] ?? '')), - 'redirect_url' => $url, - 'result_code' => 'session_created', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)), - ]); - } - - public function start_enterprise(array $request): ProviderResult { - // Ref: https://cloud.tencent.com/document/product/866/129072 - foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) { - if (empty($request[$key])) { - return ProviderResult::failed('tencent_enterprise_fields', __('企业认证资料不完整', 'aksrt')); - } - } - $response = $this->request_service('ocr', 'ocr.tencentcloudapi.com', '2018-11-19', 'VerifyBizLicenseEnterprise4', [ - 'CreditCode' => preg_replace('/\s+/', '', (string) $request['company_code']), - 'EntName' => (string) $request['company_name'], - 'LrName' => (string) $request['person_name'], - 'IdNum' => preg_replace('/\s+/', '', (string) $request['id_number']), - ]); - if (is_wp_error($response)) { - return ProviderResult::failed($response->get_error_code(), $response->get_error_message()); - } - $data = is_array($response['Response'] ?? null) ? $response['Response'] : []; - if (!empty($data['Error'])) { - return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_ocr_error'), (string) ($data['Error']['Message'] ?? '')); - } - if (!$this->enterprise_passed($data)) { - return ProviderResult::failed('tencent_enterprise_mismatch', __('企业工商四要素或法人关系核验未通过', 'aksrt')); - } - return $this->start_personal($request); - } - - public function query(array $application): ProviderResult { - // Ref: https://cloud.tencent.com/document/product/1007/41957 - $response = $this->request('GetDetectInfoEnhanced', [ - 'BizToken' => (string) ($application['provider_session_id'] ?? ''), - 'RuleId' => (string) ($application['provider_flow'] ?? ''), - 'InfoType' => '0', - ]); - if (is_wp_error($response)) { - return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]); - } - $data = is_array($response['Response'] ?? null) ? $response['Response'] : []; - if (!empty($data['Error'])) { - return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? '')); - } - $text = $data['Text'] ?? ($data['DetectInfo']['Text'] ?? []); - if (is_string($text)) { - $decoded_text = json_decode($text, true); - $text = is_array($decoded_text) ? $decoded_text : []; - } elseif (!is_array($text)) { - $text = []; - } - $result = (string) ($text['ErrCode'] ?? ''); - $live_status = (string) ($text['LiveStatus'] ?? ''); - $compare_status = (string) ($text['Comparestatus'] ?? ''); - if ($result === '0' && ($live_status === '' || $live_status === '0') && ($compare_status === '' || $compare_status === '0')) { - return ProviderResult::passed(['request_id' => (string) ($data['RequestId'] ?? ''), 'result_code' => '0']); - } - $message = sanitize_text_field((string) ($text['ErrMsg'] ?? $text['LiveMsg'] ?? $text['Comparemsg'] ?? '')); - if ($result === '') { - return ProviderResult::pending(['result_code' => 'processing', 'message' => __('腾讯云仍在处理认证结果', 'aksrt')]); - } - return ProviderResult::failed($result, $message); - } - - public function verify_callback(\WP_REST_Request $request): bool { unset($request); return false; } - - public function parse_callback(\WP_REST_Request $request): ProviderResult { unset($request); return ProviderResult::unsupported(__('腾讯云人脸核身使用服务端主动查询,不接收结果回调', 'aksrt')); } - - /** @return array|\WP_Error */ - private function request(string $action, array $payload) { - return $this->request_service(self::SERVICE, self::ENDPOINT, self::VERSION, $action, $payload); - } - - /** @return array|\WP_Error */ - private function request_service(string $service, string $host, string $version, string $action, array $payload) { - $cfg = SettingsRuntime::get(); - $endpoint = 'https://' . $host; - $timestamp = time(); - $date = gmdate('Y-m-d', $timestamp); - $body = (string) wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $canonical_headers = "content-type:application/json; charset=utf-8\nhost:{$host}\nx-tc-action:" . strtolower($action) . "\n"; - $signed_headers = 'content-type;host;x-tc-action'; - $canonical_request = "POST\n/\n\n{$canonical_headers}\n{$signed_headers}\n" . hash('sha256', $body); - $scope = $date . '/' . $service . '/tc3_request'; - $string_to_sign = "TC3-HMAC-SHA256\n{$timestamp}\n{$scope}\n" . hash('sha256', $canonical_request); - $secret_key = (string) $cfg['credentials']['secret_key']; - $secret_date = hash_hmac('sha256', $date, 'TC3' . $secret_key, true); - $secret_service = hash_hmac('sha256', $service, $secret_date, true); - $secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true); - $signature = hash_hmac('sha256', $string_to_sign, $secret_signing); - $authorization = 'TC3-HMAC-SHA256 Credential=' . $cfg['credentials']['secret_id'] . '/' . $scope . ', SignedHeaders=' . $signed_headers . ', Signature=' . $signature; - $headers = [ - 'Authorization' => $authorization, - 'Content-Type' => 'application/json; charset=utf-8', - 'Host' => $host, - 'X-TC-Action' => $action, - 'X-TC-Timestamp' => (string) $timestamp, - 'X-TC-Version' => $version, - ]; - if ($service !== self::SERVICE && !empty($cfg['region'])) { - $headers['X-TC-Region'] = (string) $cfg['region']; - } - $response = wp_remote_post($endpoint, [ - 'timeout' => 20, - 'headers' => $headers, - 'body' => $body, - ]); - if (is_wp_error($response)) { - return new \WP_Error('tencent_network_error', __('腾讯云接口请求失败', 'aksrt')); - } - $decoded = json_decode((string) wp_remote_retrieve_body($response), true); - return is_array($decoded) ? $decoded : new \WP_Error('tencent_invalid_json', __('腾讯云返回格式无效', 'aksrt')); - } - - private function enterprise_passed(array $data): bool { - if ((string) ($data['StatusCode'] ?? '') !== '0' || (string) ($data['VerifyResult'] ?? '') !== '1') { - return false; - } - foreach (['IsCreditCodeConsistent', 'IsEntNameConsistent', 'IsLrNameConsistent', 'IsIdNumConsistent'] as $field) { - $value = $data[$field] ?? null; - if (!in_array($value, [true, 1, '1', 'true'], true)) { - return false; - } - } - $status = strtoupper((string) ($data['OperatingStatus'] ?? $data['RegStatus'] ?? '')); - return !in_array($status, ['吊销', '注销', 'REVOKED', 'CANCELLED'], true); - } - - private function terminal_type(): string { - $ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); - return str_contains($ua, 'mobile') ? '1' : '2'; - } -} diff --git a/src/Identity/RestController.php b/src/Identity/RestController.php index c965c0f0..41c5e9f4 100644 --- a/src/Identity/RestController.php +++ b/src/Identity/RestController.php @@ -1,79 +1,26 @@ service = $service ?? new ApplicationService(); + } - 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 { - $uuid_args = [ - 'uuid' => [ - 'type' => 'string', - 'format' => 'uuid', - 'required' => true, - 'validate_callback' => static fn($value): bool => wp_is_uuid((string) $value), - 'sanitize_callback' => static fn($value): string => strtolower(sanitize_text_field((string) $value)), - ], - ]; - register_rest_route(self::NS, '/identity/applications', [ + public function register_routes(): void { + register_rest_route(self::NS, '/identity/applications', [ 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => [$this, 'create'], - 'permission_callback' => [$this, 'logged_in'], - 'args' => [ - 'type' => ['type' => 'string', 'required' => true, 'enum' => ['personal', 'enterprise'], 'sanitize_callback' => 'sanitize_key'], - 'person_name' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 50, 'sanitize_callback' => 'sanitize_text_field'], - 'id_number' => ['type' => 'string', 'required' => true, 'minLength' => 15, 'maxLength' => 18, 'sanitize_callback' => 'sanitize_text_field'], - 'company_name' => ['type' => 'string', 'required' => false, 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'], - 'company_code' => ['type' => 'string', 'required' => false, 'maxLength' => 32, 'sanitize_callback' => 'sanitize_text_field'], - 'consent' => ['type' => 'boolean', 'required' => true], - 'meta_info' => ['type' => 'string', 'required' => false, 'maxLength' => 65535, 'sanitize_callback' => 'sanitize_text_field'], - ], - ]); - register_rest_route(self::NS, '/identity/applications/current', [ - 'methods' => \WP_REST_Server::READABLE, - 'callback' => [$this, 'current'], - 'permission_callback' => [$this, 'logged_in'], - ]); - register_rest_route(self::NS, '/identity/applications/(?P[a-f0-9-]{36})', [ - 'methods' => \WP_REST_Server::READABLE, - 'callback' => [$this, 'get'], - 'permission_callback' => [$this, 'owns_application'], - 'args' => $uuid_args, - ]); - register_rest_route(self::NS, '/identity/applications/(?P[a-f0-9-]{36})/refresh', [ - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => [$this, 'refresh'], - 'permission_callback' => [$this, 'owns_application'], - 'args' => $uuid_args, - ]); - register_rest_route(self::NS, '/identity/applications/(?P[a-f0-9-]{36})/manual', [ - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => [$this, 'manual'], - 'permission_callback' => [$this, 'owns_application'], - 'args' => $uuid_args, - ]); - } + 'callback' => [$this, 'create'], + 'permission_callback' => [$this, 'logged_in'], + ]); + } public function logged_in(\WP_REST_Request $request) { if (!is_user_logged_in()) { @@ -84,114 +31,32 @@ final class RestController { return new \WP_Error('aksrt_identity_nonce', __('安全校验失败,请刷新页面后重试', 'aksrt'), ['status' => 403]); } return true; - } + } - public function owns_application(\WP_REST_Request $request) { - $authenticated = $this->logged_in($request); - if (is_wp_error($authenticated)) { - return $authenticated; - } - $application = $this->application_for_request($request); - if (!is_array($application) || (int) $application['user_id'] !== get_current_user_id()) { - return new \WP_Error('aksrt_identity_forbidden', __('无权访问该认证申请', 'aksrt'), ['status' => 403]); - } - return true; - } - - public function create(\WP_REST_Request $request): \WP_REST_Response { - $params = $request->get_json_params(); - $params = is_array($params) ? $params : []; - $operation_key = OperationKey::from_request($request, 'identity-create', get_current_user_id()); - if (is_wp_error($operation_key)) { - return $this->respond($operation_key); - } - $request_id = strtolower(trim((string) $request->get_header('Idempotency-Key'))); - $result = $this->service->create(get_current_user_id(), $params, $request_id, $operation_key); - return $this->respond($result, 201); - } - - public function current(): \WP_REST_Response { - $user_id = get_current_user_id(); - $data = class_exists('\\AKSRT\\User\\AuthApply') - ? \AKSRT\User\AuthApply::snapshot_view($user_id) - : ['user_id' => $user_id, 'current' => null, 'active' => null, 'latest' => null]; - return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $data])); - } - - public function get(\WP_REST_Request $request): \WP_REST_Response { - $application = $this->application_for_request($request); - if (!$application) { - return $this->no_store(new \WP_REST_Response([ - 'success' => false, - 'code' => 'aksrt_identity_missing', - 'message' => __('认证申请不存在', 'aksrt'), - ], 404)); - } - return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $this->service->view($application)])); - } - - public function refresh(\WP_REST_Request $request): \WP_REST_Response { - $application = $this->application_for_request($request); - if (!$application) { - return $this->no_store(new \WP_REST_Response([ - 'success' => false, - 'code' => 'aksrt_identity_missing', - 'message' => __('认证申请不存在', 'aksrt'), - ], 404)); - } - $operation_key = OperationKey::from_request($request, 'identity-refresh', get_current_user_id()); - if (is_wp_error($operation_key)) { - return $this->respond($operation_key); - } - return $this->respond($this->service->refresh($application, $operation_key)); - } - - public function manual(\WP_REST_Request $request): \WP_REST_Response { - $application = $this->application_for_request($request); - if (!$application) { - return $this->no_store(new \WP_REST_Response([ - 'success' => false, - 'code' => 'aksrt_identity_missing', - 'message' => __('认证申请不存在', 'aksrt'), - ], 404)); - } - $operation_key = OperationKey::from_request($request, 'identity-manual', get_current_user_id()); - if (is_wp_error($operation_key)) { - return $this->respond($operation_key); - } + public function create(\WP_REST_Request $request): \WP_REST_Response { $files = $request->get_file_params(); - return $this->respond($this->service->move_to_manual($application, $files['evidence'] ?? null, $operation_key)); - } + return $this->respond($this->service->create( + get_current_user_id(), + $request->get_params(), + $files['evidence'] ?? null + ), 201); + } - /** @return array|null */ - private function application_for_request(\WP_REST_Request $request): ?array { - $uuid = (string) $request['uuid']; - if ($uuid !== $this->request_uuid) { - $this->request_uuid = $uuid; - $this->request_application = $this->applications->find_by_uuid($uuid); - } - return is_array($this->request_application) ? $this->request_application : null; - } - - private function respond($result, int $success_status = 200): \WP_REST_Response { - if (is_wp_error($result)) { - $data = $result->get_error_data(); - $status = is_array($data) ? (int) ($data['status'] ?? 0) : 0; - if ($status < 400 || $status > 599) { - $status = in_array($result->get_error_code(), ['aksrt_identity_active', 'aksrt_identity_verified', 'aksrt_identity_conflict', 'aksrt_identity_idempotency_conflict'], true) ? 409 : 400; - } - return $this->no_store(new \WP_REST_Response([ - 'success' => false, - 'code' => $result->get_error_code(), - 'message' => $result->get_error_message(), - ], $status)); - } - return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $result], $success_status)); - } - - private function no_store(\WP_REST_Response $response): \WP_REST_Response { - $response->header('Cache-Control', 'no-store, private'); - $response->header('Pragma', 'no-cache'); - return $response; - } + private function respond($result, int $success_status = 200): \WP_REST_Response { + if (is_wp_error($result)) { + $error_data = $result->get_error_data(); + $status = is_array($error_data) ? (int) ($error_data['status'] ?? 0) : 0; + if ($status < 400 || $status > 599) $status = 400; + $response = new \WP_REST_Response([ + 'success' => false, + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], $status); + } else { + $response = new \WP_REST_Response(['success' => true, 'data' => $result], $success_status); + } + $response->header('Cache-Control', 'no-store, private'); + $response->header('Pragma', 'no-cache'); + return $response; + } } diff --git a/src/Identity/SecretStore.php b/src/Identity/SecretStore.php deleted file mode 100644 index 6fed907b..00000000 --- a/src/Identity/SecretStore.php +++ /dev/null @@ -1,135 +0,0 @@ - */ - public static function get(string $provider): array { - $stored = get_option(self::OPTION_KEY, []); - $record = is_array($stored) ? ($stored[sanitize_key($provider)] ?? null) : null; - if (!is_array($record)) { - return []; - } - - $plain = self::decrypt($record); - if ($plain === '') { - return []; - } - $data = json_decode($plain, true); - if (!is_array($data)) { - return []; - } - - $clean = []; - foreach ($data as $key => $value) { - if (is_string($key) && is_string($value)) { - $clean[sanitize_key($key)] = $value; - } - } - return $clean; - } - - /** @param array $changes */ - public static function update(string $provider, array $changes): bool { - $provider = sanitize_key($provider); - if (!in_array($provider, ['aliyun', 'tencent'], true)) { - return false; - } - - $current = self::get($provider); - foreach ($changes as $key => $value) { - $key = sanitize_key((string) $key); - if ($key === '') { - continue; - } - if ($value === null) { - unset($current[$key]); - } elseif (is_string($value) && $value !== '') { - $current[$key] = $value; - } - } - - $stored = get_option(self::OPTION_KEY, []); - $stored = is_array($stored) ? $stored : []; - if ($current === []) { - unset($stored[$provider]); - } else { - $record = self::encrypt((string) wp_json_encode($current)); - if (is_wp_error($record)) { - return false; - } - $stored[$provider] = $record; - } - return update_option(self::OPTION_KEY, $stored, false) || get_option(self::OPTION_KEY, []) === $stored; - } - - /** @return array */ - public static function status(string $provider): array { - $status = []; - foreach (self::get($provider) as $key => $value) { - $status[$key] = [ - 'configured' => $value !== '', - 'fingerprint' => $value !== '' ? substr(hash('sha256', $value), -4) : '', - ]; - } - return $status; - } - - /** @return array|\WP_Error */ - private static function encrypt(string $plain) { - $key = self::key(); - if (function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt')) { - $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); - $cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plain, self::CONTEXT, $nonce, $key); - return ['driver' => 'sodium', 'nonce' => base64_encode($nonce), 'cipher' => base64_encode($cipher)]; - } - if (function_exists('openssl_encrypt')) { - $nonce = random_bytes(12); - $tag = ''; - $cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT); - if (!is_string($cipher)) { - return new \WP_Error('aksrt_identity_encrypt', __('身份认证凭证加密失败', 'aksrt')); - } - return ['driver' => 'openssl', 'nonce' => base64_encode($nonce), 'tag' => base64_encode($tag), 'cipher' => base64_encode($cipher)]; - } - return new \WP_Error('aksrt_identity_crypto', __('服务器不支持安全凭证加密', 'aksrt')); - } - - /** @param array $record */ - private static function decrypt(array $record): string { - $nonce = base64_decode((string) ($record['nonce'] ?? ''), true); - $cipher = base64_decode((string) ($record['cipher'] ?? ''), true); - if (!is_string($nonce) || !is_string($cipher)) { - return ''; - } - $key = self::key(); - if (($record['driver'] ?? '') === 'sodium' && function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt')) { - $plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, self::CONTEXT, $nonce, $key); - return is_string($plain) ? $plain : ''; - } - if (($record['driver'] ?? '') === 'openssl' && function_exists('openssl_decrypt')) { - $tag = base64_decode((string) ($record['tag'] ?? ''), true); - if (!is_string($tag)) { - return ''; - } - $plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT); - return is_string($plain) ? $plain : ''; - } - return ''; - } - - private static function key(): string { - return hash_hmac('sha256', self::CONTEXT, wp_salt('auth') . wp_salt('secure_auth'), true); - } -} diff --git a/src/Identity/SettingsRuntime.php b/src/Identity/SettingsRuntime.php deleted file mode 100644 index 53965861..00000000 --- a/src/Identity/SettingsRuntime.php +++ /dev/null @@ -1,62 +0,0 @@ - */ - public static function get(): array { - $settings = \AKSRT\Settings\Settings::get_all(); - $provider = sanitize_key((string) ($settings['user_verify_provider'] ?? 'aliyun')); - if (!in_array($provider, ['aliyun', 'tencent'], true)) { - $provider = 'aliyun'; - } - - return [ - 'enabled' => !isset($settings['user_verify_enabled']) || !empty($settings['user_verify_enabled']), - 'allow_apply' => !isset($settings['user_verify_allow_apply']) || !empty($settings['user_verify_allow_apply']), - 'provider' => $provider, - 'allow_manual_fallback' => !isset($settings['user_verify_allow_manual_fallback']) || !empty($settings['user_verify_allow_manual_fallback']), - 'session_ttl_minutes' => max(5, min(1440, (int) ($settings['user_verify_session_ttl_minutes'] ?? 30))), - 'poll_interval_seconds' => max(3, min(60, (int) ($settings['user_verify_poll_interval_seconds'] ?? 5))), - 'max_retries' => max(0, min(3, (int) ($settings['user_verify_max_retries'] ?? 1))), - 'personal_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_personal_flow'] ?? '')), - 'product_code' => $provider === 'aliyun' && in_array(($settings['user_verify_aliyun_product_code'] ?? 'ID_PRO'), ['ID_PRO', 'PV_FV', 'LR_FR'], true) - ? (string) $settings['user_verify_aliyun_product_code'] - : 'ID_PRO', - 'enterprise_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_enterprise_flow'] ?? '')), - 'region' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_region'] ?? '')), - 'credentials' => SecretStore::get($provider), - ]; - } - - public static function hmac(string $value): string { - return hash_hmac('sha256', trim($value), wp_salt('secure_auth') . '|aksrt-identity-hmac'); - } - - public static function mask_id_number(string $value): string { - $value = preg_replace('/\s+/', '', $value) ?: ''; - $length = strlen($value); - if ($length <= 8) { - return str_repeat('*', max(0, $length - 2)) . substr($value, -2); - } - return substr($value, 0, 3) . str_repeat('*', $length - 7) . substr($value, -4); - } - - public static function mask_name(string $value): string { - $value = trim($value); - $length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); - if ($length <= 1) { - return '*'; - } - $first = function_exists('mb_substr') ? mb_substr($value, 0, 1) : substr($value, 0, 1); - return $first . str_repeat('*', max(1, $length - 1)); - } -} diff --git a/src/Identity/TransitionService.php b/src/Identity/TransitionService.php index e1c65681..24c37864 100644 --- a/src/Identity/TransitionService.php +++ b/src/Identity/TransitionService.php @@ -1,9 +1,5 @@ ['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', 'rejected', 'cancelled'], - 'provider_unsupported' => ['manual_pending', 'rejected', 'cancelled'], - 'provider_expired' => ['provider_pending', 'manual_pending', 'rejected', 'cancelled'], - 'manual_pending' => ['verified', 'rejected', 'cancelled'], - 'verified' => ['cancelled'], - ]; - - public function __construct( - private ?ApplicationRepository $applications = null, - private ?EventRepository $events = null - ) { - $this->applications ??= new ApplicationRepository(); - $this->events ??= new EventRepository(); - } - - /** @param array $fields @param array $event */ - public function transition(array $application, string $next, array $fields = [], array $event = []) { - return $this->transition_with_policy($application, $next, $fields, $event, false); + public static function public_status(string $status): string { + return match ($status) { + 'manual_pending' => 'pending', + 'verified' => 'verified', + 'rejected' => 'rejected', + default => '', + }; } - - /** @param array $fields @param array $event */ - public function admin_transition(array $application, string $next, array $fields = [], array $event = []) { - return $this->transition_with_policy($application, $next, $fields, $event, true); - } - - /** @param array $fields @param array $event */ - private function transition_with_policy(array $application, string $next, array $fields, array $event, bool $admin_override) { - $next = sanitize_key($next); - $dedupe = isset($event['dedupe_key']) ? (string) $event['dedupe_key'] : ''; - if ($dedupe !== '') { - $existing_event = $this->events->find_by_dedupe($dedupe); - if (is_array($existing_event) && (int) $existing_event['application_id'] === (int) $application['id']) { - return $this->applications->find_by_id((int) $application['id']); - } - } - - if (\AKSRT\Ledger\LedgerService::get_instance()->transaction_active()) { - return new \WP_Error('aksrt_identity_nested_transaction', __('认证状态不能开启嵌套事务', 'aksrt'), ['status' => 500]); - } - - global $wpdb; - if ($wpdb->query('START TRANSACTION') === false) { - return new \WP_Error('aksrt_identity_db', __('认证状态更新失败', 'aksrt'), ['status' => 500]); - } - $locked = $this->applications->find_by_id_for_update((int) $application['id']); - if (!is_array($locked)) { - $wpdb->query('ROLLBACK'); - return new \WP_Error('aksrt_identity_missing', __('认证申请不存在', 'aksrt'), ['status' => 404]); - } - if ($dedupe !== '') { - $existing_event = $this->events->find_by_dedupe($dedupe); - if (is_array($existing_event) && (int) $existing_event['application_id'] === (int) $locked['id']) { - $wpdb->query('COMMIT'); - return $locked; - } - } - - $changed = $this->apply_transition_locked($locked, $next, $fields, $event, $admin_override); - if (is_wp_error($changed)) { - $wpdb->query('ROLLBACK'); - return $changed; - } - $updated = $this->applications->find_by_id((int) $locked['id']); - if (!is_array($updated) || $wpdb->query('COMMIT') === false) { - $wpdb->query('ROLLBACK'); - return new \WP_Error('aksrt_identity_db', __('认证状态更新失败', 'aksrt'), ['status' => 500]); - } - $this->after_commit($locked, $next, $fields); - return $updated; - } - - /** - * Apply an identity transition inside an already-owned database transaction. - * The caller must lock the application row before invoking this method. - * - * @param array $application - * @param array $fields - * @param array $event - * @return true|\WP_Error - */ - public function transition_locked(array $application, string $next, array $fields = [], array $event = []) { - return $this->apply_transition_locked($application, $next, $fields, $event, false); - } - - /** - * @param array $application - * @param array $fields - * @param array $event - * @return true|\WP_Error - */ - private function apply_transition_locked(array $application, string $next, array $fields, array $event, bool $admin_override) { - $current = sanitize_key((string) ($application['status'] ?? '')); - $next = sanitize_key($next); - $allowed = $admin_override - ? in_array($next, ['manual_pending', 'verified', 'rejected', 'cancelled'], true) && $next !== $current - : in_array($next, self::ALLOWED[$current] ?? [], true); - if (!$allowed) { - return new \WP_Error('aksrt_identity_transition', __('该认证记录当前不可执行此操作', 'aksrt'), ['status' => 409]); - } - if (in_array($next, ['verified', 'rejected', 'cancelled'], true)) { - $fields['completed_at'] = current_time('mysql', true); - $fields['sensitive_blob'] = null; - } else { - $fields['completed_at'] = null; - } - if (!$this->applications->transition((int) $application['id'], $current, $next, $fields)) { - return new \WP_Error('aksrt_identity_conflict', __('认证状态已变化,请刷新后重试', 'aksrt'), ['status' => 409]); - } - - $dedupe = (string) ($event['dedupe_key'] ?? hash('sha256', implode('|', [ - (string) $application['application_uuid'], - $current, - $next, - wp_generate_uuid4(), - ]))); - $recorded = $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), - ]); - return $recorded - ? true - : new \WP_Error('aksrt_identity_event', __('认证事件写入失败', 'aksrt'), ['status' => 500]); - } - - /** @param array $application @param array $fields */ - public function after_commit(array $application, string $next, array $fields = []): void { - try { - 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)); - } catch (\Throwable $error) { - if (defined('WP_DEBUG') && WP_DEBUG) { - error_log('[AKSRT Identity Event] ' . $error->getMessage()); - } - } - } - - public static function public_status(string $status): string { - if ($status === 'verified') { - return 'verified'; - } - if ($status === 'cancelled') { - return 'cancelled'; - } - if ($status === 'rejected') { - return 'rejected'; - } - return in_array($status, ApplicationRepository::ACTIVE_STATUSES, true) ? 'pending' : 'rejected'; - } - - private function send_notification(int $user_id, string $status, string $note): void { - if (!class_exists('\\AKSRT\\Messages\\Message')) { - return; - } - if ($status === 'verified') { - $title = __('官方认证结果', 'aksrt'); - $message = __('您的官方认证已通过', 'aksrt'); - } elseif ($status === 'cancelled') { - $title = __('官方认证已取消', 'aksrt'); - $message = sprintf(__('您的官方认证已被取消:%s', 'aksrt'), $note); - } else { - $title = __('官方认证结果', 'aksrt'); - $message = sprintf(__('您的官方认证申请未通过:%s', 'aksrt'), $note); - } - \AKSRT\Messages\Message::get_instance()->send($user_id, 'auth_apply_reply', $message, [ - 'title' => $title, - 'target_url' => aksrt_get_user_center_tab_url('auth'), - ]); - } } diff --git a/src/Settings/IdentityAdmin.php b/src/Settings/IdentityAdmin.php deleted file mode 100644 index 06efc57a..00000000 --- a/src/Settings/IdentityAdmin.php +++ /dev/null @@ -1,106 +0,0 @@ - ['access_key_id', 'access_key_secret'], - 'tencent' => ['secret_id', 'secret_key'], - ]; - - /** @return array */ - public static function read(): array { - $stored = Settings::get_all(); - $provider = sanitize_key((string) ($stored['user_verify_provider'] ?? 'aliyun')); - if (!isset(self::PROVIDER_FIELDS[$provider])) { - $provider = 'aliyun'; - } - return [ - 'provider' => $provider, - 'providers' => [ - 'aliyun' => self::provider_state('aliyun'), - 'tencent' => self::provider_state('tencent'), - ], - ]; - } - - /** @param array $input @return array */ - public static function update(array $input): array { - if (array_key_exists('provider', $input)) { - $provider = sanitize_key((string) $input['provider']); - if (isset(self::PROVIDER_FIELDS[$provider])) { - Settings::write_domain('users', 'verify', ['user_verify_provider' => $provider]); - } - } - - $providers = isset($input['providers']) && is_array($input['providers']) ? $input['providers'] : []; - foreach (self::PROVIDER_FIELDS as $provider => $fields) { - $changes = isset($providers[$provider]) && is_array($providers[$provider]) ? $providers[$provider] : []; - $updates = []; - if (!empty($changes['clear_credentials'])) { - foreach ($fields as $field) { - $updates[$field] = null; - } - } else { - $credentials = isset($changes['credentials']) && is_array($changes['credentials']) ? $changes['credentials'] : []; - foreach ($fields as $field) { - $value = trim((string) ($credentials[$field] ?? '')); - if ($value !== '') { - $updates[$field] = $value; - } - } - } - if ($updates !== []) { - SecretStore::update($provider, $updates); - } - } - return self::read(); - } - - /** @return array{success:bool,message:string} */ - public static function test(string $provider): array { - if (!isset(self::PROVIDER_FIELDS[$provider])) { - return ['success' => false, 'message' => __('不支持的身份认证服务商', 'aksrt')]; - } - $valid = ProviderFactory::make($provider)->validate_config(); - if (is_wp_error($valid)) { - return ['success' => false, 'message' => $valid->get_error_message()]; - } - return ['success' => true, 'message' => sprintf(__('%s配置检查通过', 'aksrt'), self::label($provider))]; - } - - /** @return array */ - private static function provider_state(string $provider): array { - $secrets = SecretStore::get($provider); - $configured_fields = []; - $missing = []; - foreach (self::PROVIDER_FIELDS[$provider] as $field) { - $configured = !empty($secrets[$field]); - $configured_fields[$field] = $configured; - if (!$configured) { - $missing[] = $field; - } - } - return [ - 'label' => self::label($provider), - 'configured' => $missing === [], - 'configured_fields' => $configured_fields, - 'missing_fields' => $missing, - ]; - } - - private static function label(string $provider): string { - return $provider === 'aliyun' ? __('阿里云', 'aksrt') : __('腾讯云', 'aksrt'); - } -} diff --git a/src/Settings/MailNotifications.php b/src/Settings/MailNotifications.php index 307f6d2d..1dbc3d78 100644 --- a/src/Settings/MailNotifications.php +++ b/src/Settings/MailNotifications.php @@ -17,8 +17,6 @@ final class MailNotifications { add_action('aksrt_notification_sent', [self::class, 'notification'], 20, 3); add_action('aksrt_withdrawal_submitted', [self::class, 'withdrawal_submitted'], 20, 2); add_action('aksrt_withdrawal_status_changed', [self::class, 'withdrawal_status'], 20, 4); - add_action('aksrt_user_auth_submitted', [self::class, 'auth_submitted'], 20, 2); - add_action('aksrt_user_auth_reviewed', [self::class, 'auth_reviewed'], 20, 4); add_action('aksrt_ban_appeal_submitted', [self::class, 'ban_appeal_submitted'], 20, 2); add_action('aksrt_ban_appeal_processed', [self::class, 'ban_appeal_processed'], 20, 3); add_action('aksrt_new_posts_pending', [self::class, 'post_pending'], 20, 1); diff --git a/src/Settings/Settings.php b/src/Settings/Settings.php index b651fdd1..507236a6 100644 --- a/src/Settings/Settings.php +++ b/src/Settings/Settings.php @@ -42,7 +42,6 @@ class Settings { 'aksrt_notification_settings', 'aksrt_friend_links', 'aksrt_category_recommend', - 'aksrt_identity_secrets', ]; public const SCHEMA_MANAGED_STANDALONE_OPTION_KEYS = [ @@ -54,7 +53,6 @@ class Settings { */ public const SECRET_OPTION_KEYS = [ 'aksrt_captcha_config', - 'aksrt_identity_secrets', ]; private function __construct() {} @@ -573,7 +571,7 @@ class Settings { * User identity, level, badge, and report governance settings. * * @return array{ - * verify:array{enabled:bool,public_display:bool,badge_text:string,accept_email_verified:bool,require_manual_review:bool}, + * verify:array{enabled:bool,public_display:bool,allow_apply:bool,disable_desc:string,desc:string,minimum_level:int}, * level:array{enabled:bool,public_display:bool,source:string,label_prefix:string,thresholds:array}, * badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array}, * report:array{enabled:bool,public_entry:bool,categories:array} @@ -583,26 +581,6 @@ class Settings { return UserRuntime::get_user_identity_settings(); } - /** @return array */ - public static function get_identity_verification_settings(): array { - return \AKSRT\Identity\SettingsRuntime::get(); - } - - /** @return array */ - public static function get_identity_provider_secrets(string $provider): array { - return \AKSRT\Identity\SecretStore::get($provider); - } - - /** @param array $changes */ - public static function update_identity_provider_secrets(string $provider, array $changes): bool { - return \AKSRT\Identity\SecretStore::update($provider, $changes); - } - - /** @return array */ - public static function get_identity_provider_secret_status(string $provider): array { - return \AKSRT\Identity\SecretStore::status($provider); - } - public static function standalone_export_keys(): array { return array_values(array_diff(self::STANDALONE_OPTION_KEYS, self::SCHEMA_MANAGED_STANDALONE_OPTION_KEYS, self::SECRET_OPTION_KEYS)); } diff --git a/src/Settings/UserRuntime.php b/src/Settings/UserRuntime.php index f087ebe4..bb36450a 100644 --- a/src/Settings/UserRuntime.php +++ b/src/Settings/UserRuntime.php @@ -105,7 +105,7 @@ class UserRuntime { * User identity, level, medal, and report governance settings. * * @return array{ - * verify:array{enabled:bool,public_display:bool,allow_apply:bool,disable_desc:string,desc:string,types:array}, + * verify:array{enabled:bool,public_display:bool,allow_apply:bool,disable_desc:string,desc:string,minimum_level:int}, * level:array{enabled:bool,public_display:bool,source:string,label_prefix:string,thresholds:array}, * medal:array{enabled:bool,public_display:bool,limit:int,categories:array}>,items_by_slug:array}, * badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array}, @@ -114,41 +114,16 @@ class UserRuntime { */ public static function get_user_identity_settings(): array { $settings = Settings::get_all(); - $verify_types = [ - 'personal' => [ - 'slug' => 'personal', - 'label' => __('个人认证', 'aksrt'), - 'badge_text' => trim((string) ($settings['user_verify_personal_badge_text'] ?? '')) ?: __('个人认证', 'aksrt'), - 'fee' => self::identity_fee_setting($settings, 'user_verify_personal_fee'), - 'free_applications' => max(0, (int) ($settings['user_verify_personal_free_applications'] ?? 0)), - 'require_real_name' => true, - ], - 'enterprise' => [ - 'slug' => 'enterprise', - 'label' => __('企业认证', 'aksrt'), - 'badge_text' => trim((string) ($settings['user_verify_enterprise_badge_text'] ?? '')) ?: __('企业认证', 'aksrt'), - 'fee' => self::identity_fee_setting($settings, 'user_verify_enterprise_fee'), - 'free_applications' => max(0, (int) ($settings['user_verify_enterprise_free_applications'] ?? 0)), - 'require_real_name' => true, - ], - ]; $medal = self::parse_medal_catalog($settings); - $provider = sanitize_key((string) ($settings['user_verify_provider'] ?? 'aliyun')); - if (!in_array($provider, ['aliyun', 'tencent'], true)) { - $provider = 'aliyun'; - } return [ 'verify' => [ - 'enabled' => !isset($settings['user_verify_enabled']) || !empty($settings['user_verify_enabled']), - 'public_display' => !isset($settings['user_verify_public_display']) || !empty($settings['user_verify_public_display']), - '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' => $provider, - '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, + 'enabled' => !isset($settings['user_auth_s']) || !empty($settings['user_auth_s']), + 'public_display' => !isset($settings['user_auth_s']) || !empty($settings['user_auth_s']), + 'allow_apply' => !isset($settings['user_auth_apply_s']) || !empty($settings['user_auth_apply_s']), + 'disable_desc' => (string) ($settings['user_auth_apply_disable_desc'] ?? ''), + 'desc' => (string) ($settings['user_auth_apply_desc'] ?? ''), + 'minimum_level' => max(0, min(30, (int) ($settings['user_auth_apply_limit_level'] ?? 0))), ], 'level' => [ 'enabled' => !isset($settings['user_level_s']) || !empty($settings['user_level_s']), @@ -179,19 +154,6 @@ class UserRuntime { ]; } - /** @param array $settings */ - private static function identity_fee_setting(array $settings, string $key): string { - $value = array_key_exists($key, $settings) ? $settings[$key] : '0.00'; - if (!is_string($value)) { - return ''; - } - try { - return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal(); - } catch (\InvalidArgumentException $e) { - return ''; - } - } - /** * 禁封/举报分区的运行时读取。 * diff --git a/src/User/AuthApply.php b/src/User/AuthApply.php index 915db951..2b2f699d 100644 --- a/src/User/AuthApply.php +++ b/src/User/AuthApply.php @@ -1,510 +1,194 @@ */ - public static function settings(): array { - $identity = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_user_identity_settings() : []; - return is_array($identity['verify'] ?? null) ? $identity['verify'] : []; - } + /** @return array */ + public static function settings(): array { + $all = class_exists('AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_user_identity_settings() : []; + return is_array($all['verify'] ?? null) ? $all['verify'] : []; + } - /** @return array */ - public static function type_settings(string $type): array { - $types = self::settings()['types'] ?? []; - $type = sanitize_key($type); - return is_array($types[$type] ?? null) ? $types[$type] : []; - } + /** @return array */ + public static function snapshot(int $user_id): array { + $repo = new ApplicationRepository(); + $active = $user_id > 0 ? $repo->active_for_user($user_id) : null; + $verified = self::is_verified($user_id); + $settings = self::settings(); + $minimum_level = max(0, (int) ($settings['minimum_level'] ?? 0)); + $level_settings = class_exists('AKSRT\\Settings\\Settings') + ? \AKSRT\Settings\Settings::get_user_identity_settings() + : []; + $level_enabled = !isset($level_settings['level']['enabled']) || !empty($level_settings['level']['enabled']); + $level_ok = self::meets_minimum_level($user_id, $minimum_level, $level_enabled); + return [ + 'user_id' => $user_id, + 'verified' => $verified, + 'status' => $verified ? 'verified' : ($active ? TransitionService::public_status((string) ($active['status'] ?? '')) : ''), + 'can_apply' => !empty($settings['enabled']) && !empty($settings['allow_apply']) && !$verified && !$active && $level_ok, + 'level_ok' => $level_ok, + 'minimum_level' => $minimum_level, + 'application' => $active, + 'info' => self::get_auth_info($user_id), + ]; + } - /** - * Read the current user's identity state from one repository context. - * - * The repository memoizes the latest/active/verified records for the - * request, while this typed facade keeps all presentation callers on the - * same application selection and next-action contract. - * - * @return array - */ - public static function snapshot(int $user_id): array { - $user_id = absint($user_id); - $settings = self::settings(); - $records = (new ApplicationRepository())->snapshot_for_user($user_id); - $verified = is_array($records['verified'] ?? null) ? $records['verified'] : null; - $latest = is_array($records['latest'] ?? null) ? $records['latest'] : null; - $active = is_array($records['active'] ?? null) ? $records['active'] : null; - $status_record = $verified ?: $latest; - $status = $status_record - ? TransitionService::public_status((string) ($status_record['status'] ?? '')) - : ''; - $current = $active ?: $latest; - $stage = $active - ? (string) ($active['status'] ?? '') - : (string) ($latest['status'] ?? ''); - $next_action = self::next_action($stage); - $runtime_settings = class_exists('\\AKSRT\\Settings\\Settings') ? SettingsRuntime::get() : []; - $poll_interval = max(5, (int) ($runtime_settings['poll_interval_seconds'] ?? 5)); - $can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) && $status !== 'verified'; + public static function is_verified(int $user_id): bool { + return $user_id > 0 && (bool) get_user_meta($user_id, 'auth', true); + } - return [ - 'user_id' => $user_id, - 'verified' => $verified !== null, - 'status' => $status, - 'stage' => $stage, - 'next_action' => $next_action, - 'poll_interval' => $poll_interval, - 'can_apply' => $can_apply, - 'latest' => $latest, - 'active' => $active, - 'current' => $current, - 'info' => $verified ? self::info_from_application($verified) : [], - 'apply' => self::payload_from_application($latest), - ]; - } - - /** - * Safe browser-facing snapshot; raw application rows never cross this - * boundary. - * - * @return array - */ - public static function snapshot_view(int $user_id): array { - $snapshot = self::snapshot($user_id); - $latest = is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : null; - $active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : null; - $current = is_array($snapshot['current'] ?? null) ? $snapshot['current'] : null; - - return [ - 'user_id' => (int) ($snapshot['user_id'] ?? 0), - 'verified' => !empty($snapshot['verified']), - 'status' => (string) ($snapshot['status'] ?? ''), - 'stage' => (string) ($snapshot['stage'] ?? ''), - 'next_action' => (string) ($snapshot['next_action'] ?? 'wait'), - 'poll_interval' => (int) ($snapshot['poll_interval'] ?? 5), - 'can_apply' => !empty($snapshot['can_apply']), - 'info' => is_array($snapshot['info'] ?? null) ? $snapshot['info'] : [], - 'current' => $current ? self::application_view_model($current, (int) ($snapshot['poll_interval'] ?? 5)) : null, - 'active' => $active ? self::application_view_model($active, (int) ($snapshot['poll_interval'] ?? 5)) : null, - 'latest' => $latest ? self::application_view_model($latest, (int) ($snapshot['poll_interval'] ?? 5)) : null, - ]; - } - - private static function next_action(string $stage): string { - if (in_array($stage, ['provider_pending', 'provider_processing'], true)) { - return 'poll'; - } - if (in_array($stage, ['provider_failed', 'provider_unsupported', 'provider_expired'], true)) { - return 'manual_or_retry'; - } - if ($stage === 'verified') { - return 'complete'; - } - return 'wait'; - } - - /** @return array */ - private static function info_from_application(array $application): array { - return [ - 'name' => (string) ($application['public_name'] ?? ''), - 'type' => (string) ($application['type'] ?? ''), - 'time' => (string) (($application['completed_at'] ?? '') ?: ($application['updated_at'] ?? '')), - ]; - } - - /** @return array */ - private static function payload_from_application(?array $application): array { - if (!$application) { - return []; - } - return [ - 'application_uuid' => (string) ($application['application_uuid'] ?? ''), - 'user_id' => (int) ($application['user_id'] ?? 0), - 'name' => (string) ($application['public_name'] ?? ''), - 'real_name' => (string) ($application['person_name_masked'] ?? ''), - 'msg' => (string) ($application['manual_reason'] ?? ''), - 'type' => (string) ($application['type'] ?? ''), - 'time' => (string) ($application['created_at'] ?? ''), - 'fee' => (string) ($application['fee_amount'] ?? '0.00'), - '_application' => $application, - ]; - } - - /** @return array */ - private static function application_view_model(array $application, int $poll_interval): array { - $stage = (string) ($application['status'] ?? ''); - return [ - 'uuid' => (string) ($application['application_uuid'] ?? ''), - 'type' => (string) ($application['type'] ?? ''), - 'status' => TransitionService::public_status($stage), - 'stage' => $stage, - 'next_action' => self::next_action($stage), - 'public_name' => (string) ($application['public_name'] ?? ''), - 'result_code' => (string) ($application['provider_result_code'] ?? ''), - 'message' => (string) ($application['provider_result_summary'] ?? ''), - 'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')), - 'expires_at' => (string) ($application['expires_at'] ?? ''), - 'created_at' => (string) ($application['created_at'] ?? ''), - 'poll_interval' => $poll_interval, - ]; - } - - 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 get_status(int $user_id): string { - return (string) (self::snapshot($user_id)['status'] ?? ''); - } - - public static function is_verified(int $user_id): bool { - return !empty(self::snapshot($user_id)['verified']); - } - - /** @return array */ - public static function get_auth_info(int $user_id): array { - $info = self::snapshot($user_id)['info'] ?? []; - return is_array($info) ? $info : []; - } - - /** @return array */ - public static function get_apply_payload(int $user_id): array { - $payload = self::snapshot($user_id)['apply'] ?? []; - return is_array($payload) ? $payload : []; - } - - /** @return array */ - public static function get_evidence_items(array $application, int $owner_id = 0, bool $admin_context = false): array { - $record = is_array($application['_application'] ?? null) ? $application['_application'] : null; - if (!$record && !empty($application['application_uuid'])) { - $record = (new ApplicationRepository())->find_by_uuid((string) $application['application_uuid']); - } - return $record ? (new EvidenceService())->items($record, $admin_context) : []; - } - - /** @return array */ - public static function application_view(array $application, int $owner_id = 0, bool $admin_context = false): array { - return [ - 'name' => (string) ($application['name'] ?? ''), - 'real_name' => (string) ($application['real_name'] ?? ''), - 'desc' => (string) ($application['desc'] ?? ''), - 'msg' => (string) ($application['msg'] ?? ''), - 'type' => sanitize_key((string) ($application['type'] ?? '')), - 'time' => (string) ($application['time'] ?? ''), - 'evidence' => self::get_evidence_items($application, $owner_id, $admin_context), - ]; - } - - /** @return array> */ - 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; - } - - /** @param array $profile_fields */ - public static function review(int $user_id, string $application_uuid, string $status, string $note, int $reviewer_id, array $profile_fields) { - $repo = new ApplicationRepository(); - $application_uuid = sanitize_text_field($application_uuid); - $application = wp_is_uuid($application_uuid) ? $repo->find_by_uuid($application_uuid) : null; - if (!$application || (int) $application['user_id'] !== $user_id) { - return new \WP_Error('aksrt_verify_missing', __('认证申请不存在', 'aksrt')); - } - $status = sanitize_key($status); - $note = sanitize_textarea_field($note); - if (!in_array($status, ['manual_pending', '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')); - } - $fields = []; - foreach (['type', 'public_name'] as $key) { - if (array_key_exists($key, $profile_fields)) { - $fields[$key] = (string) $profile_fields[$key]; - } + /** @return array */ + public static function get_auth_info(int $user_id): array { + $stored = get_user_meta($user_id, 'auth_info', true); + if (!is_array($stored)) { + return []; } - $fields['review_note'] = $note; - $fields['reviewer_id'] = $reviewer_id; - $result = (new TransitionService($repo))->admin_transition($application, $status, $fields, [ - 'source' => 'admin', + return [ + 'name' => sanitize_text_field((string) ($stored['name'] ?? '')), + 'desc' => sanitize_textarea_field((string) ($stored['desc'] ?? '')), + 'time' => sanitize_text_field((string) ($stored['time'] ?? '')), + ]; + } + + public static function meets_minimum_level(int $user_id, int $minimum_level, bool $level_enabled = true): bool { + return $minimum_level <= 0 + || !$level_enabled + || !class_exists('AKSRT\\User\\Level') + || Level::get_level($user_id) >= $minimum_level; + } + + /** @param array $profile_fields */ + public static function review(int $user_id, string $application_uuid, string $status, string $note, int $reviewer_id, array $profile_fields) { + $status = sanitize_key($status); + $note = sanitize_textarea_field($note); + if (!in_array($status, ['verified', 'rejected'], true)) { + return new \WP_Error('aksrt_auth_status', __('审核结果无效', 'aksrt')); + } + if ($status === 'rejected' && $note === '') { + return new \WP_Error('aksrt_auth_note', __('请填写驳回原因', 'aksrt')); + } + $repo = new ApplicationRepository(); + $application = $repo->find_by_uuid(sanitize_text_field($application_uuid)); + if (!$application || (int) $application['user_id'] !== $user_id) { + return new \WP_Error('aksrt_auth_missing', __('认证申请不存在', 'aksrt')); + } + if ((string) $application['status'] !== 'manual_pending') { + return new \WP_Error('aksrt_auth_conflict', __('该申请已处理,请刷新后重试', 'aksrt')); + } + $name = sanitize_text_field((string) ($profile_fields['name'] ?? $application['public_name'] ?? '')); + $description = sanitize_textarea_field((string) ($profile_fields['description'] ?? $application['description'] ?? '')); + if ($status === 'verified' && $name === '') { + return new \WP_Error('aksrt_auth_name', __('请填写认证名称', 'aksrt')); + } + $updated = $repo->transition((int) $application['id'], 'manual_pending', $status, [ + 'review_note' => $note, + 'reviewer_id' => $reviewer_id, + 'completed_at' => current_time('mysql', true), + ...($status === 'verified' ? ['public_name' => $name, 'description' => $description] : []), + ]); + if (!$updated) { + return new \WP_Error('aksrt_auth_conflict', __('认证状态已变化,请刷新后重试', 'aksrt')); + } + if ($status === 'verified') { + update_user_meta($user_id, 'auth', 1); + update_user_meta($user_id, 'auth_info', ['name' => $name, 'desc' => $description, 'time' => current_time('Y-m-d H:i')]); + } else { + delete_user_meta($user_id, 'auth'); + delete_user_meta($user_id, 'auth_info'); + } + (new EventRepository())->record([ + 'application_id' => (int) $application['id'], + 'source' => 'admin', 'event_type' => 'manual_review', + 'dedupe_key' => hash('sha256', $application_uuid . '|review|' . $status), + 'from_status' => 'manual_pending', + 'to_status' => $status, + 'result_code' => $note, 'actor_user_id' => $reviewer_id, ]); - return is_wp_error($result) ? $result : ['status' => $status, 'reviewed_at' => (string) $result['completed_at']]; - } - - public function add_admin_user_column(array $columns): array { - $columns['aksrt_identity_status'] = __('身份认证', 'aksrt'); - return $columns; - } - - 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')]; - if ($status === '') { - return esc_html__('未认证', 'aksrt'); + if (class_exists('AKSRT\\Messages\\Message')) { + $title = $status === 'verified' ? __('身份认证通过', 'aksrt') : __('身份认证未通过', 'aksrt'); + $content = $status === 'verified' ? __('您的身份认证申请已通过审核', 'aksrt') : sprintf(__('您的身份认证申请未通过:%s', 'aksrt'), $note); + \AKSRT\Messages\Message::get_instance()->send($user_id, 'auth_apply_reply', $content, [ + 'title' => $title, + 'target_url' => function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('auth') : home_url('/'), + ]); } - $label = (string) ($labels[$status] ?? $status); - if ($status === 'verified') { - $info = self::get_auth_info($user_id); - $label .= ' · ' . self::admin_type_label((string) ($info['type'] ?? 'personal')); + if (class_exists('AKSRT\\Settings\\MailNotifications')) { + \AKSRT\Settings\MailNotifications::auth_reviewed($user_id, $status, $note, $reviewer_id); } - return esc_html($label); - } + return ['status' => $status]; + } - public function render_admin_profile_status(\WP_User $user): void { - $user_id = (int) $user->ID; - $snapshot = self::snapshot($user_id); - $application = is_array($snapshot['verified'] ?? null) - ? $snapshot['verified'] - : (is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : null); - $raw_status = (string) ($application['status'] ?? ''); - $can_manage = current_user_can('aksrt_review_identity') - && current_user_can('edit_user', $user_id); - $public_name = (string) ($application['public_name'] ?? ''); - $identity_type = in_array((string) ($application['type'] ?? ''), ['personal', 'enterprise'], true) - ? (string) $application['type'] - : 'personal'; - $selected_status = $raw_status === 'cancelled' ? '' : $raw_status; - $status_options = [ - '' => __('未认证', 'aksrt'), - 'manual_pending' => __('待审核', 'aksrt'), - 'verified' => __('已认证', 'aksrt'), - 'rejected' => __('已驳回', 'aksrt'), - ]; - if ($raw_status !== '' && $raw_status !== 'cancelled' && !isset($status_options[$raw_status])) { - $status_options = [$raw_status => self::admin_status_label($raw_status)] + $status_options; - } - ?> -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - ID) ) { return; } - $nonce = sanitize_text_field((string) wp_unslash($_POST['aksrt_identity_profile_nonce'])); - if (!wp_verify_nonce($nonce, 'aksrt_identity_profile_' . $user_id)) { - return; - } - - $repo = new ApplicationRepository(); - $snapshot = $repo->snapshot_for_user($user_id); - $application = is_array($snapshot['verified'] ?? null) - ? $snapshot['verified'] - : (is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : null); - $application_uuid = sanitize_text_field((string) wp_unslash($_POST['aksrt_identity_application_uuid'] ?? '')); - $current_uuid = (string) ($application['application_uuid'] ?? ''); - if (!hash_equals($current_uuid, $application_uuid)) { - $this->set_admin_profile_notice('error', __('身份认证记录已变化,请刷新后重试。', 'aksrt')); - return; - } - - $public_name = wp_html_excerpt(sanitize_text_field((string) wp_unslash($_POST['aksrt_identity_public_name'] ?? '')), 64, ''); - $identity_type = sanitize_key((string) wp_unslash($_POST['aksrt_identity_type'] ?? '')); - $status = sanitize_key((string) wp_unslash($_POST['aksrt_identity_status'] ?? '')); - $note = sanitize_textarea_field((string) wp_unslash($_POST['aksrt_identity_review_note'] ?? '')); - if (!in_array($identity_type, ['personal', 'enterprise'], true)) { - $this->set_admin_profile_notice('error', __('认证类型无效。', 'aksrt')); - return; - } - $fields = [ - 'type' => $identity_type, - 'public_name' => $public_name, - ]; - - $current_status = (string) ($application['status'] ?? ''); - if (!in_array($status, ['', 'manual_pending', 'verified', 'rejected'], true) && $status !== $current_status) { - $this->set_admin_profile_notice('error', __('认证状态无效。', 'aksrt')); - return; - } - $target_status = $status === '' ? 'cancelled' : $status; - if ($target_status === 'verified' && $public_name === '') { - $this->set_admin_profile_notice('error', __('设为已认证时请填写姓名 / 主体全称。', 'aksrt')); - return; - } - if (in_array($target_status, ['rejected', 'cancelled'], true) && $target_status !== $current_status && $note === '') { - $this->set_admin_profile_notice('error', __('改为已驳回或未认证时请填写处理说明。', 'aksrt')); - return; - } - - if ($application === null && $status === '') { - return; - } - if ($application === null) { - $application = $this->create_admin_application($repo, $user_id, $fields); - if (is_wp_error($application)) { - $this->set_admin_profile_notice('error', $application->get_error_message()); - return; - } - $application_uuid = (string) $application['application_uuid']; - $current_status = (string) $application['status']; - } - - if ($target_status === $current_status) { - $updated = $repo->update((int) $application['id'], $fields); - $this->set_admin_profile_notice($updated ? 'success' : 'error', $updated ? __('身份认证资料已更新。', 'aksrt') : __('身份认证资料更新失败。', 'aksrt')); - return; - } - - $result = self::review($user_id, $application_uuid, $target_status, $note, get_current_user_id(), $fields); - $this->set_admin_profile_notice( - is_wp_error($result) ? 'error' : 'success', - is_wp_error($result) ? $result->get_error_message() : __('身份认证已更新。', 'aksrt') - ); + $info = self::get_auth_info((int) $user->ID); + $name = (string) ($info['name'] ?? ''); + $description = (string) ($info['desc'] ?? ''); + $time = (string) ($info['time'] ?? ''); + ?> +

+ + + + + + + ID, 'aksrt_auth_profile_nonce'); ?> + $fields */ - private function create_admin_application(ApplicationRepository $repo, int $user_id, array $fields) { - $application_uuid = wp_generate_uuid4(); - $client_request_id = wp_generate_uuid4(); - return $repo->create([ - 'application_uuid' => $application_uuid, - 'client_request_id' => $client_request_id, - 'request_payload_hash' => hash('sha256', 'admin-profile|' . $user_id . '|' . $client_request_id), - 'user_id' => $user_id, - 'type' => $fields['type'], - 'channel' => 'manual', - 'provider' => '', - 'status' => 'created', - 'public_name' => $fields['public_name'], - 'fee_amount' => '0.00', - 'fee_status' => 'not_required', - 'attempt_ordinal' => $repo->count_user_type($user_id, $fields['type']) + 1, + public function save_admin_profile(int $user_id): void { + if ( + empty(self::settings()['enabled']) + || !current_user_can('aksrt_review_identity') + || !current_user_can('edit_user', $user_id) + || !isset($_POST['aksrt_auth_profile_nonce']) + ) { + return; + } + $nonce = sanitize_text_field((string) wp_unslash($_POST['aksrt_auth_profile_nonce'])); + if (!wp_verify_nonce($nonce, 'aksrt_auth_profile_' . $user_id)) return; + if (empty($_POST['aksrt_auth_enabled'])) { + delete_user_meta($user_id, 'auth'); + delete_user_meta($user_id, 'auth_info'); + return; + } + $name = sanitize_text_field((string) wp_unslash($_POST['aksrt_auth_name'] ?? '')); + if ($name === '') return; + update_user_meta($user_id, 'auth', 1); + update_user_meta($user_id, 'auth_info', [ + 'name' => $name, + 'desc' => sanitize_textarea_field((string) wp_unslash($_POST['aksrt_auth_desc'] ?? '')), + 'time' => sanitize_text_field((string) wp_unslash($_POST['aksrt_auth_time'] ?? '')) ?: current_time('Y-m-d H:i'), ]); } - - public function render_admin_profile_notice(): void { - $key = 'aksrt_identity_profile_notice_' . get_current_user_id(); - $notice = get_transient($key); - if (!is_array($notice) || empty($notice['message'])) { - return; - } - delete_transient($key); - $type = ($notice['type'] ?? '') === 'success' ? 'success' : 'error'; - echo '

' . esc_html((string) $notice['message']) . '

'; - } - - private function set_admin_profile_notice(string $type, string $message): void { - set_transient('aksrt_identity_profile_notice_' . get_current_user_id(), [ - 'type' => $type, - 'message' => $message, - ], 60); - } - - private static function admin_status_label(string $status): string { - $labels = [ - 'created' => __('已创建', 'aksrt'), - 'provider_pending' => __('等待核身', 'aksrt'), - 'provider_processing' => __('云端处理中', 'aksrt'), - 'provider_failed' => __('云认证失败', 'aksrt'), - 'provider_unsupported' => __('云端不支持', 'aksrt'), - 'provider_expired' => __('会话已过期', 'aksrt'), - 'manual_pending' => __('人工待审', 'aksrt'), - 'verified' => __('已认证', 'aksrt'), - 'rejected' => __('已驳回', 'aksrt'), - 'cancelled' => __('已取消', 'aksrt'), - ]; - return $labels[$status] ?? $status; - } - - private static function admin_type_label(string $type): string { - return $type === 'enterprise' ? __('企业认证', 'aksrt') : __('个人认证', 'aksrt'); - } - } diff --git a/src/User/Home.php b/src/User/Home.php index 9846c03b..b1fe32ff 100644 --- a/src/User/Home.php +++ b/src/User/Home.php @@ -213,11 +213,12 @@ class Home { $signals = []; if (!empty($settings['verify']['enabled']) && !empty($settings['verify']['public_display']) && $this->is_user_verified($user_id, $settings['verify'])) { - $resolved = $this->resolve_verified_type($user_id, $settings['verify']); + $info = AuthApply::get_auth_info($user_id); + $auth_name = trim((string) ($info['name'] ?? '')) ?: __('官方认证', 'aksrt'); $signals[] = [ 'type' => 'verify', - 'label' => $resolved['label'], - 'title' => $resolved['label'], + 'label' => $auth_name, + 'title' => $auth_name, 'icon' => 'check-circle', 'icon_url' => get_theme_file_uri('/assets/icons/user-certification.svg'), 'tone' => 'success', @@ -295,10 +296,9 @@ class Home { } /** - * 解析"更多资料"弹窗用的认证三态标识。 + * 解析"更多资料"弹窗用的认证状态。 * - * @return array{state:string,label:string} state 为空串表示认证公开展示已关闭; - * 否则为 none|personal|enterprise,label 为对应展示文案(与名字旁认证 chip 同源)。 + * @return array{state:string,label:string} state 为空串表示认证公开展示已关闭;否则为 none|verified。 */ public function get_verify_state(int $user_id): array { if ($user_id <= 0 || !class_exists('\AKSRT\Settings\Settings')) { @@ -314,30 +314,8 @@ class Home { return ['state' => 'none', 'label' => __('未认证', 'aksrt')]; } - $resolved = $this->resolve_verified_type($user_id, $verify); - // 邮箱核验通过等无申请类型的场景与名字旁 chip 一致,回落到首个已配置类型。 - $state = in_array($resolved['state'], ['personal', 'enterprise'], true) ? $resolved['state'] : 'personal'; - return ['state' => $state, 'label' => $resolved['label']]; - } - - /** - * @return array{state:string,label:string} state 为申请类型 personal|enterprise(可为空串),label 为展示文案。 - */ - private function resolve_verified_type(int $user_id, array $verify): array { - $info = class_exists('AKSRT\\User\\AuthApply') ? AuthApply::get_auth_info($user_id) : []; - $application = class_exists('AKSRT\\User\\AuthApply') ? AuthApply::get_apply_payload($user_id) : []; - $type = sanitize_key((string) ($info['type'] ?? $application['type'] ?? '')); - $type_settings = is_array($verify['types'][$type] ?? null) ? $verify['types'][$type] : []; - $label = trim((string) ($type_settings['badge_text'] ?? $type_settings['label'] ?? '')); - if ($label === '') { - foreach ((array) ($verify['types'] ?? []) as $configured_type) { - if (is_array($configured_type) && !empty($configured_type['badge_text'])) { - $label = trim((string) $configured_type['badge_text']); - break; - } - } - } - return ['state' => $type, 'label' => $label]; + $info = AuthApply::get_auth_info($user_id); + return ['state' => 'verified', 'label' => trim((string) ($info['name'] ?? '')) ?: __('官方认证', 'aksrt')]; } /** @@ -352,19 +330,10 @@ class Home { return !empty($settings['report']['enabled']) && !empty($settings['report']['public_entry']); } - /** - * @param array{accept_email_verified:bool} $verify_settings - */ + /** @param array $verify_settings */ private function is_user_verified(int $user_id, array $verify_settings): bool { - if (class_exists('\\AKSRT\\User\\AuthApply') && AuthApply::is_verified($user_id)) { - return true; - } - - if (!empty($verify_settings['accept_email_verified']) && class_exists('\AKSRT\Auth\Service')) { - return true; - } - - return false; + unset($verify_settings); + return AuthApply::is_verified($user_id); } /**