Fix modal scroll ownership and certification modal styling, store evidence as Media Library attachments, reconcile missed provider callbacks, validate currencies, and complete digital fulfillment only after durable entitlement delivery.
383 lines
15 KiB
PHP
383 lines
15 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';
|
|
|
|
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']);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* enabled:bool,allow_apply:bool,public_display:bool,badge_text:string,
|
|
* limit_level:int,desc:string,disable_desc:string,
|
|
* types:array<int,array{slug:string,label:string}>
|
|
* }
|
|
*/
|
|
public static function settings(): array {
|
|
$all = class_exists('\AKSRT\Settings\Settings')
|
|
? (array) \AKSRT\Settings\Settings::get_all()
|
|
: [];
|
|
$enabled = !isset($all['user_verify_enabled']) || !empty($all['user_verify_enabled']);
|
|
$types_raw = (string) ($all['user_verify_apply_option'] ?? "real_name|实名认证\norganization|企业认证");
|
|
$types = [];
|
|
foreach (preg_split('/\r\n|\r|\n/', $types_raw) as $line) {
|
|
$line = trim($line);
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
$parts = array_map('trim', explode('|', $line, 2));
|
|
$slug = sanitize_key($parts[0] ?? '');
|
|
$label = $parts[1] ?? $parts[0];
|
|
if ($slug === '') {
|
|
continue;
|
|
}
|
|
$types[] = ['slug' => $slug, 'label' => (string) $label];
|
|
}
|
|
if ($types === []) {
|
|
$types = [
|
|
['slug' => 'real_name', 'label' => __('实名认证', 'aksrt')],
|
|
['slug' => 'organization', 'label' => __('企业认证', 'aksrt')],
|
|
];
|
|
}
|
|
return [
|
|
'enabled' => $enabled,
|
|
'allow_apply' => !isset($all['user_verify_allow_apply']) || !empty($all['user_verify_allow_apply']),
|
|
'public_display' => !isset($all['user_verify_public_display']) || !empty($all['user_verify_public_display']),
|
|
'badge_text' => (string) ($all['user_verify_badge_text'] ?? __('已认证', 'aksrt')),
|
|
'limit_level' => max(0, (int) ($all['user_verify_limit_level'] ?? 0)),
|
|
'desc' => (string) ($all['user_verify_desc'] ?? ''),
|
|
'disable_desc' => (string) ($all['user_verify_disable_desc'] ?? ''),
|
|
'types' => $types,
|
|
];
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
/** @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 : [];
|
|
}
|
|
|
|
/**
|
|
* Build the only public representation of certification evidence.
|
|
*
|
|
* @param array<string,mixed> $application
|
|
* @return array<int,array{id:int,url:string,thumbnail_url:string}>
|
|
*/
|
|
public static function get_evidence_items(array $application): array {
|
|
$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') {
|
|
continue;
|
|
}
|
|
$url = wp_get_attachment_url($attachment_id);
|
|
if (!is_string($url) || $url === '') {
|
|
continue;
|
|
}
|
|
$thumb = wp_get_attachment_image_url($attachment_id, 'thumbnail');
|
|
$items[] = [
|
|
'id' => $attachment_id,
|
|
'url' => esc_url_raw($url),
|
|
'thumbnail_url' => esc_url_raw(is_string($thumb) && $thumb !== '' ? $thumb : $url),
|
|
];
|
|
}
|
|
return $items;
|
|
}
|
|
|
|
/** @param array<string,mixed> $application */
|
|
public static function application_view(array $application): array {
|
|
return [
|
|
'name' => (string) ($application['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),
|
|
];
|
|
}
|
|
|
|
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);
|
|
}
|
|
if ($s['limit_level'] > 0 && class_exists('\AKSRT\User\Level')) {
|
|
$lv = (int) Level::get_level($user_id);
|
|
if ($lv < $s['limit_level']) {
|
|
wp_send_json_error([
|
|
'message' => sprintf(
|
|
/* translators: %d: required level */
|
|
__('等级不足,需达到 LV.%d 后申请', 'aksrt'),
|
|
$s['limit_level']
|
|
),
|
|
], 403);
|
|
}
|
|
}
|
|
|
|
$name = isset($_POST['name']) ? sanitize_text_field(wp_unslash((string) $_POST['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'])) : '';
|
|
$allowed_types = array_column((array) $s['types'], 'slug');
|
|
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 === '' || !in_array($type, $allowed_types, true)) {
|
|
wp_send_json_error(['message' => __('请选择有效的认证类型', '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);
|
|
|
|
$previous_payload = self::get_apply_payload($user_id);
|
|
$payload = [
|
|
'name' => $name,
|
|
'desc' => $desc,
|
|
'msg' => $msg,
|
|
'type' => $type,
|
|
'evidence_attachment_ids' => $uploaded,
|
|
'time' => current_time('Y-m-d H:i'),
|
|
];
|
|
|
|
$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);
|
|
$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);
|
|
$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);
|
|
}
|
|
|
|
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);
|
|
delete_user_meta($user_id, self::REASON_META);
|
|
$this->delete_attachments(array_column(self::get_evidence_items($previous_payload), '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]);
|
|
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);
|
|
}
|
|
}
|