applications ??= new ApplicationRepository(); $this->transitions ??= new TransitionService($this->applications); $this->evidence ??= new EvidenceService(); } /** @param array $input */ public function create(int $user_id, array $input, string $request_id, string $operation_key) { $settings = SettingsRuntime::get(); if (empty($settings['enabled']) || empty($settings['allow_apply'])) { return new \WP_Error('aksrt_identity_disabled', __('暂不能申请身份认证', 'aksrt')); } if (!is_ssl()) { return new \WP_Error('aksrt_identity_https', __('身份认证必须通过 HTTPS 发起', 'aksrt')); } $type = sanitize_key((string) ($input['type'] ?? '')); if (!in_array($type, ['personal', 'enterprise'], true)) { return new \WP_Error('aksrt_identity_type', __('请选择有效的认证类型', 'aksrt')); } $request_id = strtolower(trim($request_id)); if (!wp_is_uuid($request_id) || $operation_key === '') { return new \WP_Error('aksrt_identity_request_id', __('请求标识无效', 'aksrt')); } $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); } } if (!$existing && $this->applications->verified_for_user($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]); } $public_name = $type === 'enterprise' ? $this->truncate($company_name, 64) : __('个人认证', 'aksrt'); 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]); } $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; } $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 = [ 'channel' => 'manual', 'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'), 'evidence_ids' => wp_json_encode($ids), ]; $event = [ '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); } return $this->view($updated, $changed['ledger']); } /** @return array */ public function view(array $application, ?LedgerResult $ledger_result = null, bool $replayed = false): array { $stage = (string) $application['status']; $next = 'wait'; if (in_array($stage, ['provider_pending', 'provider_processing'], true)) { $next = 'poll'; } elseif (in_array($stage, ['provider_failed', 'provider_unsupported', 'provider_expired'], true)) { $next = 'manual_or_retry'; } elseif ($stage === 'verified') { $next = 'complete'; } $balance_minor = LedgerService::get_instance()->balance((int) $application['user_id'], AssetType::BALANCE); return [ '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', ]; $next = $map[$result->decision] ?? 'provider_failed'; $settings = SettingsRuntime::get(); if (in_array($next, ['provider_failed', 'provider_unsupported', 'provider_expired'], true) && empty($settings['allow_manual_fallback'])) { $next = 'cancelled'; } $effective_key = $this->provider_operation_key($application, $operation_key, $next, $data, $result->decision); $fields = [ 'provider_result_code' => sanitize_text_field((string) ($data['result_code'] ?? $result->decision)), 'provider_result_summary' => sanitize_text_field((string) ($data['message'] ?? '')), ]; $event = [ 'source' => (string) $application['provider'], 'event_type' => 'provider_result', 'external_event_id' => (string) ($data['external_event_id'] ?? ''), 'result_code' => (string) ($data['result_code'] ?? $result->decision), 'dedupe_key' => $this->event_dedupe_key($application, $effective_key, $next), ]; $changed = $starting && $next === 'verified' ? $this->transition_with_charge($application, $next, $fields, $event, $effective_key) : $this->transition_without_charge($application, $next, $fields, $event); return is_wp_error($changed) ? $changed : $this->view($changed['application'], $changed['ledger']); } /** * @param array $application * @param array $fields * @param array $event * @return array{application:array,ledger:?LedgerResult}|\WP_Error */ private function transition_with_charge(array $application, string $next, array $fields, array $event, string $operation_key, array $ledger_metadata = []) { $fee_status = (string) ($application['fee_status'] ?? ''); if (in_array($fee_status, ['charged', 'not_required'], true)) { $fields['fee_status'] = $fee_status; return $this->transition_without_charge($application, $next, $fields, $event); } 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]; } /** * @param array $application * @param array $fields * @param array $event * @return array{application:array,ledger:null}|\WP_Error */ private function transition_without_charge(array $application, string $next, array $fields, array $event) { $updated = $this->transitions->transition($application, $next, $fields, $event); return is_wp_error($updated) ? $updated : ['application' => $updated, 'ledger' => null]; } /** @param array $application @param array $data */ private function provider_operation_key(array $application, string $operation_key, string $next, array $data, string $decision): string { if ($operation_key !== '') { return $operation_key; } $external = (string) ($data['external_event_id'] ?? $data['request_id'] ?? $data['result_code'] ?? $decision); return OperationKey::deterministic('identity-provider', [(int) $application['id'], $next, $external]); } /** @param array $application */ private function event_dedupe_key(array $application, string $operation_key, string $next): string { return hash('sha256', implode('|', ['identity', (int) $application['id'], $operation_key, sanitize_key($next)])); } private function request_payload_hash(string $type, string $person_name, string $id_number, string $company_name, string $company_code): string { return hash('sha256', (string) wp_json_encode([ 'type' => $type, 'person_name' => $person_name, 'id_number_hmac' => SettingsRuntime::hmac($id_number), 'company_name' => $company_name, 'company_code_hmac' => $company_code !== '' ? SettingsRuntime::hmac($company_code) : '', ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } private function length(string $value): int { return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); } private function truncate(string $value, int $length): string { return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length); } private function is_china_id_number(string $value): bool { if (!preg_match('/^(\d{17}[0-9X]|\d{15})$/', $value)) { return false; } if (strlen($value) === 15) { return true; } $weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; $codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; $sum = 0; for ($i = 0; $i < 17; $i++) { $sum += (int) $value[$i] * $weights[$i]; } return $codes[$sum % 11] === $value[17]; } }