feat(identity): align auth flow with reference theme

This commit is contained in:
2026-10-04 21:10:12 +08:00
parent 5a17b399a0
commit 59901b9e10
34 changed files with 753 additions and 3871 deletions
-8
View File
@@ -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 = {}) {
+1 -1
View File
@@ -60,7 +60,7 @@ export const usersSection = {
route: '/users/verify',
component: 'UsersVerify',
title: '身份认证',
desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。',
desc: '配置通用认证申请规则;申请审核和认证资料管理位于 WordPress 用户菜单。',
canonicalDomain: 'users',
section: 'verify',
}),
+36 -300
View File
@@ -1,220 +1,52 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="身份认证"
desc="统一管理认证申请规则与云服务商凭证;审核、驳回和取消认证仍在 WordPress 用户菜单处理。"
/>
<PageHeader title="身份认证" desc="配置通用认证申请规则;申请审核和用户认证资料管理位于 WordPress 用户菜单。" />
<n-spin :show="loading" style="width: 100%; min-height: 240px">
<n-grid :cols="3" :x-gap="12" :y-gap="12" responsive="screen" item-responsive>
<n-grid-item span="3 m:1">
<n-card class="aksrt-status-card" size="small">
<n-statistic label="身份认证功能">
<template #default>{{ verifyForm.user_verify_enabled ? '已启用' : '已停用' }}</template>
</n-statistic>
<n-tag :type="verifyForm.user_verify_enabled ? 'success' : 'default'" round size="small">
{{ verifyForm.user_verify_enabled ? '运行中' : '未运行' }}
</n-tag>
</n-card>
</n-grid-item>
<n-grid-item span="3 m:1" v-for="key in providerKeys" :key="key">
<n-card class="aksrt-status-card" size="small">
<n-statistic :label="providerLabel(key) + '凭证'">
<template #default>{{ providerState(key).configured ? '已配置' : '未配置' }}</template>
</n-statistic>
<n-tag :type="providerState(key).configured ? 'success' : 'warning'" round size="small">
{{ identityForm.provider === key ? '当前服务商' : '备用服务商' }}
</n-tag>
</n-card>
</n-grid-item>
</n-grid>
<n-card class="aksrt-section-card" title="通用认证设置">
<n-form :model="verifyForm" label-placement="left" label-width="190">
<n-form-item label="启用身份认证"><n-switch v-model:value="verifyForm.user_verify_enabled" /></n-form-item>
<n-form-item label="公开页显示认证徽标"><n-switch v-model:value="verifyForm.user_verify_public_display" /></n-form-item>
<n-form-item label="允许用户前台申请"><n-switch v-model:value="verifyForm.user_verify_allow_apply" /></n-form-item>
<n-form-item label="云失败转人工"><n-switch v-model:value="verifyForm.user_verify_allow_manual_fallback" /></n-form-item>
<n-form-item label="认证会话有效期(分钟)"><n-input-number v-model:value="verifyForm.user_verify_session_ttl_minutes" :min="5" :max="1440" /></n-form-item>
<n-form-item label="结果轮询间隔(秒)"><n-input-number v-model:value="verifyForm.user_verify_poll_interval_seconds" :min="3" :max="60" /></n-form-item>
<n-form-item label="个人认证费用"><n-input v-model:value="verifyForm.user_verify_personal_fee" placeholder="0.00" /></n-form-item>
<n-form-item label="企业认证费用"><n-input v-model:value="verifyForm.user_verify_enterprise_fee" placeholder="0.00" /></n-form-item>
<n-form-item label="个人认证徽标文案">
<n-input v-model:value="verifyForm.user_verify_personal_badge_text" placeholder="个人认证" />
<n-card class="aksrt-section-card" title="认证申请设置">
<n-form :model="form" label-placement="left" label-width="190">
<n-form-item label="启用身份认证"><n-switch v-model:value="form.user_auth_s" /></n-form-item>
<n-form-item label="允许用户前台申请"><n-switch v-model:value="form.user_auth_apply_s" :disabled="!form.user_auth_s" /></n-form-item>
<n-form-item v-if="!form.user_auth_apply_s" label="停用申请说明">
<n-input v-model:value="form.user_auth_apply_disable_desc" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="企业认证徽标文案">
<n-input v-model:value="verifyForm.user_verify_enterprise_badge_text" placeholder="企业认证" />
<n-form-item v-if="form.user_auth_apply_s" label="申请最低用户等级">
<n-input-number v-model:value="form.user_auth_apply_limit_level" :min="0" :max="30" />
<template #feedback>设置为 0 时不限制用户等级。</template>
</n-form-item>
<n-form-item label="停用申请说明">
<n-input v-model:value="verifyForm.user_verify_disable_desc" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="申请介绍">
<n-input v-model:value="verifyForm.user_verify_desc" type="textarea" :rows="3" />
<n-form-item v-if="form.user_auth_apply_s" label="申请介绍">
<n-input v-model:value="form.user_auth_apply_desc" type="textarea" :rows="3" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card" title="云服务商">
<n-form :model="identityForm" label-placement="left" label-width="190">
<n-form-item label="当前服务商">
<n-radio-group v-model:value="identityForm.provider">
<n-space>
<n-radio-button value="aliyun">阿里云</n-radio-button>
<n-radio-button value="tencent">腾讯云</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="回调地址">
<n-input-group>
<n-input :value="callbackUrlForCurrentProvider" readonly />
<n-button @click="copyCallback">复制</n-button>
</n-input-group>
<template #feedback>将此地址填入服务商控制台需要的回调地址配置。</template>
</n-form-item>
</n-form>
</n-card>
<n-tabs v-model:value="activeTab" type="segment" animated class="aksrt-provider-tabs">
<n-tab-pane v-for="key in providerKeys" :key="key" :name="key" :tab="providerLabel(key)">
<n-card class="aksrt-section-card">
<template #header>
<n-space align="center">
<span>{{ providerLabel(key) }}凭证</span>
<n-tag :type="providerState(key).configured ? 'success' : 'warning'" size="small" round>
{{ providerState(key).configured ? '已配置' : '未配置' }}
</n-tag>
</n-space>
</template>
<n-alert type="info" :show-icon="false" style="margin-bottom: 18px">
已保存凭证不会回显。留空表示保留原值;输入新值并保存会替换对应凭证。
</n-alert>
<n-form label-placement="left" label-width="190">
<n-form-item v-if="key === 'aliyun'" label="Region">
<n-input v-model:value="verifyForm.user_verify_aliyun_region" placeholder="cn-hangzhou" />
</n-form-item>
<n-form-item v-if="key === 'aliyun'" label="个人认证 SceneId">
<n-input v-model:value="verifyForm.user_verify_aliyun_personal_flow" placeholder="从阿里云实人认证控制台获取 SceneId" />
</n-form-item>
<n-form-item v-if="key === 'aliyun'" label="个人认证 ProductCode">
<n-select v-model:value="verifyForm.user_verify_aliyun_product_code" :options="aliyunProductOptions" />
</n-form-item>
<n-form-item v-if="key === 'aliyun'" label="企业认证业务标识">
<n-input v-model:value="verifyForm.user_verify_aliyun_enterprise_flow" placeholder="可留空,默认复用个人认证 SceneId" />
</n-form-item>
<n-form-item v-if="key === 'tencent'" label="Region">
<n-input v-model:value="verifyForm.user_verify_tencent_region" placeholder="ap-guangzhou" />
</n-form-item>
<n-form-item v-if="key === 'tencent'" label="个人认证 RuleId">
<n-input v-model:value="verifyForm.user_verify_tencent_personal_flow" placeholder="从腾讯云实名核身控制台获取 RuleId" />
</n-form-item>
<n-form-item v-if="key === 'tencent'" label="企业认证流程标识">
<n-input v-model:value="verifyForm.user_verify_tencent_enterprise_flow" placeholder="可留空,企业四要素后复用个人认证 RuleId" />
</n-form-item>
<n-form-item v-for="field in credentialFields[key]" :key="field.key" :label="field.label">
<n-input
v-model:value="credentials[key][field.key]"
type="password"
show-password-on="click"
:placeholder="credentialPlaceholder(key, field.key)"
autocomplete="new-password"
/>
</n-form-item>
<n-form-item label="显式清除凭证">
<n-checkbox v-model:checked="clearCredentials[key]">
保存时删除 {{ providerLabel(key) }}全部凭证
</n-checkbox>
<template #feedback>清除后该服务商立即变为未配置;此操作不会因输入框留空而触发。</template>
</n-form-item>
<n-form-item label="配置测试">
<n-button :loading="testing === key" :disabled="saving" @click="testProvider(key)">
测试 {{ providerLabel(key) }}配置
</n-button>
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
</n-tabs>
<div class="aksrt-form-actions">
<n-button :disabled="!dirty" @click="load">放弃更改</n-button>
<n-button type="primary" :loading="saving" @click="save">保存全部配置</n-button>
<n-button :disabled="!dirty || saving" @click="load">放弃更改</n-button>
<n-button type="primary" :disabled="!dirty" :loading="saving" @click="save">保存设置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useDialog, useMessage } from 'naive-ui'
import { computed, onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api'
const defaults = {
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: '请填写认证身份、简介和申请说明,提交后由管理员审核。',
}
const message = useMessage()
const dialog = useDialog()
const providerKeys = ['aliyun', 'tencent']
const aliyunProductOptions = [
{ label: '实人认证(ID_PRO)', value: 'ID_PRO' },
{ label: '活体人脸验证(PV_FV)', value: 'PV_FV' },
{ label: '活体检测(LR_FR)', value: 'LR_FR' },
]
const credentialFields = {
aliyun: [
{ key: 'access_key_id', label: 'AccessKey ID' },
{ key: 'access_key_secret', label: 'AccessKey Secret' },
],
tencent: [
{ key: 'secret_id', label: 'SecretId' },
{ key: 'secret_key', label: 'SecretKey' },
],
}
const verifyDefaults = {
user_verify_enabled: true,
user_verify_public_display: true,
user_verify_allow_apply: true,
user_verify_allow_manual_fallback: true,
user_verify_session_ttl_minutes: 30,
user_verify_poll_interval_seconds: 5,
user_verify_personal_fee: '0.00',
user_verify_enterprise_fee: '0.00',
user_verify_personal_badge_text: '个人认证',
user_verify_enterprise_badge_text: '企业认证',
user_verify_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
user_verify_desc: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
user_verify_aliyun_region: 'cn-hangzhou',
user_verify_aliyun_personal_flow: '',
user_verify_aliyun_product_code: 'ID_PRO',
user_verify_aliyun_enterprise_flow: '',
user_verify_tencent_region: 'ap-guangzhou',
user_verify_tencent_personal_flow: '',
user_verify_tencent_enterprise_flow: '',
}
const form = reactive({ ...defaults })
const initial = ref('')
const loading = ref(true)
const saving = ref(false)
const testing = ref('')
const activeTab = ref('aliyun')
const callbackUrls = reactive({ aliyun: '', tencent: '' })
const callbackUrlForCurrentProvider = computed(() => callbackUrls[identityForm.provider] || '')
const initialSnapshot = ref('')
const states = reactive({ aliyun: {}, tencent: {} })
const verifyForm = reactive({ ...verifyDefaults })
const identityForm = reactive({ provider: 'aliyun' })
const credentials = reactive({ aliyun: {}, tencent: {} })
const clearCredentials = reactive({ aliyun: false, tencent: false })
const snapshot = computed(() => JSON.stringify(form))
const dirty = computed(() => initial.value !== '' && snapshot.value !== initial.value)
const snapshot = computed(() => JSON.stringify({ verifyForm, identityForm, credentials, clearCredentials }))
const dirty = computed(() => initialSnapshot.value !== '' && snapshot.value !== initialSnapshot.value)
watch(() => identityForm.provider, value => { activeTab.value = value })
function providerLabel(key) {
return key === 'aliyun' ? '阿里云' : '腾讯云'
}
function providerState(key) {
return states[key] || {}
}
function credentialPlaceholder(provider, field) {
return providerState(provider).configured_fields?.[field] ? '已配置,留空则保留' : '尚未配置'
}
function extractData(response) {
return response?.data ?? response ?? {}
}
@@ -222,123 +54,27 @@ function extractData(response) {
async function load() {
loading.value = true
try {
const [verifyResponse, identityResponse] = await Promise.all([
api.getSchemaPage('users', 'verify'),
api.getIdentitySettings(),
])
Object.assign(verifyForm, verifyDefaults, extractData(verifyResponse))
const identity = extractData(identityResponse)
identityForm.provider = providerKeys.includes(identity.provider) ? identity.provider : 'aliyun'
Object.assign(callbackUrls, identity.callback_url || {})
providerKeys.forEach(key => {
states[key] = identity.providers?.[key] || {}
credentials[key] = {}
clearCredentials[key] = false
})
activeTab.value = identityForm.provider
initialSnapshot.value = snapshot.value
Object.assign(form, defaults, extractData(await api.getSchemaPage('users', 'verify')))
initial.value = snapshot.value
} catch (error) {
message.error(error.message || '加载身份认证配置失败')
message.error(error.message || '加载身份认证设置失败')
} finally {
loading.value = false
}
}
function buildProviderPayload(key) {
const replacement = {}
credentialFields[key].forEach(({ key: field }) => {
const value = String(credentials[key]?.[field] || '').trim()
if (value) replacement[field] = value
})
return {
credentials: replacement,
clear_credentials: clearCredentials[key],
}
}
async function persist() {
const [verifyResponse, identityResponse] = await Promise.all([
api.saveSchemaPage('users', 'verify', { ...verifyForm }),
api.saveIdentitySettings({
provider: identityForm.provider,
providers: {
aliyun: buildProviderPayload('aliyun'),
tencent: buildProviderPayload('tencent'),
},
}),
])
const identity = extractData(identityResponse)
Object.assign(verifyForm, extractData(verifyResponse))
providerKeys.forEach(key => {
states[key] = identity.providers?.[key] || states[key]
credentials[key] = {}
clearCredentials[key] = false
})
Object.assign(callbackUrls, identity.callback_url || callbackUrls)
initialSnapshot.value = snapshot.value
}
async function save() {
const hasClear = providerKeys.some(key => clearCredentials[key])
const run = async () => {
saving.value = true
try {
await persist()
message.success('身份认证配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
if (!hasClear) return run()
dialog.warning({
title: '确认清除凭证',
content: '已勾选的服务商凭证将被永久删除,是否继续保存?',
positiveText: '清除并保存',
negativeText: '取消',
onPositiveClick: run,
})
}
async function testProvider(provider) {
testing.value = provider
saving.value = true
try {
const response = await api.testIdentitySettings({ provider })
message.success(response?.message || `${providerLabel(provider)}配置测试通过`)
Object.assign(form, defaults, extractData(await api.saveSchemaPage('users', 'verify', { ...form })))
initial.value = snapshot.value
message.success('身份认证设置已保存')
} catch (error) {
message.error(error.message || `${providerLabel(provider)}配置测试失败`)
message.error(error.message || '保存身份认证设置失败')
} finally {
testing.value = ''
}
}
async function copyCallback() {
if (!callbackUrlForCurrentProvider.value) return message.warning('回调地址暂不可用')
try {
await navigator.clipboard.writeText(callbackUrlForCurrentProvider.value)
message.success('回调地址已复制')
} catch {
message.error('复制失败,请手动复制')
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.aksrt-status-card :deep(.n-card__content) {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.aksrt-section-card { margin-top: 16px; }
.aksrt-provider-tabs { margin-top: 16px; }
.aksrt-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
</style>
@@ -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 },
},
],
},
@@ -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; }
}
+83 -355
View File
@@ -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 };
@@ -1,123 +1,73 @@
<?php
/**
* User center panel: cloud identity verification.
*
* Visible UI mirrors the reference theme (Zibll) `官方认证` panel:
* a `colorful-bg c-blue zib-widget` hero card with centered badge icon,
* panel title and a left-aligned status block. The cloud-apply modal flow
* lives in the `<template>` 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 = '<i class="fa fas fa-id-badge" aria-hidden="true"></i>';
$config = [
'restBase' => untrailingslashit(rest_url('aksrt/v1/identity/applications')),
'nonce' => wp_create_nonce('wp_rest'),
];
?>
<div class="colorful-bg c-blue flex jc zib-widget" style="padding: 40px 20px;" data-identity-root data-identity-config="<?php echo esc_attr(wp_json_encode($config)); ?>">
<div class="colorful-make"></div>
<div class="text-center">
<div class="em4x"><?php echo $auth_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<div class="mt10 em14 padding-w10 font-bold mb40"><?php esc_html_e('官方认证', 'aksrt'); ?></div>
<?php if ($status === 'verified') : ?>
<div class="text-left">
<div class="mt10 em14 padding-w10 font-bold mb40"><?php echo esc_html(!empty($snapshot['verified']) ? __('我的认证信息', 'aksrt') : __('申请身份认证', 'aksrt')); ?></div>
<div class="text-left" data-auth-summary>
<?php if (!empty($snapshot['verified'])) : ?>
<div class="em12"><?php echo $auth_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><b class="ml6"><?php echo esc_html((string) ($info['name'] ?? __('官方认证', 'aksrt'))); ?></b></div>
<?php if (!empty($info['time'])) : ?>
<div class="muted-2-color mt10"><?php printf(esc_html__('认证时间:%s', 'aksrt'), esc_html((string) $info['time'])); ?></div>
<?php endif; ?>
</div>
<?php else : ?>
<div class="text-left">
<?php if (!empty($info['desc'])) : ?><div class="muted-2-color mt10"><?php echo esc_html((string) $info['desc']); ?></div><?php endif; ?>
<?php if (!empty($info['time'])) : ?><div class="muted-2-color mt10"><?php printf(esc_html__('认证时间:%s', 'aksrt'), esc_html((string) $info['time'])); ?></div><?php endif; ?>
<?php elseif ($status === 'pending') : ?>
<div class="c-red mb10"><?php esc_html_e('您的申请正在审核中,请耐心等待', 'aksrt'); ?></div>
<div><?php echo $auth_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><b class="ml3"><?php echo esc_html((string) ($application['public_name'] ?? '')); ?></b></div>
<?php if (!empty($application['description'])) : ?><div class="muted-2-color mt10"><?php echo esc_html((string) $application['description']); ?></div><?php endif; ?>
<?php if (!empty($application['manual_reason'])) : ?><div class="muted-2-color mt10"><?php printf(esc_html__('申请说明:%s', 'aksrt'), esc_html((string) $application['manual_reason'])); ?></div><?php endif; ?>
<?php if (!empty($application['created_at'])) : ?><div class="muted-2-color mt10"><?php printf(esc_html__('申请时间:%s', 'aksrt'), esc_html((string) $application['created_at'])); ?></div><?php endif; ?>
<?php $evidence_items = (new EvidenceService())->items($application); ?>
<?php if ($evidence_items) : ?><div class="mt20 flex" style="gap:8px;flex-wrap:wrap;"><?php foreach ($evidence_items as $item) : ?><a href="<?php echo esc_url($item['url']); ?>" target="_blank" rel="noopener noreferrer"><img src="<?php echo esc_url($item['thumbnail_url']); ?>" alt="<?php esc_attr_e('身份认证证明材料', 'aksrt'); ?>" style="width:64px;height:64px;object-fit:cover;border-radius:4px;"></a><?php endforeach; ?></div><?php endif; ?>
<?php else : ?>
<div class="em12 mb10"><?php echo $auth_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><b class="ml6"><?php esc_html_e('申请认证', 'aksrt'); ?></b></div>
<?php if ($reason !== '') : ?>
<div class="c-red mb10"><i class="fa mr6 fa-info-circle" aria-hidden="true"></i><?php echo esc_html($reason); ?></div>
<?php endif; ?>
<?php if ($can_apply && !empty($settings['desc'])) : ?><div class="muted-color mb20"><?php echo nl2br(esc_html((string) $settings['desc'])); ?></div><?php endif; ?>
<?php if ($can_apply) : ?>
<div class="muted-color mb20"><?php echo esc_html((string) ($settings['desc'] ?? __('个人认证将进行人脸实人核身,企业认证还会核验企业主体与法定代表人关系。', 'aksrt'))); ?></div>
<button type="button" class="but jb-blue padding-lg btn-block mt20" data-auth-modal-open><?php echo $locks_application ? esc_html__('查看认证进度', 'aksrt') : esc_html__('申请认证', 'aksrt'); ?></button>
<button type="button" class="but jb-blue padding-lg btn-block mt20" data-auth-modal-open><?php esc_html_e('申请认证', 'aksrt'); ?></button>
<?php else : ?>
<div class="c-red"><i class="fa mr6 fa-info-circle" aria-hidden="true"></i><?php esc_html_e('抱歉!暂时不能申请身份认证', 'aksrt'); ?></div>
<?php if (!empty($settings['disable_desc'])) : ?>
<div class="muted-color mt20"><?php echo nl2br(esc_html((string) $settings['disable_desc'])); ?></div>
<?php endif; ?>
<?php if (empty($settings['allow_apply']) && !empty($settings['disable_desc'])) : ?><div class="muted-color mt20"><?php echo nl2br(esc_html((string) $settings['disable_desc'])); ?></div><?php elseif (empty($snapshot['level_ok']) && !empty($snapshot['minimum_level'])) : ?><div class="muted-color mt20"><?php printf(esc_html__('由于您的等级较低,暂时无法申请身份认证,请升级到 %s 之后再来申请。', 'aksrt'), esc_html(\AKSRT\User\Level::get_level_name((int) $snapshot['minimum_level']))); ?></div><?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php if ($can_apply) : ?>
<template data-auth-modal-template>
<div class="uc-auth-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="uc-auth-modal-title">
<div class="aksrt-modal__header uc-auth-modal__header">
<div><span><i class="fa fas fa-id-badge" aria-hidden="true"></i></span><strong id="uc-auth-modal-title"><?php esc_html_e('身份认证', 'aksrt'); ?></strong></div>
<div><span><?php echo $auth_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span><strong id="uc-auth-modal-title"><?php esc_html_e('申请身份认证', 'aksrt'); ?></strong></div>
<button type="button" class="aksrt-modal__close" data-aksrt-modal-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>"><i class="fa fas fa-times" aria-hidden="true"></i></button>
</div>
<div class="aksrt-modal__body uc-auth-modal__body">
<div data-auth-status <?php echo !$locks_application ? 'hidden' : ''; ?>>
<div class="uc-auth-pending">
<span><i class="fa fas fa-shield-alt" aria-hidden="true"></i></span>
<h3 data-auth-stage><?php echo esc_html($stage ?: __('准备认证', 'aksrt')); ?></h3>
<p data-auth-status-message><?php esc_html_e('认证结果以服务端向云服务商主动查询为准。', 'aksrt'); ?></p>
</div>
<a class="btn-primary" data-auth-continue target="_blank" rel="noopener noreferrer" hidden><?php esc_html_e('打开云端核身页面', 'aksrt'); ?></a>
<button type="button" class="btn-primary" data-auth-refresh><?php esc_html_e('刷新结果', 'aksrt'); ?></button>
<?php if (!empty($settings['allow_manual_fallback'])) : ?><button type="button" class="button" data-auth-manual-open hidden><?php esc_html_e('上传材料转人工审核', 'aksrt'); ?></button><?php endif; ?>
</div>
<form class="aksrt-user-auth-form uc-auth-form" data-auth-create <?php echo $locks_application ? 'hidden' : ''; ?>>
<label class="uc-auth-field"><span><?php esc_html_e('认证类型', 'aksrt'); ?></span><select class="form-control" name="type" required><option value="personal"><?php esc_html_e('个人认证', 'aksrt'); ?></option><option value="enterprise"><?php esc_html_e('企业认证', 'aksrt'); ?></option></select></label>
<div class="uc-auth-enterprise-fields" data-enterprise-fields hidden>
<label class="uc-auth-field"><span><?php esc_html_e('企业全称', 'aksrt'); ?></span><input class="form-control" name="company_name" maxlength="191"></label>
<label class="uc-auth-field"><span><?php esc_html_e('统一社会信用代码', 'aksrt'); ?></span><input class="form-control" name="company_code" maxlength="32" autocomplete="off"></label>
</div>
<label class="uc-auth-field"><span data-auth-person-label><?php esc_html_e('真实姓名', 'aksrt'); ?></span><input class="form-control" name="person_name" maxlength="50" required autocomplete="name"></label>
<label class="uc-auth-field"><span data-auth-id-label><?php esc_html_e('身份证号码', 'aksrt'); ?></span><input class="form-control" name="id_number" maxlength="18" pattern="[0-9]{15}|[0-9]{17}[0-9Xx]" required autocomplete="off"></label>
<label class="uc-auth-consent"><input type="checkbox" name="consent" value="1" required><span><?php esc_html_e('我同意为身份认证目的将必要身份信息提交给站点当前配置的云服务商;站点不保存人脸素材,并仅保留脱敏结果。', 'aksrt'); ?></span></label>
<input type="hidden" name="return_url" value="<?php echo esc_url(aksrt_get_user_center_tab_url('auth')); ?>">
<button type="submit" class="btn-primary uc-auth-submit"><?php esc_html_e('创建认证会话', 'aksrt'); ?></button>
<form class="aksrt-user-auth-form uc-auth-form" data-auth-create enctype="multipart/form-data">
<label class="uc-auth-field"><span><?php esc_html_e('认证身份', 'aksrt'); ?></span><input class="form-control" name="name" minlength="4" maxlength="16" required placeholder="<?php esc_attr_e('请输入身份名称', 'aksrt'); ?>"></label>
<label class="uc-auth-field"><span><?php esc_html_e('身份简介', 'aksrt'); ?></span><textarea class="form-control" name="description" rows="2" placeholder="<?php esc_attr_e('请输入申请的身份简介', 'aksrt'); ?>"></textarea></label>
<label class="uc-auth-field"><span><?php esc_html_e('申请说明', 'aksrt'); ?></span><textarea class="form-control" name="message" rows="2" placeholder="<?php esc_attr_e('请输入申请缘由及说明', 'aksrt'); ?>"></textarea></label>
<label class="uc-auth-field"><span><?php esc_html_e('证明材料(选填)', 'aksrt'); ?></span><input class="form-control" type="file" name="evidence[]" accept="image/gif,image/jpeg,image/png" multiple></label>
<small class="muted-2-color"><?php esc_html_e('最多上传 3 张图片,单张不超过 2MB。材料仅本人和认证审核管理员可查看。', 'aksrt'); ?></small>
<button type="submit" class="btn-primary uc-auth-submit"><?php esc_html_e('确认提交', 'aksrt'); ?></button>
<div class="uc-auth-form__message" data-auth-message role="status"></div>
</form>
<form data-auth-manual hidden enctype="multipart/form-data">
<label><span><?php esc_html_e('证明材料', 'aksrt'); ?></span><input type="file" name="evidence[]" accept="image/jpeg,image/png,image/webp" multiple required></label>
<small><?php esc_html_e('最多 3 张,单张不超过 2MB。材料仅本人和身份审核管理员可查看。', 'aksrt'); ?></small>
<button type="submit" class="btn-primary"><?php esc_html_e('提交人工审核', 'aksrt'); ?></button>
</form>
</div>
</div>
</template>
+163 -204
View File
@@ -1,9 +1,5 @@
<?php
/**
* Native WordPress identity certification operations.
*
* @package AKSRT
*/
/** Native WordPress identity application review. @package AKSRT */
namespace AKSRT\Admin\Native;
@@ -11,214 +7,177 @@ defined('ABSPATH') || exit;
trait UsersMenusVerify {
private function count_verify_status(string $status): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('identity_application');
return (int) $wpdb->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 '<div class="wrap"><h1>' . esc_html__('身份认证', 'aksrt') . '</h1>';
$this->flash();
echo '<p>' . esc_html(sprintf(__('人工待审 %1$d · 已通过 %2$d · 已驳回 %3$d · 已取消 %4$d', 'aksrt'), $counts['pending'], $counts['verified'], $counts['rejected'], $counts['cancelled'])) . '</p>';
$this->render_verify_filters($filters);
$this->render_verify_table($result['items']);
$this->pagination($result['pages'], $page_num);
echo '</div>';
}
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 '<div class="wrap"><h1>' . esc_html__('身份认证', 'aksrt') . '</h1>';
$this->flash();
printf('<p>%s</p>', 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 '</div>';
}
/** @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<int,array<string,mixed>>,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<int,array<string,mixed>>,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 '<form method="get" style="margin:12px 0;display:flex;gap:8px;align-items:end;flex-wrap:wrap;">';
echo '<input type="hidden" name="page" value="aksrt_user_auth" />';
echo '<label>' . esc_html__('状态', 'aksrt') . '<br><select name="status">';
foreach (['pending' => __('待审核', 'aksrt'), 'approved' => __('已通过', 'aksrt'), 'rejected' => __('已拒绝', 'aksrt'), '' => __('全部', 'aksrt')] as $key => $label) {
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['status'], $key, false), esc_html($label));
}
echo '</select></label><label>' . esc_html__('搜索', 'aksrt') . '<br><input type="search" name="keyword" value="' . esc_attr($filters['keyword']) . '" /></label>';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
}
private function render_verify_filters(array $filters): void {
echo '<form method="get" style="margin:12px 0;display:flex;gap:8px;align-items:end;flex-wrap:wrap;">';
echo '<input type="hidden" name="page" value="aksrt_user_auth" />';
echo '<label>' . esc_html__('状态', 'aksrt') . '<br><select name="status">';
foreach (['pending' => __('人工待审', 'aksrt'), 'verified' => __('已通过', 'aksrt'), 'rejected' => __('已驳回', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), '' => __('全部', 'aksrt')] as $key => $label) {
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($filters['status'], $key, false), esc_html($label));
}
echo '</select></label>';
echo '<label>' . esc_html__('搜索', 'aksrt') . '<br><input type="search" name="keyword" value="' . esc_attr($filters['keyword']) . '" /></label>';
echo '<label>' . esc_html__('申请起始日期', 'aksrt') . '<br><input type="date" name="date_from" value="' . esc_attr($filters['date_from']) . '" /></label>';
echo '<label>' . esc_html__('申请结束日期', 'aksrt') . '<br><input type="date" name="date_to" value="' . esc_attr($filters['date_to']) . '" /></label>';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
}
private function render_verify_table(array $items): void {
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><th>用户</th><th>状态</th><th>认证名称</th><th>认证简介 / 申请说明</th><th>证明材料</th><th>审核</th></tr></thead><tbody>';
if ($items === []) echo '<tr><td colspan="6">' . esc_html__('暂无认证申请', 'aksrt') . '</td></tr>';
foreach ($items as $row) $this->render_verify_row($row);
echo '</tbody></table>';
}
private function render_verify_table(array $items): void {
echo '<table class="wp-list-table widefat fixed striped"><thead><tr><th>用户</th><th>状态</th><th>申请资料</th><th>审核记录</th><th>操作</th></tr></thead><tbody>';
if ($items === []) {
echo '<tr><td colspan="5">' . esc_html__('暂无认证申请', 'aksrt') . '</td></tr>';
}
foreach ($items as $row) {
$this->render_verify_row($row);
}
echo '</tbody></table>';
}
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 '<tr><td><strong><a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html((string) $row['display_name']) . '</a></strong><div>@' . esc_html((string) $row['user_login']) . ' · UID ' . $user_id . '</div><div>' . esc_html((string) $row['user_email']) . '</div></td>';
echo '<td>' . esc_html($this->verify_status_label($status)) . '<div class="description">' . esc_html((string) $row['created_at']) . '</div></td>';
echo '<td><strong>' . esc_html((string) $row['public_name']) . '</strong></td>';
echo '<td>' . esc_html((string) $row['description']) . '<p class="description">' . esc_html((string) $row['manual_reason']) . '</p>';
if (!empty($row['review_note'])) echo '<p class="description">' . esc_html__('处理留言:', 'aksrt') . esc_html((string) $row['review_note']) . '</p>';
echo '</td><td>';
foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) {
echo '<a href="' . esc_url($item['url']) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url($item['thumbnail_url']) . '" alt="" style="width:64px;height:64px;object-fit:cover;margin:4px;border-radius:4px;" /></a>';
}
echo '</td><td>';
$this->render_verify_actions($row);
echo '</td></tr>';
}
private function render_verify_row(array $row): void {
$user_id = (int) $row['user_id'];
$status = (string) $row['status'];
$application = (new \AKSRT\Identity\ApplicationRepository())->find_by_id((int) $row['id']) ?: $row;
$history = (new \AKSRT\Identity\EventRepository())->for_application((int) $row['id']);
echo '<tr><td><strong><a href="' . esc_url(get_edit_user_link($user_id)) . '">' . esc_html((string) $row['display_name']) . '</a></strong>';
echo '<div class="description">@' . esc_html((string) $row['user_login']) . ' · UID ' . $user_id . '</div><div>' . esc_html((string) $row['user_email']) . '</div></td>';
echo '<td>' . wp_kses_post($this->verify_status_badge($status)) . '<div class="description">' . esc_html((string) $row['provider']) . ' / ' . esc_html((string) $row['channel']) . '</div></td>';
echo '<td><strong>' . esc_html((string) $row['public_name']) . '</strong><div>' . esc_html((string) $row['type']) . '</div>';
echo '<div class="description">UUID: ' . esc_html((string) $row['application_uuid']) . '</div>';
if (!empty($row['provider_result_code'])) echo '<div>' . esc_html__('云结果:', 'aksrt') . esc_html((string) $row['provider_result_code']) . '</div>';
foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) {
echo '<a href="' . esc_url($item['url']) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url($item['thumbnail_url']) . '" alt="" style="width:64px;height:64px;object-fit:cover;margin:8px 8px 0 0;border-radius:4px;" /></a>';
}
echo '</td><td>';
if (!empty($row['review_note'])) echo '<div>' . esc_html((string) $row['review_note']) . '</div>';
if ($history !== []) echo '<details><summary>' . esc_html(sprintf(__('事件记录(%d)', 'aksrt'), count($history))) . '</summary><ol>';
foreach ($history as $event) echo '<li>' . esc_html((string) $event['created_at'] . ' · ' . (string) $event['event_type'] . ' · ' . (string) $event['to_status']) . '</li>';
if ($history !== []) echo '</ol></details>';
echo '</td><td>';
$this->render_verify_actions($row);
echo '</td></tr>';
}
private function verify_status_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 '<span style="display:inline-block;padding:2px 8px;border:1px solid currentColor;border-radius:999px;">' . esc_html($labels[$status] ?? $status) . '</span>';
}
private function render_verify_actions(array $row): void {
$status = (string) $row['status'];
if ($status === 'manual_pending') {
foreach (['verified' => __('通过申请', 'aksrt'), 'rejected' => __('拒绝申请', 'aksrt')] as $next => $label) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:grid;gap:5px;margin-bottom:10px;">';
echo '<input type="hidden" name="action" value="aksrt_users_review_verify"><input type="hidden" name="user_id" value="' . (int) $row['user_id'] . '"><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '"><input type="hidden" name="status" value="' . esc_attr($next) . '">';
wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']);
echo '<input name="name" type="text" maxlength="64" value="' . esc_attr((string) $row['public_name']) . '" placeholder="' . esc_attr__('认证名称', 'aksrt') . '" required>';
echo '<textarea name="description" rows="2" placeholder="' . esc_attr__('认证简介', 'aksrt') . '">' . esc_textarea((string) $row['description']) . '</textarea>';
echo '<textarea name="note" rows="2" ' . ($next === 'rejected' ? 'required' : '') . ' placeholder="' . esc_attr__('处理留言;拒绝时请填写原因', 'aksrt') . '"></textarea>';
echo '<button class="button ' . ($next === 'verified' ? 'button-primary' : '') . '">' . esc_html($label) . '</button></form>';
}
} else {
echo '<span class="description">' . esc_html__('已处理', 'aksrt') . '</span>';
}
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" onsubmit="return confirm(\'' . esc_js(__('确定删除此认证申请记录及其证明材料?', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_users_delete_verify"><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '">';
wp_nonce_field('aksrt_users_delete_verify_' . (string) $row['application_uuid']);
echo '<button class="button button-link-delete">' . esc_html__('删除记录', 'aksrt') . '</button></form>';
}
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 '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:grid;gap:6px;margin-bottom:8px;">';
echo '<input type="hidden" name="action" value="aksrt_users_review_verify" /><input type="hidden" name="user_id" value="' . (int) $row['user_id'] . '" /><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '" /><input type="hidden" name="expected_status" value="' . esc_attr($status) . '" /><input type="hidden" name="status" value="' . esc_attr($next) . '" />';
wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']);
echo '<textarea name="note" rows="2" ' . ($next === 'rejected' ? 'required' : '') . '></textarea><button class="button ' . ($next === 'verified' ? 'button-primary' : '') . '">' . esc_html($label) . '</button></form>';
}
} elseif ($status === 'verified') {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '"><input type="hidden" name="action" value="aksrt_users_review_verify" /><input type="hidden" name="user_id" value="' . (int) $row['user_id'] . '" /><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '" /><input type="hidden" name="expected_status" value="verified" /><input type="hidden" name="status" value="cancelled" />';
wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']);
echo '<textarea name="note" required></textarea><button class="button button-link-delete">' . esc_html__('取消认证', 'aksrt') . '</button></form>';
} else {
echo '<span class="description">' . esc_html__('无需人工操作', 'aksrt') . '</span>';
}
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:8px;" onsubmit="return confirm(\'' . esc_js(__('确定删除这条身份认证记录?关联事件和人工材料也会删除。', 'aksrt')) . '\');">';
echo '<input type="hidden" name="action" value="aksrt_users_delete_verify" /><input type="hidden" name="application_uuid" value="' . esc_attr((string) $row['application_uuid']) . '" />';
wp_nonce_field('aksrt_users_delete_verify_' . (string) $row['application_uuid']);
echo '<button class="button button-link-delete">' . esc_html__('删除记录', 'aksrt') . '</button></form>';
}
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');
}
}
+6 -40
View File
@@ -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]],
]],
],
],
-21
View File
@@ -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',
@@ -1,52 +0,0 @@
<?php
/**
* Admin REST handlers for identity provider configuration.
*
* @package AKSRT
*/
namespace AKSRT\Admin;
use AKSRT\Settings\IdentityAdmin;
defined('ABSPATH') || exit;
trait UserControllerIdentitySettings {
public function get_identity_settings(): \WP_REST_Response {
return $this->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;
}
}
@@ -1,105 +0,0 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
trait UserControllerVerifyIntegrations {
/**
* GET /sms/status — SMS provider connection status.
*/
public function get_sms_status(): \WP_REST_Response {
if (class_exists('\AKSRT\Sms\SMS')) {
return $this->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')]);
}
}
+4 -29
View File
@@ -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);
}
+82 -140
View File
@@ -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<int,array{latest:array<string,mixed>|null,active:array<string,mixed>|null,verified:array<string,mixed>|null}> */
private static array $snapshot_cache = [];
/** @var array<int,array<string,mixed>|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<string,mixed> $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<string,mixed>|null */
public function find_by_id(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id), ARRAY_A);
$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<string,mixed>|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<string,mixed>|null */
public function find_by_uuid(string $uuid): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE application_uuid = %s", $uuid), ARRAY_A);
return is_array($row) ? $this->normalize($row) : null;
}
/** @return array<string,mixed>|null */
public function find_by_client_request(int $user_id, string $request_id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table} WHERE user_id = %d AND client_request_id = %s LIMIT 1",
$user_id,
$request_id
), ARRAY_A);
"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<string,mixed>|null,active:array<string,mixed>|null,verified:array<string,mixed>|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<string,mixed>|null */
public function latest_for_user(int $user_id): ?array {
return $this->snapshot_for_user($user_id)['latest'];
}
/** @return array<string,mixed>|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<string,mixed>|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<string,mixed> $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<string,mixed> $fields */
public function update(int $id, array $fields): bool {
global $wpdb;
if ($fields === []) {
return true;
}
$fields['updated_at'] = current_time('mysql', true);
$updated = $wpdb->update($this->table, $fields, ['id' => $id]);
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<string,mixed> $record @param array<string,mixed> $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<int,array<string,mixed>> */
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<string,mixed> */
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<string,mixed> $fields @return array<string,mixed> */
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;
}
}
+83 -433
View File
@@ -1,461 +1,111 @@
<?php
/**
* Identity verification application orchestration.
*
* @package AKSRT
*/
/** Manual identity application flow. @package AKSRT */
namespace AKSRT\Identity;
use AKSRT\Identity\Provider\ProviderFactory;
use AKSRT\Identity\Provider\ProviderResult;
use AKSRT\Ledger\AssetAmount;
use AKSRT\Ledger\AssetType;
use AKSRT\Ledger\LedgerResult;
use AKSRT\Ledger\LedgerService;
use AKSRT\Ledger\OperationKey;
defined('ABSPATH') || exit;
final class ApplicationService {
public function __construct(
private ?ApplicationRepository $applications = null,
private ?TransitionService $transitions = null,
private ?EvidenceService $evidence = null
) {
$this->applications ??= new ApplicationRepository();
$this->transitions ??= new TransitionService($this->applications);
$this->evidence ??= new EvidenceService();
}
private ApplicationRepository $applications;
private EvidenceService $evidence;
/** @param array<string,mixed> $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<string,mixed>|\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<string,mixed> */
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<string,mixed> */
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<string,mixed> $application
* @param array<string,mixed> $fields
* @param array<string,mixed> $event
* @return array{application:array<string,mixed>,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<string,mixed> $application
* @param array<string,mixed> $fields
* @param array<string,mixed> $event
* @return array{application:array<string,mixed>,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<string,mixed> $application @param array<string,mixed> $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<string,mixed> $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];
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
/**
* Identity provider callback endpoints.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
use AKSRT\Identity\Provider\ProviderFactory;
defined('ABSPATH') || exit;
final class CallbackController {
private const NS = 'aksrt/v1';
public function register_routes(): void {
foreach (['aliyun', 'tencent'] as $provider) {
register_rest_route(self::NS, '/identity/callback/' . $provider, [
'methods' => $provider === 'aliyun' ? ['GET', 'POST'] : 'POST',
'callback' => function (\WP_REST_Request $request) use ($provider): \WP_REST_Response {
return $this->handle($provider, $request);
},
'permission_callback' => '__return_true',
]);
}
}
private function handle(string $provider_key, \WP_REST_Request $request): \WP_REST_Response {
$body = $request->get_body();
if (strlen($body) > 1024 * 1024) {
return new \WP_REST_Response(['success' => false], 413);
}
$provider = ProviderFactory::make($provider_key);
if (!$provider->verify_callback($request)) {
return new \WP_REST_Response(['success' => false], 403);
}
$result = $provider->parse_callback($request);
$session = sanitize_text_field((string) ($result->data['provider_session_id'] ?? ''));
$outer_order = sanitize_text_field((string) ($result->data['outer_order_no'] ?? ''));
if ($session === '' && $outer_order === '') {
return new \WP_REST_Response(['success' => false], 422);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('identity_application');
if ($session !== '') {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE provider = %s AND provider_session_id = %s ORDER BY id DESC LIMIT 1",
$provider_key,
$session
), ARRAY_A);
} else {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE provider = %s AND application_uuid = %s ORDER BY id DESC LIMIT 1",
$provider_key,
$outer_order
), ARRAY_A);
}
if (!is_array($row)) {
return new \WP_REST_Response(['success' => false], 404);
}
// The callback only wakes server-side reconciliation. A provider query
// remains the source of truth for a successful identity decision.
$service = new ApplicationService();
$response = $service->refresh($row);
if (is_wp_error($response)) {
return new \WP_REST_Response(['success' => false], 409);
}
return new \WP_REST_Response(['success' => true]);
}
}
-21
View File
@@ -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<string,mixed>|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<int,array<string,mixed>> */
public function for_application(int $application_id): array {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table} WHERE application_id = %d ORDER BY id DESC",
$application_id
), ARRAY_A);
return is_array($rows) ? $rows : [];
}
public function delete_for_application(int $application_id): bool {
global $wpdb;
return $wpdb->delete($this->table, ['application_id' => $application_id], ['%d']) !== false;
+2 -23
View File
@@ -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<int,int>|\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();
+6 -11
View File
@@ -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');
}
}
-351
View File
@@ -1,351 +0,0 @@
<?php
/**
* Alibaba Cloud Cloudauth provider.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
use AKSRT\Identity\SettingsRuntime;
defined('ABSPATH') || exit;
final class AliyunProvider implements ProviderInterface {
public function key(): string { return 'aliyun'; }
public function capabilities(): array { return ['personal' => true, 'enterprise' => true]; }
public function validate_config() {
$cfg = SettingsRuntime::get();
foreach (['access_key_id', 'access_key_secret'] as $key) {
if (empty($cfg['credentials'][$key])) {
return new \WP_Error('aksrt_identity_aliyun_credentials', __('阿里云实人认证凭证不完整', 'aksrt'));
}
}
if (empty($cfg['personal_flow'])) {
return new \WP_Error('aksrt_identity_aliyun_scene', __('请配置阿里云实人认证 SceneId', 'aksrt'));
}
if (!empty($cfg['enterprise_flow']) && preg_match('/[^A-Za-z0-9_-]/', (string) $cfg['enterprise_flow'])) {
return new \WP_Error('aksrt_identity_aliyun_enterprise_scene', __('阿里云企业认证 SceneCode 格式无效', 'aksrt'));
}
return true;
}
public function start_personal(array $request): ProviderResult {
return $this->init_face_verify($request);
}
public function start_enterprise(array $request): ProviderResult {
$cfg = SettingsRuntime::get();
// Ref: https://help.aliyun.com/zh/id-verification/enterprise-factor-validation-v2
foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) {
if (empty($request[$key])) {
return ProviderResult::failed('aliyun_enterprise_fields', __('企业认证资料不完整', 'aksrt'));
}
}
$response = $this->rpc('cloudauth.aliyuncs.com', '2022-11-25', 'EntElementVerifyV2', [
'SceneCode' => (string) ($cfg['enterprise_flow'] ?: $cfg['personal_flow']),
'MerchantBizId' => preg_replace('/[^A-Za-z0-9]/', '', (string) $request['application_uuid']),
'MerchantUserId' => preg_replace('/[^A-Za-z0-9]/', '', 'U' . (string) ($request['user_id'] ?? '0')),
'UserAuthorization' => '1',
'InfoVerifyType' => 'ENT_4META',
'LicenseNo' => preg_replace('/\s+/', '', (string) $request['company_code']),
'EntName' => (string) $request['company_name'],
'LegalPersonName' => (string) $request['person_name'],
'LegalPersonCertNo' => preg_replace('/\s+/', '', (string) $request['id_number']),
]);
if (is_wp_error($response)) {
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
}
if (!$this->enterprise_passed($response)) {
return ProviderResult::failed(
sanitize_text_field((string) ($response['Code'] ?? $response['ResultCode'] ?? 'enterprise_failed')),
sanitize_text_field((string) ($response['Message'] ?? __('企业要素或法人关系核验未通过', 'aksrt')))
);
}
return $this->init_face_verify($request);
}
public function query(array $application): ProviderResult {
// Ref: https://api.aliyun.com/document/Cloudauth/2019-03-07/DescribeFaceVerify
$response = $this->rpc('cloudauth.aliyuncs.com', '2019-03-07', 'DescribeFaceVerify', [
'SceneId' => (string) ($application['provider_flow'] ?? ''),
'CertifyId' => (string) ($application['provider_session_id'] ?? ''),
]);
if (is_wp_error($response)) {
return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]);
}
$code = sanitize_text_field((string) ($response['Code'] ?? ''));
if ($code === '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<string,mixed>|\WP_Error */
private function rpc(string $host, string $version, string $action, array $business) {
$cfg = SettingsRuntime::get();
$endpoint = 'https://' . $host . '/';
// The official PHP SDK uses ACS3-HMAC-SHA256 by default. This zero-dependency
// fallback deliberately uses the documented RPC v2 signature until the SDK
// packages can be installed and locked in this deployment.
$params = array_merge([
'Format' => 'JSON',
'Version' => $version,
'AccessKeyId' => (string) $cfg['credentials']['access_key_id'],
'SignatureMethod' => 'HMAC-SHA1',
'SignatureNonce' => wp_generate_uuid4(),
'SignatureVersion' => '1.0',
'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
'Action' => $action,
], $business);
ksort($params);
$canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$string_to_sign = 'POST&%2F&' . rawurlencode($canonical);
$params['Signature'] = base64_encode(hash_hmac('sha1', $string_to_sign, (string) $cfg['credentials']['access_key_secret'] . '&', true));
$response = wp_remote_post($endpoint, [
'timeout' => 20,
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => $params,
]);
if (is_wp_error($response)) {
return new \WP_Error('aliyun_network_error', __('阿里云接口请求失败', 'aksrt'));
}
$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<string,mixed> */
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<string,mixed> $response @param array<int,array<int,string>> $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<string,mixed> $response @param array<string,mixed> $object @param array<string,mixed> $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);
}
}
-21
View File
@@ -1,21 +0,0 @@
<?php
/**
* Identity provider factory.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
defined('ABSPATH') || exit;
final class ProviderFactory {
public static function make(string $provider): ProviderInterface {
$provider = sanitize_key($provider);
if ($provider === 'tencent') {
return new TencentProvider();
}
return new AliyunProvider();
}
}
@@ -1,34 +0,0 @@
<?php
/**
* Identity provider contract.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
defined('ABSPATH') || exit;
interface ProviderInterface {
public function key(): string;
/** @return array{personal:bool,enterprise:bool} */
public function capabilities(): array;
/** @return true|\WP_Error */
public function validate_config();
/** @param array<string,mixed> $request */
public function start_personal(array $request): ProviderResult;
/** @param array<string,mixed> $request */
public function start_enterprise(array $request): ProviderResult;
/** @param array<string,mixed> $application */
public function query(array $application): ProviderResult;
public function verify_callback(\WP_REST_Request $request): bool;
public function parse_callback(\WP_REST_Request $request): ProviderResult;
}
-41
View File
@@ -1,41 +0,0 @@
<?php
/**
* Normalized identity provider result.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
defined('ABSPATH') || exit;
final class ProviderResult {
public const PENDING = 'pending';
public const PASSED = 'passed';
public const FAILED = 'failed';
public const UNSUPPORTED = 'unsupported';
public const EXPIRED = 'expired';
/** @param array<string,mixed> $data */
public function __construct(
public readonly string $decision,
public readonly array $data = []
) {}
public static function pending(array $data = []): self {
return new self(self::PENDING, $data);
}
public static function passed(array $data = []): self {
return new self(self::PASSED, $data);
}
public static function failed(string $code, string $message = ''): self {
return new self(self::FAILED, ['result_code' => $code, 'message' => $message]);
}
public static function unsupported(string $message): self {
return new self(self::UNSUPPORTED, ['result_code' => 'unsupported', 'message' => $message]);
}
}
-202
View File
@@ -1,202 +0,0 @@
<?php
/**
* Tencent Cloud FaceId provider.
*
* @package AKSRT
*/
namespace AKSRT\Identity\Provider;
use AKSRT\Identity\SettingsRuntime;
defined('ABSPATH') || exit;
final class TencentProvider implements ProviderInterface {
private const VERSION = '2018-03-01';
private const SERVICE = 'faceid';
private const ENDPOINT = 'faceid.tencentcloudapi.com';
public function key(): string { return 'tencent'; }
public function capabilities(): array { return ['personal' => true, 'enterprise' => true]; }
public function validate_config() {
$cfg = SettingsRuntime::get();
foreach (['secret_id', 'secret_key'] as $key) {
if (empty($cfg['credentials'][$key])) {
return new \WP_Error('aksrt_identity_tencent_credentials', __('腾讯云人脸核身凭证不完整', 'aksrt'));
}
}
if (empty($cfg['personal_flow'])) {
return new \WP_Error('aksrt_identity_tencent_rule', __('请配置腾讯云人脸核身 RuleId', 'aksrt'));
}
return true;
}
public function start_personal(array $request): ProviderResult {
// Ref: https://cloud.tencent.com/document/product/1007/31816
$cfg = SettingsRuntime::get();
$payload = [
'RuleId' => (string) $cfg['personal_flow'],
'TerminalType' => $this->terminal_type(),
'RedirectUrl' => esc_url_raw((string) ($request['return_url'] ?? '')),
'Extra' => (string) ($request['application_uuid'] ?? ''),
];
if (!empty($request['person_name'])) {
$payload['Name'] = (string) $request['person_name'];
}
if (!empty($request['id_number'])) {
$payload['IdCard'] = preg_replace('/\s+/', '', (string) $request['id_number']);
}
$response = $this->request('DetectAuth', $payload);
if (is_wp_error($response)) {
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
}
$data = is_array($response['Response'] ?? null) ? $response['Response'] : [];
if (!empty($data['Error'])) {
return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? ''));
}
$biz_token = sanitize_text_field((string) ($data['BizToken'] ?? ''));
$url = esc_url_raw((string) ($data['Url'] ?? ''));
if ($biz_token === '' || $url === '') {
return ProviderResult::failed('tencent_invalid_response', __('腾讯云未返回有效认证会话', 'aksrt'));
}
return ProviderResult::pending([
'provider_session_id' => $biz_token,
'request_id' => sanitize_text_field((string) ($data['RequestId'] ?? '')),
'redirect_url' => $url,
'result_code' => 'session_created',
'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)),
]);
}
public function start_enterprise(array $request): ProviderResult {
// Ref: https://cloud.tencent.com/document/product/866/129072
foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) {
if (empty($request[$key])) {
return ProviderResult::failed('tencent_enterprise_fields', __('企业认证资料不完整', 'aksrt'));
}
}
$response = $this->request_service('ocr', 'ocr.tencentcloudapi.com', '2018-11-19', 'VerifyBizLicenseEnterprise4', [
'CreditCode' => preg_replace('/\s+/', '', (string) $request['company_code']),
'EntName' => (string) $request['company_name'],
'LrName' => (string) $request['person_name'],
'IdNum' => preg_replace('/\s+/', '', (string) $request['id_number']),
]);
if (is_wp_error($response)) {
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
}
$data = is_array($response['Response'] ?? null) ? $response['Response'] : [];
if (!empty($data['Error'])) {
return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_ocr_error'), (string) ($data['Error']['Message'] ?? ''));
}
if (!$this->enterprise_passed($data)) {
return ProviderResult::failed('tencent_enterprise_mismatch', __('企业工商四要素或法人关系核验未通过', 'aksrt'));
}
return $this->start_personal($request);
}
public function query(array $application): ProviderResult {
// Ref: https://cloud.tencent.com/document/product/1007/41957
$response = $this->request('GetDetectInfoEnhanced', [
'BizToken' => (string) ($application['provider_session_id'] ?? ''),
'RuleId' => (string) ($application['provider_flow'] ?? ''),
'InfoType' => '0',
]);
if (is_wp_error($response)) {
return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]);
}
$data = is_array($response['Response'] ?? null) ? $response['Response'] : [];
if (!empty($data['Error'])) {
return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? ''));
}
$text = $data['Text'] ?? ($data['DetectInfo']['Text'] ?? []);
if (is_string($text)) {
$decoded_text = json_decode($text, true);
$text = is_array($decoded_text) ? $decoded_text : [];
} elseif (!is_array($text)) {
$text = [];
}
$result = (string) ($text['ErrCode'] ?? '');
$live_status = (string) ($text['LiveStatus'] ?? '');
$compare_status = (string) ($text['Comparestatus'] ?? '');
if ($result === '0' && ($live_status === '' || $live_status === '0') && ($compare_status === '' || $compare_status === '0')) {
return ProviderResult::passed(['request_id' => (string) ($data['RequestId'] ?? ''), 'result_code' => '0']);
}
$message = sanitize_text_field((string) ($text['ErrMsg'] ?? $text['LiveMsg'] ?? $text['Comparemsg'] ?? ''));
if ($result === '') {
return ProviderResult::pending(['result_code' => 'processing', 'message' => __('腾讯云仍在处理认证结果', 'aksrt')]);
}
return ProviderResult::failed($result, $message);
}
public function verify_callback(\WP_REST_Request $request): bool { unset($request); return false; }
public function parse_callback(\WP_REST_Request $request): ProviderResult { unset($request); return ProviderResult::unsupported(__('腾讯云人脸核身使用服务端主动查询,不接收结果回调', 'aksrt')); }
/** @return array<string,mixed>|\WP_Error */
private function request(string $action, array $payload) {
return $this->request_service(self::SERVICE, self::ENDPOINT, self::VERSION, $action, $payload);
}
/** @return array<string,mixed>|\WP_Error */
private function request_service(string $service, string $host, string $version, string $action, array $payload) {
$cfg = SettingsRuntime::get();
$endpoint = 'https://' . $host;
$timestamp = time();
$date = gmdate('Y-m-d', $timestamp);
$body = (string) wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$canonical_headers = "content-type:application/json; charset=utf-8\nhost:{$host}\nx-tc-action:" . strtolower($action) . "\n";
$signed_headers = 'content-type;host;x-tc-action';
$canonical_request = "POST\n/\n\n{$canonical_headers}\n{$signed_headers}\n" . hash('sha256', $body);
$scope = $date . '/' . $service . '/tc3_request';
$string_to_sign = "TC3-HMAC-SHA256\n{$timestamp}\n{$scope}\n" . hash('sha256', $canonical_request);
$secret_key = (string) $cfg['credentials']['secret_key'];
$secret_date = hash_hmac('sha256', $date, 'TC3' . $secret_key, true);
$secret_service = hash_hmac('sha256', $service, $secret_date, true);
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true);
$signature = hash_hmac('sha256', $string_to_sign, $secret_signing);
$authorization = 'TC3-HMAC-SHA256 Credential=' . $cfg['credentials']['secret_id'] . '/' . $scope . ', SignedHeaders=' . $signed_headers . ', Signature=' . $signature;
$headers = [
'Authorization' => $authorization,
'Content-Type' => 'application/json; charset=utf-8',
'Host' => $host,
'X-TC-Action' => $action,
'X-TC-Timestamp' => (string) $timestamp,
'X-TC-Version' => $version,
];
if ($service !== self::SERVICE && !empty($cfg['region'])) {
$headers['X-TC-Region'] = (string) $cfg['region'];
}
$response = wp_remote_post($endpoint, [
'timeout' => 20,
'headers' => $headers,
'body' => $body,
]);
if (is_wp_error($response)) {
return new \WP_Error('tencent_network_error', __('腾讯云接口请求失败', 'aksrt'));
}
$decoded = json_decode((string) wp_remote_retrieve_body($response), true);
return is_array($decoded) ? $decoded : new \WP_Error('tencent_invalid_json', __('腾讯云返回格式无效', 'aksrt'));
}
private function enterprise_passed(array $data): bool {
if ((string) ($data['StatusCode'] ?? '') !== '0' || (string) ($data['VerifyResult'] ?? '') !== '1') {
return false;
}
foreach (['IsCreditCodeConsistent', 'IsEntNameConsistent', 'IsLrNameConsistent', 'IsIdNumConsistent'] as $field) {
$value = $data[$field] ?? null;
if (!in_array($value, [true, 1, '1', 'true'], true)) {
return false;
}
}
$status = strtoupper((string) ($data['OperatingStatus'] ?? $data['RegStatus'] ?? ''));
return !in_array($status, ['吊销', '注销', 'REVOKED', 'CANCELLED'], true);
}
private function terminal_type(): string {
$ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
return str_contains($ua, 'mobile') ? '1' : '2';
}
}
+37 -172
View File
@@ -1,79 +1,26 @@
<?php
/**
* Public identity verification REST API.
*
* @package AKSRT
*/
/** Identity application REST endpoints. @package AKSRT */
namespace AKSRT\Identity;
use AKSRT\Ledger\OperationKey;
defined('ABSPATH') || exit;
final class RestController {
private const NS = 'aksrt/v1';
private const NS = 'aksrt/v1';
private ApplicationService $service;
private ?array $request_application = null;
private string $request_uuid = '';
public function __construct(?ApplicationService $service = null) {
$this->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<uuid>[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<uuid>[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<uuid>[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<string,mixed>|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;
}
}
-135
View File
@@ -1,135 +0,0 @@
<?php
/**
* Encrypted identity provider credential storage.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class SecretStore {
public const OPTION_KEY = 'aksrt_identity_secrets';
private const CONTEXT = 'aksrt-identity-secrets-v1';
/** @return array<string,string> */
public static function get(string $provider): array {
$stored = get_option(self::OPTION_KEY, []);
$record = is_array($stored) ? ($stored[sanitize_key($provider)] ?? null) : null;
if (!is_array($record)) {
return [];
}
$plain = self::decrypt($record);
if ($plain === '') {
return [];
}
$data = json_decode($plain, true);
if (!is_array($data)) {
return [];
}
$clean = [];
foreach ($data as $key => $value) {
if (is_string($key) && is_string($value)) {
$clean[sanitize_key($key)] = $value;
}
}
return $clean;
}
/** @param array<string,string|null> $changes */
public static function update(string $provider, array $changes): bool {
$provider = sanitize_key($provider);
if (!in_array($provider, ['aliyun', 'tencent'], true)) {
return false;
}
$current = self::get($provider);
foreach ($changes as $key => $value) {
$key = sanitize_key((string) $key);
if ($key === '') {
continue;
}
if ($value === null) {
unset($current[$key]);
} elseif (is_string($value) && $value !== '') {
$current[$key] = $value;
}
}
$stored = get_option(self::OPTION_KEY, []);
$stored = is_array($stored) ? $stored : [];
if ($current === []) {
unset($stored[$provider]);
} else {
$record = self::encrypt((string) wp_json_encode($current));
if (is_wp_error($record)) {
return false;
}
$stored[$provider] = $record;
}
return update_option(self::OPTION_KEY, $stored, false) || get_option(self::OPTION_KEY, []) === $stored;
}
/** @return array<string,array{configured:bool,fingerprint:string}> */
public static function status(string $provider): array {
$status = [];
foreach (self::get($provider) as $key => $value) {
$status[$key] = [
'configured' => $value !== '',
'fingerprint' => $value !== '' ? substr(hash('sha256', $value), -4) : '',
];
}
return $status;
}
/** @return array<string,string>|\WP_Error */
private static function encrypt(string $plain) {
$key = self::key();
if (function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt')) {
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plain, self::CONTEXT, $nonce, $key);
return ['driver' => 'sodium', 'nonce' => base64_encode($nonce), 'cipher' => base64_encode($cipher)];
}
if (function_exists('openssl_encrypt')) {
$nonce = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT);
if (!is_string($cipher)) {
return new \WP_Error('aksrt_identity_encrypt', __('身份认证凭证加密失败', 'aksrt'));
}
return ['driver' => 'openssl', 'nonce' => base64_encode($nonce), 'tag' => base64_encode($tag), 'cipher' => base64_encode($cipher)];
}
return new \WP_Error('aksrt_identity_crypto', __('服务器不支持安全凭证加密', 'aksrt'));
}
/** @param array<string,mixed> $record */
private static function decrypt(array $record): string {
$nonce = base64_decode((string) ($record['nonce'] ?? ''), true);
$cipher = base64_decode((string) ($record['cipher'] ?? ''), true);
if (!is_string($nonce) || !is_string($cipher)) {
return '';
}
$key = self::key();
if (($record['driver'] ?? '') === 'sodium' && function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt')) {
$plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, self::CONTEXT, $nonce, $key);
return is_string($plain) ? $plain : '';
}
if (($record['driver'] ?? '') === 'openssl' && function_exists('openssl_decrypt')) {
$tag = base64_decode((string) ($record['tag'] ?? ''), true);
if (!is_string($tag)) {
return '';
}
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT);
return is_string($plain) ? $plain : '';
}
return '';
}
private static function key(): string {
return hash_hmac('sha256', self::CONTEXT, wp_salt('auth') . wp_salt('secure_auth'), true);
}
}
-62
View File
@@ -1,62 +0,0 @@
<?php
/**
* Identity verification settings facade.
*
* @package AKSRT
*/
namespace AKSRT\Identity;
defined('ABSPATH') || exit;
final class SettingsRuntime {
/** @return array<string,mixed> */
public static function get(): array {
$settings = \AKSRT\Settings\Settings::get_all();
$provider = sanitize_key((string) ($settings['user_verify_provider'] ?? 'aliyun'));
if (!in_array($provider, ['aliyun', 'tencent'], true)) {
$provider = 'aliyun';
}
return [
'enabled' => !isset($settings['user_verify_enabled']) || !empty($settings['user_verify_enabled']),
'allow_apply' => !isset($settings['user_verify_allow_apply']) || !empty($settings['user_verify_allow_apply']),
'provider' => $provider,
'allow_manual_fallback' => !isset($settings['user_verify_allow_manual_fallback']) || !empty($settings['user_verify_allow_manual_fallback']),
'session_ttl_minutes' => max(5, min(1440, (int) ($settings['user_verify_session_ttl_minutes'] ?? 30))),
'poll_interval_seconds' => max(3, min(60, (int) ($settings['user_verify_poll_interval_seconds'] ?? 5))),
'max_retries' => max(0, min(3, (int) ($settings['user_verify_max_retries'] ?? 1))),
'personal_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_personal_flow'] ?? '')),
'product_code' => $provider === 'aliyun' && in_array(($settings['user_verify_aliyun_product_code'] ?? 'ID_PRO'), ['ID_PRO', 'PV_FV', 'LR_FR'], true)
? (string) $settings['user_verify_aliyun_product_code']
: 'ID_PRO',
'enterprise_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_enterprise_flow'] ?? '')),
'region' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_region'] ?? '')),
'credentials' => SecretStore::get($provider),
];
}
public static function hmac(string $value): string {
return hash_hmac('sha256', trim($value), wp_salt('secure_auth') . '|aksrt-identity-hmac');
}
public static function mask_id_number(string $value): string {
$value = preg_replace('/\s+/', '', $value) ?: '';
$length = strlen($value);
if ($length <= 8) {
return str_repeat('*', max(0, $length - 2)) . substr($value, -2);
}
return substr($value, 0, 3) . str_repeat('*', $length - 7) . substr($value, -4);
}
public static function mask_name(string $value): string {
$value = trim($value);
$length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
if ($length <= 1) {
return '*';
}
$first = function_exists('mb_substr') ? mb_substr($value, 0, 1) : substr($value, 0, 1);
return $first . str_repeat('*', max(1, $length - 1));
}
}
+8 -188
View File
@@ -1,9 +1,5 @@
<?php
/**
* Identity verification state transitions.
*
* @package AKSRT
*/
/** Public status labels for manual identity applications. @package AKSRT */
namespace AKSRT\Identity;
@@ -11,188 +7,12 @@ defined('ABSPATH') || exit;
final class TransitionService {
private const ALLOWED = [
'created' => ['provider_pending', 'provider_failed', 'provider_unsupported', 'provider_expired', 'manual_pending', 'cancelled'],
'provider_pending' => ['provider_processing', 'provider_failed', 'provider_unsupported', 'provider_expired', 'verified', 'manual_pending', 'cancelled'],
'provider_processing' => ['provider_failed', 'provider_unsupported', 'provider_expired', 'verified', 'manual_pending'],
'provider_failed' => ['provider_pending', 'manual_pending', '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<string,mixed> $fields @param array<string,mixed> $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<string,mixed> $fields @param array<string,mixed> $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<string,mixed> $fields @param array<string,mixed> $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<string,mixed> $application
* @param array<string,mixed> $fields
* @param array<string,mixed> $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<string,mixed> $application
* @param array<string,mixed> $fields
* @param array<string,mixed> $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<string,mixed> $application @param array<string,mixed> $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'),
]);
}
}
-106
View File
@@ -1,106 +0,0 @@
<?php
/**
* Identity provider configuration for the admin REST surface.
*
* @package AKSRT
*/
namespace AKSRT\Settings;
use AKSRT\Identity\Provider\ProviderFactory;
use AKSRT\Identity\SecretStore;
defined('ABSPATH') || exit;
final class IdentityAdmin {
private const PROVIDER_FIELDS = [
'aliyun' => ['access_key_id', 'access_key_secret'],
'tencent' => ['secret_id', 'secret_key'],
];
/** @return array<string,mixed> */
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<string,mixed> $input @return array<string,mixed> */
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<string,mixed> */
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');
}
}
-2
View File
@@ -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);
+1 -23
View File
@@ -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<int,int>},
* badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array<int,array{slug:string,label:string,tone:string}>},
* report:array{enabled:bool,public_entry:bool,categories:array<int,array{slug:string,label:string}>}
@@ -583,26 +581,6 @@ class Settings {
return UserRuntime::get_user_identity_settings();
}
/** @return array<string,mixed> */
public static function get_identity_verification_settings(): array {
return \AKSRT\Identity\SettingsRuntime::get();
}
/** @return array<string,string> */
public static function get_identity_provider_secrets(string $provider): array {
return \AKSRT\Identity\SecretStore::get($provider);
}
/** @param array<string,string|null> $changes */
public static function update_identity_provider_secrets(string $provider, array $changes): bool {
return \AKSRT\Identity\SecretStore::update($provider, $changes);
}
/** @return array<string,array{configured:bool,fingerprint:string}> */
public static function get_identity_provider_secret_status(string $provider): array {
return \AKSRT\Identity\SecretStore::status($provider);
}
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));
}
+7 -45
View File
@@ -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<string,array{slug:string,label:string,badge_text:string,fee:string,free_applications:int,require_real_name: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<int,int>},
* medal:array{enabled:bool,public_display:bool,limit:int,categories:array<int,array{cat_name:string,items:array<int,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int}>}>,items_by_slug:array<string,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int,cat_name:string}>},
* badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array<int,array{slug:string,label:string,tone:string,desc:string,icon_url?:string}>},
@@ -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<string,mixed> $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 '';
}
}
/**
* 禁封/举报分区的运行时读取。
*
+161 -477
View File
@@ -1,510 +1,194 @@
<?php
/**
* Identity verification facade for existing presentation call sites.
*
* @package AKSRT
*/
/** General user identity certification matching the reference theme flow. @package AKSRT */
namespace AKSRT\User;
use AKSRT\Identity\ApplicationRepository;
use AKSRT\Identity\EvidenceService;
use AKSRT\Identity\EventRepository;
use AKSRT\Identity\TransitionService;
use AKSRT\Identity\SettingsRuntime;
defined('ABSPATH') || exit;
class AuthApply {
final class AuthApply {
private static ?self $instance = null;
private static ?self $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
public static function get_instance(): self {
return self::$instance ??= new self();
}
public function init(): void {
if (is_admin()) {
add_filter('manage_users_columns', [$this, 'add_admin_user_column']);
add_filter('manage_users_custom_column', [$this, 'render_admin_user_column'], 10, 3);
add_action('show_user_profile', [$this, 'render_admin_profile_status']);
add_action('edit_user_profile', [$this, 'render_admin_profile_status']);
add_action('personal_options_update', [$this, 'handle_admin_profile_update']);
add_action('edit_user_profile_update', [$this, 'handle_admin_profile_update']);
add_action('admin_notices', [$this, 'render_admin_profile_notice']);
}
}
public function init(): void {
if (!is_admin()) return;
add_action('show_user_profile', [$this, 'render_admin_profile']);
add_action('edit_user_profile', [$this, 'render_admin_profile']);
add_action('personal_options_update', [$this, 'save_admin_profile']);
add_action('edit_user_profile_update', [$this, 'save_admin_profile']);
}
/** @return array<string,mixed> */
public static function settings(): array {
$identity = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_user_identity_settings() : [];
return is_array($identity['verify'] ?? null) ? $identity['verify'] : [];
}
/** @return array<string,mixed> */
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<string,mixed> */
public static function type_settings(string $type): array {
$types = self::settings()['types'] ?? [];
$type = sanitize_key($type);
return is_array($types[$type] ?? null) ? $types[$type] : [];
}
/** @return array<string,mixed> */
public static function 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<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
public static function get_auth_info(int $user_id): array {
$info = self::snapshot($user_id)['info'] ?? [];
return is_array($info) ? $info : [];
}
/** @return array<string,mixed> */
public static function get_apply_payload(int $user_id): array {
$payload = self::snapshot($user_id)['apply'] ?? [];
return is_array($payload) ? $payload : [];
}
/** @return array<int,array{id:int,url:string,thumbnail_url:string}> */
public static function get_evidence_items(array $application, int $owner_id = 0, bool $admin_context = false): array {
$record = is_array($application['_application'] ?? null) ? $application['_application'] : null;
if (!$record && !empty($application['application_uuid'])) {
$record = (new ApplicationRepository())->find_by_uuid((string) $application['application_uuid']);
}
return $record ? (new EvidenceService())->items($record, $admin_context) : [];
}
/** @return array<string,mixed> */
public static function application_view(array $application, int $owner_id = 0, bool $admin_context = false): array {
return [
'name' => (string) ($application['name'] ?? ''),
'real_name' => (string) ($application['real_name'] ?? ''),
'desc' => (string) ($application['desc'] ?? ''),
'msg' => (string) ($application['msg'] ?? ''),
'type' => sanitize_key((string) ($application['type'] ?? '')),
'time' => (string) ($application['time'] ?? ''),
'evidence' => self::get_evidence_items($application, $owner_id, $admin_context),
];
}
/** @return array<int,array<string,mixed>> */
public static function get_review_history(int $user_id): array {
$application = (new ApplicationRepository())->latest_for_user($user_id);
if (!$application) {
return [];
}
$history = [];
foreach ((new EventRepository())->for_application((int) $application['id']) as $event) {
$history[] = [
'status' => TransitionService::public_status((string) ($event['to_status'] ?? '')),
'reason' => (string) ($event['result_code'] ?? ''),
'reviewer' => (int) ($event['actor_user_id'] ?? 0),
'time' => (string) ($event['created_at'] ?? ''),
];
}
return $history;
}
/** @param array<string,string> $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<string,mixed> */
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<string,string> $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;
}
?>
<h2><?php esc_html_e('身份认证(AKSRT)', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<?php if ($can_manage) : ?>
<tr>
<th><label for="aksrt_identity_status"><?php esc_html_e('认证状态', 'aksrt'); ?></label></th>
<td>
<select id="aksrt_identity_status" name="aksrt_identity_status">
<?php foreach ($status_options as $value => $label) : ?>
<option value="<?php echo esc_attr($value); ?>" <?php selected($selected_status, $value); ?>><?php echo esc_html($label); ?></option>
<?php endforeach; ?>
</select>
<input type="hidden" name="aksrt_identity_application_uuid" value="<?php echo esc_attr((string) ($application['application_uuid'] ?? '')); ?>">
<?php wp_nonce_field('aksrt_identity_profile_' . $user_id, 'aksrt_identity_profile_nonce'); ?>
</td>
</tr>
<tr>
<th><label for="aksrt_identity_type"><?php esc_html_e('认证类型', 'aksrt'); ?></label></th>
<td>
<select id="aksrt_identity_type" name="aksrt_identity_type">
<option value="personal" <?php selected($identity_type, 'personal'); ?>><?php esc_html_e('个人认证', 'aksrt'); ?></option>
<option value="enterprise" <?php selected($identity_type, 'enterprise'); ?>><?php esc_html_e('企业认证', 'aksrt'); ?></option>
</select>
</td>
</tr>
<tr>
<th><label for="aksrt_identity_public_name"><?php esc_html_e('姓名 / 主体全称', 'aksrt'); ?></label></th>
<td>
<input type="text" class="regular-text" id="aksrt_identity_public_name" name="aksrt_identity_public_name" maxlength="64" value="<?php echo esc_attr($public_name); ?>">
</td>
</tr>
<tr>
<th><label for="aksrt_identity_review_note"><?php esc_html_e('处理说明', 'aksrt'); ?></label></th>
<td>
<textarea class="large-text" id="aksrt_identity_review_note" name="aksrt_identity_review_note" rows="2"></textarea>
<p class="description"><?php esc_html_e('改为“已驳回”或“未认证”时必须填写处理说明。', 'aksrt'); ?></p>
</td>
</tr>
<?php elseif ($application !== null) : ?>
<tr>
<th><?php esc_html_e('认证状态', 'aksrt'); ?></th>
<td><strong><?php echo esc_html(self::admin_status_label($raw_status)); ?></strong></td>
</tr>
<tr><th><?php esc_html_e('认证类型', 'aksrt'); ?></th><td><?php echo esc_html(self::admin_type_label($identity_type)); ?></td></tr>
<?php if ($public_name !== '') : ?><tr><th><?php esc_html_e('姓名 / 主体全称', 'aksrt'); ?></th><td><?php echo esc_html($public_name); ?></td></tr><?php endif; ?>
<?php endif; ?>
</table>
<?php
}
public function handle_admin_profile_update(int $user_id): void {
public function render_admin_profile(\WP_User $user): void {
if (
!current_user_can('aksrt_review_identity')
|| !current_user_can('edit_user', $user_id)
|| !isset($_POST['aksrt_identity_profile_nonce'])
empty(self::settings()['enabled'])
|| !current_user_can('aksrt_review_identity')
|| !current_user_can('edit_user', (int) $user->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'] ?? '');
?>
<h2><?php esc_html_e('身份认证', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr><th><label for="aksrt_auth_enabled"><?php esc_html_e('已认证用户', 'aksrt'); ?></label></th><td><input id="aksrt_auth_enabled" type="checkbox" name="aksrt_auth_enabled" value="1" <?php checked(self::is_verified((int) $user->ID)); ?>></td></tr>
<tr><th><label for="aksrt_auth_name"><?php esc_html_e('认证名称', 'aksrt'); ?></label></th><td><input id="aksrt_auth_name" class="regular-text" type="text" maxlength="64" name="aksrt_auth_name" value="<?php echo esc_attr($name); ?>"></td></tr>
<tr><th><label for="aksrt_auth_desc"><?php esc_html_e('认证简介', 'aksrt'); ?></label></th><td><textarea id="aksrt_auth_desc" class="large-text" rows="2" name="aksrt_auth_desc"><?php echo esc_textarea($description); ?></textarea></td></tr>
<tr><th><label for="aksrt_auth_time"><?php esc_html_e('认证时间', 'aksrt'); ?></label></th><td><input id="aksrt_auth_time" class="regular-text" type="text" name="aksrt_auth_time" value="<?php echo esc_attr($time); ?>"></td></tr>
</table>
<?php wp_nonce_field('aksrt_auth_profile_' . (int) $user->ID, 'aksrt_auth_profile_nonce'); ?>
<?php
}
/** @param array<string,string> $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 '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p>' . esc_html((string) $notice['message']) . '</p></div>';
}
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');
}
}
+11 -42
View File
@@ -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<string,mixed> $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);
}
/**