Implementiere Shopify-Webhook-Eingang
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function shopify_webhook_order_gid(array $payload, string $topic): ?string
|
||||
{
|
||||
if (!str_starts_with($topic, 'orders/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graphqlId = trim((string) ($payload['admin_graphql_api_id'] ?? ''));
|
||||
if ($graphqlId !== '' && str_starts_with($graphqlId, 'gid://shopify/Order/')) {
|
||||
return $graphqlId;
|
||||
}
|
||||
|
||||
$numericId = $payload['id'] ?? null;
|
||||
if (is_int($numericId) || (is_string($numericId) && ctype_digit($numericId))) {
|
||||
return 'gid://shopify/Order/' . (string) $numericId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function shopify_webhook_headers(array $server): array
|
||||
{
|
||||
return [
|
||||
'hmac' => trim((string) ($server['HTTP_X_SHOPIFY_HMAC_SHA256'] ?? '')),
|
||||
'topic' => trim((string) ($server['HTTP_X_SHOPIFY_TOPIC'] ?? '')),
|
||||
'webhook_id' => trim((string) ($server['HTTP_X_SHOPIFY_WEBHOOK_ID'] ?? '')),
|
||||
'shop_domain' => trim((string) ($server['HTTP_X_SHOPIFY_SHOP_DOMAIN'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
function shopify_webhook_hmac_is_valid(string $rawPayload, string $providedHmac, string $secret): bool
|
||||
{
|
||||
if ($providedHmac === '' || $secret === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedHmac = base64_encode(hash_hmac('sha256', $rawPayload, $secret, true));
|
||||
return hash_equals($expectedHmac, $providedHmac);
|
||||
}
|
||||
|
||||
function shopify_webhook_start_run(PDO $pdo, array $headers): int
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO public.process_runs (process_name, status, scope_json, started_at)
|
||||
VALUES (:process_name, \'running\', :scope_json::jsonb, NOW())
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute([
|
||||
':process_name' => 'erp.import-integration.shopify_order_sync',
|
||||
':scope_json' => json_encode([
|
||||
'topic' => $headers['topic'],
|
||||
'webhook_id' => $headers['webhook_id'],
|
||||
'shop_domain' => $headers['shop_domain'],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function shopify_webhook_finish_run(PDO $pdo, int $runId, string $status, array $result, ?string $errorCode = null, ?string $errorMessage = null): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE public.process_runs
|
||||
SET status = :status, result_json = :result_json::jsonb, finished_at = NOW(),
|
||||
error_code = :error_code, error_message = :error_message
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
':status' => $status,
|
||||
':result_json' => json_encode($result, JSON_THROW_ON_ERROR),
|
||||
':error_code' => $errorCode,
|
||||
':error_message' => $errorMessage,
|
||||
':id' => $runId,
|
||||
]);
|
||||
}
|
||||
|
||||
function handle_shopify_webhook(PDO $pdo, array $env, string $rawPayload, array $server): array
|
||||
{
|
||||
$headers = shopify_webhook_headers($server);
|
||||
$secret = env_value('SHOPIFY_API_SECRET', $env);
|
||||
|
||||
if (($server['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
return ['http_status' => 405, 'payload' => ['status' => 'rejected', 'error' => 'method_not_allowed']];
|
||||
}
|
||||
|
||||
if ($headers['hmac'] === '' || $headers['topic'] === '' || $headers['webhook_id'] === '' || $headers['shop_domain'] === '') {
|
||||
return ['http_status' => 400, 'payload' => ['status' => 'rejected', 'error' => 'required_shopify_header_missing']];
|
||||
}
|
||||
|
||||
if (!shopify_webhook_hmac_is_valid($rawPayload, $headers['hmac'], $secret)) {
|
||||
return ['http_status' => 401, 'payload' => ['status' => 'rejected', 'error' => 'invalid_shopify_hmac']];
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return ['http_status' => 400, 'payload' => ['status' => 'rejected', 'error' => 'invalid_json']];
|
||||
}
|
||||
|
||||
if (!is_array($data) || array_is_list($data)) {
|
||||
return ['http_status' => 400, 'payload' => ['status' => 'rejected', 'error' => 'json_object_expected']];
|
||||
}
|
||||
|
||||
$runId = shopify_webhook_start_run($pdo, $headers);
|
||||
$payloadHash = hash('sha256', $rawPayload);
|
||||
$orderGid = shopify_webhook_order_gid($data, $headers['topic']);
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO public.shopify_webhook_event
|
||||
(webhook_id, topic, shop_domain, payload_sha256, payload, shopify_order_gid, status)
|
||||
VALUES (:webhook_id, :topic, :shop_domain, :payload_sha256, :payload::jsonb, :shopify_order_gid, \'processed\')
|
||||
ON CONFLICT (webhook_id) DO NOTHING
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute([
|
||||
':webhook_id' => $headers['webhook_id'],
|
||||
':topic' => $headers['topic'],
|
||||
':shop_domain' => $headers['shop_domain'],
|
||||
':payload_sha256' => $payloadHash,
|
||||
':payload' => $rawPayload,
|
||||
':shopify_order_gid' => $orderGid,
|
||||
]);
|
||||
|
||||
$eventId = $stmt->fetchColumn();
|
||||
if ($eventId === false) {
|
||||
$duplicateStmt = $pdo->prepare('SELECT id, shopify_order_gid FROM public.shopify_webhook_event WHERE webhook_id = :webhook_id');
|
||||
$duplicateStmt->execute([':webhook_id' => $headers['webhook_id']]);
|
||||
$duplicate = $duplicateStmt->fetch();
|
||||
$pdo->commit();
|
||||
shopify_webhook_finish_run($pdo, $runId, 'done', ['status' => 'duplicate', 'technical_event_id' => (int) ($duplicate['id'] ?? 0)]);
|
||||
|
||||
return ['http_status' => 200, 'payload' => [
|
||||
'status' => 'duplicate',
|
||||
'webhook_id' => $headers['webhook_id'],
|
||||
'topic' => $headers['topic'],
|
||||
'shop_domain' => $headers['shop_domain'],
|
||||
'shopify_order_gid' => $duplicate['shopify_order_gid'] ?? $orderGid,
|
||||
'technical_run_id' => $runId,
|
||||
]];
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
shopify_webhook_finish_run($pdo, $runId, 'done', ['status' => 'accepted', 'technical_event_id' => (int) $eventId]);
|
||||
|
||||
return ['http_status' => 200, 'payload' => [
|
||||
'status' => 'accepted',
|
||||
'webhook_id' => $headers['webhook_id'],
|
||||
'topic' => $headers['topic'],
|
||||
'shop_domain' => $headers['shop_domain'],
|
||||
'shopify_order_gid' => $orderGid,
|
||||
'technical_run_id' => $runId,
|
||||
]];
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
shopify_webhook_finish_run($pdo, $runId, 'failed', ['status' => 'failed'], 'shopify_webhook_persistence_failed', $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../modules/shared/db.php';
|
||||
require_once __DIR__ . '/../../../modules/erp/import-integration/shopify-webhook.php';
|
||||
|
||||
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
|
||||
|
||||
try {
|
||||
$pdo = connect_database($env);
|
||||
$rawPayload = file_get_contents('php://input');
|
||||
if ($rawPayload === false || $rawPayload === '') {
|
||||
json_response(400, ['status' => 'rejected', 'error' => 'empty_body']);
|
||||
}
|
||||
|
||||
$result = handle_shopify_webhook($pdo, $env, $rawPayload, $_SERVER);
|
||||
json_response($result['http_status'], $result['payload']);
|
||||
} catch (Throwable) {
|
||||
json_response(500, ['status' => 'failed', 'error' => 'technical_error']);
|
||||
}
|
||||
Reference in New Issue
Block a user