PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Services / CustomerIdentity / CustomerRecoveryService.php

CustomerRecoveryService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Services/CustomerIdentity/CustomerRecoveryService.php

159 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\CustomerIdentity;
4
5 use FluentCart\App\Models\Customer;
6 use FluentCart\App\Models\User;
7 use FluentCart\Framework\Database\Orm\Builder;
8
9 /** Resumes large, verified recoveries without retaining frontend transactions. */
10 class CustomerRecoveryService
11 {
12 const META_KEY = '_fct_customer_recovery';
13 const HOOK = 'fluent_cart/customer/recover_verified_history';
14 const FOREGROUND_SOURCES = 5;
15 const MAX_ATTEMPTS = 3;
16
17 public static function start(int $userId, Customer $target, string $email): void
18 {
19 if (!CustomerMerger::supportsTransactions()) {
20 throw new \RuntimeException('Customer recovery requires transactional storage.');
21 }
22
23 $state = [
24 'id' => wp_generate_uuid4(),
25 'customer_id' => (int) $target->id,
26 'email' => $email,
27 'cursor' => 0,
28 'upper_id' => (int) static::sources($email, (int) $target->id)->max('id'),
29 'processed' => 0,
30 'attempts' => 0,
31 'incomplete' => false,
32 'status' => 'pending',
33 ];
34 update_user_meta($userId, static::META_KEY, $state);
35 if (!wp_schedule_single_event(time() + 1, static::HOOK, [['user_id' => $userId, 'id' => $state['id']]])) {
36 throw new \RuntimeException('Unable to schedule customer recovery.');
37 }
38 }
39
40 public static function progress(int $userId): array
41 {
42 $state = get_user_meta($userId, static::META_KEY, true);
43 return is_array($state) ? $state : [];
44 }
45
46 protected static function sources(string $email, int $targetId): Builder
47 {
48 return Customer::query()->where('email', $email)->where('id', '!=', $targetId)->unclaimed();
49 }
50
51 /** One customer and at most BATCH_SIZE rows per resource table per invocation. */
52 public static function run(array $payload): void
53 {
54 $userId = (int) ($payload['user_id'] ?? 0);
55 $jobId = (string) ($payload['id'] ?? '');
56 wp_cache_delete($userId, 'user_meta');
57 $state = static::progress($userId);
58 if (($state['id'] ?? '') !== $jobId || ($state['status'] ?? '') !== 'pending') {
59 return;
60 }
61
62 if (!CustomerMerger::supportsTransactions()) {
63 // One compare-and-set write; no rollback is assumed on this path.
64 // Do not replace a newer claim or advance any transfer progress.
65 $blocked = array_merge($state, ['status' => 'blocked_storage']);
66 update_user_meta($userId, static::META_KEY, $blocked, $state);
67 return;
68 }
69
70 // Persist a watchdog BEFORE opening the work transaction. A hard timeout
71 // rolls back that batch but leaves a scheduled retry and its attempt count.
72 $args = [['user_id' => $userId, 'id' => $jobId]];
73 $retryAt = time() + 15 * MINUTE_IN_SECONDS;
74 $connection = Customer::query()->getConnection();
75 if (!wp_schedule_single_event($retryAt, static::HOOK, $args) && !wp_next_scheduled(static::HOOK, $args)) {
76 $connection->transaction(function () use ($userId, $jobId) {
77 User::query()->where('ID', $userId)->lockForUpdate()->first();
78 wp_cache_delete($userId, 'user_meta');
79 $state = static::progress($userId);
80 if (($state['id'] ?? '') === $jobId && ($state['status'] ?? '') === 'pending') {
81 $state['status'] = 'failed';
82 update_user_meta($userId, static::META_KEY, $state);
83 }
84 });
85 return;
86 }
87 try {
88 $ready = $connection->transaction(function () use ($userId, $jobId) {
89 User::query()->where('ID', $userId)->lockForUpdate()->first();
90 wp_cache_delete($userId, 'user_meta');
91 $state = static::progress($userId);
92 if (($state['id'] ?? '') !== $jobId || ($state['status'] ?? '') !== 'pending') {
93 return false;
94 }
95 if ($state['attempts'] >= static::MAX_ATTEMPTS) {
96 $state['status'] = 'failed';
97 update_user_meta($userId, static::META_KEY, $state);
98 return false;
99 }
100 $state['attempts']++;
101 update_user_meta($userId, static::META_KEY, $state);
102 return true;
103 });
104 if ($ready) {
105 $connection->transaction(function () use ($userId, $jobId) {
106 User::query()->where('ID', $userId)->lockForUpdate()->first();
107 clean_user_cache($userId);
108 wp_cache_delete($userId, 'user_meta');
109 $state = static::progress($userId);
110 if (($state['id'] ?? '') !== $jobId || ($state['status'] ?? '') !== 'pending') {
111 return;
112 }
113 $user = get_userdata($userId);
114 $target = Customer::query()->where('user_id', $userId)->orderBy('id')->lockForUpdate()->first();
115 $conflict = Customer::query()->where('email', $state['email'])->where('id', '!=', $state['customer_id'])->where('user_id', '>', 0)->exists();
116 if (!$user || !$target || (int) $target->id !== $state['customer_id'] || !EmailClaimService::isEnabled() || EmailVerificationService::isRequired($userId)
117 || !EmailVerificationService::isSame($user->user_email, $state['email'])
118 || !EmailVerificationService::isSame($target->email, $state['email']) || $conflict) {
119 $state['status'] = 'cancelled';
120 update_user_meta($userId, static::META_KEY, $state);
121 return;
122 }
123 $source = static::sources($state['email'], (int) $target->id)
124 ->where('id', '>', $state['cursor'])->where('id', '<=', $state['upper_id'])
125 ->orderBy('id')->lockForUpdate()->first();
126 if ($source) {
127 $needsAnotherBatch = false;
128 $complete = CustomerMerger::absorb($source, $target, $needsAnotherBatch);
129 if (!$needsAnotherBatch) {
130 $state['cursor'] = (int) $source->id;
131 $state['processed']++;
132 $state['incomplete'] = $state['incomplete'] || !$complete;
133 }
134 } else {
135 $target->recountStat();
136 $state['status'] = $state['incomplete'] ? 'incomplete' : 'completed';
137 }
138 $state['attempts'] = 0;
139 update_user_meta($userId, static::META_KEY, $state);
140 });
141 }
142 } catch (\Throwable $exception) {
143 // The durable watchdog retries the same cursor; no partial batch commits.
144 wp_cache_delete($userId, 'user_meta');
145 return;
146 }
147
148 wp_cache_delete($userId, 'user_meta');
149 $state = static::progress($userId);
150 if (($state['id'] ?? '') === $jobId && ($state['status'] ?? '') === 'pending') {
151 // Keep the watchdog unless the next invocation is durably scheduled.
152 if (!wp_schedule_single_event(time() + 1, static::HOOK, $args)) {
153 return;
154 }
155 }
156 wp_unschedule_event($retryAt, static::HOOK, $args);
157 }
158 }
159