llp hace 1 semana
padre
commit
4f2d39dc7f

+ 4 - 0
config/api-platform.php

@@ -57,6 +57,10 @@ return [
         base_path('packages/Webkul/BagistoApi/src/Dto/CustomerOrder/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/ProductSearch/'),
         base_path('packages/Webkul/BagistoApi/src/Dto/CategoryProducts/'),
+
+        // Longyi\FreeGift:GraphQL 赠品候选(giftCandidates)与
+        // 领取 / 移除 / 恢复(createAddGift、createRemoveGift、createRestoreGifts)
+        base_path('packages/Longyi/FreeGift/src/ApiResource/'),
     ],
 
     'formats' => [

+ 36 - 0
packages/Longyi/FreeGift/src/ApiResource/AddGift.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Longyi\FreeGift\State\GiftCartProcessor;
+
+/**
+ * GraphQL:手动领取一个赠品(赠品弹窗选择)。
+ *
+ * mutation createAddGift(input: {sku: "SP-003", ruleId: 4, qty: 1}) { addGift { success message cartItemId } }
+ *
+ * 内部复用 GiftProcessor::addGiftToCart(),会把该候选的
+ * is_item_deleted 标记清掉 —— 即「手动加回被我删过的赠品」。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'AddGift',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: AddGiftInput::class,
+            output: GiftActionResult::class,
+            processor: GiftCartProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: ['groups' => ['mutation']],
+            description: '手动把某个赠品加进购物车',
+        ),
+    ]
+)]
+class AddGift {}

+ 28 - 0
packages/Longyi/FreeGift/src/ApiResource/AddGiftInput.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * createAddGift 的入参。
+ *
+ * ⚠ 属性一律 snake_case:GraphQL 传入的 camelCase 字段名会被转换后再
+ * 反序列化到属性上,写成 camelCase 会收到 null(同输出侧)。GraphQL 侧
+ * 的字段名仍是 camelCase(sku / ruleId / qty)。
+ */
+class AddGiftInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '赠品 SKU', required: true)]
+    public ?string $sku = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '赠品规则(cart_rules.id)', required: true)]
+    public ?int $rule_id = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '数量,默认 1,超出剩余名额自动截断')]
+    public ?float $qty = null;
+}

+ 30 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftActionResult.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 赠品操作的统一返回(addGift / removeGift / restoreGifts)。
+ *
+ * 只放单词字段:GraphQL 输出 DTO 的 camelCase(多词)字段会取不到值
+ * (项目里 PaymentMethodOutput::isAllowed 同样为 null),操作后需要
+ * 行 ID / 最新名额请重新查 giftCandidates 与 giftProducts。
+ */
+class GiftActionResult
+{
+    /**
+     * API Platform 需要一个标识属性,否则 normalize 阶段会抛
+     * "Not able to retrieve identifiers"(参考 DeleteAllCompareItems)。
+     */
+    #[Groups(['mutation'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['mutation'])]
+    public ?bool $success = null;
+
+    #[Groups(['mutation'])]
+    public ?string $message = null;
+}

+ 51 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftBannerData.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Longyi\FreeGift\State\GiftCartProvider;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 赠品活动 Banner(受 sales.free_gift.general.show_banner 与规则 show_banner 控制)。
+ *
+ * ⚠ 属性一律 snake_case(原因见 GiftProductData 的注释)。字段名 = giftBanners。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'GiftBanner',
+    operations: [],
+    graphQlOperations: [
+        // item_query 用于「被别的输出嵌套时的标识提取」,见 GiftProductData 的注释
+        new Query(name: 'item_query', output: false),
+        new QueryCollection(
+            paginationEnabled: false,
+            provider: GiftCartProvider::class,
+            normalizationContext: ['groups' => ['query']],
+        ),
+    ]
+)]
+class GiftBannerData
+{
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['query'])]
+    public ?int $rule_id = null;
+
+    #[Groups(['query'])]
+    public ?string $text = null;
+
+    #[Groups(['query'])]
+    public ?string $image = null;
+
+    #[Groups(['query'])]
+    public ?float $allowed_qty = null;
+
+    #[Groups(['query'])]
+    public ?float $remaining_qty = null;
+}

+ 69 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftGroupData.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Longyi\FreeGift\State\GiftCartProvider;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 一条赠品规则对应的候选分组(只有规则级信息)。
+ *
+ * 具体可选的赠品走 giftProducts,用返回里的 rule_id 归组 —— 不在这里嵌套
+ * 资源数组,否则 API Platform 会把它当「关联资源」去调 GiftProduct 的
+ * provider,导致每组都塞进全量赠品。
+ *
+ * ⚠ 属性一律 snake_case(原因见 GiftProductData 的注释)。
+ * 字段名 = pluralize(lcfirst(GiftCandidate)) = giftCandidates。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'GiftCandidate',
+    operations: [],
+    graphQlOperations: [
+        // item_query 用于「被别的输出嵌套时的标识提取」,见 GiftProductData 的注释
+        new Query(name: 'item_query', output: false),
+        new QueryCollection(
+            paginationEnabled: false,
+            provider: GiftCartProvider::class,
+            normalizationContext: ['groups' => ['query']],
+        ),
+    ]
+)]
+class GiftGroupData
+{
+    /**
+     * API Platform 需要标识属性(参考 PaymentMethodOutput),否则
+     * GraphQL 输出会被清空。
+     */
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?int $rule_id = null;
+
+    #[Groups(['query'])]
+    public ?string $rule_name = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '0 = 每个 SKU 各送 N 个;1 = 整组合计送 N 个(用户自选)')]
+    public ?int $type = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '是否自动加购(false 时需用户手动挑选)')]
+    public ?bool $auto_add = null;
+
+    #[Groups(['query'])]
+    public ?float $allowed_qty = null;
+
+    #[Groups(['query'])]
+    public ?float $reserved_qty = null;
+
+    #[Groups(['query'])]
+    public ?float $remaining_qty = null;
+}

+ 91 - 0
packages/Longyi/FreeGift/src/ApiResource/GiftProductData.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Longyi\FreeGift\State\GiftCartProvider;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * 单个赠品候选(对应 REST GiftController::candidates 里 groups[*].gifts[*])。
+ *
+ * ⚠ 属性一律 snake_case:GraphQL 的字段解析是按「转换后的属性名」从对象上取值,
+ * camelCase 多词属性会取不到(null)。输出成 camelCase 由
+ * api-platform.name_converter 完成,见 Webkul\BagistoApi\Models\CustomerProfile。
+ *
+ * 字段名 = giftProducts。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'GiftProduct',
+    operations: [],
+    graphQlOperations: [
+        // 必须有 item_query(即便 output: false):CartData::$freeGifts 这种
+        // 「嵌套在别的输出里」的场景,API Platform 要靠它来提取标识,
+        // 缺了会抛 IdentifiersExtractor ... $parameterName must be of type string, null given。
+        // 同 Webkul\BagistoApi\Dto\CartItemData。
+        new Query(name: 'item_query', output: false),
+        new QueryCollection(
+            paginationEnabled: false,
+            provider: GiftCartProvider::class,
+            normalizationContext: ['groups' => ['query']],
+        ),
+    ]
+)]
+class GiftProductData
+{
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false, identifier: true)]
+    public ?string $id = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '赠品 SKU')]
+    public ?string $sku = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '所属赠品规则(cart_rules.id),用于与 giftCandidates 归组')]
+    public ?int $rule_id = null;
+
+    #[Groups(['query'])]
+    public ?string $rule_name = null;
+
+    #[Groups(['query'])]
+    public ?int $product_id = null;
+
+    #[Groups(['query'])]
+    public ?string $name = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '商品详情页地址')]
+    public ?string $url = null;
+
+    #[Groups(['query'])]
+    public ?string $image = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '格式化后的原价')]
+    public ?string $price = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '本条规则允许赠送的数量')]
+    public ?float $allowed_qty = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '已经放进购物车的数量')]
+    public ?float $reserved_qty = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '还能再选的数量')]
+    public ?float $remaining_qty = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '已加进购物车的赠品行 ID,未加为 null')]
+    public ?int $cart_item_id = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(description: '用户是否手动删除过该赠品(true 时可用 restoreGifts 恢复自动加购)')]
+    public ?bool $is_deleted = null;
+}

+ 36 - 0
packages/Longyi/FreeGift/src/ApiResource/RemoveGift.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Longyi\FreeGift\State\GiftCartProcessor;
+
+/**
+ * GraphQL:移除赠品。
+ *
+ * mutation createRemoveGift(input: {cartItemId: 167}) { removeGift { success message } }
+ *
+ * 走原生 Cart::removeItem(),删除监听器会打 is_item_deleted 标记
+ * (用户删过就不再自动加回,可用 createRestoreGifts 或 createAddGift 解锁)。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'RemoveGift',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: RemoveGiftInput::class,
+            output: GiftActionResult::class,
+            processor: GiftCartProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: ['groups' => ['mutation']],
+            description: '从购物车里移除赠品',
+        ),
+    ]
+)]
+class RemoveGift {}

+ 18 - 0
packages/Longyi/FreeGift/src/ApiResource/RemoveGiftInput.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * createRemoveGift 的入参。
+ *
+ * ⚠ 属性 snake_case,理由见 AddGiftInput;GraphQL 侧字段名仍是 cartItemId。
+ */
+class RemoveGiftInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: '赠品行在购物车里的 cart_items.id', required: true)]
+    public ?int $cart_item_id = null;
+}

+ 8 - 0
packages/Longyi/FreeGift/src/ApiResource/RestoreGiftInput.php

@@ -0,0 +1,8 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+/**
+ * createRestoreGifts 无需入参(占位,保证 GraphQL 生成 input 类型)。
+ */
+class RestoreGiftInput {}

+ 36 - 0
packages/Longyi/FreeGift/src/ApiResource/RestoreGifts.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Longyi\FreeGift\ApiResource;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Longyi\FreeGift\State\GiftCartProcessor;
+
+/**
+ * GraphQL:恢复当前购物车的赠品自动加购资格。
+ *
+ * mutation createRestoreGifts(input: {}) { restoreGifts { success message } }
+ *
+ * 清掉 free_gift_items.is_item_deleted 标记后重算 totals,
+ * 相当于把购物车「当作新车」看待 —— 用于用户误删赠品后一键恢复。
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'RestoreGifts',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: RestoreGiftInput::class,
+            output: GiftActionResult::class,
+            processor: GiftCartProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: ['groups' => ['mutation']],
+            description: '清除「用户已删除」标记,恢复赠品的自动加购',
+        ),
+    ]
+)]
+class RestoreGifts {}

+ 10 - 0
packages/Longyi/FreeGift/src/Providers/FreeGiftServiceProvider.php

@@ -2,10 +2,14 @@
 
 namespace Longyi\FreeGift\Providers;
 
+use ApiPlatform\State\ProcessorInterface;
+use ApiPlatform\State\ProviderInterface;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Support\ServiceProvider;
 use Longyi\FreeGift\Http\Requests\GiftCartRuleRequest;
 use Longyi\FreeGift\Repositories\GiftCartRuleRepository;
+use Longyi\FreeGift\State\GiftCartProcessor;
+use Longyi\FreeGift\State\GiftCartProvider;
 use Webkul\Admin\Http\Requests\CartRuleRequest;
 use Webkul\CartRule\Repositories\CartRuleRepository;
 
@@ -18,6 +22,12 @@ class FreeGiftServiceProvider extends ServiceProvider
     {
         $this->registerConfig();
 
+        // GraphQL(API Platform)的 provider / processor 必须注册进容器,
+        // 否则 CallableProcessor 的 service locator 找不到它们:
+        // "Processor ... not found on operation create"
+        $this->app->tag(GiftCartProvider::class, ProviderInterface::class);
+        $this->app->tag(GiftCartProcessor::class, ProcessorInterface::class);
+
         // 复用 Bagisto 原生的营销规则(Marketing > Promotions > Cart Rules):
         // 规则本身的字段不重复造,只追加 free_gift[*] 配置。
         $this->app->bind(CartRuleRequest::class, GiftCartRuleRequest::class);

+ 2 - 0
packages/Longyi/FreeGift/src/Resources/lang/en/app.php

@@ -21,5 +21,7 @@ return [
         'gift-close'           => 'Close',
         'gift-error'           => 'Unable to update the free gift, please try again.',
         'gift-limit-reached'   => 'All the free gifts of this promotion have been added.',
+        'gift-restored'        => 'Free gifts restored.',
+        'cart-not-found'       => 'Cart not found or no longer active.',
     ],
 ];

+ 2 - 0
packages/Longyi/FreeGift/src/Resources/lang/zh_CN/app.php

@@ -21,5 +21,7 @@ return [
         'gift-close'           => '关闭',
         'gift-error'           => '赠品更新失败,请稍后重试。',
         'gift-limit-reached'   => '该活动的赠品名额已全部领取。',
+        'gift-restored'        => '赠品已恢复自动加入。',
+        'cart-not-found'       => '购物车不存在或已失效。',
     ],
 ];

+ 180 - 0
packages/Longyi/FreeGift/src/Services/GiftCartSummary.php

@@ -0,0 +1,180 @@
+<?php
+
+namespace Longyi\FreeGift\Services;
+
+use Longyi\FreeGift\ApiResource\GiftBannerData;
+use Longyi\FreeGift\ApiResource\GiftGroupData;
+use Longyi\FreeGift\ApiResource\GiftProductData;
+use Longyi\FreeGift\Config\Gift;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
+use Webkul\Product\ProductImage;
+use Webkul\Product\Repositories\ProductRepository;
+
+/**
+ * 购物车的赠品候选快照(规则名额 + 每个赠品是否已加购)。
+ *
+ * 只读 free_gift_items / cart_items 的现状,不触发重算 —— 调用前购物车
+ * 应已经过 collectTotals()(readCart / 加购 / 更新数量内部都会跑)。
+ *
+ * 被两处复用:
+ *  - GraphQL 的 giftCandidates / giftProducts / giftBanners(GiftCartProvider)
+ *  - 购物车接口本身的 CartData::freeGifts(直接内嵌,省一次请求)
+ */
+class GiftCartSummary
+{
+    public function __construct(
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected FreeGiftRuleRepository $giftRuleRepository,
+        protected ProductRepository $productRepository,
+        protected ProductImage $productImage
+    ) {}
+
+    /**
+     * @return array{0: GiftGroupData[], 1: GiftProductData[], 2: GiftBannerData[]}
+     */
+    public function forCart(int $cartId): array
+    {
+        [$groups, $gifts] = $this->buildGroups($cartId);
+
+        return [$groups, $gifts, $this->buildBanners($groups)];
+    }
+
+    /**
+     * 只取赠品候选列表(购物车接口内嵌用)。
+     *
+     * @return GiftProductData[]
+     */
+    public function gifts(int $cartId): array
+    {
+        return $this->buildGroups($cartId)[1];
+    }
+
+    /**
+     * 按规则聚合:groups 只放名额信息,gifts 是全部候选(带 rule_id / cart_item_id)。
+     *
+     * @return array{0: GiftGroupData[], 1: GiftProductData[]}
+     */
+    protected function buildGroups(int $cartId): array
+    {
+        $groups = [];
+
+        $gifts = [];
+
+        $allowedByRule = [];
+        $reservedByRule = [];
+
+        foreach ($this->giftItemRepository->getByCart($cartId) as $row) {
+            if ($row->allowed_qty < 1) {
+                continue;
+            }
+
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($row->rule_id);
+
+            if (! $giftRule) {
+                continue;
+            }
+
+            $product = $row->product_id ? $this->productRepository->find($row->product_id) : null;
+
+            if (! $product) {
+                continue;
+            }
+
+            $ruleId = (int) $row->rule_id;
+            $ruleName = $giftRule->cart_rule?->name;
+
+            if (! isset($groups[$ruleId])) {
+                $group = new GiftGroupData;
+
+                $group->id = (string) $ruleId;
+                $group->rule_id = $ruleId;
+                $group->rule_name = $ruleName;
+                $group->type = (int) $row->rule_type;
+                $group->auto_add = (bool) $row->auto_add;
+                $group->allowed_qty = 0;
+                $group->reserved_qty = 0;
+                $group->remaining_qty = 0;
+
+                $groups[$ruleId] = $group;
+
+                $allowedByRule[$ruleId] = [];
+                $reservedByRule[$ruleId] = 0.0;
+            }
+
+            $allowedByRule[$ruleId][] = (float) $row->allowed_qty;
+            $reservedByRule[$ruleId] += (float) $row->reserved_qty;
+
+            $image = $this->productImage->getProductBaseImage($product);
+
+            $gift = new GiftProductData;
+
+            $gift->id = $ruleId.':'.$row->sku;
+            $gift->sku = $row->sku;
+            $gift->rule_id = $ruleId;
+            $gift->rule_name = $ruleName;
+            $gift->product_id = (int) $product->id;
+            $gift->name = $product->name;
+            $gift->url = route('shop.product_or_category.index', $product->url_key);
+            $gift->image = $image['medium_image_url'] ?? null;
+            $gift->price = core()->formatPrice((float) $product->price);
+            $gift->allowed_qty = (float) $row->allowed_qty;
+            $gift->reserved_qty = (float) $row->reserved_qty;
+            $gift->remaining_qty = max(0, (float) $row->allowed_qty - (float) $row->reserved_qty);
+            $gift->cart_item_id = $row->cart_item_id ? (int) $row->cart_item_id : null;
+            $gift->is_deleted = (bool) $row->is_item_deleted;
+
+            $gifts[] = $gift;
+        }
+
+        foreach ($groups as $ruleId => $group) {
+            $allowed = $allowedByRule[$ruleId] ?: [0.0];
+
+            // 「一组合计送 N 个」:整组共用 N 个名额,N 取单条的允许量而不是求和
+            $group->allowed_qty = $group->type === Gift::TYPE_ONE
+                ? (float) max($allowed)
+                : (float) array_sum($allowed);
+
+            $group->reserved_qty = (float) ($reservedByRule[$ruleId] ?? 0);
+            $group->remaining_qty = max(0, $group->allowed_qty - $group->reserved_qty);
+        }
+
+        return [array_values($groups), $gifts];
+    }
+
+    /**
+     * 需要展示 Banner 的规则(受总开关 + 规则自身 show_banner 控制)。
+     *
+     * @param  GiftGroupData[]  $groups
+     * @return GiftBannerData[]
+     */
+    protected function buildBanners(array $groups): array
+    {
+        $banners = [];
+
+        if (! core()->getConfigData('sales.free_gift.general.show_banner')) {
+            return $banners;
+        }
+
+        foreach ($groups as $group) {
+            $giftRule = $this->giftRuleRepository->findByCartRuleId($group->rule_id);
+
+            if (! $giftRule || ! $giftRule->show_banner) {
+                continue;
+            }
+
+            $banner = new GiftBannerData;
+
+            $banner->id = (string) $group->rule_id;
+            $banner->rule_id = $group->rule_id;
+            $banner->text = $giftRule->banner_alt ?: trans('free_gift::app.cart.banner-default');
+            $banner->image = $giftRule->bannerImageUrl();
+            $banner->allowed_qty = $group->allowed_qty;
+            $banner->remaining_qty = $group->remaining_qty;
+
+            $banners[] = $banner;
+        }
+
+        return $banners;
+    }
+}

+ 35 - 0
packages/Longyi/FreeGift/src/State/GiftCartContext.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Longyi\FreeGift\State;
+
+use Webkul\BagistoApi\Facades\CartTokenFacade;
+use Webkul\BagistoApi\Facades\TokenHeaderFacade;
+use Webkul\Checkout\Facades\Cart;
+
+/**
+ * 在 API / GraphQL 上下文里定位当前购物车。
+ *
+ * Blade 前台靠 session,API 请求没有 session —— 只能从 Authorization: Bearer
+ * 解析(guest cart token、customer.token 或 Sanctum personal access token),
+ * 与 Webkul\BagistoApi\State\CartTokenProcessor 同一套口径。
+ */
+class GiftCartContext
+{
+    /**
+     * 解析并激活当前购物车(同时把 Cart facade 指向它)。
+     */
+    public function resolveCart(): ?object
+    {
+        $request = request();
+
+        $token = $request ? TokenHeaderFacade::getAuthorizationBearerToken($request) : null;
+
+        $cart = $token ? CartTokenFacade::getCartByToken($token) : null;
+
+        if ($cart) {
+            Cart::setCart($cart);
+        }
+
+        return $cart;
+    }
+}

+ 136 - 0
packages/Longyi/FreeGift/src/State/GiftCartProcessor.php

@@ -0,0 +1,136 @@
+<?php
+
+namespace Longyi\FreeGift\State;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProcessorInterface;
+use Longyi\FreeGift\ApiResource\GiftActionResult;
+use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
+use Longyi\FreeGift\Services\GiftProcessor;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Checkout\Repositories\CartItemRepository;
+
+/**
+ * GraphQL 赠品写操作(createAddGift / createRemoveGift / createRestoreGifts)。
+ *
+ * 三个 mutation 共用一个 processor,按 shortName 分派;购物车一律通过
+ * Bearer token 解析(见 GiftCartContext)。
+ */
+class GiftCartProcessor implements ProcessorInterface
+{
+    public function __construct(
+        protected GiftCartContext $context,
+        protected GiftProcessor $giftProcessor,
+        protected FreeGiftItemRepository $giftItemRepository,
+        protected CartItemRepository $cartItemRepository
+    ) {}
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiftActionResult
+    {
+        $result = new GiftActionResult;
+
+        try {
+            $cart = $this->context->resolveCart();
+
+            if (! $cart) {
+                $result->success = false;
+                $result->message = trans('free_gift::app.cart.cart-not-found');
+
+                return $result;
+            }
+
+            return match ($operation->getShortName()) {
+                'AddGift'      => $this->addGift($cart, $data),
+                'RemoveGift'   => $this->removeGift($cart, $data),
+                'RestoreGifts' => $this->restoreGifts($cart),
+                default        => $this->failure(trans('free_gift::app.cart.gift-error')),
+            };
+        } catch (\Throwable $e) {
+            report($e);
+
+            return $this->failure($e->getMessage());
+        }
+    }
+
+    /**
+     * 手动领取赠品:复用 GiftProcessor::addGiftToCart(),
+     * 它会把该候选的 is_item_deleted 标记清掉,等于「加回我删过的赠品」。
+     */
+    protected function addGift($cart, mixed $data): GiftActionResult
+    {
+        $sku = (string) ($data->sku ?? '');
+        $ruleId = (int) ($data->rule_id ?? 0);
+        $qty = (float) ($data->qty ?? 1);
+
+        if ($sku === '' || ! $ruleId) {
+            return $this->failure(trans('free_gift::app.cart.gift-unavailable'));
+        }
+
+        $outcome = $this->giftProcessor->addGiftToCart($cart, $sku, $ruleId, $qty > 0 ? $qty : 1.0);
+
+        Cart::collectTotals();
+
+        return $this->make(
+            (bool) ($outcome['success'] ?? false),
+            $outcome['message'] ?? trans('free_gift::app.cart.gift-error'),
+            $outcome['cart_item_id'] ?? null
+        );
+    }
+
+    /**
+     * 移除赠品:走原生 removeItem(),删除监听器负责打 is_item_deleted 标记。
+     */
+    protected function removeGift($cart, mixed $data): GiftActionResult
+    {
+        $cartItemId = (int) ($data->cart_item_id ?? 0);
+
+        $item = $cartItemId ? $this->cartItemRepository->find($cartItemId) : null;
+
+        if (! $item || (int) $item->cart_id !== (int) $cart->id) {
+            return $this->failure(trans('free_gift::app.cart.gift-unavailable'));
+        }
+
+        Cart::removeItem($cartItemId);
+
+        Cart::collectTotals();
+
+        return $this->success(trans('free_gift::app.cart.gift-removed'));
+    }
+
+    /**
+     * 一键恢复:清掉「用户已删除」标记后重算,
+     * 让规则重新把赠品补进购物车。
+     */
+    protected function restoreGifts($cart): GiftActionResult
+    {
+        $this->giftItemRepository->resetDeleted((int) $cart->id);
+
+        Cart::collectTotals();
+
+        return $this->success(trans('free_gift::app.cart.gift-restored'));
+    }
+
+    protected function success(string $message, ?int $cartItemId = null): GiftActionResult
+    {
+        return $this->make(true, $message, $cartItemId);
+    }
+
+    protected function failure(string $message): GiftActionResult
+    {
+        return $this->make(false, $message);
+    }
+
+    /**
+     * 组装返回。id 必须给值,API Platform 在 normalize 阶段依赖标识属性。
+     */
+    protected function make(bool $success, string $message, ?int $cartItemId = null): GiftActionResult
+    {
+        $result = new GiftActionResult;
+
+        $result->id = (string) ($cartItemId ?? 'gift-action');
+        $result->success = $success;
+        $result->message = $message;
+
+        return $result;
+    }
+}

+ 54 - 0
packages/Longyi/FreeGift/src/State/GiftCartProvider.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Longyi\FreeGift\State;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use Longyi\FreeGift\ApiResource\GiftBannerData;
+use Longyi\FreeGift\ApiResource\GiftGroupData;
+use Longyi\FreeGift\ApiResource\GiftProductData;
+use Longyi\FreeGift\Services\GiftCartSummary;
+use Webkul\Checkout\Facades\Cart;
+
+/**
+ * GraphQL 赠品候选数据源。
+ *
+ *  - giftCandidates → 按赠品规则分组的名额信息(GiftCandidate)
+ *  - giftProducts   → 每条规则对应的可选赠品(带 rule_id / cart_item_id / is_deleted)
+ *  - giftBanners    → 需要展示的赠品 Banner
+ *
+ * 数据组装在 Longyi\FreeGift\Services\GiftCartSummary(购物车接口的
+ * CartData::freeGifts 复用同一份逻辑)。
+ */
+class GiftCartProvider implements ProviderInterface
+{
+    public function __construct(
+        protected GiftCartContext $context,
+        protected GiftCartSummary $summary
+    ) {}
+
+    /**
+     * @return GiftGroupData[]|GiftProductData[]|GiftBannerData[]
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
+    {
+        $cart = $this->context->resolveCart();
+
+        if (! $cart) {
+            return [];
+        }
+
+        // 先把规则跑一轮,保证 free_gift_items / cart_items 是最新状态
+        Cart::collectTotals();
+
+        $cart = Cart::getCart() ?: $cart;
+
+        [$groups, $gifts, $banners] = $this->summary->forCart((int) $cart->id);
+
+        return match ($operation->getShortName()) {
+            'GiftProduct' => $gifts,
+            'GiftBanner'  => $banners,
+            default       => $groups,
+        };
+    }
+}

+ 40 - 0
packages/Webkul/BagistoApi/src/Dto/CartData.php

@@ -35,6 +35,28 @@ class CartData
     #[ApiProperty(description: 'Individual cart items')]
     public ?array $items = [];
 
+    /**
+     * 赠品状态(Longyi\FreeGift),两个布尔就够前端决策:
+     *
+     *  hasFreeGift           购物车里现在有赠品行(行内 items[].isFreeGift 也是 true)
+     *  freeGiftAvailable     还有赠品可以领 —— 名额没领完,或用户删掉过(删掉后用
+     *                        createAddGift / createRestoreGifts 都能重新拿到)
+     *  freeGiftAvailableQty  可领件数,用于显示「还有 N 件赠品」
+     *
+     * 逐个赠品的明细(SKU / 图片 / ruleId / cartItemId)查 giftCandidates / giftProducts。
+     */
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Whether the cart currently contains any free gift item')]
+    public ?bool $hasFreeGift = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Whether the cart still has free gifts to claim (including ones the user removed)')]
+    public ?bool $freeGiftAvailable = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'How many free gifts can still be claimed')]
+    public ?int $freeGiftAvailableQty = null;
+
     #[Groups(['query', 'mutation'])]
     #[ApiProperty(description: 'Subtotal before discounts and taxes')]
     public ?float $subtotal = null;
@@ -342,6 +364,24 @@ class CartData
 
         $data->items = $items->toArray();
 
+        /**
+         * 赠品状态:Longyi\FreeGift 的规则名额 + 加购/删除情况。
+         * 包未启用或出错时都保持 false / 0,不影响购物车接口本身。
+         */
+        $data->hasFreeGift = $items->contains(fn ($item) => (bool) ($item->isFreeGift ?? false));
+        $data->freeGiftAvailable = false;
+        $data->freeGiftAvailableQty = 0;
+
+        try {
+            foreach (app(\Longyi\FreeGift\Services\GiftCartSummary::class)->gifts((int) $cart->id) as $gift) {
+                $data->freeGiftAvailableQty += (int) $gift->remaining_qty;
+            }
+
+            $data->freeGiftAvailable = $data->freeGiftAvailableQty > 0;
+        } catch (\Throwable $e) {
+            report($e);
+        }
+
         $data->subtotal = (float) core()->convertPrice($cart->base_sub_total ?? 0);
         $data->baseSubtotal = (float) ($cart->base_sub_total ?? 0);
         $data->formattedSubtotal = core()->currency($cart->base_sub_total ?? 0);