积分与余额账务系统硬切重构
This commit is contained in:
@@ -48,6 +48,7 @@ final class ApplicationRepository {
|
||||
$row = wp_parse_args($data, [
|
||||
'application_uuid' => wp_generate_uuid4(),
|
||||
'client_request_id' => wp_generate_uuid4(),
|
||||
'request_payload_hash' => '',
|
||||
'user_id' => 0,
|
||||
'type' => 'personal',
|
||||
'channel' => 'cloud',
|
||||
@@ -55,7 +56,7 @@ final class ApplicationRepository {
|
||||
'status' => 'created',
|
||||
'public_name' => '',
|
||||
'public_desc' => '',
|
||||
'fee_amount' => 0,
|
||||
'fee_amount' => '0.00',
|
||||
'fee_status' => 'not_required',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
@@ -75,6 +76,13 @@ final class ApplicationRepository {
|
||||
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;
|
||||
@@ -164,12 +172,6 @@ final class ApplicationRepository {
|
||||
$formats[$index] = '%d';
|
||||
}
|
||||
}
|
||||
foreach (['fee_amount'] as $float_key) {
|
||||
$index = array_search($float_key, array_keys($fields), true);
|
||||
if ($index !== false) {
|
||||
$formats[$index] = '%f';
|
||||
}
|
||||
}
|
||||
$updated = $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1;
|
||||
if ($updated) {
|
||||
self::clear_snapshot_cache();
|
||||
@@ -229,7 +231,11 @@ final class ApplicationRepository {
|
||||
$row[$nullable_key] = '';
|
||||
}
|
||||
}
|
||||
$row['fee_amount'] = round((float) ($row['fee_amount'] ?? 0), 2);
|
||||
try {
|
||||
$row['fee_amount'] = \AKSRT\Ledger\AssetAmount::balance((string) ($row['fee_amount'] ?? ''))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$row['fee_amount'] = '';
|
||||
}
|
||||
$evidence = json_decode((string) ($row['evidence_ids'] ?? ''), true);
|
||||
$row['evidence_ids'] = is_array($evidence) ? array_values(array_map('intval', $evidence)) : [];
|
||||
return $row;
|
||||
|
||||
+284
-140
@@ -9,6 +9,11 @@ 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;
|
||||
|
||||
@@ -25,7 +30,7 @@ final class ApplicationService {
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function create(int $user_id, 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'));
|
||||
@@ -33,25 +38,14 @@ final class ApplicationService {
|
||||
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)) {
|
||||
$request_id = strtolower(trim($request_id));
|
||||
if (!wp_is_uuid($request_id) || $operation_key === '') {
|
||||
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'] ?? ''))) ?: '');
|
||||
@@ -66,16 +60,41 @@ final class ApplicationService {
|
||||
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');
|
||||
$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([
|
||||
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'],
|
||||
@@ -88,22 +107,27 @@ final class ApplicationService {
|
||||
'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',
|
||||
'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) $settings['provider']);
|
||||
$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, [
|
||||
$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']);
|
||||
], [
|
||||
'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);
|
||||
}
|
||||
|
||||
@@ -117,29 +141,52 @@ final class ApplicationService {
|
||||
$result = $type === 'personal'
|
||||
? $provider->start_personal($provider_request)
|
||||
: $provider->start_enterprise($provider_request);
|
||||
return $this->apply_provider_result($application, $result, true);
|
||||
return $this->apply_provider_result($application, $result, true, $operation_key);
|
||||
}
|
||||
|
||||
public function refresh(array $application) {
|
||||
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';
|
||||
$expired = $this->transitions->transition($application, $next, [
|
||||
$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']);
|
||||
], [
|
||||
'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);
|
||||
return $this->apply_provider_result($application, $provider->query($application), false, $operation_key);
|
||||
}
|
||||
|
||||
public function move_to_manual(array $application, $files) {
|
||||
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'));
|
||||
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'));
|
||||
@@ -148,27 +195,32 @@ final class ApplicationService {
|
||||
if (is_wp_error($ids)) {
|
||||
return $ids;
|
||||
}
|
||||
$charge = $this->charge($application);
|
||||
if (is_wp_error($charge)) {
|
||||
$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 $charge;
|
||||
return $changed;
|
||||
}
|
||||
$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);
|
||||
$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<string,mixed> */
|
||||
public function view(array $application): 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)) {
|
||||
@@ -178,7 +230,8 @@ final class ApplicationService {
|
||||
} elseif ($stage === 'verified') {
|
||||
$next = 'complete';
|
||||
}
|
||||
return [
|
||||
$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),
|
||||
@@ -192,103 +245,194 @@ final class ApplicationService {
|
||||
'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) {
|
||||
$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);
|
||||
}
|
||||
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';
|
||||
}
|
||||
$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);
|
||||
}
|
||||
$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']);
|
||||
}
|
||||
|
||||
/** @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';
|
||||
}
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
$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<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);
|
||||
|
||||
@@ -39,9 +39,26 @@ final class EventRepository {
|
||||
if ($inserted === 1) {
|
||||
return true;
|
||||
}
|
||||
return str_contains(strtolower((string) $wpdb->last_error), 'duplicate');
|
||||
if (!str_contains(strtolower((string) $wpdb->last_error), 'duplicate')) {
|
||||
return false;
|
||||
}
|
||||
$existing_application_id = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT application_id FROM {$this->table} WHERE dedupe_key = %s LIMIT 1",
|
||||
$row['dedupe_key']
|
||||
));
|
||||
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;
|
||||
|
||||
@@ -18,6 +18,27 @@ 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);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
namespace AKSRT\Identity;
|
||||
|
||||
use AKSRT\Ledger\OperationKey;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class RestController {
|
||||
@@ -25,49 +27,86 @@ final class RestController {
|
||||
}
|
||||
|
||||
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', [
|
||||
'methods' => 'POST',
|
||||
'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' => 'GET',
|
||||
'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' => 'GET',
|
||||
'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' => 'POST',
|
||||
'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' => 'POST',
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'manual'],
|
||||
'permission_callback' => [$this, 'owns_application'],
|
||||
'args' => $uuid_args,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logged_in(): bool {
|
||||
return is_user_logged_in();
|
||||
public function logged_in(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_identity_auth', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('aksrt_identity_nonce', __('安全校验失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function owns_application(\WP_REST_Request $request): bool {
|
||||
if (!is_user_logged_in()) {
|
||||
return false;
|
||||
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);
|
||||
return is_array($application) && (int) $application['user_id'] === get_current_user_id();
|
||||
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 : [];
|
||||
$result = $this->service->create(get_current_user_id(), $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);
|
||||
}
|
||||
|
||||
@@ -100,7 +139,11 @@ final class RestController {
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->respond($this->service->refresh($application));
|
||||
$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 {
|
||||
@@ -112,7 +155,12 @@ final class RestController {
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->respond($this->service->move_to_manual($application, $_FILES['evidence'] ?? null));
|
||||
$operation_key = OperationKey::from_request($request, 'identity-manual', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->respond($operation_key);
|
||||
}
|
||||
$files = $request->get_file_params();
|
||||
return $this->respond($this->service->move_to_manual($application, $files['evidence'] ?? null, $operation_key));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
@@ -127,7 +175,11 @@ final class RestController {
|
||||
|
||||
private function respond($result, int $success_status = 200): \WP_REST_Response {
|
||||
if (is_wp_error($result)) {
|
||||
$status = in_array($result->get_error_code(), ['aksrt_identity_active', 'aksrt_identity_verified', 'aksrt_identity_conflict'], true) ? 409 : 422;
|
||||
$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(),
|
||||
|
||||
@@ -32,45 +32,112 @@ final class TransitionService {
|
||||
|
||||
/** @param array<string,mixed> $fields @param array<string,mixed> $event */
|
||||
public function transition(array $application, string $next, array $fields = [], array $event = []) {
|
||||
$current = sanitize_key((string) ($application['status'] ?? ''));
|
||||
$next = sanitize_key($next);
|
||||
if (!in_array($next, self::ALLOWED[$current] ?? [], true)) {
|
||||
return new \WP_Error('aksrt_identity_transition', __('该认证记录当前不可执行此操作', 'aksrt'));
|
||||
}
|
||||
if (in_array($next, ['verified', 'rejected', 'cancelled'], true)) {
|
||||
$fields['completed_at'] = current_time('mysql', true);
|
||||
$fields['sensitive_blob'] = null;
|
||||
}
|
||||
if (!$this->applications->transition((int) $application['id'], $current, $next, $fields)) {
|
||||
return new \WP_Error('aksrt_identity_conflict', __('认证状态已变化,请刷新后重试', 'aksrt'));
|
||||
}
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
|
||||
$dedupe = (string) ($event['dedupe_key'] ?? hash('sha256', implode('|', [
|
||||
(string) $application['application_uuid'], $current, $next, wp_generate_uuid4(),
|
||||
])));
|
||||
$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),
|
||||
]);
|
||||
if (\AKSRT\Ledger\LedgerService::get_instance()->transaction_active()) {
|
||||
return new \WP_Error('aksrt_identity_nested_transaction', __('认证状态不能开启嵌套事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->applications->find_by_id((int) $application['id']);
|
||||
$changed = $this->transition_locked($locked, $next, $fields, $event);
|
||||
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 = []) {
|
||||
$current = sanitize_key((string) ($application['status'] ?? ''));
|
||||
$next = sanitize_key($next);
|
||||
if (!in_array($next, self::ALLOWED[$current] ?? [], true)) {
|
||||
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;
|
||||
}
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user