Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

GraphQL-Endpoint: Punktestand und Prämienkatalog abfragen

GraphQL-Endpoint: Punktestand und Prämienkatalog abfragen

~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Kapitel 79-81 haben die REST-Seite fertiggestellt. Dieses Kapitel bringt dieselben Daten - Punktestand und Prämienkatalog - als GraphQL-Query, nach exakt demselben schema.graphqls/Resolver/DataProvider-Muster, das die separate GraphQL-Tutorial-Serie dieses Katalogs im Detail einführt (Skript tools/generate-tutorial-graphql-csv.py, hier vorausgesetzt).

Zwei Felder, zwei Sicherheitsmodelle

Genau wie bei den beiden REST-Routen aus Kapitel 80 stehen sich hier zwei grundverschiedene Zugriffsmodelle gegenüber: loyaltyPointsSummary ist personenbezogen und erfordert einen eingeloggten Kunden, loyaltyRewards ist der öffentliche, gästefähige Katalog (dieselbe Unterscheidung wie zwischen den Controllern History\Index und Catalog\Index aus Kapitel 45/49).

app/code/Mironsoft/Loyalty/etc/schema.graphqls
type Query {
    loyaltyPointsSummary: LoyaltyPointsSummary
        @resolver(class: "Mironsoft\\Loyalty\\Model\\Resolver\\PointsSummary")
        @doc(description: "Returns the current customer's points balance, tier, and ledger history")

    loyaltyRewards(
        rewardType: String
        maxPoints: Int
    ): [LoyaltyReward]
        @resolver(class: "Mironsoft\\Loyalty\\Model\\Resolver\\RewardCatalog")
        @doc(description: "Returns the active reward catalog, optionally filtered")
}

type LoyaltyPointsSummary @doc(description: "A customer's aggregated points summary") {
    points_balance: Int! @doc(description: "Current points balance")
    tier: String! @doc(description: "One of bronze, silver, gold")
    ledger_entries: [LoyaltyPointsLedgerEntry] @doc(description: "Recent ledger entries")
}

type LoyaltyPointsLedgerEntry @doc(description: "A single points ledger entry") {
    points: Int! @doc(description: "Positive for a credit, negative for a debit")
    type: String! @doc(description: "One of earn, redeem, expire, adjust")
    balance_after: Int! @doc(description: "Balance immediately after this entry")
    created_at: String @doc(description: "When this entry was booked")
}

type LoyaltyReward @doc(description: "A single redeemable reward") {
    reward_id: Int! @doc(description: "Reward entity ID")
    identifier: String! @doc(description: "URL-safe reward identifier")
    title: String! @doc(description: "Reward title")
    description: String @doc(description: "Reward description")
    points_cost: Int! @doc(description: "Points required to redeem this reward")
    discount_value: Float @doc(description: "Discount value, for discount-type rewards")
    reward_type: String! @doc(description: "One of discount, free_product, free_shipping")
    is_active: Boolean! @doc(description: "Whether the reward is currently redeemable")
}

Der PointsSummary-Resolver

Statt eigener Geschäftslogik injiziert der Resolver direkt Api\PointsManagementInterface aus Kapitel 80 - dasselbe Service-Contract-Wiederverwendungsprinzip, das Kapitel 79 als Kapitelziel formuliert hat, hier zum ersten Mal ganz konkret.

app/code/Mironsoft/Loyalty/Model/Resolver/PointsSummary.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\PointsManagementInterface;

/**
 * Resolves the loyaltyPointsSummary query field - deliberately delegates to
 * the same PointsManagementInterface the REST endpoint uses (chapter 80),
 * no separate GraphQL-only business logic.
 */
class PointsSummary implements ResolverInterface
{
    /**
     * @param PointsManagementInterface $pointsManagement Aggregates balance, tier, and ledger history.
     */
    public function __construct(
        private readonly PointsManagementInterface $pointsManagement,
    ) {
    }

    /**
     * @param Field $field Resolved GraphQL field configuration.
     * @param mixed $context Resolver context, carries the customer ID (see graphql-magento series, chapter 17).
     * @param ResolveInfo $info GraphQL resolve tree info.
     * @param array|null $value Parent resolver's value, unused for a top-level field.
     * @param array|null $args Arguments passed to the loyaltyPointsSummary field, unused - no arguments declared.
     * @return array<string, mixed>
     * @throws GraphQlAuthorizationException
     */
    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): array {
        if (!$context->getExtensionAttributes()->getIsCustomer()) {
            throw new GraphQlAuthorizationException(
                __('The current customer isn\'t authorized.')
            );
        }

        $customerId = (int) $context->getUserId();
        $summary = $this->pointsManagement->getPointsSummary($customerId);

        return [
            'points_balance' => $summary->getPointsBalance(),
            'tier' => $summary->getTier(),
            'ledger_entries' => array_map(
                static fn (PointsLedgerInterface $entry): array => [
                    'points' => $entry->getPoints(),
                    'type' => $entry->getType(),
                    'balance_after' => $entry->getBalanceAfter(),
                    'created_at' => $entry->getCreatedAt(),
                ],
                $summary->getLedgerEntries()
            ),
        ];
    }
}

Achtung: $context->getExtensionAttributes()->getIsCustomer() ist hier von Anfang an vorhanden, nicht - wie im Einstiegsbeispiel der GraphQL-Serie - nachträglich ergänzt: Ein Punktestand ist per Definition personenbezogen, ein Gastzugriff darf niemals stillschweigend customerId = 0 auflösen. GraphQlAuthorizationException stoppt die Anfrage sauber, statt Daten eines zufälligen Kunden zurückzugeben.

Caching: die FPC-Authorization-Regel greift automatisch

Da loyaltyPointsSummary immer einen Authorization-Header mit Kunden-Token voraussetzt, markiert Magento die Antwort automatisch als nicht öffentlich cachefähig - derselbe Mechanismus, den die GraphQL-Serie an is_favorite zeigt. Kein eigener Cache-Code nötig, keine Gefahr, dass ein Kunde versehentlich den im FPC gespeicherten Punktestand eines anderen Kunden sieht.

Der RewardCatalog-Resolver und Datenprovider

loyaltyRewards ist dagegen öffentlich cachefähig - hier lohnt sich ein eigenes Cache-Tag über IdentityInterface, damit eine im Admin geänderte Prämie den zugehörigen FPC-Eintrag zuverlässig invalidiert:

app/code/Mironsoft/Loyalty/Model/Resolver/RewardCatalog.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\Loyalty\Model\Resolver\DataProvider\RewardCatalog as RewardCatalogDataProvider;

/**
 * Resolves the loyaltyRewards query field - kept thin, all data access lives
 * in the DataProvider (same split as the graphql-magento series' Events resolver).
 */
class RewardCatalog implements ResolverInterface, IdentityInterface
{
    private const CACHE_TAG = 'mironsoft_loyalty_reward';

    /**
     * @param RewardCatalogDataProvider $dataProvider Loads and shapes the reward catalog.
     */
    public function __construct(
        private readonly RewardCatalogDataProvider $dataProvider,
    ) {
    }

    /**
     * @param Field $field Resolved GraphQL field configuration.
     * @param mixed $context Resolver context, unused - the catalog is guest-accessible.
     * @param ResolveInfo $info GraphQL resolve tree info.
     * @param array|null $value Parent resolver's value, unused for a top-level field.
     * @param array|null $args Arguments passed to the loyaltyRewards field (rewardType, maxPoints).
     * @return array<int, array<string, mixed>>
     */
    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): array {
        return $this->dataProvider->getRewards(
            isset($args['rewardType']) ? (string) $args['rewardType'] : null,
            isset($args['maxPoints']) ? (int) $args['maxPoints'] : null
        );
    }

    /**
     * Returns the cache tags this resolved data should be invalidated by.
     *
     * @param array<int, array<string, mixed>> $resolvedData The array returned by resolve().
     * @return string[]
     */
    public function getIdentities(array $resolvedData): array
    {
        $tags = [self::CACHE_TAG];

        foreach ($resolvedData as $reward) {
            if (isset($reward['reward_id'])) {
                $tags[] = self::CACHE_TAG . '_' . $reward['reward_id'];
            }
        }

        return $tags;
    }
}
app/code/Mironsoft/Loyalty/Model/Resolver/DataProvider/RewardCatalog.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Resolver\DataProvider;

use Mironsoft\Loyalty\Model\ResourceModel\Reward;
use Mironsoft\Loyalty\Model\ResourceModel\Reward\CollectionFactory;

/**
 * Loads and shapes the active reward catalog for the loyaltyRewards GraphQL
 * field, reusing the same EAV collection filters the storefront catalog
 * page already uses (chapter 15/49).
 */
class RewardCatalog
{
    /**
     * @param CollectionFactory $collectionFactory Factory for the EAV reward collection (chapter 15).
     */
    public function __construct(
        private readonly CollectionFactory $collectionFactory,
    ) {
    }

    /**
     * Returns the active reward catalog, optionally filtered.
     *
     * @param string|null $rewardType One of Model\Reward\Source\RewardType::TYPE_*, or null for all.
     * @param int|null $maxPoints Maximum points cost, or null for no limit.
     * @return array<int, array<string, mixed>>
     */
    public function getRewards(?string $rewardType, ?int $maxPoints): array
    {
        $collection = $this->collectionFactory->create();
        $collection->addActiveFilter();

        if ($rewardType !== null) {
            $collection->addRewardTypeFilter($rewardType);
        }

        if ($maxPoints !== null) {
            $collection->addMaxPointsCostFilter($maxPoints);
        }

        $rewards = [];
        /** @var Reward $reward */
        foreach ($collection as $reward) {
            $rewards[] = [
                'reward_id' => (int) $reward->getId(),
                'identifier' => (string) $reward->getData('identifier'),
                'title' => (string) $reward->getData('title'),
                'description' => $reward->getData('description'),
                'points_cost' => (int) $reward->getData('points_cost'),
                'discount_value' => $reward->getData('discount_value'),
                'reward_type' => (string) $reward->getData('reward_type'),
                'is_active' => (bool) $reward->getData('is_active'),
            ];
        }

        return $rewards;
    }
}

Der DataProvider ruft ausschließlich die bereits in Kapitel 15 gebauten Collection-Filtermethoden auf (addActiveFilter(), addRewardTypeFilter(), addMaxPointsCostFilter()) - identisch zu ViewModel\RewardCatalog aus Kapitel 49, nur als GraphQL- statt als Storefront-Aufrufer.

Beide Queries testen

query MyPoints {
  loyaltyPointsSummary {
    points_balance
    tier
    ledger_entries {
      points
      type
      created_at
    }
  }
}

query ActiveDiscountRewards {
  loyaltyRewards(rewardType: "discount", maxPoints: 500) {
    reward_id
    title
    points_cost
    discount_value
  }
}

Tipp: Genau wie webapi.xml/acl.xml (Kapitel 80) fließt auch schema.graphqls in den config_webservice-Cache - nach jeder Änderung an dieser Datei ist bin/cache-clean config_webservice Pflicht. Bleibt dieser Schritt aus, meldet /graphql hartnäckig "Cannot query field ... on type Query", obwohl das Feld längst im Schema steht.

Kapitel 83 ergänzt die fehlende Gegenseite: eine Mutation, die Kapitel 81s Einlöse-Logik ohne jede Duplizierung wiederverwendet.