Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
318 lines
16 KiB
PHP
318 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Identity verification application orchestration.
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Identity;
|
|
|
|
use AKSRT\Identity\Provider\ProviderFactory;
|
|
use AKSRT\Identity\Provider\ProviderResult;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
final class ApplicationService {
|
|
|
|
public function __construct(
|
|
private ?ApplicationRepository $applications = null,
|
|
private ?TransitionService $transitions = null,
|
|
private ?EvidenceService $evidence = null
|
|
) {
|
|
$this->applications ??= new ApplicationRepository();
|
|
$this->transitions ??= new TransitionService($this->applications);
|
|
$this->evidence ??= new EvidenceService();
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function create(int $user_id, array $input) {
|
|
$settings = SettingsRuntime::get();
|
|
if (empty($settings['enabled']) || empty($settings['allow_apply'])) {
|
|
return new \WP_Error('aksrt_identity_disabled', __('暂不能申请身份认证', 'aksrt'));
|
|
}
|
|
if (!is_ssl()) {
|
|
return new \WP_Error('aksrt_identity_https', __('身份认证必须通过 HTTPS 发起', 'aksrt'));
|
|
}
|
|
if ($this->applications->verified_for_user($user_id)) {
|
|
return new \WP_Error('aksrt_identity_verified', __('您已是认证用户', 'aksrt'));
|
|
}
|
|
if ($this->applications->active_for_user($user_id)) {
|
|
return new \WP_Error('aksrt_identity_active', __('您已有进行中的认证申请', 'aksrt'));
|
|
}
|
|
|
|
$type = sanitize_key((string) ($input['type'] ?? ''));
|
|
if (!in_array($type, ['personal', 'enterprise'], true)) {
|
|
return new \WP_Error('aksrt_identity_type', __('请选择有效的认证类型', 'aksrt'));
|
|
}
|
|
$request_id = sanitize_text_field((string) ($input['client_request_id'] ?? ''));
|
|
if (!wp_is_uuid($request_id)) {
|
|
return new \WP_Error('aksrt_identity_request_id', __('请求标识无效', 'aksrt'));
|
|
}
|
|
$existing = $this->applications->find_by_client_request($user_id, $request_id);
|
|
if ($existing) {
|
|
return $this->view($existing);
|
|
}
|
|
|
|
$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'));
|
|
}
|
|
$public_name = $type === 'enterprise' ? $this->truncate($company_name, 64) : __('个人认证', 'aksrt');
|
|
$public_desc = '';
|
|
|
|
$type_settings = \AKSRT\User\AuthApply::type_settings($type);
|
|
$attempt = $this->applications->count_user_type($user_id, $type);
|
|
$free = max(0, (int) ($type_settings['free_applications'] ?? 0));
|
|
$fee = $attempt < $free ? 0.0 : max(0, (float) ($type_settings['fee'] ?? 0));
|
|
$application = $this->applications->create([
|
|
'application_uuid' => wp_generate_uuid4(),
|
|
'client_request_id' => $request_id,
|
|
'user_id' => $user_id,
|
|
'type' => $type,
|
|
'provider' => $settings['provider'],
|
|
'provider_flow' => (string) $settings['personal_flow'],
|
|
'public_name' => $public_name,
|
|
'public_desc' => $public_desc,
|
|
'person_name_masked' => SettingsRuntime::mask_name($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,
|
|
'fee_status' => $fee > 0 ? 'pending' : 'not_required',
|
|
'attempt_ordinal' => $attempt + 1,
|
|
]);
|
|
if (is_wp_error($application)) {
|
|
return $application;
|
|
}
|
|
|
|
$provider = ProviderFactory::make((string) $settings['provider']);
|
|
$valid = $provider->validate_config();
|
|
if (is_wp_error($valid)) {
|
|
$next = !empty($settings['allow_manual_fallback']) ? 'provider_unsupported' : 'cancelled';
|
|
$failed = $this->transitions->transition($application, $next, [
|
|
'provider_result_code' => $valid->get_error_code(),
|
|
'provider_result_summary' => $valid->get_error_message(),
|
|
], ['source' => 'system', 'event_type' => 'provider_preflight_failed']);
|
|
return is_wp_error($failed) ? $failed : $this->view($failed);
|
|
}
|
|
|
|
$provider_request = $input;
|
|
$provider_request['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);
|
|
}
|
|
|
|
public function refresh(array $application) {
|
|
if (!in_array((string) $application['status'], ['provider_pending', 'provider_processing'], true)) {
|
|
return $this->view($application);
|
|
}
|
|
if (!empty($application['expires_at']) && strtotime((string) $application['expires_at'] . ' UTC') <= time()) {
|
|
$settings = SettingsRuntime::get();
|
|
$next = !empty($settings['allow_manual_fallback']) ? 'provider_expired' : 'cancelled';
|
|
$expired = $this->transitions->transition($application, $next, [
|
|
'provider_result_code' => 'session_expired',
|
|
'provider_result_summary' => __('云认证会话已过期', 'aksrt'),
|
|
], ['source' => 'system', 'event_type' => 'session_expired']);
|
|
return is_wp_error($expired) ? $expired : $this->view($expired);
|
|
}
|
|
$provider = ProviderFactory::make((string) $application['provider']);
|
|
return $this->apply_provider_result($application, $provider->query($application), false);
|
|
}
|
|
|
|
public function move_to_manual(array $application, $files) {
|
|
if (!in_array((string) $application['status'], ['created', 'provider_failed', 'provider_unsupported', 'provider_expired'], true)) {
|
|
return new \WP_Error('aksrt_identity_manual_state', __('该申请当前不能转人工审核', 'aksrt'));
|
|
}
|
|
if (empty(SettingsRuntime::get()['allow_manual_fallback'])) {
|
|
return new \WP_Error('aksrt_identity_manual_disabled', __('站点未开放人工审核回退', 'aksrt'));
|
|
}
|
|
$ids = $this->evidence->upload((int) $application['user_id'], (string) $application['application_uuid'], $files);
|
|
if (is_wp_error($ids)) {
|
|
return $ids;
|
|
}
|
|
$charge = $this->charge($application);
|
|
if (is_wp_error($charge)) {
|
|
$this->evidence->delete($ids);
|
|
return $charge;
|
|
}
|
|
$updated = $this->transitions->transition($application, 'manual_pending', [
|
|
'channel' => 'manual',
|
|
'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'),
|
|
'evidence_ids' => wp_json_encode($ids),
|
|
'fee_status' => $charge,
|
|
], ['source' => 'user', 'event_type' => 'manual_fallback', 'actor_user_id' => (int) $application['user_id']]);
|
|
if (is_wp_error($updated)) {
|
|
$this->evidence->delete($ids);
|
|
$this->refund($application);
|
|
return $updated;
|
|
}
|
|
return $this->view($updated);
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function view(array $application): array {
|
|
$stage = (string) $application['status'];
|
|
$next = 'wait';
|
|
if (in_array($stage, ['provider_pending', 'provider_processing'], true)) {
|
|
$next = 'poll';
|
|
} elseif (in_array($stage, ['provider_failed', 'provider_unsupported', 'provider_expired'], true)) {
|
|
$next = 'manual_or_retry';
|
|
} elseif ($stage === 'verified') {
|
|
$next = 'complete';
|
|
}
|
|
return [
|
|
'uuid' => (string) $application['application_uuid'],
|
|
'type' => (string) $application['type'],
|
|
'status' => TransitionService::public_status($stage),
|
|
'stage' => $stage,
|
|
'next_action' => $next,
|
|
'public_name' => (string) $application['public_name'],
|
|
'public_desc' => (string) $application['public_desc'],
|
|
'result_code' => (string) $application['provider_result_code'],
|
|
'message' => (string) $application['provider_result_summary'],
|
|
'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')),
|
|
'qr_payload' => sanitize_text_field((string) ($application['qr_payload'] ?? '')),
|
|
'expires_at' => (string) ($application['expires_at'] ?? ''),
|
|
'created_at' => (string) $application['created_at'],
|
|
'poll_interval' => (int) SettingsRuntime::get()['poll_interval_seconds'],
|
|
];
|
|
}
|
|
|
|
private function apply_provider_result(array $application, ProviderResult $result, bool $starting) {
|
|
$data = $result->data;
|
|
if ($result->decision === ProviderResult::PENDING) {
|
|
$fee_status = (string) $application['fee_status'];
|
|
if ($starting) {
|
|
$charged = $this->charge($application);
|
|
if (is_wp_error($charged)) {
|
|
return $charged;
|
|
}
|
|
$fee_status = $charged;
|
|
}
|
|
$next = $starting ? 'provider_pending' : 'provider_processing';
|
|
$fields = [
|
|
'provider_session_id' => sanitize_text_field((string) ($data['provider_session_id'] ?? ($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'] ?? '')),
|
|
'fee_status' => $fee_status,
|
|
];
|
|
$updated = $this->transitions->transition($application, $next, $fields, [
|
|
'source' => (string) $application['provider'],
|
|
'event_type' => $starting ? 'session_started' : 'query_pending',
|
|
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
|
]);
|
|
if (is_wp_error($updated)) {
|
|
if ($starting) {
|
|
$this->refund(array_merge($application, ['fee_status' => $fee_status]));
|
|
}
|
|
return $updated;
|
|
}
|
|
$updated['redirect_url'] = (string) ($updated['redirect_url'] ?? '');
|
|
$updated['qr_payload'] = (string) ($updated['qr_payload'] ?? '');
|
|
return $this->view($updated);
|
|
}
|
|
|
|
$map = [
|
|
ProviderResult::PASSED => 'verified',
|
|
ProviderResult::FAILED => 'provider_failed',
|
|
ProviderResult::UNSUPPORTED => 'provider_unsupported',
|
|
ProviderResult::EXPIRED => 'provider_expired',
|
|
];
|
|
$next = $map[$result->decision] ?? 'provider_failed';
|
|
$settings = SettingsRuntime::get();
|
|
if (in_array($next, ['provider_failed', 'provider_unsupported', 'provider_expired'], true) && empty($settings['allow_manual_fallback'])) {
|
|
$next = 'cancelled';
|
|
}
|
|
$updated = $this->transitions->transition($application, $next, [
|
|
'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)),
|
|
'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')),
|
|
], [
|
|
'source' => (string) $application['provider'],
|
|
'event_type' => 'provider_result',
|
|
'external_event_id' => (string) ($data['external_event_id'] ?? ''),
|
|
'result_code' => (string) ($data['result_code'] ?? $result->decision),
|
|
]);
|
|
return is_wp_error($updated) ? $updated : $this->view($updated);
|
|
}
|
|
|
|
/** @return string|\WP_Error */
|
|
private function charge(array $application) {
|
|
if (in_array((string) $application['fee_status'], ['charged', 'not_required'], true)) {
|
|
return (string) $application['fee_status'];
|
|
}
|
|
$fee = (float) $application['fee_amount'];
|
|
if ($fee <= 0) {
|
|
return 'not_required';
|
|
}
|
|
$result = \AKSRT\Credits\Balance::deduct(
|
|
(int) $application['user_id'],
|
|
$fee,
|
|
'verify_application',
|
|
['type' => 'identity', 'id' => (int) $application['id']],
|
|
sprintf(__('提交%s认证申请', 'aksrt'), (string) $application['type'])
|
|
);
|
|
return is_wp_error($result) ? $result : 'charged';
|
|
}
|
|
|
|
private function refund(array $application): void {
|
|
if ((float) $application['fee_amount'] <= 0 || !in_array((string) $application['fee_status'], ['charged', 'pending'], true)) {
|
|
return;
|
|
}
|
|
\AKSRT\Credits\Balance::add(
|
|
(int) $application['user_id'],
|
|
(float) $application['fee_amount'],
|
|
'verify_application_refund',
|
|
['type' => 'identity', 'id' => (int) $application['id']],
|
|
__('认证会话创建失败退款', 'aksrt')
|
|
);
|
|
$this->applications->update((int) $application['id'], ['fee_status' => 'refunded']);
|
|
}
|
|
|
|
private function length(string $value): int {
|
|
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
|
}
|
|
|
|
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];
|
|
}
|
|
}
|