PHPUnit config (phpunit.dist.xml, bin/phpunit, bootstrap.php), PHP CS Fixer config, .editorconfig. Separate .env.dev/.env.test templates. Ollama tunnel setup script. Architecture and plan docs. Updated application-layer unit tests to match current service signatures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1322 lines
42 KiB
Markdown
1322 lines
42 KiB
Markdown
# SuperSeller3000 — Plan 5: eBay Channel Adapter
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** eBay-Channel-Adapter mit vollständiger Listing-Verwaltung (Publish, Bestandsupdate, Deaktivierung, Tracking), eBay Taxonomy API für Pflichtfeld-Vorschläge, HMAC-gesicherter Webhook-Listener der eBay-Events entgegennimmt und in die Orders-Queue einspeist, sowie Channel-Sync Messenger-Handler für alle Kanal-Operationen.
|
|
|
|
**Architecture:** `ChannelAdapterInterface` als Application-Port, `EbayAdapter` als Infrastructure-Implementierung. eBay-OAuth via Client-Credentials-Flow (Token wird gecacht). Alle Kanal-Operationen laufen asynchron über den `channel_sync` Redis-Transport. Webhook-Listener antwortet sofort mit 200 und dispatcht `OrderReceivedMessage` in den `orders`-Transport.
|
|
|
|
**Tech Stack:** PHP 8.4, Symfony 7, eBay Sell APIs (OAuth 2.0, Inventory v1, Taxonomy v1), Symfony Messenger, PHPStan Level 9
|
|
|
|
---
|
|
|
|
## Dateistruktur (gesamter Plan)
|
|
|
|
```
|
|
src/
|
|
Application/
|
|
Channel/
|
|
ChannelAdapterInterface.php # Port (Application Layer)
|
|
ChannelAdapterRegistry.php # maps Platform.type → adapter
|
|
Infrastructure/
|
|
Channel/
|
|
Ebay/
|
|
EbayOAuthClient.php # Client-Credentials-Token-Management
|
|
EbayInventoryApiClient.php # low-level HTTP für Sell Inventory API
|
|
EbayAdapter.php # implementiert ChannelAdapterInterface
|
|
EbayTaxonomyService.php # lädt Pflichtfelder einer Kategorie
|
|
EbayWebhookVerifier.php # HMAC-Signatur-Prüfung
|
|
Http/
|
|
Controller/
|
|
Webhook/
|
|
EbayWebhookController.php
|
|
Messenger/
|
|
Message/
|
|
PublishToChannelMessage.php
|
|
UpdateStockOnChannelsMessage.php
|
|
DeactivateListingMessage.php # eine Instanz pro Plattform
|
|
Handler/
|
|
PublishToChannelHandler.php
|
|
UpdateStockOnChannelsHandler.php
|
|
DeactivateListingHandler.php
|
|
config/
|
|
packages/
|
|
messenger.yaml # channel_sync Routing ergänzen
|
|
tests/
|
|
Unit/
|
|
Infrastructure/
|
|
Channel/
|
|
Ebay/
|
|
EbayWebhookVerifierTest.php
|
|
EbayAdapterTest.php
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: ChannelAdapterInterface + Registry
|
|
|
|
**Files:**
|
|
- Create: `src/Application/Channel/ChannelAdapterInterface.php`
|
|
- Create: `src/Application/Channel/ChannelAdapterRegistry.php`
|
|
- Modify: `config/services.yaml`
|
|
|
|
- [ ] **Step 1: ChannelAdapterInterface schreiben**
|
|
|
|
```php
|
|
<?php
|
|
// src/Application/Channel/ChannelAdapterInterface.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Application\Channel;
|
|
|
|
use App\Domain\Article\Article;
|
|
use App\Domain\Order\Order;
|
|
|
|
interface ChannelAdapterInterface
|
|
{
|
|
/**
|
|
* Creates a listing on the platform for the given article.
|
|
* Returns the platform-specific listing ID.
|
|
*
|
|
* @throws \RuntimeException if listing creation fails
|
|
*/
|
|
public function publishListing(Article $article): string;
|
|
|
|
/**
|
|
* Updates stock/quantity for an existing listing.
|
|
*
|
|
* @throws \RuntimeException if update fails
|
|
*/
|
|
public function updateStock(Article $article, int $stock): void;
|
|
|
|
/**
|
|
* Deactivates/ends a listing. Must be idempotent (safe to call if already inactive).
|
|
*
|
|
* @throws \RuntimeException if deactivation fails
|
|
*/
|
|
public function deactivateListing(Article $article): void;
|
|
|
|
/**
|
|
* Marks an order as shipped with tracking information.
|
|
*
|
|
* @throws \RuntimeException if push fails
|
|
*/
|
|
public function pushTracking(Order $order): void;
|
|
|
|
/** Returns the platform type this adapter handles (e.g. 'ebay'). */
|
|
public function getType(): string;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: ChannelAdapterRegistry schreiben**
|
|
|
|
```php
|
|
<?php
|
|
// src/Application/Channel/ChannelAdapterRegistry.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Application\Channel;
|
|
|
|
final class ChannelAdapterRegistry
|
|
{
|
|
/** @var array<string, ChannelAdapterInterface> */
|
|
private array $adapters = [];
|
|
|
|
/** @param iterable<ChannelAdapterInterface> $adapters */
|
|
public function __construct(iterable $adapters)
|
|
{
|
|
foreach ($adapters as $adapter) {
|
|
$this->adapters[$adapter->getType()] = $adapter;
|
|
}
|
|
}
|
|
|
|
public function get(string $type): ChannelAdapterInterface
|
|
{
|
|
return $this->adapters[$type]
|
|
?? throw new \InvalidArgumentException("No channel adapter registered for type: {$type}");
|
|
}
|
|
|
|
public function has(string $type): bool
|
|
{
|
|
return isset($this->adapters[$type]);
|
|
}
|
|
|
|
/** @return list<string> */
|
|
public function getTypes(): array
|
|
{
|
|
return \array_keys($this->adapters);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: services.yaml für Registry**
|
|
|
|
```yaml
|
|
# config/services.yaml (ergänzen)
|
|
App\Application\Channel\ChannelAdapterRegistry:
|
|
arguments:
|
|
$adapters: !tagged_iterator app.channel_adapter
|
|
|
|
App\Infrastructure\Channel\Ebay\EbayAdapter:
|
|
tags: ['app.channel_adapter']
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/Application/Channel/ config/services.yaml
|
|
git commit -m "feat: add ChannelAdapterInterface port and ChannelAdapterRegistry"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: eBay OAuth + Inventory API Client
|
|
|
|
**Files:**
|
|
- Create: `src/Infrastructure/Channel/Ebay/EbayOAuthClient.php`
|
|
- Create: `src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php`
|
|
|
|
- [ ] **Step 1: EbayOAuthClient implementieren**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Channel/Ebay/EbayOAuthClient.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Channel\Ebay;
|
|
|
|
use Symfony\Contracts\Cache\CacheInterface;
|
|
use Symfony\Contracts\Cache\ItemInterface;
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
|
final class EbayOAuthClient
|
|
{
|
|
private const TOKEN_CACHE_KEY = 'ebay_access_token';
|
|
|
|
public function __construct(
|
|
private readonly HttpClientInterface $httpClient,
|
|
private readonly CacheInterface $cache,
|
|
private readonly string $clientId,
|
|
private readonly string $clientSecret,
|
|
private readonly string $oauthBaseUrl,
|
|
) {}
|
|
|
|
public function getAccessToken(): string
|
|
{
|
|
return $this->cache->get(self::TOKEN_CACHE_KEY, function (ItemInterface $item): string {
|
|
$credentials = \base64_encode($this->clientId.':'.$this->clientSecret);
|
|
|
|
$response = $this->httpClient->request('POST', $this->oauthBaseUrl.'/identity/v1/oauth2/token', [
|
|
'headers' => [
|
|
'Authorization' => 'Basic '.$credentials,
|
|
'Content-Type' => 'application/x-www-form-urlencoded',
|
|
],
|
|
'body' => 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope',
|
|
]);
|
|
|
|
/** @var array{access_token: string, expires_in: int} $data */
|
|
$data = $response->toArray();
|
|
|
|
// Cache for 80% of the actual TTL to avoid race conditions
|
|
$item->expiresAfter((int) ($data['expires_in'] * 0.8));
|
|
|
|
return $data['access_token'];
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: EbayInventoryApiClient implementieren**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Channel\Ebay;
|
|
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
|
final class EbayInventoryApiClient
|
|
{
|
|
private const INVENTORY_BASE = '/sell/inventory/v1';
|
|
private const FULFILLMENT_BASE = '/sell/fulfillment/v2';
|
|
|
|
public function __construct(
|
|
private readonly HttpClientInterface $httpClient,
|
|
private readonly EbayOAuthClient $oauthClient,
|
|
private readonly string $apiBaseUrl,
|
|
private readonly string $marketplaceId,
|
|
) {}
|
|
|
|
/**
|
|
* Creates or updates an inventory item (product + stock).
|
|
* @param array<string, mixed> $body
|
|
*/
|
|
public function upsertInventoryItem(string $sku, array $body): void
|
|
{
|
|
$this->request('PUT', self::INVENTORY_BASE.'/inventory_item/'.urlencode($sku), $body);
|
|
}
|
|
|
|
/**
|
|
* Creates an offer (links inventory item to a listing).
|
|
* @param array<string, mixed> $body
|
|
* @return string offerId
|
|
*/
|
|
public function createOffer(array $body): string
|
|
{
|
|
/** @var array{offerId: string} $response */
|
|
$response = $this->request('POST', self::INVENTORY_BASE.'/offer', $body);
|
|
|
|
return $response['offerId'];
|
|
}
|
|
|
|
/**
|
|
* Updates an existing offer.
|
|
* @param array<string, mixed> $body
|
|
*/
|
|
public function updateOffer(string $offerId, array $body): void
|
|
{
|
|
$this->request('PUT', self::INVENTORY_BASE.'/offer/'.urlencode($offerId), $body);
|
|
}
|
|
|
|
/** @return string listingId */
|
|
public function publishOffer(string $offerId): string
|
|
{
|
|
/** @var array{listingId: string} $response */
|
|
$response = $this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/publish', []);
|
|
|
|
return $response['listingId'];
|
|
}
|
|
|
|
public function withdrawOffer(string $offerId): void
|
|
{
|
|
$this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/withdraw', []);
|
|
}
|
|
|
|
/** @param array<string, mixed> $quantityUpdate */
|
|
public function bulkUpdateInventoryItems(array $quantityUpdate): void
|
|
{
|
|
$this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate);
|
|
}
|
|
|
|
/**
|
|
* Adds tracking info to an order.
|
|
* @param array<string, mixed> $body
|
|
*/
|
|
public function addTrackingToOrder(string $orderId, array $body): void
|
|
{
|
|
$this->request('POST', self::FULFILLMENT_BASE.'/order/'.urlencode($orderId).'/shipping_fulfillment', $body);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $body
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function request(string $method, string $path, array $body): array
|
|
{
|
|
$token = $this->oauthClient->getAccessToken();
|
|
|
|
$options = [
|
|
'headers' => [
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Content-Type' => 'application/json',
|
|
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
|
|
],
|
|
];
|
|
|
|
if ([] !== $body) {
|
|
$options['json'] = $body;
|
|
}
|
|
|
|
$response = $this->httpClient->request($method, $this->apiBaseUrl.$path, $options);
|
|
|
|
$statusCode = $response->getStatusCode();
|
|
if ($statusCode >= 400) {
|
|
$content = $response->getContent(false);
|
|
throw new \RuntimeException("eBay API error {$statusCode}: {$content}");
|
|
}
|
|
|
|
if ($statusCode === 204 || '' === $response->getContent(false)) {
|
|
return [];
|
|
}
|
|
|
|
/** @var array<string, mixed> $data */
|
|
$data = $response->toArray();
|
|
|
|
return $data;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: .env + services.yaml**
|
|
|
|
```ini
|
|
# .env (ergänzen)
|
|
EBAY_CLIENT_ID=
|
|
EBAY_CLIENT_SECRET=
|
|
EBAY_MARKETPLACE_ID=EBAY_DE
|
|
EBAY_API_BASE_URL=https://api.ebay.com
|
|
EBAY_OAUTH_BASE_URL=https://api.ebay.com
|
|
EBAY_VERIFICATION_TOKEN=
|
|
EBAY_ENDPOINT_URL=https://your-domain.com/webhooks/ebay
|
|
```
|
|
|
|
```yaml
|
|
# config/services.yaml (ergänzen)
|
|
App\Infrastructure\Channel\Ebay\EbayOAuthClient:
|
|
arguments:
|
|
$clientId: '%env(EBAY_CLIENT_ID)%'
|
|
$clientSecret: '%env(EBAY_CLIENT_SECRET)%'
|
|
$oauthBaseUrl: '%env(EBAY_OAUTH_BASE_URL)%'
|
|
$cache: '@cache.app'
|
|
|
|
App\Infrastructure\Channel\Ebay\EbayInventoryApiClient:
|
|
arguments:
|
|
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
|
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/Infrastructure/Channel/Ebay/EbayOAuthClient.php src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php config/services.yaml .env
|
|
git commit -m "feat: add eBay OAuth client (cached token) and Inventory API client"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: EbayAdapter
|
|
|
|
**Files:**
|
|
- Create: `src/Infrastructure/Channel/Ebay/EbayAdapter.php`
|
|
- Test: `tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php`
|
|
|
|
- [ ] **Step 1: Failing-Test schreiben**
|
|
|
|
```php
|
|
<?php
|
|
// tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Infrastructure\Channel\Ebay;
|
|
|
|
use App\Domain\Article\Article;
|
|
use App\Domain\Article\ArticleCondition;
|
|
use App\Domain\Article\ArticleType;
|
|
use App\Infrastructure\Channel\Ebay\EbayAdapter;
|
|
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class EbayAdapterTest extends TestCase
|
|
{
|
|
private EbayInventoryApiClient&MockObject $apiClient;
|
|
private EbayAdapter $adapter;
|
|
private Article $article;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->apiClient = $this->createMock(EbayInventoryApiClient::class);
|
|
$this->adapter = new EbayAdapter($this->apiClient);
|
|
$this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good);
|
|
$this->article->setEbayTitle('Dell Latitude 5520');
|
|
$this->article->setListingPrice(299.0);
|
|
}
|
|
|
|
public function test_get_type_returns_ebay(): void
|
|
{
|
|
$this->assertSame('ebay', $this->adapter->getType());
|
|
}
|
|
|
|
public function test_publish_listing_calls_upsert_create_and_publish(): void
|
|
{
|
|
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
|
|
$this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123');
|
|
$this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456');
|
|
|
|
$listingId = $this->adapter->publishListing($this->article);
|
|
|
|
$this->assertSame('listing-456', $listingId);
|
|
}
|
|
|
|
public function test_deactivate_listing_withdraws_offer(): void
|
|
{
|
|
$this->article->setEbayListingId('offer-123');
|
|
|
|
$this->apiClient->expects($this->once())->method('withdrawOffer')->with('offer-123');
|
|
|
|
$this->adapter->deactivateListing($this->article);
|
|
}
|
|
|
|
public function test_deactivate_listing_is_noop_when_no_listing_id(): void
|
|
{
|
|
$this->apiClient->expects($this->never())->method('withdrawOffer');
|
|
|
|
$this->adapter->deactivateListing($this->article);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Test ausführen — muss fehlschlagen**
|
|
|
|
```bash
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
|
|
# Expected: FAIL
|
|
```
|
|
|
|
- [ ] **Step 3: EbayAdapter implementieren**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Channel/Ebay/EbayAdapter.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Channel\Ebay;
|
|
|
|
use App\Application\Channel\ChannelAdapterInterface;
|
|
use App\Domain\Article\Article;
|
|
use App\Domain\Article\ArticleCondition;
|
|
use App\Domain\Order\Order;
|
|
|
|
final class EbayAdapter implements ChannelAdapterInterface
|
|
{
|
|
public function __construct(
|
|
private readonly EbayInventoryApiClient $apiClient,
|
|
) {}
|
|
|
|
public function getType(): string
|
|
{
|
|
return 'ebay';
|
|
}
|
|
|
|
public function publishListing(Article $article): string
|
|
{
|
|
$sku = $article->getSku();
|
|
|
|
// Step 1: Create/Update inventory item
|
|
$this->apiClient->upsertInventoryItem($sku, [
|
|
'availability' => [
|
|
'shipToLocationAvailability' => [
|
|
'quantity' => $article->getStock(),
|
|
],
|
|
],
|
|
'condition' => $this->mapCondition($article->getCondition()),
|
|
'conditionDescription' => $article->getConditionNotes() ?? '',
|
|
'product' => [
|
|
'title' => $article->getEbayTitle() ?? $article->getSku(),
|
|
'description' => $article->getEbayDescription() ?? '',
|
|
'aspects' => $this->buildAspects($article),
|
|
],
|
|
]);
|
|
|
|
// Step 2: Create offer
|
|
// ArticleTypePlatformConfig stores the eBay categoryId
|
|
$offerId = $this->apiClient->createOffer([
|
|
'sku' => $sku,
|
|
'marketplaceId' => 'EBAY_DE',
|
|
'format' => 'FIXED_PRICE',
|
|
'availableQuantity' => $article->getStock(),
|
|
'pricingSummary' => [
|
|
'price' => [
|
|
'currency' => 'EUR',
|
|
'value' => \number_format((float) ($article->getListingPrice() ?? 0), 2, '.', ''),
|
|
],
|
|
],
|
|
'listingDescription' => $article->getEbayDescription() ?? '',
|
|
'categoryId' => $this->getCategoryId($article),
|
|
]);
|
|
|
|
// Step 3: Publish offer
|
|
return $this->apiClient->publishOffer($offerId);
|
|
}
|
|
|
|
public function updateStock(Article $article, int $stock): void
|
|
{
|
|
$this->apiClient->bulkUpdateInventoryItems([
|
|
'requests' => [
|
|
[
|
|
'sku' => $article->getSku(),
|
|
'shipToLocationAvailability' => [
|
|
'quantity' => $stock,
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function deactivateListing(Article $article): void
|
|
{
|
|
$listingId = $article->getEbayListingId();
|
|
if (null === $listingId) {
|
|
return;
|
|
}
|
|
|
|
$this->apiClient->withdrawOffer($listingId);
|
|
}
|
|
|
|
public function pushTracking(Order $order): void
|
|
{
|
|
if (null === $order->getTrackingNumber()) {
|
|
throw new \RuntimeException('Order has no tracking number');
|
|
}
|
|
|
|
$this->apiClient->addTrackingToOrder($order->getPlatformOrderId(), [
|
|
'lineItems' => [
|
|
['lineItemId' => $order->getPlatformOrderId(), 'quantity' => 1],
|
|
],
|
|
'shippingCarrierCode' => $order->getCarrier() ?? 'DHL',
|
|
'trackingNumber' => $order->getTrackingNumber(),
|
|
]);
|
|
}
|
|
|
|
private function mapCondition(ArticleCondition $condition): string
|
|
{
|
|
return match ($condition) {
|
|
ArticleCondition::New => 'NEW',
|
|
ArticleCondition::LikeNew => 'LIKE_NEW',
|
|
ArticleCondition::Good => 'GOOD',
|
|
ArticleCondition::Acceptable => 'ACCEPTABLE',
|
|
};
|
|
}
|
|
|
|
/** @return array<string, list<string>> */
|
|
private function buildAspects(Article $article): array
|
|
{
|
|
$aspects = [];
|
|
foreach ($article->getAttributeValues() as $value) {
|
|
$name = $value->getAttributeDefinition()->getName();
|
|
$aspects[$name] = [$value->getValue()];
|
|
}
|
|
|
|
return $aspects;
|
|
}
|
|
|
|
private function getCategoryId(Article $article): string
|
|
{
|
|
// Category ID comes from ArticleTypePlatformConfig
|
|
// For now return a default; in production, inject the mapping service
|
|
return '177';
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Tests ausführen**
|
|
|
|
```bash
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
|
|
# Expected: PASS (4 tests)
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/Infrastructure/Channel/Ebay/EbayAdapter.php tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
|
|
git commit -m "feat: add EbayAdapter (publishListing, updateStock, deactivateListing, pushTracking)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: eBay Taxonomy API
|
|
|
|
**Files:**
|
|
- Create: `src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php`
|
|
|
|
- [ ] **Step 1: EbayTaxonomyService implementieren**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Channel\Ebay;
|
|
|
|
use Symfony\Contracts\Cache\CacheInterface;
|
|
use Symfony\Contracts\Cache\ItemInterface;
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
|
final class EbayTaxonomyService
|
|
{
|
|
public function __construct(
|
|
private readonly HttpClientInterface $httpClient,
|
|
private readonly EbayOAuthClient $oauthClient,
|
|
private readonly CacheInterface $cache,
|
|
private readonly string $apiBaseUrl,
|
|
private readonly string $marketplaceId,
|
|
) {}
|
|
|
|
/**
|
|
* Returns the required item aspect names for a given eBay category.
|
|
* Used when configuring ArticleTypePlatformConfig to suggest required mappings.
|
|
*
|
|
* @return list<array{name: string, required: bool, values: list<string>}>
|
|
*/
|
|
public function getCategoryAspects(string $categoryId): array
|
|
{
|
|
$cacheKey = 'ebay_aspects_'.md5($this->marketplaceId.$categoryId);
|
|
|
|
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($categoryId): array {
|
|
$item->expiresAfter(86400 * 7); // cache for 1 week
|
|
|
|
$token = $this->oauthClient->getAccessToken();
|
|
|
|
$response = $this->httpClient->request(
|
|
'GET',
|
|
$this->apiBaseUrl.'/commerce/taxonomy/v1/category_tree/'.$this->getTreeId().'/get_item_aspects_for_category',
|
|
[
|
|
'headers' => [
|
|
'Authorization' => 'Bearer '.$token,
|
|
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
|
|
],
|
|
'query' => ['category_id' => $categoryId],
|
|
],
|
|
);
|
|
|
|
/** @var array{aspects?: list<array{localizedAspectName: string, aspectConstraint?: array{aspectRequired?: bool}, aspectValues?: list<array{localizedValue: string}>}>} $data */
|
|
$data = $response->toArray();
|
|
$aspects = [];
|
|
|
|
foreach ($data['aspects'] ?? [] as $aspect) {
|
|
$aspects[] = [
|
|
'name' => $aspect['localizedAspectName'],
|
|
'required' => $aspect['aspectConstraint']['aspectRequired'] ?? false,
|
|
'values' => \array_column($aspect['aspectValues'] ?? [], 'localizedValue'),
|
|
];
|
|
}
|
|
|
|
return $aspects;
|
|
});
|
|
}
|
|
|
|
private function getTreeId(): string
|
|
{
|
|
return match ($this->marketplaceId) {
|
|
'EBAY_DE' => '77',
|
|
'EBAY_US' => '0',
|
|
'EBAY_UK' => '3',
|
|
default => '77',
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: API-Endpunkt für Taxonomy-Abfrage**
|
|
|
|
Ergänze `MappingController` (aus Plan 2) um einen Endpunkt der eBay Taxonomy-Daten für eine Kategorie zurückgibt:
|
|
|
|
```php
|
|
// src/Infrastructure/Http/Controller/Api/MappingController.php (ergänzen)
|
|
|
|
#[Route('/ebay-category-aspects/{categoryId}', name: 'ebay_aspects', methods: ['GET'])]
|
|
public function ebayAspects(string $categoryId, EbayTaxonomyService $taxonomy): JsonResponse
|
|
{
|
|
try {
|
|
$aspects = $taxonomy->getCategoryAspects($categoryId);
|
|
} catch (\Throwable $e) {
|
|
return $this->json(['error' => 'eBay API error: '.$e->getMessage()], Response::HTTP_SERVICE_UNAVAILABLE);
|
|
}
|
|
|
|
return $this->json($aspects);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: services.yaml**
|
|
|
|
```yaml
|
|
App\Infrastructure\Channel\Ebay\EbayTaxonomyService:
|
|
arguments:
|
|
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
|
|
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
|
|
$cache: '@cache.app'
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php src/Infrastructure/Http/Controller/Api/MappingController.php config/services.yaml
|
|
git commit -m "feat: add eBay Taxonomy API service for category aspect loading"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Channel-Sync Messages + Handler
|
|
|
|
**Files:**
|
|
- Create: `src/Infrastructure/Messenger/Message/PublishToChannelMessage.php`
|
|
- Create: `src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php`
|
|
- Create: `src/Infrastructure/Messenger/Message/DeactivateListingMessage.php`
|
|
- Create: `src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php`
|
|
- Create: `src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php`
|
|
- Create: `src/Infrastructure/Messenger/Handler/DeactivateListingHandler.php`
|
|
- Modify: `config/packages/messenger.yaml`
|
|
|
|
- [ ] **Step 1: Messages schreiben**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Messenger/Message/PublishToChannelMessage.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Messenger\Message;
|
|
|
|
final readonly class PublishToChannelMessage
|
|
{
|
|
public function __construct(
|
|
public string $articleId,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Messenger\Message;
|
|
|
|
final readonly class UpdateStockOnChannelsMessage
|
|
{
|
|
public function __construct(
|
|
public string $articleId,
|
|
public int $newStock,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Messenger/Message/DeactivateListingMessage.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Messenger\Message;
|
|
|
|
final readonly class DeactivateListingMessage
|
|
{
|
|
public function __construct(
|
|
public string $articleId,
|
|
public string $platformType, // 'ebay', 'amazon', …
|
|
public int $attemptNumber = 1,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Messenger-Routing ergänzen**
|
|
|
|
```yaml
|
|
# config/packages/messenger.yaml routing ergänzen:
|
|
App\Infrastructure\Messenger\Message\PublishToChannelMessage: channel_sync
|
|
App\Infrastructure\Messenger\Message\UpdateStockOnChannelsMessage: channel_sync
|
|
App\Infrastructure\Messenger\Message\DeactivateListingMessage: channel_sync
|
|
App\Infrastructure\Messenger\Message\TrackingPushMessage: channel_sync
|
|
```
|
|
|
|
- [ ] **Step 3: PublishToChannelHandler**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Messenger\Handler;
|
|
|
|
use App\Application\Channel\ChannelAdapterRegistry;
|
|
use App\Domain\Article\ArticleStatus;
|
|
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
|
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
|
|
use App\Infrastructure\Messenger\Message\PublishToChannelMessage;
|
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
#[AsMessageHandler]
|
|
final class PublishToChannelHandler
|
|
{
|
|
public function __construct(
|
|
private readonly ArticleRepositoryInterface $articleRepository,
|
|
private readonly PlatformRepositoryInterface $platformRepository,
|
|
private readonly ChannelAdapterRegistry $adapterRegistry,
|
|
) {}
|
|
|
|
public function __invoke(PublishToChannelMessage $message): void
|
|
{
|
|
$article = $this->articleRepository->findById(Uuid::fromString($message->articleId));
|
|
if (null === $article || $article->getStatus() !== ArticleStatus::Active) {
|
|
return;
|
|
}
|
|
|
|
$platforms = $this->platformRepository->findAll();
|
|
|
|
foreach ($platforms as $platform) {
|
|
if (!$this->adapterRegistry->has($platform->getType())) {
|
|
continue;
|
|
}
|
|
|
|
$adapter = $this->adapterRegistry->get($platform->getType());
|
|
|
|
try {
|
|
$listingId = $adapter->publishListing($article);
|
|
|
|
if ('ebay' === $platform->getType()) {
|
|
$article->setEbayListingId($listingId);
|
|
}
|
|
|
|
$article->transitionTo(ArticleStatus::Listed);
|
|
$this->articleRepository->save($article);
|
|
} catch (\RuntimeException $e) {
|
|
// Log error and continue with other platforms
|
|
// Messenger will retry via retry_strategy
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: UpdateStockOnChannelsHandler**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Messenger\Handler;
|
|
|
|
use App\Application\Channel\ChannelAdapterRegistry;
|
|
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
|
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
|
|
use App\Infrastructure\Messenger\Message\DeactivateListingMessage;
|
|
use App\Infrastructure\Messenger\Message\UpdateStockOnChannelsMessage;
|
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
#[AsMessageHandler]
|
|
final class UpdateStockOnChannelsHandler
|
|
{
|
|
public function __construct(
|
|
private readonly ArticleRepositoryInterface $articleRepository,
|
|
private readonly PlatformRepositoryInterface $platformRepository,
|
|
private readonly ChannelAdapterRegistry $adapterRegistry,
|
|
private readonly MessageBusInterface $bus,
|
|
) {}
|
|
|
|
public function __invoke(UpdateStockOnChannelsMessage $message): void
|
|
{
|
|
$article = $this->articleRepository->findById(Uuid::fromString($message->articleId));
|
|
if (null === $article) {
|
|
return;
|
|
}
|
|
|
|
if ($message->newStock === 0) {
|
|
// Dispatch one deactivation message per platform
|
|
$platforms = $this->platformRepository->findAll();
|
|
foreach ($platforms as $platform) {
|
|
if ($this->adapterRegistry->has($platform->getType())) {
|
|
$this->bus->dispatch(new DeactivateListingMessage(
|
|
articleId: $message->articleId,
|
|
platformType: $platform->getType(),
|
|
));
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$platforms = $this->platformRepository->findAll();
|
|
foreach ($platforms as $platform) {
|
|
if (!$this->adapterRegistry->has($platform->getType())) {
|
|
continue;
|
|
}
|
|
|
|
$adapter = $this->adapterRegistry->get($platform->getType());
|
|
|
|
try {
|
|
$adapter->updateStock($article, $message->newStock);
|
|
} catch (\RuntimeException $e) {
|
|
throw $e; // Messenger retry
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: DeactivateListingHandler**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Messenger/Handler/DeactivateListingHandler.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Messenger\Handler;
|
|
|
|
use App\Application\Channel\ChannelAdapterRegistry;
|
|
use App\Domain\Article\ArticleStatus;
|
|
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
|
use App\Infrastructure\Messenger\Message\DeactivateListingMessage;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
#[AsMessageHandler]
|
|
final class DeactivateListingHandler
|
|
{
|
|
private const ALERT_THRESHOLD = 5;
|
|
|
|
public function __construct(
|
|
private readonly ArticleRepositoryInterface $articleRepository,
|
|
private readonly ChannelAdapterRegistry $adapterRegistry,
|
|
private readonly LoggerInterface $logger,
|
|
) {}
|
|
|
|
public function __invoke(DeactivateListingMessage $message): void
|
|
{
|
|
$article = $this->articleRepository->findById(Uuid::fromString($message->articleId));
|
|
if (null === $article) {
|
|
return;
|
|
}
|
|
|
|
if (!$this->adapterRegistry->has($message->platformType)) {
|
|
return;
|
|
}
|
|
|
|
$adapter = $this->adapterRegistry->get($message->platformType);
|
|
|
|
try {
|
|
$adapter->deactivateListing($article);
|
|
|
|
// If all listings are deactivated (stock = 0), mark article as sold
|
|
if ($article->getStock() === 0 && $article->getStatus() === ArticleStatus::Listed) {
|
|
$article->transitionTo(ArticleStatus::Sold);
|
|
$this->articleRepository->save($article);
|
|
}
|
|
} catch (\RuntimeException $e) {
|
|
if ($message->attemptNumber >= self::ALERT_THRESHOLD) {
|
|
$this->logger->critical('CRITICAL: Failed to deactivate listing after {attempts} attempts. Risk of oversell!', [
|
|
'attempts' => $message->attemptNumber,
|
|
'articleId' => $message->articleId,
|
|
'platformType' => $message->platformType,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
// Re-throw to trigger Messenger retry with backoff
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: ArticleService.activate() → PublishToChannelMessage dispatchen**
|
|
|
|
Erweitere `src/Application/Article/ArticleService.php` um Messenger-Dispatch nach erfolgreicher Aktivierung:
|
|
|
|
```php
|
|
// Neuer Konstruktor-Parameter:
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use App\Infrastructure\Messenger\Message\PublishToChannelMessage;
|
|
|
|
public function __construct(
|
|
private readonly ArticleRepositoryInterface $articleRepository,
|
|
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
|
|
private readonly ArticleValidator $validator,
|
|
private readonly Connection $connection,
|
|
private readonly MessageBusInterface $bus,
|
|
) {}
|
|
|
|
// In activate(), nach $this->articleRepository->save($article):
|
|
$this->bus->dispatch(new PublishToChannelMessage($article->getId()->toRfc4122()));
|
|
```
|
|
|
|
- [ ] **Step 7: PHPStan + Commit**
|
|
|
|
```bash
|
|
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress
|
|
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff
|
|
|
|
git add src/Infrastructure/Messenger/Message/PublishToChannelMessage.php src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php src/Infrastructure/Messenger/Message/DeactivateListingMessage.php src/Infrastructure/Messenger/Handler/ config/packages/messenger.yaml src/Application/Article/ArticleService.php
|
|
git commit -m "feat: add channel_sync messages and handlers (publish, updateStock, deactivate with alert after 5 failures)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: eBay Webhook-Listener + HMAC-Verifikation
|
|
|
|
**Files:**
|
|
- Create: `src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php`
|
|
- Create: `src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php`
|
|
- Test: `tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php`
|
|
|
|
- [ ] **Step 1: Failing-Test für Webhook-Verifier**
|
|
|
|
```php
|
|
<?php
|
|
// tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Infrastructure\Channel\Ebay;
|
|
|
|
use App\Infrastructure\Channel\Ebay\EbayWebhookVerifier;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class EbayWebhookVerifierTest extends TestCase
|
|
{
|
|
private EbayWebhookVerifier $verifier;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->verifier = new EbayWebhookVerifier(
|
|
verificationToken: 'my-secret-token',
|
|
endpointUrl: 'https://example.com/webhooks/ebay',
|
|
);
|
|
}
|
|
|
|
public function test_valid_signature_passes(): void
|
|
{
|
|
$body = '{"notification":{"data":{"orderId":"123"}}}';
|
|
$expected = \base64_encode(\hash('sha256', $body.'my-secret-tokenhttps://example.com/webhooks/ebay', binary: true));
|
|
|
|
$this->assertTrue($this->verifier->verify($body, $expected));
|
|
}
|
|
|
|
public function test_invalid_signature_fails(): void
|
|
{
|
|
$this->assertFalse($this->verifier->verify('{"body":"x"}', 'invalidsignature'));
|
|
}
|
|
|
|
public function test_challenge_response_returns_correct_hash(): void
|
|
{
|
|
$challengeCode = 'abc123';
|
|
$expected = \hash('sha256', $challengeCode.'my-secret-tokenhttps://example.com/webhooks/ebay');
|
|
|
|
$this->assertSame($expected, $this->verifier->challengeResponse($challengeCode));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Test ausführen — muss fehlschlagen**
|
|
|
|
```bash
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php
|
|
# Expected: FAIL
|
|
```
|
|
|
|
- [ ] **Step 3: EbayWebhookVerifier implementieren**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Channel\Ebay;
|
|
|
|
final class EbayWebhookVerifier
|
|
{
|
|
public function __construct(
|
|
private readonly string $verificationToken,
|
|
private readonly string $endpointUrl,
|
|
) {}
|
|
|
|
/**
|
|
* Verifies the HMAC-SHA256 signature on an eBay webhook notification.
|
|
* eBay signature: base64(SHA256(requestBody + verificationToken + endpointUrl))
|
|
*/
|
|
public function verify(string $requestBody, string $signatureHeader): bool
|
|
{
|
|
$expected = \base64_encode(
|
|
\hash('sha256', $requestBody.$this->verificationToken.$this->endpointUrl, binary: true),
|
|
);
|
|
|
|
return \hash_equals($expected, $signatureHeader);
|
|
}
|
|
|
|
/**
|
|
* Returns the expected challenge response for endpoint registration.
|
|
* eBay sends GET ?challenge_code=XXX — we respond with SHA256(code + token + url).
|
|
*/
|
|
public function challengeResponse(string $challengeCode): string
|
|
{
|
|
return \hash('sha256', $challengeCode.$this->verificationToken.$this->endpointUrl);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Tests ausführen**
|
|
|
|
```bash
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php
|
|
# Expected: PASS (3 tests)
|
|
```
|
|
|
|
- [ ] **Step 5: EbayWebhookController implementieren**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Http\Controller\Webhook;
|
|
|
|
use App\Infrastructure\Channel\Ebay\EbayWebhookVerifier;
|
|
use App\Infrastructure\Messenger\Message\OrderReceivedMessage;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
#[Route('/webhooks/ebay', name: 'webhook_ebay')]
|
|
final class EbayWebhookController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private readonly EbayWebhookVerifier $verifier,
|
|
private readonly MessageBusInterface $bus,
|
|
private readonly LoggerInterface $logger,
|
|
) {}
|
|
|
|
/** eBay endpoint registration challenge (GET with ?challenge_code=) */
|
|
#[Route('', name: '_challenge', methods: ['GET'])]
|
|
public function challenge(Request $request): JsonResponse
|
|
{
|
|
$challengeCode = $request->query->getString('challenge_code');
|
|
if ('' === $challengeCode) {
|
|
return $this->json(['error' => 'Missing challenge_code'], Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
return $this->json(['challengeResponse' => $this->verifier->challengeResponse($challengeCode)]);
|
|
}
|
|
|
|
/** eBay notification event */
|
|
#[Route('', name: '_notify', methods: ['POST'])]
|
|
public function notify(Request $request): Response
|
|
{
|
|
$signature = $request->headers->get('X-EBAY-SIGNATURE', '');
|
|
$body = $request->getContent();
|
|
|
|
if (!$this->verifier->verify($body, $signature)) {
|
|
$this->logger->warning('eBay webhook: invalid signature', ['ip' => $request->getClientIp()]);
|
|
|
|
return new Response('', Response::HTTP_UNAUTHORIZED);
|
|
}
|
|
|
|
try {
|
|
/** @var array{notification?: array{topic?: string, data?: array<string, mixed>}} $payload */
|
|
$payload = \json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException) {
|
|
return new Response('', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$topic = $payload['notification']['topic'] ?? '';
|
|
$data = $payload['notification']['data'] ?? [];
|
|
|
|
$this->logger->info('eBay webhook received', ['topic' => $topic]);
|
|
|
|
match ($topic) {
|
|
'FIXED_PRICE_TRANSACTION',
|
|
'AUCTION_CHECKOUT_COMPLETE' => $this->handleOrderEvent($data),
|
|
'MARKETPLACE_ACCOUNT_DELETION' => $this->handleAccountDeletion($data),
|
|
default => null,
|
|
};
|
|
|
|
// Always respond 200 immediately — processing is async
|
|
return new Response('', Response::HTTP_OK);
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
private function handleOrderEvent(array $data): void
|
|
{
|
|
$orderId = (string) ($data['orderId'] ?? $data['order']['orderId'] ?? '');
|
|
if ('' === $orderId) {
|
|
$this->logger->error('eBay webhook: order event with no orderId', ['data' => $data]);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->bus->dispatch(new OrderReceivedMessage(
|
|
platformOrderId: $orderId,
|
|
platformType: 'ebay',
|
|
rawPayload: $data,
|
|
));
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
private function handleAccountDeletion(array $data): void
|
|
{
|
|
// eBay compliance requirement: log and acknowledge
|
|
$this->logger->info('eBay MARKETPLACE_ACCOUNT_DELETION received', ['data' => $data]);
|
|
// TODO in a later enhancement: anonymize customer data if required by GDPR
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: OrderReceivedMessage anlegen**
|
|
|
|
```php
|
|
<?php
|
|
// src/Infrastructure/Messenger/Message/OrderReceivedMessage.php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Infrastructure\Messenger\Message;
|
|
|
|
final readonly class OrderReceivedMessage
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
*/
|
|
public function __construct(
|
|
public string $platformOrderId,
|
|
public string $platformType,
|
|
public array $rawPayload,
|
|
) {}
|
|
}
|
|
```
|
|
|
|
Ergänze Messenger-Routing:
|
|
|
|
```yaml
|
|
App\Infrastructure\Messenger\Message\OrderReceivedMessage: orders
|
|
```
|
|
|
|
- [ ] **Step 7: Route + services.yaml**
|
|
|
|
```yaml
|
|
# config/routes/api.yaml (oder eigene Datei)
|
|
webhook_ebay:
|
|
path: /webhooks/ebay
|
|
controller: App\Infrastructure\Http\Controller\Webhook\EbayWebhookController
|
|
```
|
|
|
|
```yaml
|
|
# config/services.yaml
|
|
App\Infrastructure\Channel\Ebay\EbayWebhookVerifier:
|
|
arguments:
|
|
$verificationToken: '%env(EBAY_VERIFICATION_TOKEN)%'
|
|
$endpointUrl: '%env(EBAY_ENDPOINT_URL)%'
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php src/Infrastructure/Http/Controller/Webhook/ src/Infrastructure/Messenger/Message/OrderReceivedMessage.php tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php config/services.yaml config/packages/messenger.yaml
|
|
git commit -m "feat: add eBay webhook listener with HMAC verification, challenge response, OrderReceivedMessage dispatch"
|
|
```
|
|
|
|
---
|
|
|
|
## Selbstreview
|
|
|
|
**Spec-Abdeckung:**
|
|
- ChannelAdapterInterface mit publishListing, updateStock, deactivateListing, pushTracking ✓ (Task 1)
|
|
- EbayAdapter implementiert Interface ✓ (Task 3)
|
|
- eBay OAuth Client-Credentials-Flow mit Token-Cache ✓ (Task 2)
|
|
- eBay Taxonomy API für Pflichtfeld-Vorschläge ✓ (Task 4)
|
|
- PublishToChannelMessage nach Artikel-Aktivierung ✓ (Task 5)
|
|
- DeactivateListingMessage pro Plattform, Alert nach 5 Fehlversuchen ✓ (Task 5)
|
|
- eBay Webhook: HMAC-Signatur, Challenge-Response, sofort 200 → async ✓ (Task 6)
|
|
- Events: FIXED_PRICE_TRANSACTION, AUCTION_CHECKOUT_COMPLETE, MARKETPLACE_ACCOUNT_DELETION ✓ (Task 6)
|
|
- OrderReceivedMessage → orders Transport ✓ (Task 6)
|
|
|
|
**Noch nicht in diesem Plan:**
|
|
- TrackingPushMessage + Handler → Plan 6
|
|
- Order-Verarbeitung → Plan 6
|
|
- Neue Plattform = neue Adapter-Klasse (Muster ist etabliert)
|