Add idempotency handling for order booking creation using Redis to prevent duplicate requests.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\redis;
|
||||
use Exception;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
use objects\departments_o;
|
||||
@@ -65,11 +66,29 @@ class orderBookingRoute
|
||||
'pickup' => $pickup,
|
||||
'items' => $items,
|
||||
];
|
||||
$fingerprint = $this->buildBookingCreationFingerprint($data);
|
||||
$cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint);
|
||||
if ($cachedBooking !== null) {
|
||||
$response->success($cachedBooking->asArray());
|
||||
}
|
||||
if (!$this->reserveBookingCreationSlot($fingerprint)) {
|
||||
$cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint);
|
||||
if ($cachedBooking !== null) {
|
||||
$response->success($cachedBooking->asArray());
|
||||
}
|
||||
$response->error('A similar booking request is already being processed. Please wait a moment and retry.', 429);
|
||||
}
|
||||
/**
|
||||
* Create the object
|
||||
*/
|
||||
$order_bookings_o = new order_bookings_o();
|
||||
$order_bookings_o->add($data);
|
||||
try {
|
||||
$order_bookings_o->add($data);
|
||||
$this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id);
|
||||
} catch (\Throwable $e) {
|
||||
$this->clearBookingCreationSlot($fingerprint);
|
||||
throw $e;
|
||||
}
|
||||
$response->success($order_bookings_o->asArray());
|
||||
},
|
||||
[
|
||||
@@ -534,4 +553,175 @@ class orderBookingRoute
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
private function buildBookingCreationFingerprint(array $data): string
|
||||
{
|
||||
$payload = [
|
||||
'customer_number' => (int)($data['customer_number'] ?? 0),
|
||||
'department' => (int)($data['department'] ?? 0),
|
||||
'reg_1' => (string)($data['reg_1'] ?? ''),
|
||||
'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null),
|
||||
'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null),
|
||||
'datetime' => (string)($data['datetime'] ?? ''),
|
||||
'note' => $this->normalizeNullableString($data['note'] ?? null),
|
||||
'reference' => $this->normalizeNullableString($data['reference'] ?? null),
|
||||
'po' => $this->normalizeNullableString($data['po'] ?? null),
|
||||
'pickup' => (bool)($data['pickup'] ?? false),
|
||||
'items' => $this->normalizeItemsForFingerprint((array)($data['items'] ?? [])),
|
||||
];
|
||||
|
||||
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($encoded === false) {
|
||||
return md5((string)microtime(true));
|
||||
}
|
||||
|
||||
return hash('sha256', $encoded);
|
||||
}
|
||||
|
||||
private function normalizeNullableString(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
if (strtolower($normalized) === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeItemsForFingerprint(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int)($item['id'] ?? 0);
|
||||
$quantity = (int)($item['quantity'] ?? 0);
|
||||
if ($id < 1 || $quantity < 1) {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = [
|
||||
'id' => $id,
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, static function (array $a, array $b): int {
|
||||
if ($a['id'] === $b['id']) {
|
||||
return $a['quantity'] <=> $b['quantity'];
|
||||
}
|
||||
return $a['id'] <=> $b['id'];
|
||||
});
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o
|
||||
{
|
||||
try {
|
||||
$redis = $this->resolveRedisClient();
|
||||
if ($redis === null) {
|
||||
return null;
|
||||
}
|
||||
$id = (int)($redis->get($this->bookingIdempotencyResultKey($fingerprint)) ?? 0);
|
||||
if ($id < 1) {
|
||||
return null;
|
||||
}
|
||||
$booking = (new order_bookings_o())->select($id);
|
||||
if (!$booking->exists()) {
|
||||
return null;
|
||||
}
|
||||
return $booking;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function reserveBookingCreationSlot(string $fingerprint): bool
|
||||
{
|
||||
try {
|
||||
$redis = $this->resolveRedisClient();
|
||||
if ($redis === null) {
|
||||
return true;
|
||||
}
|
||||
return $redis->set_if_absent_with_expiration(
|
||||
$this->bookingIdempotencyLockKey($fingerprint),
|
||||
'1',
|
||||
$this->bookingIdempotencyLockTtlSeconds()
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Fail open when Redis is unavailable to avoid blocking all bookings.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function storeBookingIdempotencyResult(string $fingerprint, int $bookingId): void
|
||||
{
|
||||
try {
|
||||
$redis = $this->resolveRedisClient();
|
||||
if ($redis === null) {
|
||||
return;
|
||||
}
|
||||
$redis->setEx(
|
||||
$this->bookingIdempotencyResultKey($fingerprint),
|
||||
(string)$bookingId,
|
||||
$this->bookingIdempotencyResultTtlSeconds()
|
||||
);
|
||||
$redis->delete($this->bookingIdempotencyLockKey($fingerprint));
|
||||
} catch (\Throwable) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private function clearBookingCreationSlot(string $fingerprint): void
|
||||
{
|
||||
try {
|
||||
$redis = $this->resolveRedisClient();
|
||||
if ($redis === null) {
|
||||
return;
|
||||
}
|
||||
$redis->delete($this->bookingIdempotencyLockKey($fingerprint));
|
||||
} catch (\Throwable) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveRedisClient(): ?redis
|
||||
{
|
||||
try {
|
||||
if (defined('redis')) {
|
||||
$instance = constant('redis');
|
||||
if ($instance instanceof redis) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
return (new redis())->connect();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function bookingIdempotencyLockKey(string $fingerprint): string
|
||||
{
|
||||
return 'order_booking:idempotency:lock:' . $fingerprint;
|
||||
}
|
||||
|
||||
private function bookingIdempotencyResultKey(string $fingerprint): string
|
||||
{
|
||||
return 'order_booking:idempotency:result:' . $fingerprint;
|
||||
}
|
||||
|
||||
private function bookingIdempotencyLockTtlSeconds(): int
|
||||
{
|
||||
return 30;
|
||||
}
|
||||
|
||||
private function bookingIdempotencyResultTtlSeconds(): int
|
||||
{
|
||||
return 300;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user