Files
wordpress-theme-aksrt/src/User/AuthApply.php
T
2026-08-25 06:41:57 +08:00

511 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Identity verification facade for existing presentation call sites.
*
* @package AKSRT
*/
namespace AKSRT\User;
use AKSRT\Identity\ApplicationRepository;
use AKSRT\Identity\EvidenceService;
use AKSRT\Identity\EventRepository;
use AKSRT\Identity\TransitionService;
use AKSRT\Identity\SettingsRuntime;
defined('ABSPATH') || exit;
class AuthApply {
private static ?self $instance = null;
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']);
}
}
/** @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 type_settings(string $type): array {
$types = self::settings()['types'] ?? [];
$type = sanitize_key($type);
return is_array($types[$type] ?? null) ? $types[$type] : [];
}
/**
* 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';
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];
}
}
$fields['review_note'] = $note;
$fields['reviewer_id'] = $reviewer_id;
$result = (new TransitionService($repo))->admin_transition($application, $status, $fields, [
'source' => 'admin',
'event_type' => 'manual_review',
'actor_user_id' => $reviewer_id,
]);
return is_wp_error($result) ? $result : ['status' => $status, 'reviewed_at' => (string) $result['completed_at']];
}
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');
}
$label = (string) ($labels[$status] ?? $status);
if ($status === 'verified') {
$info = self::get_auth_info($user_id);
$label .= ' · ' . self::admin_type_label((string) ($info['type'] ?? 'personal'));
}
return esc_html($label);
}
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 {
if (
!current_user_can('aksrt_review_identity')
|| !current_user_can('edit_user', $user_id)
|| !isset($_POST['aksrt_identity_profile_nonce'])
) {
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')
);
}
/** @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 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');
}
}