feat(identity): align auth flow with reference theme
This commit is contained in:
@@ -11,31 +11,30 @@ defined('ABSPATH') || exit;
|
||||
|
||||
final class ApplicationRepository {
|
||||
|
||||
public const ACTIVE_STATUSES = [
|
||||
'created', 'provider_pending', 'provider_processing', 'manual_pending',
|
||||
];
|
||||
public const ACTIVE_STATUSES = ['manual_pending'];
|
||||
private const APPLICATION_COLUMNS = 'id, application_uuid, user_id, type, channel, status, public_name, provider_result_summary, manual_reason, review_note, reviewer_id, evidence_ids, created_at, completed_at, updated_at';
|
||||
|
||||
private string $table;
|
||||
|
||||
/** @var array<int,array{latest:array<string,mixed>|null,active:array<string,mixed>|null,verified:array<string,mixed>|null}> */
|
||||
private static array $snapshot_cache = [];
|
||||
/** @var array<int,array<string,mixed>|null> */
|
||||
private static array $active_cache = [];
|
||||
|
||||
public function __construct() {
|
||||
$this->table = \AKSRT\Core\DBMigrator::table('identity_application');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the request-local application snapshot cache.
|
||||
* Clear the request-local active application cache.
|
||||
*
|
||||
* Writes can change the active/latest/verified selection, so callers must
|
||||
* Writes can change the active application, so callers must
|
||||
* invalidate before reading the next view in the same request.
|
||||
*/
|
||||
public static function clear_snapshot_cache(int $user_id = 0): void {
|
||||
public static function clear_active_cache(int $user_id = 0): void {
|
||||
if ($user_id > 0) {
|
||||
unset(self::$snapshot_cache[$user_id]);
|
||||
unset(self::$active_cache[$user_id]);
|
||||
return;
|
||||
}
|
||||
self::$snapshot_cache = [];
|
||||
self::$active_cache = [];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
@@ -44,19 +43,17 @@ final class ApplicationRepository {
|
||||
if ($this->table === '') {
|
||||
return new \WP_Error('aksrt_identity_table', __('身份认证数据表不可用', 'aksrt'));
|
||||
}
|
||||
$data = $this->map_description_to_storage($data);
|
||||
$now = current_time('mysql', true);
|
||||
$row = wp_parse_args($data, [
|
||||
'application_uuid' => wp_generate_uuid4(),
|
||||
'client_request_id' => wp_generate_uuid4(),
|
||||
'request_payload_hash' => '',
|
||||
'request_payload_hash' => '',
|
||||
'user_id' => 0,
|
||||
'type' => 'personal',
|
||||
'channel' => 'cloud',
|
||||
'provider' => '',
|
||||
'type' => 'general',
|
||||
'channel' => 'manual',
|
||||
'status' => 'created',
|
||||
'public_name' => '',
|
||||
'fee_amount' => '0.00',
|
||||
'fee_status' => 'not_required',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
@@ -64,179 +61,124 @@ final class ApplicationRepository {
|
||||
if ($inserted !== 1) {
|
||||
return new \WP_Error('aksrt_identity_create', __('认证申请创建失败', 'aksrt'));
|
||||
}
|
||||
self::clear_snapshot_cache((int) $row['user_id']);
|
||||
self::clear_active_cache((int) $row['user_id']);
|
||||
return $this->find_by_id((int) $wpdb->insert_id);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_by_id(int $id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id), ARRAY_A);
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT " . self::APPLICATION_COLUMNS . " FROM {$this->table} WHERE id = %d AND type = %s AND channel = %s",
|
||||
$id,
|
||||
'general',
|
||||
'manual'
|
||||
), ARRAY_A);
|
||||
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;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table} WHERE application_uuid = %s", $uuid), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_by_client_request(int $user_id, string $request_id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE user_id = %d AND client_request_id = %s LIMIT 1",
|
||||
$user_id,
|
||||
$request_id
|
||||
), ARRAY_A);
|
||||
"SELECT " . self::APPLICATION_COLUMNS . " FROM {$this->table} WHERE application_uuid = %s AND type = %s AND channel = %s",
|
||||
$uuid,
|
||||
'general',
|
||||
'manual'
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the latest, active, and verified applications in one request-local
|
||||
* context. The three views are selected from one ordered result set so
|
||||
* facade callers cannot repeat the same application SELECT.
|
||||
*
|
||||
* @return array{latest:array<string,mixed>|null,active:array<string,mixed>|null,verified:array<string,mixed>|null}
|
||||
*/
|
||||
public function snapshot_for_user(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['latest' => null, 'active' => null, 'verified' => null];
|
||||
}
|
||||
if (array_key_exists($user_id, self::$snapshot_cache)) {
|
||||
return self::$snapshot_cache[$user_id];
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE user_id = %d ORDER BY id DESC",
|
||||
$user_id
|
||||
), ARRAY_A);
|
||||
$snapshot = ['latest' => null, 'active' => null, 'verified' => null];
|
||||
foreach ((array) $rows as $raw) {
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
$record = $this->normalize($raw);
|
||||
if ($snapshot['latest'] === null) {
|
||||
$snapshot['latest'] = $record;
|
||||
}
|
||||
if ($snapshot['active'] === null && in_array((string) $record['status'], self::ACTIVE_STATUSES, true)) {
|
||||
$snapshot['active'] = $record;
|
||||
}
|
||||
if ((string) $record['status'] === 'verified') {
|
||||
$current = $snapshot['verified'];
|
||||
if ($current === null || $this->verified_is_newer($record, $current)) {
|
||||
$snapshot['verified'] = $record;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$snapshot_cache[$user_id] = $snapshot;
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function latest_for_user(int $user_id): ?array {
|
||||
return $this->snapshot_for_user($user_id)['latest'];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function active_for_user(int $user_id): ?array {
|
||||
return $this->snapshot_for_user($user_id)['active'];
|
||||
}
|
||||
if ($user_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (array_key_exists($user_id, self::$active_cache)) {
|
||||
return self::$active_cache[$user_id];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function verified_for_user(int $user_id): ?array {
|
||||
return $this->snapshot_for_user($user_id)['verified'];
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT " . self::APPLICATION_COLUMNS . " FROM {$this->table} WHERE user_id = %d AND type = %s AND channel = %s AND status = %s ORDER BY id DESC LIMIT 1",
|
||||
$user_id,
|
||||
'general',
|
||||
'manual',
|
||||
self::ACTIVE_STATUSES[0]
|
||||
), ARRAY_A);
|
||||
self::$active_cache[$user_id] = is_array($row) ? $this->normalize($row) : null;
|
||||
return self::$active_cache[$user_id];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
public function transition(int $id, string $expected, string $next, array $fields = []): bool {
|
||||
global $wpdb;
|
||||
$fields = $this->map_description_to_storage($fields);
|
||||
$fields['status'] = $next;
|
||||
$fields['updated_at'] = current_time('mysql', true);
|
||||
$formats = array_fill(0, count($fields), '%s');
|
||||
foreach (['reviewer_id', 'retry_count', 'attempt_ordinal'] as $int_key) {
|
||||
$index = array_search($int_key, array_keys($fields), true);
|
||||
if ($index !== false) {
|
||||
$formats[$index] = '%d';
|
||||
}
|
||||
$index = array_search('reviewer_id', array_keys($fields), true);
|
||||
if ($index !== false) {
|
||||
$formats[$index] = '%d';
|
||||
}
|
||||
$updated = $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1;
|
||||
$updated = $wpdb->update(
|
||||
$this->table,
|
||||
$fields,
|
||||
['id' => $id, 'type' => 'general', 'channel' => 'manual', 'status' => $expected],
|
||||
$formats,
|
||||
['%d', '%s', '%s', '%s']
|
||||
) === 1;
|
||||
if ($updated) {
|
||||
self::clear_snapshot_cache();
|
||||
self::clear_active_cache();
|
||||
}
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
public function update(int $id, array $fields): bool {
|
||||
global $wpdb;
|
||||
if ($fields === []) {
|
||||
return true;
|
||||
}
|
||||
$fields['updated_at'] = current_time('mysql', true);
|
||||
$updated = $wpdb->update($this->table, $fields, ['id' => $id]);
|
||||
if ($updated !== false) {
|
||||
self::clear_snapshot_cache();
|
||||
}
|
||||
return $updated !== false;
|
||||
}
|
||||
|
||||
public function count_user_type(int $user_id, string $type): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table} WHERE user_id = %d AND type = %s AND fee_status IN ('charged','not_required')",
|
||||
$user_id,
|
||||
$type
|
||||
));
|
||||
}
|
||||
|
||||
public function delete(int $id): bool {
|
||||
global $wpdb;
|
||||
$deleted = $wpdb->delete($this->table, ['id' => $id], ['%d']) === 1;
|
||||
$deleted = $wpdb->delete(
|
||||
$this->table,
|
||||
['id' => $id, 'type' => 'general', 'channel' => 'manual'],
|
||||
['%d', '%s', '%s']
|
||||
) === 1;
|
||||
if ($deleted) {
|
||||
self::clear_snapshot_cache();
|
||||
self::clear_active_cache();
|
||||
}
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record @param array<string,mixed> $current */
|
||||
private function verified_is_newer(array $record, array $current): bool {
|
||||
$record_time = strtotime((string) ($record['completed_at'] ?? '')) ?: 0;
|
||||
$current_time = strtotime((string) ($current['completed_at'] ?? '')) ?: 0;
|
||||
if ($record_time !== $current_time) {
|
||||
return $record_time > $current_time;
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function delete_user(int $user_id): array {
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id, application_uuid, user_id, evidence_ids FROM {$this->table} WHERE user_id = %d",
|
||||
$user_id
|
||||
), ARRAY_A);
|
||||
if ($wpdb->delete($this->table, ['user_id' => $user_id], ['%d']) === false) {
|
||||
return [];
|
||||
}
|
||||
return (int) ($record['id'] ?? 0) > (int) ($current['id'] ?? 0);
|
||||
self::clear_active_cache($user_id);
|
||||
return array_map([$this, 'normalize'], is_array($rows) ? $rows : []);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function normalize(array $row): array {
|
||||
foreach (['id', 'user_id', 'reviewer_id', 'attempt_ordinal', 'retry_count'] as $key) {
|
||||
foreach (['id', 'user_id', 'reviewer_id'] as $key) {
|
||||
$row[$key] = (int) ($row[$key] ?? 0);
|
||||
}
|
||||
foreach (['provider_started_at', 'completed_at', 'expires_at'] as $nullable_key) {
|
||||
if (($row[$nullable_key] ?? null) === null) {
|
||||
$row[$nullable_key] = '';
|
||||
}
|
||||
}
|
||||
try {
|
||||
$row['fee_amount'] = \AKSRT\Ledger\AssetAmount::balance((string) ($row['fee_amount'] ?? ''))->decimal();
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$row['fee_amount'] = '';
|
||||
}
|
||||
$row['description'] = (string) ($row['provider_result_summary'] ?? '');
|
||||
unset($row['provider_result_summary']);
|
||||
$evidence = json_decode((string) ($row['evidence_ids'] ?? ''), true);
|
||||
$row['evidence_ids'] = is_array($evidence) ? array_values(array_map('intval', $evidence)) : [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields @return array<string,mixed> */
|
||||
private function map_description_to_storage(array $fields): array {
|
||||
if (array_key_exists('description', $fields)) {
|
||||
$fields['provider_result_summary'] = $fields['description'];
|
||||
unset($fields['description']);
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,461 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* Identity verification application orchestration.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
/** Manual identity application flow. @package AKSRT */
|
||||
|
||||
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;
|
||||
|
||||
final class ApplicationService {
|
||||
|
||||
public function __construct(
|
||||
private ?ApplicationRepository $applications = null,
|
||||
private ?TransitionService $transitions = null,
|
||||
private ?EvidenceService $evidence = null
|
||||
) {
|
||||
$this->applications ??= new ApplicationRepository();
|
||||
$this->transitions ??= new TransitionService($this->applications);
|
||||
$this->evidence ??= new EvidenceService();
|
||||
}
|
||||
private ApplicationRepository $applications;
|
||||
private EvidenceService $evidence;
|
||||
|
||||
/** @param array<string,mixed> $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'));
|
||||
}
|
||||
public function __construct(?ApplicationRepository $applications = null, ?EvidenceService $evidence = null) {
|
||||
$this->applications = $applications ?? new ApplicationRepository();
|
||||
$this->evidence = $evidence ?? new EvidenceService();
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
/** @return array<string,mixed>|\WP_Error */
|
||||
public function create(int $user_id, array $input, $files = null) {
|
||||
$settings = \AKSRT\User\AuthApply::settings();
|
||||
if (empty($settings['enabled']) || empty($settings['allow_apply'])) {
|
||||
return new \WP_Error('aksrt_identity_disabled', __('暂不能申请身份认证', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!$existing && $this->applications->verified_for_user($user_id)) {
|
||||
if (\AKSRT\User\AuthApply::is_verified($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]);
|
||||
if ($this->applications->active_for_user($user_id)) {
|
||||
return new \WP_Error('aksrt_identity_active', __('您的申请已提交,请耐心等待', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$minimum_level = max(0, (int) ($settings['minimum_level'] ?? 0));
|
||||
$identity_settings = class_exists('AKSRT\\Settings\\Settings')
|
||||
? \AKSRT\Settings\Settings::get_user_identity_settings()
|
||||
: [];
|
||||
$level_enabled = !isset($identity_settings['level']['enabled']) || !empty($identity_settings['level']['enabled']);
|
||||
if (!\AKSRT\User\AuthApply::meets_minimum_level($user_id, $minimum_level, $level_enabled)) {
|
||||
return new \WP_Error('aksrt_identity_level', __('当前等级暂不能申请身份认证', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$public_name = $type === 'enterprise' ? $this->truncate($company_name, 64) : __('个人认证', 'aksrt');
|
||||
|
||||
$is_new_application = !$existing;
|
||||
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]);
|
||||
$name = sanitize_text_field((string) ($input['name'] ?? ''));
|
||||
$description = sanitize_textarea_field((string) ($input['description'] ?? ''));
|
||||
$message = sanitize_textarea_field((string) ($input['message'] ?? ''));
|
||||
if ($this->length($name) < 4 || $this->length($name) > 16) {
|
||||
return new \WP_Error('aksrt_identity_name', __('认证名称不能少于 4 个或超过 16 个字符', 'aksrt'));
|
||||
}
|
||||
|
||||
$uuid = wp_generate_uuid4();
|
||||
$evidence_ids = [];
|
||||
if ($files !== null) {
|
||||
$evidence_ids = $this->evidence->upload($user_id, $uuid, $files);
|
||||
if (is_wp_error($evidence_ids)) {
|
||||
return $evidence_ids;
|
||||
}
|
||||
$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;
|
||||
}
|
||||
if ($is_new_application) {
|
||||
do_action('aksrt_user_auth_submitted', (int) $application['user_id'], $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 = [
|
||||
$created = $this->applications->create([
|
||||
'application_uuid' => $uuid,
|
||||
'client_request_id' => wp_generate_uuid4(),
|
||||
'request_payload_hash' => hash('sha256', wp_json_encode([$name, $description, $message])),
|
||||
'user_id' => $user_id,
|
||||
'type' => 'general',
|
||||
'channel' => 'manual',
|
||||
'manual_reason' => __('云认证未完成,用户转入人工审核', 'aksrt'),
|
||||
'evidence_ids' => wp_json_encode($ids),
|
||||
];
|
||||
$event = [
|
||||
'status' => 'manual_pending',
|
||||
'public_name' => $name,
|
||||
'description' => $description,
|
||||
'manual_reason' => $message,
|
||||
'evidence_ids' => wp_json_encode(array_map('absint', $evidence_ids)),
|
||||
]);
|
||||
if (is_wp_error($created)) {
|
||||
$this->evidence->delete($evidence_ids);
|
||||
return $created;
|
||||
}
|
||||
(new EventRepository())->record([
|
||||
'application_id' => (int) $created['id'],
|
||||
'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);
|
||||
'event_type' => 'application_submitted',
|
||||
'dedupe_key' => hash('sha256', $uuid . '|submitted'),
|
||||
'to_status' => 'manual_pending',
|
||||
'actor_user_id' => $user_id,
|
||||
'summary' => ['name' => $name],
|
||||
]);
|
||||
if (class_exists('AKSRT\\Settings\\MailNotifications')) {
|
||||
\AKSRT\Settings\MailNotifications::auth_submitted($user_id, $created);
|
||||
}
|
||||
return $this->view($updated, $changed['ledger']);
|
||||
}
|
||||
return $this->view($created);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
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 array<string,mixed> */
|
||||
private function view(array $application): array {
|
||||
$items = [];
|
||||
foreach ($this->evidence->items($application) as $item) {
|
||||
$items[] = ['url' => $item['url'], 'thumbnail_url' => $item['thumbnail_url']];
|
||||
}
|
||||
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',
|
||||
'uuid' => (string) ($application['application_uuid'] ?? ''),
|
||||
'status' => TransitionService::public_status((string) ($application['status'] ?? '')),
|
||||
'name' => (string) ($application['public_name'] ?? ''),
|
||||
'description' => (string) ($application['description'] ?? ''),
|
||||
'message' => (string) ($application['manual_reason'] ?? ''),
|
||||
'admin_note' => (string) ($application['review_note'] ?? ''),
|
||||
'time' => (string) ($application['created_at'] ?? ''),
|
||||
'evidence' => $items,
|
||||
];
|
||||
$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<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);
|
||||
private function length(string $value): int {
|
||||
if (function_exists('mb_strlen')) {
|
||||
return mb_strlen($value);
|
||||
}
|
||||
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];
|
||||
$count = preg_match_all('/./us', $value, $matches);
|
||||
return $count === false ? strlen($value) : $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Identity provider callback endpoints.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity;
|
||||
|
||||
use AKSRT\Identity\Provider\ProviderFactory;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class CallbackController {
|
||||
|
||||
private const NS = 'aksrt/v1';
|
||||
|
||||
public function register_routes(): void {
|
||||
foreach (['aliyun', 'tencent'] as $provider) {
|
||||
register_rest_route(self::NS, '/identity/callback/' . $provider, [
|
||||
'methods' => $provider === 'aliyun' ? ['GET', 'POST'] : 'POST',
|
||||
'callback' => function (\WP_REST_Request $request) use ($provider): \WP_REST_Response {
|
||||
return $this->handle($provider, $request);
|
||||
},
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function handle(string $provider_key, \WP_REST_Request $request): \WP_REST_Response {
|
||||
$body = $request->get_body();
|
||||
if (strlen($body) > 1024 * 1024) {
|
||||
return new \WP_REST_Response(['success' => false], 413);
|
||||
}
|
||||
$provider = ProviderFactory::make($provider_key);
|
||||
if (!$provider->verify_callback($request)) {
|
||||
return new \WP_REST_Response(['success' => false], 403);
|
||||
}
|
||||
$result = $provider->parse_callback($request);
|
||||
$session = sanitize_text_field((string) ($result->data['provider_session_id'] ?? ''));
|
||||
$outer_order = sanitize_text_field((string) ($result->data['outer_order_no'] ?? ''));
|
||||
if ($session === '' && $outer_order === '') {
|
||||
return new \WP_REST_Response(['success' => false], 422);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('identity_application');
|
||||
if ($session !== '') {
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE provider = %s AND provider_session_id = %s ORDER BY id DESC LIMIT 1",
|
||||
$provider_key,
|
||||
$session
|
||||
), ARRAY_A);
|
||||
} else {
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE provider = %s AND application_uuid = %s ORDER BY id DESC LIMIT 1",
|
||||
$provider_key,
|
||||
$outer_order
|
||||
), ARRAY_A);
|
||||
}
|
||||
if (!is_array($row)) {
|
||||
return new \WP_REST_Response(['success' => false], 404);
|
||||
}
|
||||
|
||||
// The callback only wakes server-side reconciliation. A provider query
|
||||
// remains the source of truth for a successful identity decision.
|
||||
$service = new ApplicationService();
|
||||
$response = $service->refresh($row);
|
||||
if (is_wp_error($response)) {
|
||||
return new \WP_REST_Response(['success' => false], 409);
|
||||
}
|
||||
return new \WP_REST_Response(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ final class EventRepository {
|
||||
'event_uuid' => wp_generate_uuid4(),
|
||||
'source' => sanitize_key((string) ($event['source'] ?? 'system')),
|
||||
'event_type' => sanitize_key((string) ($event['event_type'] ?? 'status_changed')),
|
||||
'external_event_id' => sanitize_text_field((string) ($event['external_event_id'] ?? '')),
|
||||
'dedupe_key' => (string) ($event['dedupe_key'] ?? hash('sha256', wp_generate_uuid4())),
|
||||
'from_status' => sanitize_key((string) ($event['from_status'] ?? '')),
|
||||
'to_status' => sanitize_key((string) ($event['to_status'] ?? '')),
|
||||
@@ -49,26 +48,6 @@ final class EventRepository {
|
||||
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;
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE application_id = %d ORDER BY id DESC",
|
||||
$application_id
|
||||
), ARRAY_A);
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
public function delete_for_application(int $application_id): bool {
|
||||
global $wpdb;
|
||||
return $wpdb->delete($this->table, ['application_id' => $application_id], ['%d']) !== false;
|
||||
|
||||
@@ -18,27 +18,6 @@ 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);
|
||||
@@ -60,7 +39,7 @@ final class EvidenceService {
|
||||
$checked = wp_check_filetype_and_ext($tmp, sanitize_file_name((string) ($file['name'] ?? 'evidence.jpg')));
|
||||
$mime = (string) ($checked['type'] ?? '');
|
||||
$ext = (string) ($checked['ext'] ?? '');
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true) || $ext === '') {
|
||||
if (!in_array($mime, ['image/gif', 'image/jpeg', 'image/png'], true) || $ext === '') {
|
||||
$this->delete($ids);
|
||||
return new \WP_Error('aksrt_identity_upload_type', __('证明材料仅支持 JPG、PNG 或 WebP 图片', 'aksrt'));
|
||||
}
|
||||
@@ -123,7 +102,7 @@ final class EvidenceService {
|
||||
if (!is_string($path) || !is_file($path) || !is_readable($path)) {
|
||||
wp_die(esc_html__('证明材料不存在', 'aksrt'), '', ['response' => 404]);
|
||||
}
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
if (!in_array($mime, ['image/gif', 'image/jpeg', 'image/png'], true)) {
|
||||
wp_die(esc_html__('证明材料类型无效', 'aksrt'), '', ['response' => 415]);
|
||||
}
|
||||
nocache_headers();
|
||||
|
||||
@@ -19,7 +19,6 @@ final class Identity {
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [new RestController(), 'register_routes']);
|
||||
add_action('rest_api_init', [new CallbackController(), 'register_routes']);
|
||||
(new EvidenceService())->init();
|
||||
add_action('init', [$this, 'ensure_reviewer_capability']);
|
||||
add_action('delete_user', [$this, 'delete_user_data']);
|
||||
@@ -33,16 +32,12 @@ final class Identity {
|
||||
}
|
||||
|
||||
public function delete_user_data(int $user_id): void {
|
||||
global $wpdb;
|
||||
$applications = \AKSRT\Core\DBMigrator::table('identity_application');
|
||||
$events = \AKSRT\Core\DBMigrator::table('identity_event');
|
||||
if ($applications === '' || $events === '') {
|
||||
return;
|
||||
$repository = new ApplicationRepository();
|
||||
foreach ($repository->delete_user($user_id) as $application) {
|
||||
(new EvidenceService())->delete((array) ($application['evidence_ids'] ?? []));
|
||||
(new EventRepository())->delete_for_application((int) ($application['id'] ?? 0));
|
||||
}
|
||||
$ids = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$applications} WHERE user_id = %d", $user_id));
|
||||
foreach ((array) $ids as $id) {
|
||||
$wpdb->delete($events, ['application_id' => (int) $id], ['%d']);
|
||||
}
|
||||
$wpdb->delete($applications, ['user_id' => $user_id], ['%d']);
|
||||
delete_user_meta($user_id, 'auth');
|
||||
delete_user_meta($user_id, 'auth_info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Alibaba Cloud Cloudauth provider.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity\Provider;
|
||||
|
||||
use AKSRT\Identity\SettingsRuntime;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class AliyunProvider implements ProviderInterface {
|
||||
|
||||
public function key(): string { return 'aliyun'; }
|
||||
|
||||
public function capabilities(): array { return ['personal' => true, 'enterprise' => true]; }
|
||||
|
||||
public function validate_config() {
|
||||
$cfg = SettingsRuntime::get();
|
||||
foreach (['access_key_id', 'access_key_secret'] as $key) {
|
||||
if (empty($cfg['credentials'][$key])) {
|
||||
return new \WP_Error('aksrt_identity_aliyun_credentials', __('阿里云实人认证凭证不完整', 'aksrt'));
|
||||
}
|
||||
}
|
||||
if (empty($cfg['personal_flow'])) {
|
||||
return new \WP_Error('aksrt_identity_aliyun_scene', __('请配置阿里云实人认证 SceneId', 'aksrt'));
|
||||
}
|
||||
if (!empty($cfg['enterprise_flow']) && preg_match('/[^A-Za-z0-9_-]/', (string) $cfg['enterprise_flow'])) {
|
||||
return new \WP_Error('aksrt_identity_aliyun_enterprise_scene', __('阿里云企业认证 SceneCode 格式无效', 'aksrt'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function start_personal(array $request): ProviderResult {
|
||||
return $this->init_face_verify($request);
|
||||
}
|
||||
|
||||
public function start_enterprise(array $request): ProviderResult {
|
||||
$cfg = SettingsRuntime::get();
|
||||
// Ref: https://help.aliyun.com/zh/id-verification/enterprise-factor-validation-v2
|
||||
foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) {
|
||||
if (empty($request[$key])) {
|
||||
return ProviderResult::failed('aliyun_enterprise_fields', __('企业认证资料不完整', 'aksrt'));
|
||||
}
|
||||
}
|
||||
$response = $this->rpc('cloudauth.aliyuncs.com', '2022-11-25', 'EntElementVerifyV2', [
|
||||
'SceneCode' => (string) ($cfg['enterprise_flow'] ?: $cfg['personal_flow']),
|
||||
'MerchantBizId' => preg_replace('/[^A-Za-z0-9]/', '', (string) $request['application_uuid']),
|
||||
'MerchantUserId' => preg_replace('/[^A-Za-z0-9]/', '', 'U' . (string) ($request['user_id'] ?? '0')),
|
||||
'UserAuthorization' => '1',
|
||||
'InfoVerifyType' => 'ENT_4META',
|
||||
'LicenseNo' => preg_replace('/\s+/', '', (string) $request['company_code']),
|
||||
'EntName' => (string) $request['company_name'],
|
||||
'LegalPersonName' => (string) $request['person_name'],
|
||||
'LegalPersonCertNo' => preg_replace('/\s+/', '', (string) $request['id_number']),
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
|
||||
}
|
||||
if (!$this->enterprise_passed($response)) {
|
||||
return ProviderResult::failed(
|
||||
sanitize_text_field((string) ($response['Code'] ?? $response['ResultCode'] ?? 'enterprise_failed')),
|
||||
sanitize_text_field((string) ($response['Message'] ?? __('企业要素或法人关系核验未通过', 'aksrt')))
|
||||
);
|
||||
}
|
||||
return $this->init_face_verify($request);
|
||||
}
|
||||
|
||||
public function query(array $application): ProviderResult {
|
||||
// Ref: https://api.aliyun.com/document/Cloudauth/2019-03-07/DescribeFaceVerify
|
||||
$response = $this->rpc('cloudauth.aliyuncs.com', '2019-03-07', 'DescribeFaceVerify', [
|
||||
'SceneId' => (string) ($application['provider_flow'] ?? ''),
|
||||
'CertifyId' => (string) ($application['provider_session_id'] ?? ''),
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]);
|
||||
}
|
||||
$code = sanitize_text_field((string) ($response['Code'] ?? ''));
|
||||
if ($code === '424') {
|
||||
return ProviderResult::pending([
|
||||
'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')),
|
||||
'result_code' => $code,
|
||||
'message' => __('用户尚未在阿里云核身页面提交认证,请完成后再刷新结果', 'aksrt'),
|
||||
]);
|
||||
}
|
||||
if ($code !== '' && $code !== '200') {
|
||||
return ProviderResult::failed($code, sanitize_text_field((string) ($response['Message'] ?? '')));
|
||||
}
|
||||
$object = $this->normalize_result_object($response['ResultObject'] ?? []);
|
||||
$passed = $object['Passed'] ?? $object['passed'] ?? $response['Passed'] ?? null;
|
||||
$success = $object['Success'] ?? $object['success'] ?? $response['Success'] ?? null;
|
||||
if ((string) ($response['Code'] ?? '') === '200' && ($success === true || $success === 'True' || $success === 'true') && $passed === 'T') {
|
||||
return ProviderResult::passed(['request_id' => (string) ($response['RequestId'] ?? ''), 'result_code' => 'passed']);
|
||||
}
|
||||
$status = strtoupper((string) ($object['SubCode'] ?? $object['Status'] ?? $object['subCode'] ?? $object['status'] ?? ''));
|
||||
if ((string) ($response['Code'] ?? '') === '200' && $status === '200') {
|
||||
return ProviderResult::passed(['request_id' => (string) ($response['RequestId'] ?? ''), 'result_code' => 'passed']);
|
||||
}
|
||||
$message = $object['MaterialInfo'] ?? $object['Message'] ?? $object['message'] ?? __('阿里云仍在处理认证结果', 'aksrt');
|
||||
if ($passed === 'F') {
|
||||
$debug = $this->response_debug_summary($response, $object, ['passed' => 'F']);
|
||||
$this->log_debug('DescribeFaceVerify not passed', $debug);
|
||||
return ProviderResult::failed($status ?: 'not_passed', sanitize_text_field((string) $message));
|
||||
}
|
||||
if ($object === []) {
|
||||
$debug = $this->response_debug_summary($response, $object);
|
||||
$this->log_debug('DescribeFaceVerify empty ResultObject', $debug);
|
||||
return ProviderResult::pending([
|
||||
'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')),
|
||||
'result_code' => 'processing',
|
||||
'message' => sprintf(__('阿里云已返回成功状态,认证明细仍在生成中:%s', 'aksrt'), $debug),
|
||||
]);
|
||||
}
|
||||
if ($status === '' || in_array($status, ['PROCESSING', 'INIT', 'PENDING'], true)) {
|
||||
return ProviderResult::pending(['result_code' => $status ?: 'processing', 'message' => sanitize_text_field((string) $message)]);
|
||||
}
|
||||
return ProviderResult::failed($status, sanitize_text_field((string) $message));
|
||||
}
|
||||
|
||||
private function is_public_https_url(string $url): bool {
|
||||
if ($url === '') {
|
||||
return false;
|
||||
}
|
||||
$parts = wp_parse_url($url);
|
||||
if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') {
|
||||
return false;
|
||||
}
|
||||
$host = strtolower((string) ($parts['host'] ?? ''));
|
||||
return $host !== '' && !in_array($host, ['localhost', '127.0.0.1', '::1'], true) && !str_ends_with($host, '.local');
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): bool {
|
||||
$token = sanitize_text_field((string) ($request->get_param('CallbackToken') ?: $request->get_param('callbackToken')));
|
||||
$session = sanitize_text_field((string) ($request->get_param('certifyId') ?: $request->get_param('CertifyId')));
|
||||
if ($token === '' || $session === '') {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('identity_application');
|
||||
$uuid = (string) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT application_uuid FROM {$table} WHERE provider = 'aliyun' AND provider_session_id = %s LIMIT 1",
|
||||
$session
|
||||
));
|
||||
return $uuid !== '' && hash_equals(hash_hmac('sha256', $uuid, wp_salt('nonce')), $token);
|
||||
}
|
||||
|
||||
public function parse_callback(\WP_REST_Request $request): ProviderResult {
|
||||
$session = sanitize_text_field((string) ($request->get_param('certifyId') ?: $request->get_param('CertifyId')));
|
||||
$outer_order = sanitize_text_field((string) ($request->get_param('outerOrderNo') ?: $request->get_param('OuterOrderNo')));
|
||||
return ProviderResult::pending(['provider_session_id' => $session, 'outer_order_no' => $outer_order, 'result_code' => 'callback_received']);
|
||||
}
|
||||
|
||||
private function init_face_verify(array $request): ProviderResult {
|
||||
// Ref: https://api.aliyun.com/document/Cloudauth/2019-03-07/InitFaceVerify
|
||||
$cfg = SettingsRuntime::get();
|
||||
$callback_token = hash_hmac('sha256', (string) ($request['application_uuid'] ?? ''), wp_salt('nonce'));
|
||||
$params = [
|
||||
'SceneId' => (string) $cfg['personal_flow'],
|
||||
'OuterOrderNo' => (string) ($request['application_uuid'] ?? wp_generate_uuid4()),
|
||||
'ProductCode' => (string) $cfg['product_code'],
|
||||
'CertType' => 'IDENTITY_CARD',
|
||||
'CertName' => (string) ($request['person_name'] ?? ''),
|
||||
'CertNo' => preg_replace('/\s+/', '', (string) ($request['id_number'] ?? '')),
|
||||
];
|
||||
$meta_info = trim((string) ($request['meta_info'] ?? ''));
|
||||
if ($meta_info !== '') {
|
||||
$params['MetaInfo'] = $meta_info;
|
||||
}
|
||||
$return_url = esc_url_raw((string) ($request['return_url'] ?? ''));
|
||||
if ($this->is_public_https_url($return_url)) {
|
||||
$params['ReturnUrl'] = $return_url;
|
||||
}
|
||||
$callback_url = esc_url_raw(rest_url('aksrt/v1/identity/callback/aliyun'));
|
||||
if ($this->is_public_https_url($callback_url)) {
|
||||
$params['CallbackUrl'] = $callback_url;
|
||||
$params['CallbackToken'] = $callback_token;
|
||||
}
|
||||
$response = $this->rpc('cloudauth.aliyuncs.com', '2019-03-07', 'InitFaceVerify', $params);
|
||||
if (is_wp_error($response)) {
|
||||
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
|
||||
}
|
||||
$code = sanitize_text_field((string) ($response['Code'] ?? ''));
|
||||
if ($code !== '' && $code !== '200') {
|
||||
return ProviderResult::failed($code, sanitize_text_field((string) ($response['Message'] ?? '')));
|
||||
}
|
||||
$object = $this->normalize_result_object($response['ResultObject'] ?? []);
|
||||
$certify_id = sanitize_text_field((string) $this->first_response_value($response, [
|
||||
['ResultObject', 'CertifyId'],
|
||||
['ResultObject', 'certifyId'],
|
||||
['CertifyId'],
|
||||
['certifyId'],
|
||||
['Data', 'CertifyId'],
|
||||
['Data', 'certifyId'],
|
||||
]));
|
||||
$url = esc_url_raw((string) $this->first_response_value(array_merge($response, ['ResultObject' => $object]), [
|
||||
['ResultObject', 'CertifyUrl'],
|
||||
['ResultObject', 'certifyUrl'],
|
||||
['ResultObject', 'Url'],
|
||||
['ResultObject', 'url'],
|
||||
['CertifyUrl'],
|
||||
['certifyUrl'],
|
||||
['Url'],
|
||||
['url'],
|
||||
['Data', 'CertifyUrl'],
|
||||
['Data', 'certifyUrl'],
|
||||
['Data', 'Url'],
|
||||
['Data', 'url'],
|
||||
]));
|
||||
if ($certify_id === '') {
|
||||
$debug = $this->response_debug_summary($response, $object, [
|
||||
'has_certify_id' => false,
|
||||
'has_certify_url' => $url !== '',
|
||||
]);
|
||||
$this->log_debug('InitFaceVerify missing CertifyId', $debug);
|
||||
return ProviderResult::failed('aliyun_invalid_response', sprintf(
|
||||
__('阿里云未返回有效认证会话:%s', 'aksrt'),
|
||||
$debug
|
||||
));
|
||||
}
|
||||
if ($url === '') {
|
||||
$debug = $this->response_debug_summary($response, $object, [
|
||||
'has_certify_id' => true,
|
||||
'has_certify_url' => false,
|
||||
]);
|
||||
$this->log_debug('InitFaceVerify returned CertifyId without URL', $debug);
|
||||
return ProviderResult::failed('aliyun_missing_certify_url', sprintf(
|
||||
__('阿里云只返回了 CertifyId,未返回可打开的核身链接。请确认 SceneId/ProductCode 对应的是支持 H5 链接的实人认证方案;调试信息:%s', 'aksrt'),
|
||||
$debug
|
||||
));
|
||||
}
|
||||
return ProviderResult::pending([
|
||||
'provider_session_id' => $certify_id,
|
||||
'request_id' => sanitize_text_field((string) ($response['RequestId'] ?? '')),
|
||||
'redirect_url' => $url,
|
||||
'result_code' => 'session_created',
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|\WP_Error */
|
||||
private function rpc(string $host, string $version, string $action, array $business) {
|
||||
$cfg = SettingsRuntime::get();
|
||||
$endpoint = 'https://' . $host . '/';
|
||||
// The official PHP SDK uses ACS3-HMAC-SHA256 by default. This zero-dependency
|
||||
// fallback deliberately uses the documented RPC v2 signature until the SDK
|
||||
// packages can be installed and locked in this deployment.
|
||||
$params = array_merge([
|
||||
'Format' => 'JSON',
|
||||
'Version' => $version,
|
||||
'AccessKeyId' => (string) $cfg['credentials']['access_key_id'],
|
||||
'SignatureMethod' => 'HMAC-SHA1',
|
||||
'SignatureNonce' => wp_generate_uuid4(),
|
||||
'SignatureVersion' => '1.0',
|
||||
'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'Action' => $action,
|
||||
], $business);
|
||||
ksort($params);
|
||||
$canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||
$string_to_sign = 'POST&%2F&' . rawurlencode($canonical);
|
||||
$params['Signature'] = base64_encode(hash_hmac('sha1', $string_to_sign, (string) $cfg['credentials']['access_key_secret'] . '&', true));
|
||||
$response = wp_remote_post($endpoint, [
|
||||
'timeout' => 20,
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
'body' => $params,
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return new \WP_Error('aliyun_network_error', __('阿里云接口请求失败', 'aksrt'));
|
||||
}
|
||||
$status_code = (int) wp_remote_retrieve_response_code($response);
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded)) {
|
||||
$this->log_debug('Aliyun invalid JSON response', [
|
||||
'http_status' => $status_code,
|
||||
'body_preview' => $this->truncate_debug($body, 800),
|
||||
]);
|
||||
return new \WP_Error('aliyun_invalid_json', __('阿里云返回格式无效', 'aksrt'));
|
||||
}
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
$this->log_debug('Aliyun RPC response', [
|
||||
'action' => $action,
|
||||
'http_status' => $status_code,
|
||||
'summary' => $this->response_debug_summary($decoded),
|
||||
]);
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function enterprise_passed(array $response): bool {
|
||||
$result = is_array($response['Result'] ?? null) ? $response['Result'] : [];
|
||||
return (string) ($response['Code'] ?? '') === 'Success'
|
||||
&& (string) ($result['BizCode'] ?? '') === '1'
|
||||
&& (string) ($result['Status'] ?? '') === '1';
|
||||
}
|
||||
|
||||
/** @param mixed $value @return array<string,mixed> */
|
||||
private function normalize_result_object($value): array {
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $response @param array<int,array<int,string>> $paths */
|
||||
private function first_response_value(array $response, array $paths): string {
|
||||
foreach ($paths as $path) {
|
||||
$value = $response;
|
||||
foreach ($path as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
$value = null;
|
||||
break;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
if (is_scalar($value) && (string) $value !== '') {
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $response @param array<string,mixed> $object @param array<string,mixed> $extra */
|
||||
private function response_debug_summary(array $response, array $object = [], array $extra = []): string {
|
||||
$summary = array_merge([
|
||||
'code' => (string) ($response['Code'] ?? ''),
|
||||
'message' => (string) ($response['Message'] ?? ''),
|
||||
'request_id' => (string) ($response['RequestId'] ?? ''),
|
||||
'top_keys' => implode(',', array_slice(array_keys($response), 0, 20)),
|
||||
'result_keys' => $object !== [] ? implode(',', array_slice(array_keys($object), 0, 20)) : '',
|
||||
], $extra);
|
||||
return wp_json_encode($summary, JSON_UNESCAPED_UNICODE) ?: '';
|
||||
}
|
||||
|
||||
/** @param mixed $data */
|
||||
private function log_debug(string $message, $data): void {
|
||||
if (!defined('WP_DEBUG') || !WP_DEBUG) {
|
||||
return;
|
||||
}
|
||||
error_log('[AKSRT Identity Aliyun] ' . $message . ': ' . (is_string($data) ? $data : (wp_json_encode($data, JSON_UNESCAPED_UNICODE) ?: '')));
|
||||
}
|
||||
|
||||
private function truncate_debug(string $value, int $length): string {
|
||||
return function_exists('mb_substr') ? mb_substr($value, 0, $length) : substr($value, 0, $length);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Identity provider factory.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity\Provider;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class ProviderFactory {
|
||||
|
||||
public static function make(string $provider): ProviderInterface {
|
||||
$provider = sanitize_key($provider);
|
||||
if ($provider === 'tencent') {
|
||||
return new TencentProvider();
|
||||
}
|
||||
return new AliyunProvider();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Identity provider contract.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity\Provider;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
interface ProviderInterface {
|
||||
|
||||
public function key(): string;
|
||||
|
||||
/** @return array{personal:bool,enterprise:bool} */
|
||||
public function capabilities(): array;
|
||||
|
||||
/** @return true|\WP_Error */
|
||||
public function validate_config();
|
||||
|
||||
/** @param array<string,mixed> $request */
|
||||
public function start_personal(array $request): ProviderResult;
|
||||
|
||||
/** @param array<string,mixed> $request */
|
||||
public function start_enterprise(array $request): ProviderResult;
|
||||
|
||||
/** @param array<string,mixed> $application */
|
||||
public function query(array $application): ProviderResult;
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): bool;
|
||||
|
||||
public function parse_callback(\WP_REST_Request $request): ProviderResult;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Normalized identity provider result.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity\Provider;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class ProviderResult {
|
||||
|
||||
public const PENDING = 'pending';
|
||||
public const PASSED = 'passed';
|
||||
public const FAILED = 'failed';
|
||||
public const UNSUPPORTED = 'unsupported';
|
||||
public const EXPIRED = 'expired';
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function __construct(
|
||||
public readonly string $decision,
|
||||
public readonly array $data = []
|
||||
) {}
|
||||
|
||||
public static function pending(array $data = []): self {
|
||||
return new self(self::PENDING, $data);
|
||||
}
|
||||
|
||||
public static function passed(array $data = []): self {
|
||||
return new self(self::PASSED, $data);
|
||||
}
|
||||
|
||||
public static function failed(string $code, string $message = ''): self {
|
||||
return new self(self::FAILED, ['result_code' => $code, 'message' => $message]);
|
||||
}
|
||||
|
||||
public static function unsupported(string $message): self {
|
||||
return new self(self::UNSUPPORTED, ['result_code' => 'unsupported', 'message' => $message]);
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tencent Cloud FaceId provider.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity\Provider;
|
||||
|
||||
use AKSRT\Identity\SettingsRuntime;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TencentProvider implements ProviderInterface {
|
||||
|
||||
private const VERSION = '2018-03-01';
|
||||
private const SERVICE = 'faceid';
|
||||
private const ENDPOINT = 'faceid.tencentcloudapi.com';
|
||||
|
||||
public function key(): string { return 'tencent'; }
|
||||
|
||||
public function capabilities(): array { return ['personal' => true, 'enterprise' => true]; }
|
||||
|
||||
public function validate_config() {
|
||||
$cfg = SettingsRuntime::get();
|
||||
foreach (['secret_id', 'secret_key'] as $key) {
|
||||
if (empty($cfg['credentials'][$key])) {
|
||||
return new \WP_Error('aksrt_identity_tencent_credentials', __('腾讯云人脸核身凭证不完整', 'aksrt'));
|
||||
}
|
||||
}
|
||||
if (empty($cfg['personal_flow'])) {
|
||||
return new \WP_Error('aksrt_identity_tencent_rule', __('请配置腾讯云人脸核身 RuleId', 'aksrt'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function start_personal(array $request): ProviderResult {
|
||||
// Ref: https://cloud.tencent.com/document/product/1007/31816
|
||||
$cfg = SettingsRuntime::get();
|
||||
$payload = [
|
||||
'RuleId' => (string) $cfg['personal_flow'],
|
||||
'TerminalType' => $this->terminal_type(),
|
||||
'RedirectUrl' => esc_url_raw((string) ($request['return_url'] ?? '')),
|
||||
'Extra' => (string) ($request['application_uuid'] ?? ''),
|
||||
];
|
||||
if (!empty($request['person_name'])) {
|
||||
$payload['Name'] = (string) $request['person_name'];
|
||||
}
|
||||
if (!empty($request['id_number'])) {
|
||||
$payload['IdCard'] = preg_replace('/\s+/', '', (string) $request['id_number']);
|
||||
}
|
||||
$response = $this->request('DetectAuth', $payload);
|
||||
if (is_wp_error($response)) {
|
||||
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
|
||||
}
|
||||
$data = is_array($response['Response'] ?? null) ? $response['Response'] : [];
|
||||
if (!empty($data['Error'])) {
|
||||
return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? ''));
|
||||
}
|
||||
$biz_token = sanitize_text_field((string) ($data['BizToken'] ?? ''));
|
||||
$url = esc_url_raw((string) ($data['Url'] ?? ''));
|
||||
if ($biz_token === '' || $url === '') {
|
||||
return ProviderResult::failed('tencent_invalid_response', __('腾讯云未返回有效认证会话', 'aksrt'));
|
||||
}
|
||||
return ProviderResult::pending([
|
||||
'provider_session_id' => $biz_token,
|
||||
'request_id' => sanitize_text_field((string) ($data['RequestId'] ?? '')),
|
||||
'redirect_url' => $url,
|
||||
'result_code' => 'session_created',
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', time() + ((int) $cfg['session_ttl_minutes'] * MINUTE_IN_SECONDS)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function start_enterprise(array $request): ProviderResult {
|
||||
// Ref: https://cloud.tencent.com/document/product/866/129072
|
||||
foreach (['company_name', 'company_code', 'person_name', 'id_number'] as $key) {
|
||||
if (empty($request[$key])) {
|
||||
return ProviderResult::failed('tencent_enterprise_fields', __('企业认证资料不完整', 'aksrt'));
|
||||
}
|
||||
}
|
||||
$response = $this->request_service('ocr', 'ocr.tencentcloudapi.com', '2018-11-19', 'VerifyBizLicenseEnterprise4', [
|
||||
'CreditCode' => preg_replace('/\s+/', '', (string) $request['company_code']),
|
||||
'EntName' => (string) $request['company_name'],
|
||||
'LrName' => (string) $request['person_name'],
|
||||
'IdNum' => preg_replace('/\s+/', '', (string) $request['id_number']),
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return ProviderResult::failed($response->get_error_code(), $response->get_error_message());
|
||||
}
|
||||
$data = is_array($response['Response'] ?? null) ? $response['Response'] : [];
|
||||
if (!empty($data['Error'])) {
|
||||
return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_ocr_error'), (string) ($data['Error']['Message'] ?? ''));
|
||||
}
|
||||
if (!$this->enterprise_passed($data)) {
|
||||
return ProviderResult::failed('tencent_enterprise_mismatch', __('企业工商四要素或法人关系核验未通过', 'aksrt'));
|
||||
}
|
||||
return $this->start_personal($request);
|
||||
}
|
||||
|
||||
public function query(array $application): ProviderResult {
|
||||
// Ref: https://cloud.tencent.com/document/product/1007/41957
|
||||
$response = $this->request('GetDetectInfoEnhanced', [
|
||||
'BizToken' => (string) ($application['provider_session_id'] ?? ''),
|
||||
'RuleId' => (string) ($application['provider_flow'] ?? ''),
|
||||
'InfoType' => '0',
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return ProviderResult::pending(['result_code' => $response->get_error_code(), 'message' => __('认证结果暂未取回,请稍后重试', 'aksrt')]);
|
||||
}
|
||||
$data = is_array($response['Response'] ?? null) ? $response['Response'] : [];
|
||||
if (!empty($data['Error'])) {
|
||||
return ProviderResult::failed((string) ($data['Error']['Code'] ?? 'tencent_error'), (string) ($data['Error']['Message'] ?? ''));
|
||||
}
|
||||
$text = $data['Text'] ?? ($data['DetectInfo']['Text'] ?? []);
|
||||
if (is_string($text)) {
|
||||
$decoded_text = json_decode($text, true);
|
||||
$text = is_array($decoded_text) ? $decoded_text : [];
|
||||
} elseif (!is_array($text)) {
|
||||
$text = [];
|
||||
}
|
||||
$result = (string) ($text['ErrCode'] ?? '');
|
||||
$live_status = (string) ($text['LiveStatus'] ?? '');
|
||||
$compare_status = (string) ($text['Comparestatus'] ?? '');
|
||||
if ($result === '0' && ($live_status === '' || $live_status === '0') && ($compare_status === '' || $compare_status === '0')) {
|
||||
return ProviderResult::passed(['request_id' => (string) ($data['RequestId'] ?? ''), 'result_code' => '0']);
|
||||
}
|
||||
$message = sanitize_text_field((string) ($text['ErrMsg'] ?? $text['LiveMsg'] ?? $text['Comparemsg'] ?? ''));
|
||||
if ($result === '') {
|
||||
return ProviderResult::pending(['result_code' => 'processing', 'message' => __('腾讯云仍在处理认证结果', 'aksrt')]);
|
||||
}
|
||||
return ProviderResult::failed($result, $message);
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): bool { unset($request); return false; }
|
||||
|
||||
public function parse_callback(\WP_REST_Request $request): ProviderResult { unset($request); return ProviderResult::unsupported(__('腾讯云人脸核身使用服务端主动查询,不接收结果回调', 'aksrt')); }
|
||||
|
||||
/** @return array<string,mixed>|\WP_Error */
|
||||
private function request(string $action, array $payload) {
|
||||
return $this->request_service(self::SERVICE, self::ENDPOINT, self::VERSION, $action, $payload);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|\WP_Error */
|
||||
private function request_service(string $service, string $host, string $version, string $action, array $payload) {
|
||||
$cfg = SettingsRuntime::get();
|
||||
$endpoint = 'https://' . $host;
|
||||
$timestamp = time();
|
||||
$date = gmdate('Y-m-d', $timestamp);
|
||||
$body = (string) wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$canonical_headers = "content-type:application/json; charset=utf-8\nhost:{$host}\nx-tc-action:" . strtolower($action) . "\n";
|
||||
$signed_headers = 'content-type;host;x-tc-action';
|
||||
$canonical_request = "POST\n/\n\n{$canonical_headers}\n{$signed_headers}\n" . hash('sha256', $body);
|
||||
$scope = $date . '/' . $service . '/tc3_request';
|
||||
$string_to_sign = "TC3-HMAC-SHA256\n{$timestamp}\n{$scope}\n" . hash('sha256', $canonical_request);
|
||||
$secret_key = (string) $cfg['credentials']['secret_key'];
|
||||
$secret_date = hash_hmac('sha256', $date, 'TC3' . $secret_key, true);
|
||||
$secret_service = hash_hmac('sha256', $service, $secret_date, true);
|
||||
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true);
|
||||
$signature = hash_hmac('sha256', $string_to_sign, $secret_signing);
|
||||
$authorization = 'TC3-HMAC-SHA256 Credential=' . $cfg['credentials']['secret_id'] . '/' . $scope . ', SignedHeaders=' . $signed_headers . ', Signature=' . $signature;
|
||||
$headers = [
|
||||
'Authorization' => $authorization,
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'Host' => $host,
|
||||
'X-TC-Action' => $action,
|
||||
'X-TC-Timestamp' => (string) $timestamp,
|
||||
'X-TC-Version' => $version,
|
||||
];
|
||||
if ($service !== self::SERVICE && !empty($cfg['region'])) {
|
||||
$headers['X-TC-Region'] = (string) $cfg['region'];
|
||||
}
|
||||
$response = wp_remote_post($endpoint, [
|
||||
'timeout' => 20,
|
||||
'headers' => $headers,
|
||||
'body' => $body,
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return new \WP_Error('tencent_network_error', __('腾讯云接口请求失败', 'aksrt'));
|
||||
}
|
||||
$decoded = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
return is_array($decoded) ? $decoded : new \WP_Error('tencent_invalid_json', __('腾讯云返回格式无效', 'aksrt'));
|
||||
}
|
||||
|
||||
private function enterprise_passed(array $data): bool {
|
||||
if ((string) ($data['StatusCode'] ?? '') !== '0' || (string) ($data['VerifyResult'] ?? '') !== '1') {
|
||||
return false;
|
||||
}
|
||||
foreach (['IsCreditCodeConsistent', 'IsEntNameConsistent', 'IsLrNameConsistent', 'IsIdNumConsistent'] as $field) {
|
||||
$value = $data[$field] ?? null;
|
||||
if (!in_array($value, [true, 1, '1', 'true'], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$status = strtoupper((string) ($data['OperatingStatus'] ?? $data['RegStatus'] ?? ''));
|
||||
return !in_array($status, ['吊销', '注销', 'REVOKED', 'CANCELLED'], true);
|
||||
}
|
||||
|
||||
private function terminal_type(): string {
|
||||
$ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
return str_contains($ua, 'mobile') ? '1' : '2';
|
||||
}
|
||||
}
|
||||
+37
-172
@@ -1,79 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* Public identity verification REST API.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
/** Identity application REST endpoints. @package AKSRT */
|
||||
|
||||
namespace AKSRT\Identity;
|
||||
|
||||
use AKSRT\Ledger\OperationKey;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class RestController {
|
||||
|
||||
private const NS = 'aksrt/v1';
|
||||
private const NS = 'aksrt/v1';
|
||||
private ApplicationService $service;
|
||||
|
||||
private ?array $request_application = null;
|
||||
private string $request_uuid = '';
|
||||
public function __construct(?ApplicationService $service = null) {
|
||||
$this->service = $service ?? new ApplicationService();
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private ?ApplicationRepository $applications = null,
|
||||
private ?ApplicationService $service = null
|
||||
) {
|
||||
$this->applications ??= new ApplicationRepository();
|
||||
$this->service ??= new ApplicationService($this->applications);
|
||||
}
|
||||
|
||||
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', [
|
||||
public function register_routes(): void {
|
||||
register_rest_route(self::NS, '/identity/applications', [
|
||||
'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' => \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' => \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' => \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' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'manual'],
|
||||
'permission_callback' => [$this, 'owns_application'],
|
||||
'args' => $uuid_args,
|
||||
]);
|
||||
}
|
||||
'callback' => [$this, 'create'],
|
||||
'permission_callback' => [$this, 'logged_in'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function logged_in(\WP_REST_Request $request) {
|
||||
if (!is_user_logged_in()) {
|
||||
@@ -84,114 +31,32 @@ final class RestController {
|
||||
return new \WP_Error('aksrt_identity_nonce', __('安全校验失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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 : [];
|
||||
$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);
|
||||
}
|
||||
|
||||
public function current(): \WP_REST_Response {
|
||||
$user_id = get_current_user_id();
|
||||
$data = class_exists('\\AKSRT\\User\\AuthApply')
|
||||
? \AKSRT\User\AuthApply::snapshot_view($user_id)
|
||||
: ['user_id' => $user_id, 'current' => null, 'active' => null, 'latest' => null];
|
||||
return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $data]));
|
||||
}
|
||||
|
||||
public function get(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$application = $this->application_for_request($request);
|
||||
if (!$application) {
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => 'aksrt_identity_missing',
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $this->service->view($application)]));
|
||||
}
|
||||
|
||||
public function refresh(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$application = $this->application_for_request($request);
|
||||
if (!$application) {
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => 'aksrt_identity_missing',
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
$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 {
|
||||
$application = $this->application_for_request($request);
|
||||
if (!$application) {
|
||||
return $this->no_store(new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => 'aksrt_identity_missing',
|
||||
'message' => __('认证申请不存在', 'aksrt'),
|
||||
], 404));
|
||||
}
|
||||
$operation_key = OperationKey::from_request($request, 'identity-manual', get_current_user_id());
|
||||
if (is_wp_error($operation_key)) {
|
||||
return $this->respond($operation_key);
|
||||
}
|
||||
public function create(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$files = $request->get_file_params();
|
||||
return $this->respond($this->service->move_to_manual($application, $files['evidence'] ?? null, $operation_key));
|
||||
}
|
||||
return $this->respond($this->service->create(
|
||||
get_current_user_id(),
|
||||
$request->get_params(),
|
||||
$files['evidence'] ?? null
|
||||
), 201);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function application_for_request(\WP_REST_Request $request): ?array {
|
||||
$uuid = (string) $request['uuid'];
|
||||
if ($uuid !== $this->request_uuid) {
|
||||
$this->request_uuid = $uuid;
|
||||
$this->request_application = $this->applications->find_by_uuid($uuid);
|
||||
}
|
||||
return is_array($this->request_application) ? $this->request_application : null;
|
||||
}
|
||||
|
||||
private function respond($result, int $success_status = 200): \WP_REST_Response {
|
||||
if (is_wp_error($result)) {
|
||||
$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(),
|
||||
'message' => $result->get_error_message(),
|
||||
], $status));
|
||||
}
|
||||
return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $result], $success_status));
|
||||
}
|
||||
|
||||
private function no_store(\WP_REST_Response $response): \WP_REST_Response {
|
||||
$response->header('Cache-Control', 'no-store, private');
|
||||
$response->header('Pragma', 'no-cache');
|
||||
return $response;
|
||||
}
|
||||
private function respond($result, int $success_status = 200): \WP_REST_Response {
|
||||
if (is_wp_error($result)) {
|
||||
$error_data = $result->get_error_data();
|
||||
$status = is_array($error_data) ? (int) ($error_data['status'] ?? 0) : 0;
|
||||
if ($status < 400 || $status > 599) $status = 400;
|
||||
$response = new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'code' => $result->get_error_code(),
|
||||
'message' => $result->get_error_message(),
|
||||
], $status);
|
||||
} else {
|
||||
$response = new \WP_REST_Response(['success' => true, 'data' => $result], $success_status);
|
||||
}
|
||||
$response->header('Cache-Control', 'no-store, private');
|
||||
$response->header('Pragma', 'no-cache');
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Encrypted identity provider credential storage.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class SecretStore {
|
||||
|
||||
public const OPTION_KEY = 'aksrt_identity_secrets';
|
||||
private const CONTEXT = 'aksrt-identity-secrets-v1';
|
||||
|
||||
/** @return array<string,string> */
|
||||
public static function get(string $provider): array {
|
||||
$stored = get_option(self::OPTION_KEY, []);
|
||||
$record = is_array($stored) ? ($stored[sanitize_key($provider)] ?? null) : null;
|
||||
if (!is_array($record)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$plain = self::decrypt($record);
|
||||
if ($plain === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($plain, true);
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($key) && is_string($value)) {
|
||||
$clean[sanitize_key($key)] = $value;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** @param array<string,string|null> $changes */
|
||||
public static function update(string $provider, array $changes): bool {
|
||||
$provider = sanitize_key($provider);
|
||||
if (!in_array($provider, ['aliyun', 'tencent'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$current = self::get($provider);
|
||||
foreach ($changes as $key => $value) {
|
||||
$key = sanitize_key((string) $key);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if ($value === null) {
|
||||
unset($current[$key]);
|
||||
} elseif (is_string($value) && $value !== '') {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$stored = get_option(self::OPTION_KEY, []);
|
||||
$stored = is_array($stored) ? $stored : [];
|
||||
if ($current === []) {
|
||||
unset($stored[$provider]);
|
||||
} else {
|
||||
$record = self::encrypt((string) wp_json_encode($current));
|
||||
if (is_wp_error($record)) {
|
||||
return false;
|
||||
}
|
||||
$stored[$provider] = $record;
|
||||
}
|
||||
return update_option(self::OPTION_KEY, $stored, false) || get_option(self::OPTION_KEY, []) === $stored;
|
||||
}
|
||||
|
||||
/** @return array<string,array{configured:bool,fingerprint:string}> */
|
||||
public static function status(string $provider): array {
|
||||
$status = [];
|
||||
foreach (self::get($provider) as $key => $value) {
|
||||
$status[$key] = [
|
||||
'configured' => $value !== '',
|
||||
'fingerprint' => $value !== '' ? substr(hash('sha256', $value), -4) : '',
|
||||
];
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
/** @return array<string,string>|\WP_Error */
|
||||
private static function encrypt(string $plain) {
|
||||
$key = self::key();
|
||||
if (function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt')) {
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plain, self::CONTEXT, $nonce, $key);
|
||||
return ['driver' => 'sodium', 'nonce' => base64_encode($nonce), 'cipher' => base64_encode($cipher)];
|
||||
}
|
||||
if (function_exists('openssl_encrypt')) {
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT);
|
||||
if (!is_string($cipher)) {
|
||||
return new \WP_Error('aksrt_identity_encrypt', __('身份认证凭证加密失败', 'aksrt'));
|
||||
}
|
||||
return ['driver' => 'openssl', 'nonce' => base64_encode($nonce), 'tag' => base64_encode($tag), 'cipher' => base64_encode($cipher)];
|
||||
}
|
||||
return new \WP_Error('aksrt_identity_crypto', __('服务器不支持安全凭证加密', 'aksrt'));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record */
|
||||
private static function decrypt(array $record): string {
|
||||
$nonce = base64_decode((string) ($record['nonce'] ?? ''), true);
|
||||
$cipher = base64_decode((string) ($record['cipher'] ?? ''), true);
|
||||
if (!is_string($nonce) || !is_string($cipher)) {
|
||||
return '';
|
||||
}
|
||||
$key = self::key();
|
||||
if (($record['driver'] ?? '') === 'sodium' && function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt')) {
|
||||
$plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, self::CONTEXT, $nonce, $key);
|
||||
return is_string($plain) ? $plain : '';
|
||||
}
|
||||
if (($record['driver'] ?? '') === 'openssl' && function_exists('openssl_decrypt')) {
|
||||
$tag = base64_decode((string) ($record['tag'] ?? ''), true);
|
||||
if (!is_string($tag)) {
|
||||
return '';
|
||||
}
|
||||
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, self::CONTEXT);
|
||||
return is_string($plain) ? $plain : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function key(): string {
|
||||
return hash_hmac('sha256', self::CONTEXT, wp_salt('auth') . wp_salt('secure_auth'), true);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Identity verification settings facade.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Identity;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class SettingsRuntime {
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function get(): array {
|
||||
$settings = \AKSRT\Settings\Settings::get_all();
|
||||
$provider = sanitize_key((string) ($settings['user_verify_provider'] ?? 'aliyun'));
|
||||
if (!in_array($provider, ['aliyun', 'tencent'], true)) {
|
||||
$provider = 'aliyun';
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !isset($settings['user_verify_enabled']) || !empty($settings['user_verify_enabled']),
|
||||
'allow_apply' => !isset($settings['user_verify_allow_apply']) || !empty($settings['user_verify_allow_apply']),
|
||||
'provider' => $provider,
|
||||
'allow_manual_fallback' => !isset($settings['user_verify_allow_manual_fallback']) || !empty($settings['user_verify_allow_manual_fallback']),
|
||||
'session_ttl_minutes' => max(5, min(1440, (int) ($settings['user_verify_session_ttl_minutes'] ?? 30))),
|
||||
'poll_interval_seconds' => max(3, min(60, (int) ($settings['user_verify_poll_interval_seconds'] ?? 5))),
|
||||
'max_retries' => max(0, min(3, (int) ($settings['user_verify_max_retries'] ?? 1))),
|
||||
'personal_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_personal_flow'] ?? '')),
|
||||
'product_code' => $provider === 'aliyun' && in_array(($settings['user_verify_aliyun_product_code'] ?? 'ID_PRO'), ['ID_PRO', 'PV_FV', 'LR_FR'], true)
|
||||
? (string) $settings['user_verify_aliyun_product_code']
|
||||
: 'ID_PRO',
|
||||
'enterprise_flow' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_enterprise_flow'] ?? '')),
|
||||
'region' => sanitize_text_field((string) ($settings['user_verify_' . $provider . '_region'] ?? '')),
|
||||
'credentials' => SecretStore::get($provider),
|
||||
];
|
||||
}
|
||||
|
||||
public static function hmac(string $value): string {
|
||||
return hash_hmac('sha256', trim($value), wp_salt('secure_auth') . '|aksrt-identity-hmac');
|
||||
}
|
||||
|
||||
public static function mask_id_number(string $value): string {
|
||||
$value = preg_replace('/\s+/', '', $value) ?: '';
|
||||
$length = strlen($value);
|
||||
if ($length <= 8) {
|
||||
return str_repeat('*', max(0, $length - 2)) . substr($value, -2);
|
||||
}
|
||||
return substr($value, 0, 3) . str_repeat('*', $length - 7) . substr($value, -4);
|
||||
}
|
||||
|
||||
public static function mask_name(string $value): string {
|
||||
$value = trim($value);
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
||||
if ($length <= 1) {
|
||||
return '*';
|
||||
}
|
||||
$first = function_exists('mb_substr') ? mb_substr($value, 0, 1) : substr($value, 0, 1);
|
||||
return $first . str_repeat('*', max(1, $length - 1));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
<?php
|
||||
/**
|
||||
* Identity verification state transitions.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
/** Public status labels for manual identity applications. @package AKSRT */
|
||||
|
||||
namespace AKSRT\Identity;
|
||||
|
||||
@@ -11,188 +7,12 @@ defined('ABSPATH') || exit;
|
||||
|
||||
final class TransitionService {
|
||||
|
||||
private const ALLOWED = [
|
||||
'created' => ['provider_pending', 'provider_failed', 'provider_unsupported', 'provider_expired', 'manual_pending', 'cancelled'],
|
||||
'provider_pending' => ['provider_processing', 'provider_failed', 'provider_unsupported', 'provider_expired', 'verified', 'manual_pending', 'cancelled'],
|
||||
'provider_processing' => ['provider_failed', 'provider_unsupported', 'provider_expired', 'verified', 'manual_pending'],
|
||||
'provider_failed' => ['provider_pending', 'manual_pending', 'rejected', 'cancelled'],
|
||||
'provider_unsupported' => ['manual_pending', 'rejected', 'cancelled'],
|
||||
'provider_expired' => ['provider_pending', 'manual_pending', 'rejected', 'cancelled'],
|
||||
'manual_pending' => ['verified', 'rejected', 'cancelled'],
|
||||
'verified' => ['cancelled'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private ?ApplicationRepository $applications = null,
|
||||
private ?EventRepository $events = null
|
||||
) {
|
||||
$this->applications ??= new ApplicationRepository();
|
||||
$this->events ??= new EventRepository();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields @param array<string,mixed> $event */
|
||||
public function transition(array $application, string $next, array $fields = [], array $event = []) {
|
||||
return $this->transition_with_policy($application, $next, $fields, $event, false);
|
||||
public static function public_status(string $status): string {
|
||||
return match ($status) {
|
||||
'manual_pending' => 'pending',
|
||||
'verified' => 'verified',
|
||||
'rejected' => 'rejected',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields @param array<string,mixed> $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<string,mixed> $fields @param array<string,mixed> $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 !== '') {
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
|
||||
if (\AKSRT\Ledger\LedgerService::get_instance()->transaction_active()) {
|
||||
return new \WP_Error('aksrt_identity_nested_transaction', __('认证状态不能开启嵌套事务', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$changed = $this->apply_transition_locked($locked, $next, $fields, $event, $admin_override);
|
||||
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 = []) {
|
||||
return $this->apply_transition_locked($application, $next, $fields, $event, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $application
|
||||
* @param array<string,mixed> $fields
|
||||
* @param array<string,mixed> $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);
|
||||
$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]);
|
||||
}
|
||||
|
||||
$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';
|
||||
}
|
||||
if ($status === 'cancelled') {
|
||||
return 'cancelled';
|
||||
}
|
||||
if ($status === 'rejected') {
|
||||
return 'rejected';
|
||||
}
|
||||
return in_array($status, ApplicationRepository::ACTIVE_STATUSES, true) ? 'pending' : 'rejected';
|
||||
}
|
||||
|
||||
private function send_notification(int $user_id, string $status, string $note): void {
|
||||
if (!class_exists('\\AKSRT\\Messages\\Message')) {
|
||||
return;
|
||||
}
|
||||
if ($status === 'verified') {
|
||||
$title = __('官方认证结果', 'aksrt');
|
||||
$message = __('您的官方认证已通过', 'aksrt');
|
||||
} elseif ($status === 'cancelled') {
|
||||
$title = __('官方认证已取消', 'aksrt');
|
||||
$message = sprintf(__('您的官方认证已被取消:%s', 'aksrt'), $note);
|
||||
} else {
|
||||
$title = __('官方认证结果', 'aksrt');
|
||||
$message = sprintf(__('您的官方认证申请未通过:%s', 'aksrt'), $note);
|
||||
}
|
||||
\AKSRT\Messages\Message::get_instance()->send($user_id, 'auth_apply_reply', $message, [
|
||||
'title' => $title,
|
||||
'target_url' => aksrt_get_user_center_tab_url('auth'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user