diff --git a/src/Identity/TransitionService.php b/src/Identity/TransitionService.php index bff1be68..e1c65681 100644 --- a/src/Identity/TransitionService.php +++ b/src/Identity/TransitionService.php @@ -32,6 +32,16 @@ final class TransitionService { /** @param array $fields @param array $event */ public function transition(array $application, string $next, array $fields = [], array $event = []) { + return $this->transition_with_policy($application, $next, $fields, $event, false); + } + + /** @param array $fields @param array $event */ + public function admin_transition(array $application, string $next, array $fields = [], array $event = []) { + return $this->transition_with_policy($application, $next, $fields, $event, true); + } + + /** @param array $fields @param array $event */ + private function transition_with_policy(array $application, string $next, array $fields, array $event, bool $admin_override) { $next = sanitize_key($next); $dedupe = isset($event['dedupe_key']) ? (string) $event['dedupe_key'] : ''; if ($dedupe !== '') { @@ -62,7 +72,7 @@ final class TransitionService { } } - $changed = $this->transition_locked($locked, $next, $fields, $event); + $changed = $this->apply_transition_locked($locked, $next, $fields, $event, $admin_override); if (is_wp_error($changed)) { $wpdb->query('ROLLBACK'); return $changed; @@ -76,11 +86,6 @@ final class TransitionService { return $updated; } - /** @return array */ - public static function allowed_targets(string $status): array { - return self::ALLOWED[sanitize_key($status)] ?? []; - } - /** * Apply an identity transition inside an already-owned database transaction. * The caller must lock the application row before invoking this method. @@ -91,14 +96,29 @@ final class TransitionService { * @return true|\WP_Error */ public function transition_locked(array $application, string $next, array $fields = [], array $event = []) { + return $this->apply_transition_locked($application, $next, $fields, $event, false); + } + + /** + * @param array $application + * @param array $fields + * @param array $event + * @return true|\WP_Error + */ + private function apply_transition_locked(array $application, string $next, array $fields, array $event, bool $admin_override) { $current = sanitize_key((string) ($application['status'] ?? '')); $next = sanitize_key($next); - if (!in_array($next, self::ALLOWED[$current] ?? [], true)) { + $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]); diff --git a/src/User/AuthApply.php b/src/User/AuthApply.php index 53fe586f..4decd9ea 100644 --- a/src/User/AuthApply.php +++ b/src/User/AuthApply.php @@ -256,21 +256,21 @@ class AuthApply { } $status = sanitize_key($status); $note = sanitize_textarea_field($note); - if (!in_array($status, ['verified', 'rejected', 'cancelled'], true)) { + 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 (['public_name', 'public_desc'] as $key) { + foreach (['type', 'public_name', 'public_desc'] 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))->transition($application, $status, $fields, [ + $result = (new TransitionService($repo))->admin_transition($application, $status, $fields, [ 'source' => 'admin', 'event_type' => 'manual_review', 'actor_user_id' => $reviewer_id, @@ -289,7 +289,15 @@ class AuthApply { } $status = self::get_status($user_id); $labels = ['pending' => __('进行中', 'aksrt'), 'verified' => __('已认证', 'aksrt'), 'rejected' => __('未通过', 'aksrt'), 'cancelled' => __('已取消', 'aksrt')]; - return $status === '' ? esc_html__('未认证', 'aksrt') : esc_html($labels[$status] ?? $status); + 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 { @@ -299,29 +307,52 @@ class AuthApply { ? $snapshot['verified'] : (is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : null); $raw_status = (string) ($application['status'] ?? ''); - $can_manage = $application !== null - && current_user_can('aksrt_review_identity') + $can_manage = current_user_can('aksrt_review_identity') && current_user_can('edit_user', $user_id); - $allowed_actions = array_values(array_intersect( - TransitionService::allowed_targets($raw_status), - ['verified', 'rejected', 'cancelled'] - )); $public_name = (string) ($application['public_name'] ?? ''); $public_desc = (string) ($application['public_desc'] ?? ''); + $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; + } ?>

+ - - + + + + + + - @@ -330,23 +361,19 @@ class AuthApply { - - + - + + + + + @@ -372,51 +399,90 @@ class AuthApply { $application = is_array($snapshot['verified'] ?? null) ? $snapshot['verified'] : (is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : null); - if ($application === null) { - return; - } $application_uuid = sanitize_text_field((string) wp_unslash($_POST['aksrt_identity_application_uuid'] ?? '')); - if ($application_uuid === '' || !hash_equals((string) $application['application_uuid'], $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, ''); $public_desc = wp_html_excerpt(sanitize_textarea_field((string) wp_unslash($_POST['aksrt_identity_public_desc'] ?? '')), 300, ''); - $action = sanitize_key((string) wp_unslash($_POST['aksrt_identity_action'] ?? '')); + $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, 'public_desc' => $public_desc, ]; - if ($action === '') { + $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; } - $allowed = TransitionService::allowed_targets((string) $application['status']); - if (!in_array($action, $allowed, true) || !in_array($action, ['verified', 'rejected', 'cancelled'], true)) { - $this->set_admin_profile_notice('error', __('当前认证状态不能执行该操作。', 'aksrt')); - return; - } - if ($action === 'verified' && $public_name === '') { - $this->set_admin_profile_notice('error', __('通过认证时请填写认证身份。', 'aksrt')); - return; - } - if (in_array($action, ['rejected', 'cancelled'], true) && $note === '') { - $this->set_admin_profile_notice('error', __('驳回或取消认证时请填写处理说明。', 'aksrt')); - return; - } - - $result = self::review($user_id, $application_uuid, $action, $note, get_current_user_id(), $fields); + $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 $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'], + 'public_desc' => $fields['public_desc'], + '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); @@ -451,12 +517,8 @@ class AuthApply { return $labels[$status] ?? $status; } - private static function admin_action_label(string $action): string { - $labels = [ - 'verified' => __('通过认证', 'aksrt'), - 'rejected' => __('驳回申请', 'aksrt'), - 'cancelled' => __('取消认证', 'aksrt'), - ]; - return $labels[$action] ?? $action; + private static function admin_type_label(string $type): string { + return $type === 'enterprise' ? __('企业认证', 'aksrt') : __('个人认证', 'aksrt'); } + } diff --git a/tests/widget-system/user-features-contract.test.cjs b/tests/widget-system/user-features-contract.test.cjs index 5163aac7..d2379728 100644 --- a/tests/widget-system/user-features-contract.test.cjs +++ b/tests/widget-system/user-features-contract.test.cjs @@ -185,7 +185,7 @@ test('native user profile omits ledger tables and renders canonical images eager assert.doesNotMatch(level, /Level::get_badge_html/); }); -test('identity details and governed status actions are editable on the native profile', () => { +test('identity status and public details are directly editable on the native profile', () => { const auth = read('src/User/AuthApply.php'); const transitions = read('src/Identity/TransitionService.php'); @@ -193,10 +193,19 @@ test('identity details and governed status actions are editable on the native pr assert.match(auth, /current_user_can\('aksrt_review_identity'\)/); assert.match(auth, /wp_nonce_field\('aksrt_identity_profile_' \.[\s\S]*?'aksrt_identity_profile_nonce'\)/); assert.match(auth, /name="aksrt_identity_application_uuid"/); + assert.match(auth, /name="aksrt_identity_type"/); + assert.match(auth, /value="personal"[\s\S]*?个人认证/); + assert.match(auth, /value="enterprise"[\s\S]*?企业认证/); assert.match(auth, /name="aksrt_identity_public_name"/); assert.match(auth, /name="aksrt_identity_public_desc"/); - assert.match(auth, /name="aksrt_identity_action"/); - assert.match(auth, /TransitionService::allowed_targets/); - assert.match(auth, /self::review\(\$user_id, \$application_uuid, \$action, \$note, get_current_user_id\(\), \$fields\)/); - assert.match(transitions, /public static function allowed_targets/); + assert.match(auth, /name="aksrt_identity_status"/); + assert.match(auth, /'manual_pending' => __\('待审核'/); + assert.match(auth, /'verified' => __\('已认证'/); + assert.match(auth, /'rejected' => __\('已驳回'/); + assert.match(auth, /create_admin_application\(\$repo, \$user_id, \$fields\)/); + assert.match(auth, /'type' => \$fields\['type'\]/); + assert.match(auth, /self::review\(\$user_id, \$application_uuid, \$target_status, \$note, get_current_user_id\(\), \$fields\)/); + assert.match(transitions, /public function admin_transition/); + assert.match(transitions, /\? in_array\(\$next, \['manual_pending', 'verified', 'rejected', 'cancelled'\], true\)/); + assert.match(transitions, /'verified' => \['cancelled'\]/); });