646 lines
26 KiB
PHP
646 lines
26 KiB
PHP
<?php
|
||
/**
|
||
* Frontend identity verification application.
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\User;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class AuthApply {
|
||
|
||
public const STATUS_META = 'aksrt_user_verify_status';
|
||
public const REASON_META = 'aksrt_user_verify_reason';
|
||
public const APPLY_META = 'aksrt_user_verify_apply';
|
||
public const INFO_META = 'aksrt_user_verify_info';
|
||
public const REQUESTED_AT_META = 'aksrt_user_verify_requested_at';
|
||
public const REVIEWED_AT_META = 'aksrt_user_verify_reviewed_at';
|
||
public const REVIEWER_META = 'aksrt_user_verify_reviewer';
|
||
public const HISTORY_META = 'aksrt_user_verify_history';
|
||
public const APPLICATION_COUNTS_META = 'aksrt_user_verify_application_counts';
|
||
|
||
private static $instance = null;
|
||
|
||
public static function get_instance(): self {
|
||
if (null === self::$instance) {
|
||
self::$instance = new self();
|
||
}
|
||
return self::$instance;
|
||
}
|
||
|
||
public function init(): void {
|
||
add_action('wp_ajax_aksrt_user_auth_apply', [$this, 'ajax_apply']);
|
||
add_action('admin_post_aksrt_verify_evidence', [$this, 'serve_evidence']);
|
||
if (is_admin()) {
|
||
add_filter('manage_users_columns', [$this, 'add_admin_user_column']);
|
||
add_filter('manage_users_custom_column', [$this, 'render_admin_user_column'], 10, 3);
|
||
add_action('show_user_profile', [$this, 'render_admin_profile_status']);
|
||
add_action('edit_user_profile', [$this, 'render_admin_profile_status']);
|
||
}
|
||
}
|
||
|
||
/** @return array{enabled:bool,allow_apply:bool,public_display:bool,limit_level:int,desc:string,disable_desc:string,types:array<string,array{slug:string,label:string,badge_text:string,fee:float,free_applications:int,require_real_name:bool}>} */
|
||
public static function settings(): array {
|
||
$identity = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_user_identity_settings() : [];
|
||
return is_array($identity['verify'] ?? null) ? $identity['verify'] : [];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function type_settings(string $type): array {
|
||
$types = self::settings()['types'] ?? [];
|
||
return is_array($types[sanitize_key($type)] ?? null) ? $types[sanitize_key($type)] : [];
|
||
}
|
||
|
||
public static function application_count(int $user_id, string $type): int {
|
||
$counts = get_user_meta($user_id, self::APPLICATION_COUNTS_META, true);
|
||
$counts = is_array($counts) ? $counts : [];
|
||
return max(0, (int) ($counts[sanitize_key($type)] ?? 0));
|
||
}
|
||
|
||
private static function increment_application_count(int $user_id, string $type): void {
|
||
$counts = get_user_meta($user_id, self::APPLICATION_COUNTS_META, true);
|
||
$counts = is_array($counts) ? $counts : [];
|
||
$type = sanitize_key($type);
|
||
$counts[$type] = max(0, (int) ($counts[$type] ?? 0)) + 1;
|
||
update_user_meta($user_id, self::APPLICATION_COUNTS_META, $counts);
|
||
}
|
||
|
||
public static function get_status(int $user_id): string {
|
||
if ($user_id <= 0) {
|
||
return '';
|
||
}
|
||
return sanitize_key((string) get_user_meta($user_id, self::STATUS_META, true));
|
||
}
|
||
|
||
public static function is_verified(int $user_id): bool {
|
||
return self::get_status($user_id) === 'verified';
|
||
}
|
||
|
||
public function add_admin_user_column(array $columns): array {
|
||
$columns['aksrt_identity_status'] = __('身份认证', 'aksrt');
|
||
return $columns;
|
||
}
|
||
|
||
public function render_admin_user_column(string $output, string $column_name, int $user_id): string {
|
||
if ($column_name !== 'aksrt_identity_status') {
|
||
return $output;
|
||
}
|
||
$status = self::get_status($user_id);
|
||
$labels = self::admin_status_labels();
|
||
if ($status === '' || !isset($labels[$status])) {
|
||
return '<span style="color:#646970;">' . esc_html__('未认证', 'aksrt') . '</span>';
|
||
}
|
||
$colors = ['pending' => '#996800', 'verified' => '#008a20', 'rejected' => '#b32d2e', 'cancelled' => '#646970'];
|
||
$html = '<span style="display:inline-block;padding:2px 8px;border:1px solid currentColor;border-radius:999px;color:'
|
||
. esc_attr($colors[$status] ?? '#646970') . '">' . esc_html($labels[$status]) . '</span>';
|
||
$info = self::get_auth_info($user_id);
|
||
if ($status === 'verified' && !empty($info['name'])) {
|
||
$html .= '<div class="description">' . esc_html((string) $info['name']) . '</div>';
|
||
}
|
||
if ($status === 'pending') {
|
||
$html .= '<div><a href="' . esc_url(admin_url('users.php?page=aksrt_user_auth&status=pending&keyword=' . $user_id)) . '">' . esc_html__('查看申请', 'aksrt') . '</a></div>';
|
||
}
|
||
return $html;
|
||
}
|
||
|
||
public function render_admin_profile_status(\WP_User $user): void {
|
||
$status = self::get_status((int) $user->ID);
|
||
$labels = self::admin_status_labels();
|
||
$apply = self::get_apply_payload((int) $user->ID);
|
||
$info = self::get_auth_info((int) $user->ID);
|
||
$reason = (string) get_user_meta((int) $user->ID, self::REASON_META, true);
|
||
$reviewed_at = (string) get_user_meta((int) $user->ID, self::REVIEWED_AT_META, true);
|
||
$reviewer_id = (int) get_user_meta((int) $user->ID, self::REVIEWER_META, true);
|
||
$reviewer = $reviewer_id > 0 ? get_userdata($reviewer_id) : null;
|
||
$display = $status !== '' && isset($labels[$status]) ? $labels[$status] : __('未认证', 'aksrt');
|
||
$identity_name = (string) ($info['name'] ?? $apply['name'] ?? '');
|
||
?>
|
||
<h2><?php esc_html_e('身份认证(AKSRT)', 'aksrt'); ?></h2>
|
||
<table class="form-table" role="presentation">
|
||
<tr>
|
||
<th><?php esc_html_e('认证状态', 'aksrt'); ?></th>
|
||
<td>
|
||
<strong><?php echo esc_html($display); ?></strong>
|
||
<?php if ($status === 'pending') : ?>
|
||
<a class="button button-small" href="<?php echo esc_url(admin_url('users.php?page=aksrt_user_auth&status=pending&keyword=' . (int) $user->ID)); ?>"><?php esc_html_e('前往审核', 'aksrt'); ?></a>
|
||
<?php elseif ($status === 'verified') : ?>
|
||
<a class="button button-small" href="<?php echo esc_url(admin_url('users.php?page=aksrt_user_auth&status=verified&keyword=' . (int) $user->ID)); ?>"><?php esc_html_e('查看并管理认证', 'aksrt'); ?></a>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php if ($identity_name !== '') : ?>
|
||
<tr><th><?php esc_html_e('认证身份', 'aksrt'); ?></th><td><?php echo esc_html($identity_name); ?></td></tr>
|
||
<?php endif; ?>
|
||
<?php if ($reviewed_at !== '') : ?>
|
||
<tr><th><?php esc_html_e('最近处理', 'aksrt'); ?></th><td><?php echo esc_html($reviewed_at); ?><?php if ($reviewer) : ?> · <?php echo esc_html($reviewer->display_name); ?><?php endif; ?></td></tr>
|
||
<?php endif; ?>
|
||
<?php if ($reason !== '') : ?>
|
||
<tr><th><?php esc_html_e('审核说明', 'aksrt'); ?></th><td><?php echo nl2br(esc_html($reason)); ?></td></tr>
|
||
<?php endif; ?>
|
||
</table>
|
||
<?php
|
||
}
|
||
|
||
/** @return array<string,string> */
|
||
private static function admin_status_labels(): array {
|
||
return [
|
||
'pending' => __('待审核', 'aksrt'),
|
||
'verified' => __('已认证', 'aksrt'),
|
||
'rejected' => __('已驳回', 'aksrt'),
|
||
'cancelled' => __('已取消', 'aksrt'),
|
||
];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function get_auth_info(int $user_id): array {
|
||
$info = get_user_meta($user_id, self::INFO_META, true);
|
||
return is_array($info) ? $info : [];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function get_apply_payload(int $user_id): array {
|
||
$apply = get_user_meta($user_id, self::APPLY_META, true);
|
||
return is_array($apply) ? $apply : [];
|
||
}
|
||
|
||
/**
|
||
* Return certification evidence only to its owner or an administrator.
|
||
*
|
||
* @param array<string,mixed> $application
|
||
* @return array<int,array{id:int,url:string,thumbnail_url:string}>
|
||
*/
|
||
public static function get_evidence_items(array $application, int $owner_id = 0, bool $admin_context = false): array {
|
||
if ($owner_id <= 0) {
|
||
return [];
|
||
}
|
||
$can_view = $admin_context
|
||
? current_user_can('manage_options')
|
||
: get_current_user_id() === $owner_id;
|
||
if (!$can_view) {
|
||
return [];
|
||
}
|
||
|
||
$items = [];
|
||
foreach ((array) ($application['evidence_attachment_ids'] ?? []) as $attachment_id) {
|
||
$attachment_id = (int) $attachment_id;
|
||
if (
|
||
$attachment_id <= 0
|
||
|| get_post_type($attachment_id) !== 'attachment'
|
||
|| (int) get_post_meta($attachment_id, '_aksrt_verify_evidence_owner', true) !== $owner_id
|
||
) {
|
||
continue;
|
||
}
|
||
$url = add_query_arg([
|
||
'action' => 'aksrt_verify_evidence',
|
||
'attachment' => $attachment_id,
|
||
'_wpnonce' => wp_create_nonce('aksrt_verify_evidence_' . $attachment_id),
|
||
], admin_url('admin-post.php'));
|
||
$items[] = [
|
||
'id' => $attachment_id,
|
||
'url' => esc_url_raw($url),
|
||
'thumbnail_url' => esc_url_raw($url),
|
||
];
|
||
}
|
||
return $items;
|
||
}
|
||
|
||
public function serve_evidence(): void {
|
||
if (!is_user_logged_in()) {
|
||
wp_die(esc_html__('请先登录', 'aksrt'), '', ['response' => 401]);
|
||
}
|
||
$attachment_id = isset($_GET['attachment']) ? (int) $_GET['attachment'] : 0;
|
||
if ($attachment_id <= 0 || !check_admin_referer('aksrt_verify_evidence_' . $attachment_id)) {
|
||
wp_die(esc_html__('安全校验失败', 'aksrt'), '', ['response' => 403]);
|
||
}
|
||
$owner_id = (int) get_post_meta($attachment_id, '_aksrt_verify_evidence_owner', true);
|
||
if ($owner_id <= 0 || (get_current_user_id() !== $owner_id && !current_user_can('manage_options'))) {
|
||
wp_die(esc_html__('您没有权限查看该证明材料', 'aksrt'), '', ['response' => 403]);
|
||
}
|
||
$file_path = get_attached_file($attachment_id);
|
||
if (!is_string($file_path) || $file_path === '' || !is_file($file_path) || !is_readable($file_path)) {
|
||
wp_die(esc_html__('证明材料不存在', 'aksrt'), '', ['response' => 404]);
|
||
}
|
||
$mime = (string) get_post_mime_type($attachment_id);
|
||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
|
||
wp_die(esc_html__('证明材料类型无效', 'aksrt'), '', ['response' => 415]);
|
||
}
|
||
|
||
nocache_headers();
|
||
header('Content-Type: ' . $mime);
|
||
header('Content-Disposition: inline; filename="' . rawurlencode(basename($file_path)) . '"');
|
||
header('Content-Length: ' . (string) filesize($file_path));
|
||
readfile($file_path);
|
||
exit;
|
||
}
|
||
|
||
/** @param array<string,mixed> $application */
|
||
public static function application_view(array $application, int $owner_id = 0, bool $admin_context = false): array {
|
||
return [
|
||
'name' => (string) ($application['name'] ?? ''),
|
||
'real_name' => (string) ($application['real_name'] ?? ''),
|
||
'desc' => (string) ($application['desc'] ?? ''),
|
||
'msg' => (string) ($application['msg'] ?? ''),
|
||
'type' => sanitize_key((string) ($application['type'] ?? '')),
|
||
'time' => (string) ($application['time'] ?? ''),
|
||
'evidence' => self::get_evidence_items($application, $owner_id, $admin_context),
|
||
];
|
||
}
|
||
|
||
/** @return array<int,array<string,mixed>> */
|
||
public static function get_review_history(int $user_id): array {
|
||
$history = get_user_meta($user_id, self::HISTORY_META, true);
|
||
$history = is_array($history) ? array_values($history) : [];
|
||
usort($history, static fn(array $left, array $right): int => strcmp(
|
||
(string) ($right['time'] ?? ''),
|
||
(string) ($left['time'] ?? '')
|
||
));
|
||
return $history;
|
||
}
|
||
|
||
/**
|
||
* Apply one authoritative certification status transition.
|
||
*
|
||
* @return array<string,mixed>|\WP_Error
|
||
*/
|
||
public static function review(int $user_id, string $status, string $note, int $reviewer_id) {
|
||
$status = sanitize_key($status);
|
||
$note = sanitize_textarea_field($note);
|
||
if (!in_array($status, ['verified', 'rejected', 'cancelled'], true)) {
|
||
return new \WP_Error('aksrt_verify_status', __('审核状态无效', 'aksrt'));
|
||
}
|
||
if ($user_id <= 0 || !get_userdata($user_id) || $reviewer_id <= 0) {
|
||
return new \WP_Error('aksrt_verify_user', __('用户不存在', 'aksrt'));
|
||
}
|
||
if ($status === 'cancelled' && $note === '') {
|
||
return new \WP_Error('aksrt_verify_note', __('取消认证时必须填写原因', 'aksrt'));
|
||
}
|
||
if ($status === 'rejected' && $note === '') {
|
||
return new \WP_Error('aksrt_verify_note', __('驳回申请时必须填写原因', 'aksrt'));
|
||
}
|
||
|
||
$current = self::get_status($user_id);
|
||
$allowed = $status === 'cancelled'
|
||
? $current === 'verified'
|
||
: $current === 'pending';
|
||
$application = self::get_apply_payload($user_id);
|
||
if (!$allowed || $application === [] || empty($application['name']) || empty($application['type'])) {
|
||
return new \WP_Error('aksrt_verify_transition', __('该认证记录当前不可执行此操作', 'aksrt'));
|
||
}
|
||
|
||
global $wpdb;
|
||
$rows = $wpdb->get_results($wpdb->prepare(
|
||
"SELECT umeta_id, meta_value
|
||
FROM {$wpdb->usermeta}
|
||
WHERE user_id = %d AND meta_key = %s
|
||
ORDER BY umeta_id ASC",
|
||
$user_id,
|
||
self::STATUS_META
|
||
), ARRAY_A);
|
||
if (count((array) $rows) !== 1 || sanitize_key((string) ($rows[0]['meta_value'] ?? '')) !== $current) {
|
||
return new \WP_Error('aksrt_verify_state', __('认证状态数据异常,请修复后重试', 'aksrt'));
|
||
}
|
||
$updated = $wpdb->query($wpdb->prepare(
|
||
"UPDATE {$wpdb->usermeta}
|
||
SET meta_value = %s
|
||
WHERE umeta_id = %d AND meta_value = %s",
|
||
$status,
|
||
(int) $rows[0]['umeta_id'],
|
||
$current
|
||
));
|
||
if ($updated !== 1) {
|
||
return new \WP_Error('aksrt_verify_conflict', __('认证状态已变化,请刷新后重试', 'aksrt'));
|
||
}
|
||
|
||
$reviewed_at = current_time('mysql');
|
||
update_user_meta($user_id, self::REVIEWED_AT_META, $reviewed_at);
|
||
update_user_meta($user_id, self::REVIEWER_META, $reviewer_id);
|
||
if ($note !== '') {
|
||
update_user_meta($user_id, self::REASON_META, $note);
|
||
} else {
|
||
delete_user_meta($user_id, self::REASON_META);
|
||
}
|
||
|
||
if ($status === 'verified') {
|
||
update_user_meta($user_id, self::INFO_META, [
|
||
'name' => (string) ($application['name'] ?? ''),
|
||
'desc' => (string) ($application['desc'] ?? ''),
|
||
'type' => sanitize_key((string) ($application['type'] ?? '')),
|
||
'time' => current_time('Y-m-d H:i'),
|
||
]);
|
||
} elseif ($status === 'cancelled') {
|
||
delete_user_meta($user_id, self::INFO_META);
|
||
}
|
||
|
||
$entry = [
|
||
'status' => $status,
|
||
'reason' => $note,
|
||
'reviewer' => $reviewer_id,
|
||
'time' => $reviewed_at,
|
||
'application' => self::application_view($application),
|
||
];
|
||
$history = self::get_review_history($user_id);
|
||
array_unshift($history, $entry);
|
||
update_user_meta($user_id, self::HISTORY_META, array_slice($history, 0, 50));
|
||
|
||
self::send_review_notification($user_id, $status, $note);
|
||
do_action('aksrt_user_auth_reviewed', $user_id, $status, $note, $reviewer_id);
|
||
|
||
return ['status' => $status, 'reviewed_at' => $reviewed_at];
|
||
}
|
||
|
||
private static function send_review_notification(int $user_id, string $status, string $note): void {
|
||
if (!class_exists('\\AKSRT\\Messages\\Message')) {
|
||
return;
|
||
}
|
||
if ($status === 'verified') {
|
||
$message = __('您的官方认证申请已通过审核', 'aksrt');
|
||
$title = __('官方认证审核结果', 'aksrt');
|
||
} elseif ($status === 'cancelled') {
|
||
$message = sprintf(__('您的官方认证已被取消:%s', 'aksrt'), $note);
|
||
$title = __('官方认证已取消', 'aksrt');
|
||
} else {
|
||
$message = sprintf(__('您的官方认证申请未通过:%s', 'aksrt'), $note);
|
||
$title = __('官方认证审核结果', 'aksrt');
|
||
}
|
||
\AKSRT\Messages\Message::get_instance()->send($user_id, 'auth_apply_reply', $message, [
|
||
'title' => $title,
|
||
'target_url' => aksrt_get_user_center_tab_url('auth'),
|
||
]);
|
||
}
|
||
|
||
public function ajax_apply(): void {
|
||
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') {
|
||
wp_send_json_error(['message' => __('请求方式无效', 'aksrt')], 405);
|
||
}
|
||
if (!is_user_logged_in()) {
|
||
wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401);
|
||
}
|
||
if (!check_ajax_referer('aksrt_user_auth_apply', '_wpnonce', false)) {
|
||
wp_send_json_error(['message' => __('安全校验失败', 'aksrt')], 403);
|
||
}
|
||
|
||
$user_id = get_current_user_id();
|
||
$s = self::settings();
|
||
if (!$s['enabled'] || !$s['allow_apply']) {
|
||
wp_send_json_error(['message' => __('暂不能申请身份认证', 'aksrt')], 403);
|
||
}
|
||
if (self::is_verified($user_id)) {
|
||
wp_send_json_error(['message' => __('您已是认证用户', 'aksrt')], 409);
|
||
}
|
||
if (self::get_status($user_id) === 'pending') {
|
||
wp_send_json_error(['message' => __('您的申请正在审核中', 'aksrt')], 409);
|
||
}
|
||
$name = isset($_POST['name']) ? sanitize_text_field(wp_unslash((string) $_POST['name'])) : '';
|
||
$real_name = isset($_POST['real_name']) ? sanitize_text_field(wp_unslash((string) $_POST['real_name'])) : '';
|
||
$desc = isset($_POST['desc']) ? sanitize_textarea_field(wp_unslash((string) $_POST['desc'])) : '';
|
||
$msg = isset($_POST['msg']) ? sanitize_textarea_field(wp_unslash((string) $_POST['msg'])) : '';
|
||
$type = isset($_POST['type']) ? sanitize_key((string) wp_unslash($_POST['type'])) : '';
|
||
$type_settings = self::type_settings($type);
|
||
if ($this->text_length($name) < 4 || $this->text_length($name) > 16) {
|
||
wp_send_json_error(['message' => __('认证身份长度需在 4-16 个字符之间', 'aksrt')], 422);
|
||
}
|
||
if ($this->text_length($desc) > 300 || $this->text_length($msg) > 500) {
|
||
wp_send_json_error(['message' => __('认证说明内容过长', 'aksrt')], 422);
|
||
}
|
||
if ($type_settings === []) {
|
||
wp_send_json_error(['message' => __('请选择有效的认证类型', 'aksrt')], 422);
|
||
}
|
||
if (!empty($type_settings['require_real_name']) && $real_name === '') {
|
||
wp_send_json_error(['message' => __('请填写真实姓名', 'aksrt')], 422);
|
||
}
|
||
if ($this->text_length($real_name) > 50) {
|
||
wp_send_json_error(['message' => __('真实姓名不能超过 50 个字符', 'aksrt')], 422);
|
||
}
|
||
|
||
$lock_key = 'aksrt_auth_apply_lock_' . $user_id;
|
||
$lock_token = wp_generate_uuid4();
|
||
$lock_payload = ['token' => $lock_token, 'expires' => time() + 300];
|
||
if (!add_option($lock_key, $lock_payload, '', false)) {
|
||
$current_lock = get_option($lock_key, []);
|
||
if (is_array($current_lock) && (int) ($current_lock['expires'] ?? 0) < time()) {
|
||
delete_option($lock_key);
|
||
}
|
||
if (!add_option($lock_key, $lock_payload, '', false)) {
|
||
wp_send_json_error(['message' => __('认证申请正在提交,请勿重复操作', 'aksrt')], 409);
|
||
}
|
||
}
|
||
|
||
$uploaded = [];
|
||
if (self::is_verified($user_id)) {
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
wp_send_json_error(['message' => __('您已是认证用户', 'aksrt')], 409);
|
||
}
|
||
if (self::get_status($user_id) === 'pending') {
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
wp_send_json_error(['message' => __('您的申请正在审核中', 'aksrt')], 409);
|
||
}
|
||
|
||
$uploaded = $this->upload_evidence($user_id);
|
||
if (is_wp_error($uploaded)) {
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
wp_send_json_error(['message' => $uploaded->get_error_message()], 422);
|
||
}
|
||
|
||
if (!$this->owns_submission_lock($lock_key, $lock_token)) {
|
||
$this->delete_attachments($uploaded);
|
||
wp_send_json_error(['message' => __('认证申请提交超时,请重新提交', 'aksrt')], 409);
|
||
}
|
||
$this->renew_submission_lock($lock_key, $lock_token);
|
||
|
||
$free_applications = max(0, (int) ($type_settings['free_applications'] ?? 0));
|
||
$application_count = self::application_count($user_id, $type);
|
||
$fee = $application_count < $free_applications ? 0.0 : max(0, (float) ($type_settings['fee'] ?? 0));
|
||
if ($fee > 0) {
|
||
$deducted = \AKSRT\Credits\Balance::deduct(
|
||
$user_id,
|
||
$fee,
|
||
'verify_application',
|
||
['type' => 'verify', 'id' => $user_id],
|
||
sprintf(__('提交%s申请', 'aksrt'), (string) $type_settings['label'])
|
||
);
|
||
if (is_wp_error($deducted)) {
|
||
$this->delete_attachments($uploaded);
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
wp_send_json_error(['message' => $deducted->get_error_message()], 422);
|
||
}
|
||
}
|
||
|
||
$previous_payload = self::get_apply_payload($user_id);
|
||
$requested_at = current_time('Y-m-d H:i');
|
||
$payload = [
|
||
'name' => $name,
|
||
'real_name' => $real_name,
|
||
'desc' => $desc,
|
||
'msg' => $msg,
|
||
'type' => $type,
|
||
'evidence_attachment_ids' => $uploaded,
|
||
'time' => $requested_at,
|
||
'fee' => $fee,
|
||
];
|
||
|
||
$payload_saved = update_user_meta($user_id, self::APPLY_META, $payload);
|
||
if ($payload_saved === false && get_user_meta($user_id, self::APPLY_META, true) !== $payload) {
|
||
$this->delete_attachments($uploaded);
|
||
if ($fee > 0) {
|
||
\AKSRT\Credits\Balance::add($user_id, $fee, 'verify_application_refund', ['type' => 'verify', 'id' => $user_id], __('认证申请保存失败退款', 'aksrt'));
|
||
}
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
wp_send_json_error(['message' => __('认证申请保存失败,请重试', 'aksrt')], 500);
|
||
}
|
||
$status_saved = update_user_meta($user_id, self::STATUS_META, 'pending');
|
||
if ($status_saved === false && self::get_status($user_id) !== 'pending') {
|
||
$this->delete_attachments($uploaded);
|
||
if ($fee > 0) {
|
||
\AKSRT\Credits\Balance::add($user_id, $fee, 'verify_application_refund', ['type' => 'verify', 'id' => $user_id], __('认证申请保存失败退款', 'aksrt'));
|
||
}
|
||
$previous_payload === []
|
||
? delete_user_meta($user_id, self::APPLY_META)
|
||
: update_user_meta($user_id, self::APPLY_META, $previous_payload);
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
wp_send_json_error(['message' => __('认证申请保存失败,请重试', 'aksrt')], 500);
|
||
}
|
||
update_user_meta($user_id, self::REQUESTED_AT_META, $requested_at);
|
||
delete_user_meta($user_id, self::REVIEWED_AT_META);
|
||
delete_user_meta($user_id, self::REVIEWER_META);
|
||
delete_user_meta($user_id, self::REASON_META);
|
||
self::increment_application_count($user_id, $type);
|
||
|
||
if (!$this->owns_submission_lock($lock_key, $lock_token)) {
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
wp_send_json_error(['message' => __('认证申请已提交,请刷新查看最新状态', 'aksrt')], 409);
|
||
}
|
||
$this->renew_submission_lock($lock_key, $lock_token);
|
||
$this->delete_attachments(array_column(self::get_evidence_items($previous_payload, $user_id), 'id'));
|
||
|
||
/** @param int $user_id @param array $payload */
|
||
do_action('aksrt_user_auth_apply_submitted', $user_id, $payload);
|
||
$this->release_submission_lock($lock_key, $lock_token);
|
||
|
||
wp_send_json_success([
|
||
'message' => __('提交成功,请等待管理员审核', 'aksrt'),
|
||
'status' => 'pending',
|
||
], 201);
|
||
}
|
||
|
||
/** @return array<int,int>|\WP_Error */
|
||
private function upload_evidence(int $user_id) {
|
||
$files = $this->normalize_uploads($_FILES['image'] ?? null);
|
||
if (is_wp_error($files)) {
|
||
return $files;
|
||
}
|
||
if ($files === []) {
|
||
return [];
|
||
}
|
||
|
||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||
|
||
$attachment_ids = [];
|
||
foreach ($files as $file) {
|
||
$filename = sanitize_file_name((string) ($file['name'] ?? ''));
|
||
$tmp_name = (string) ($file['tmp_name'] ?? '');
|
||
$size = (int) ($file['size'] ?? 0);
|
||
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||
if ($error !== UPLOAD_ERR_OK || $filename === '' || $size <= 0 || $size > 2 * MB_IN_BYTES || !is_uploaded_file($tmp_name)) {
|
||
$this->delete_attachments($attachment_ids);
|
||
return new \WP_Error('aksrt_auth_upload_failed', __('证明材料上传失败,单张图片不能超过 2MB', 'aksrt'));
|
||
}
|
||
$file_check = wp_check_filetype_and_ext($tmp_name, $filename);
|
||
$mime = (string) ($file_check['type'] ?? '');
|
||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
|
||
$this->delete_attachments($attachment_ids);
|
||
return new \WP_Error('aksrt_auth_upload_type', __('证明材料仅支持 JPG、PNG、GIF 或 WebP 图片', 'aksrt'));
|
||
}
|
||
|
||
$attachment_id = media_handle_sideload([
|
||
'name' => $filename,
|
||
'tmp_name' => $tmp_name,
|
||
'type' => $mime,
|
||
'error' => UPLOAD_ERR_OK,
|
||
'size' => $size,
|
||
], 0, __('官方认证证明材料', 'aksrt'));
|
||
if (is_wp_error($attachment_id)) {
|
||
$this->delete_attachments($attachment_ids);
|
||
return new \WP_Error('aksrt_auth_upload_failed', __('证明材料写入媒体库失败', 'aksrt'));
|
||
}
|
||
$attachment_id = (int) $attachment_id;
|
||
$stored_mime = (string) get_post_mime_type($attachment_id);
|
||
if (!in_array($stored_mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
|
||
wp_delete_attachment($attachment_id, true);
|
||
$this->delete_attachments($attachment_ids);
|
||
return new \WP_Error('aksrt_auth_upload_type', __('证明材料类型校验失败', 'aksrt'));
|
||
}
|
||
wp_update_post([
|
||
'ID' => $attachment_id,
|
||
'post_author' => $user_id,
|
||
'post_status' => 'private',
|
||
]);
|
||
update_post_meta($attachment_id, '_aksrt_verify_evidence_owner', $user_id);
|
||
$attachment_ids[] = $attachment_id;
|
||
}
|
||
return $attachment_ids;
|
||
}
|
||
|
||
/** @return array<int,array<string,mixed>>|\WP_Error */
|
||
private function normalize_uploads($input) {
|
||
if (!is_array($input) || empty($input['name'])) {
|
||
return [];
|
||
}
|
||
$names = is_array($input['name']) ? $input['name'] : [$input['name']];
|
||
if (count($names) > 3) {
|
||
return new \WP_Error('aksrt_auth_upload_limit', __('证明材料最多上传 3 张', 'aksrt'));
|
||
}
|
||
$files = [];
|
||
foreach ($names as $index => $name) {
|
||
if ((string) $name === '') {
|
||
continue;
|
||
}
|
||
$files[] = [
|
||
'name' => $name,
|
||
'type' => is_array($input['type'] ?? null) ? ($input['type'][$index] ?? '') : ($input['type'] ?? ''),
|
||
'tmp_name' => is_array($input['tmp_name'] ?? null) ? ($input['tmp_name'][$index] ?? '') : ($input['tmp_name'] ?? ''),
|
||
'error' => is_array($input['error'] ?? null) ? ($input['error'][$index] ?? UPLOAD_ERR_NO_FILE) : ($input['error'] ?? UPLOAD_ERR_NO_FILE),
|
||
'size' => is_array($input['size'] ?? null) ? ($input['size'][$index] ?? 0) : ($input['size'] ?? 0),
|
||
];
|
||
}
|
||
return $files;
|
||
}
|
||
|
||
/** @param array<int,int|string> $attachment_ids */
|
||
private function delete_attachments(array $attachment_ids): void {
|
||
foreach ($attachment_ids as $attachment_id) {
|
||
$attachment_id = (int) $attachment_id;
|
||
if ($attachment_id > 0 && get_post_type($attachment_id) === 'attachment') {
|
||
wp_delete_attachment($attachment_id, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function owns_submission_lock(string $lock_key, string $lock_token): bool {
|
||
$current_lock = get_option($lock_key, []);
|
||
return is_array($current_lock)
|
||
&& (int) ($current_lock['expires'] ?? 0) >= time()
|
||
&& hash_equals((string) ($current_lock['token'] ?? ''), $lock_token);
|
||
}
|
||
|
||
private function renew_submission_lock(string $lock_key, string $lock_token): void {
|
||
if ($this->owns_submission_lock($lock_key, $lock_token)) {
|
||
update_option($lock_key, ['token' => $lock_token, 'expires' => time() + 300], false);
|
||
}
|
||
}
|
||
|
||
private function release_submission_lock(string $lock_key, string $lock_token): void {
|
||
$current_lock = get_option($lock_key, []);
|
||
if (is_array($current_lock) && hash_equals((string) ($current_lock['token'] ?? ''), $lock_token)) {
|
||
delete_option($lock_key);
|
||
}
|
||
}
|
||
|
||
private function text_length(string $value): int {
|
||
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
||
}
|
||
}
|