Przeglądaj źródła

Merge branch 'dev-rewardPoints' into dev

bianjunhui 3 dni temu
rodzic
commit
72a5657639

+ 7 - 0
config/logging.php

@@ -64,6 +64,13 @@ return [
             'level'                => env('LOG_LEVEL', 'debug'),
             'level'                => env('LOG_LEVEL', 'debug'),
             'replace_placeholders' => true,
             'replace_placeholders' => true,
         ],
         ],
+        'api' => [
+            'driver'               => 'daily',
+            'path'                 => storage_path('logs/api.log'),
+            'level'                => env('LOG_LEVEL', 'info'),
+            'days'                 => 14,
+            'replace_placeholders' => true,
+        ],
         'giftcard' => [
         'giftcard' => [
             'driver'               => 'daily',
             'driver'               => 'daily',
             'path'                 => storage_path('logs/giftcard.log'),
             'path'                 => storage_path('logs/giftcard.log'),

+ 67 - 801
packages/Longyi/Email/src/Providers/EventServiceProvider.php

@@ -5,8 +5,6 @@ namespace Longyi\Email\Providers;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\ServiceProvider;
 use Longyi\Email\Models\EmailLog;
 use Longyi\Email\Models\EmailLog;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Mail;
-use Illuminate\Mail\Events\MessageSending;
 use Illuminate\Mail\Events\MessageSent;
 use Illuminate\Mail\Events\MessageSent;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Event;
 
 
@@ -14,405 +12,55 @@ class EventServiceProvider extends ServiceProvider
 {
 {
     public function boot(): void
     public function boot(): void
     {
     {
-        //Log::info('Email EventServiceProvider booting...');
-
-        // 方法1: 监听 Laravel 邮件事件(同步和异步都有效)
-        //$this->registerMailEvents();
-
-        // 方法2: 监听队列事件(作为备用)
-        $this->registerQueueEvents();
-
-        // 方法3: Hook Mail facade(最可靠)
-        //$this->hookMailFacade();
-
-        //Log::info('Email EventServiceProvider booted successfully');
+        // 只监听 Laravel 官方邮件事件(MessageSent),
+        // 无论是同步 Mail::send() 还是异步 Mail::queue() 都会触发,
+        // 且不会响应非邮件队列任务,避免产生日志噪音。
+        $this->registerMailEvents();
     }
     }
 
 
     /**
     /**
      * 注册邮件事件监听器
      * 注册邮件事件监听器
+     *
+     * 只监听 Laravel 官方的邮件事件,而不是队列事件,这样:
+     *  - 同步 Mail::send() 与异步 Mail::queue() 都会触发;
+     *  - 非邮件队列任务(如 LogApiRequestJob)不会触发,避免日志噪音。
      */
      */
     protected function registerMailEvents(): void
     protected function registerMailEvents(): void
     {
     {
-        // Laravel 9+ 使用这些事件
-        Event::listen(MessageSending::class, function (MessageSending $event) {
-            Log::info('MessageSending event triggered', [
-                'message_id' => $event->message->getMessageId(),
-            ]);
-
-            $this->logEmailFromMessage($event->message, 'pending');
-        });
-
+        // 邮件发送成功后记录(Symfony Mailer 的 $event->message 是 Symfony\Component\Mime\Email)
         Event::listen(MessageSent::class, function (MessageSent $event) {
         Event::listen(MessageSent::class, function (MessageSent $event) {
-            Log::info('MessageSent event triggered');
-
-            $this->updateEmailStatus($event->message, 'sent');
-        });
-
-        // 兼容旧版本的事件名称
-        Event::listen('illuminate.mail.sending', function ($event) {
-            Log::info('illuminate.mail.sending event triggered');
-
-            if (isset($event->message)) {
-                $this->logEmailFromMessage($event->message, 'pending');
-            }
-        });
-
-        Event::listen('illuminate.mail.sent', function ($event) {
-            Log::info('illuminate.mail.sent event triggered');
-
-            if (isset($event->message)) {
-                $this->updateEmailStatus($event->message, 'sent');
-            }
+            $this->logSentEmail($event->message);
         });
         });
     }
     }
 
 
     /**
     /**
-     * 注册队列事件监听器
+     * 从 Symfony Mailer 的 Email 对象记录已发送邮件。
+     *
+     * @param  \Symfony\Component\Mime\Email  $message
      */
      */
-    protected function registerQueueEvents(): void
-    {
-        // 监听邮件任务被推入队列时
-        Event::listen('Illuminate\Queue\Events\JobQueued', function ($event) {
-            try {
-                /*Log::info('JobQueued event triggered', [
-                    'event_class' => get_class($event),
-                ]);*/
-
-                // JobQueued 事件有不同的属性结构
-                $payload = null;
-
-                if (is_object($event)) {
-                    // Laravel 10+ 的结构
-                    if (isset($event->payload)) {
-                        $payload = $event->payload;
-                    }
-                    // 或者通过 job 获取
-                    elseif (isset($event->job)) {
-                        $payload = $event->job->payload();
-                    }
-                }
-
-                // 如果 payload 是字符串,解析为数组
-                if (is_string($payload)) {
-                    $payload = json_decode($payload, true);
-                }
-
-                // 如果还是无法获取 payload,直接返回
-                if (!is_array($payload)) {
-                    Log::warning('Payload is not an array', ['payload_type' => gettype($payload)]);
-                    return;
-                }
-
-                if (!isset($payload['data']['command'])) {
-                    Log::info('No command in payload', ['payload_keys' => array_keys($payload)]);
-                    return;
-                }
-
-                $command = @unserialize($payload['data']['command']);
-
-                if (!$command) {
-                    Log::warning('Failed to unserialize command');
-                    return;
-                }
-
-                // 检查是否是 SendQueuedMailable
-                if (!$command instanceof \Illuminate\Mail\SendQueuedMailable) {
-                    Log::info('Not a SendQueuedMailable', [
-                        'command_class' => get_class($command),
-                    ]);
-                    return;
-                }
-
-                $mailable = $command->mailable;
-
-                if (!$mailable instanceof \Illuminate\Mail\Mailable) {
-                    Log::warning('Mailable is not valid');
-                    return;
-                }
-
-                Log::info('Mail job queued', [
-                    'mailable_class' => get_class($mailable),
-                    'queue' => $payload['queue'] ?? 'pending',
-                ]);
-
-                // 关键修复:从 SendQueuedMailable 命令中提取收件人
-                $recipients = $this->extractRecipientsFromCommand($command, $mailable);
-
-                if (empty($recipients)) {
-                    Log::warning('No recipients found in command or mailable');
-                    return;
-                }
-
-                $subject = $this->extractSubjectFromMailable($mailable);
-
-                // 记录日志
-                foreach ($recipients as $recipient) {
-                    $email = is_array($recipient) ? ($recipient['address'] ?? $recipient['email'] ?? null) : $recipient;
-                    $name = is_array($recipient) ? ($recipient['name'] ?? null) : null;
-
-                    if (!$email) {
-                        Log::warning('No email address found for recipient', ['recipient' => $recipient]);
-                        continue;
-                    }
-
-                    // 清理姓名(去除多余空格,如果为空则设为 null)
-                    if ($name) {
-                        $name = trim(preg_replace('/\s+/', ' ', $name));
-                        if (empty($name)) {
-                            $name = null;
-                        }
-                    }
-
-                    // 检查是否已存在(扩大时间窗口到30分钟,避免重复但允许同一用户多次收到不同邮件)
-                    $exists = EmailLog::where('recipient_email', $email)
-                        ->where('subject', $subject)
-                        ->where('created_at', '>', now()->subMinutes(30))
-                        ->exists();
-
-                    if ($exists) {
-                        Log::info('Email log already exists, skipping', [
-                            'email' => $email,
-                            'subject' => $subject,
-                        ]);
-                        continue;
-                    }
-
-                    $log = EmailLog::create([
-                        'recipient_email' => $email,
-                        'recipient_name' => $name,
-                        'subject' => $subject,
-                        'content' => $this->extractContentFromMailable($mailable),
-                        'template' => $this->detectTemplateFromMailable($mailable),
-                        'status' => 'sent',  // 初始状态为 pending(待发送)
-                        'sent_at' => now(),  // 发送时间为空,等待发送成功后更新
-                        'metadata' => json_encode([
-                            'job_id' => $payload['uuid'] ?? null,
-                            'mailable_class' => get_class($mailable),
-                            'queue' => $payload['queue'] ?? null,
-                            'connection' => $payload['connection'] ?? null,
-                            'created_via' => 'JobQueued',
-                        ]),
-                    ]);
-
-                    Log::info('Email log created from queued job', [
-                        'log_id' => $log->id,
-                        'email' => $email,
-                        'name' => $name,
-                        'subject' => $subject,
-                        'status' => 'sent',
-                        'sent_at' => $log->sent_at,
-                    ]);
-                }
-
-            } catch (\Exception $e) {
-                Log::error('Failed to process queued mail job: ' . $e->getMessage(), [
-                    'trace' => $e->getTraceAsString(),
-                ]);
-            }
-        });
-
-    }
-
-
-    /**
-     * 从 SendQueuedMailable 命令中提取收件人
-     */
-    protected function extractRecipientsFromCommand($command, $mailable): array
-    {
-        try {
-            Log::info('Extracting recipients from SendQueuedMailable command', [
-                'command_class' => get_class($command),
-                'mailable_class' => get_class($mailable),
-            ]);
-
-            // 方法1: 从命令对象的 to 属性获取(Laravel 会在构建时填充)
-            if (property_exists($command, 'to') && !empty($command->to)) {
-                Log::info('Found recipients in command->to', ['to' => $command->to]);
-                return is_array($command->to) ? $command->to : [$command->to];
-            }
-
-            // 方法2: 尝试反射获取命令的 to 属性
-            $reflection = new \ReflectionClass($command);
-            if ($reflection->hasProperty('to')) {
-                $property = $reflection->getProperty('to');
-                $property->setAccessible(true);
-                $to = $property->getValue($command);
-
-                if (!empty($to)) {
-                    Log::info('Extracted recipients from command via reflection', ['to' => $to]);
-                    return is_array($to) ? $to : [$to];
-                }
-            }
-
-            // 方法3: 从 Mailable 的特定属性推断(customer, subscribersList, order 等)
-            $recipientProperties = ['customer', 'subscribersList', 'order', 'invoice', 'shipment', 'refund'];
-
-            foreach ($recipientProperties as $propName) {
-                if (property_exists($mailable, $propName)) {
-                    $reflection = new \ReflectionClass($mailable);
-                    $property = $reflection->getProperty($propName);
-                    $property->setAccessible(true);
-                    $obj = $property->getValue($mailable);
-
-                    if ($obj && isset($obj->email) && filter_var($obj->email, FILTER_VALIDATE_EMAIL)) {
-                        $recipients = [[
-                            'address' => $obj->email,
-                            'name' => $obj->name ?? $obj->first_name . ' ' . ($obj->last_name ?? '') ?? $obj->customer_full_name ?? null,
-                        ]];
-                        Log::info("Extracted recipient from mailable->{$propName}", [
-                            'recipients' => $recipients,
-                            'object_type' => get_class($obj),
-                        ]);
-                        return $recipients;
-                    }
-                }
-            }
-
-            // 方法4: 检查 toAddresses 属性
-            if (property_exists($mailable, 'toAddresses')) {
-                $toAddresses = $mailable->toAddresses;
-                if (!empty($toAddresses)) {
-                    Log::info('Found toAddresses property', ['toAddresses' => $toAddresses]);
-                    return is_array($toAddresses) ? $toAddresses : [$toAddresses];
-                }
-            }
-
-            // 方法5: 通过反射遍历所有属性,智能识别邮箱
-            $reflection = new \ReflectionClass($mailable);
-            $properties = $reflection->getProperties();
-
-            foreach ($properties as $property) {
-                $property->setAccessible(true);
-                $value = $property->getValue($mailable);
-
-                // 如果属性值是字符串且看起来像邮箱
-                if (is_string($value) && filter_var($value, FILTER_VALIDATE_EMAIL)) {
-                    $recipients = [[
-                        'address' => $value,
-                        'name' => null,
-                    ]];
-                    Log::info("Extracted email from property {$property->getName()}", ['recipients' => $recipients]);
-                    return $recipients;
-                }
-
-                // 如果属性是对象且有 email 字段
-                if (is_object($value) && isset($value->email) && filter_var($value->email, FILTER_VALIDATE_EMAIL)) {
-                    $recipients = [[
-                        'address' => $value->email,
-                        'name' => $value->name ?? $value->first_name . ' ' . ($value->last_name ?? '') ?? null,
-                    ]];
-                    Log::info("Extracted email from object property {$property->getName()}", [
-                        'recipients' => $recipients,
-                        'object_type' => get_class($value),
-                    ]);
-                    return $recipients;
-                }
-            }
-
-            Log::warning('Could not extract recipients from command or mailable', [
-                'command_class' => get_class($command),
-                'mailable_class' => get_class($mailable),
-                'mailable_properties' => array_keys(get_object_vars($mailable)),
-            ]);
-
-            return [];
-        } catch (\Exception $e) {
-            Log::error('Extract recipients from command error: ' . $e->getMessage(), [
-                'trace' => $e->getTraceAsString(),
-            ]);
-            return [];
-        }
-    }
-
-
-    /**
-     * Hook Mail facade 以捕获所有邮件发送
-     */
-    protected function hookMailFacade(): void
-    {
-        // 这个方法通过扩展 Mailer 来实现
-        // 但由于复杂性,我们主要依赖事件监听
-    }
-
-    /**
-     * 从 Swift_Message 对象记录邮件
-     */
-    protected function logEmailFromMessage($message, string $status): void
+    protected function logSentEmail($message): void
     {
     {
         try {
         try {
             $to = $message->getTo();
             $to = $message->getTo();
 
 
             if (empty($to)) {
             if (empty($to)) {
-                Log::warning('No recipients in message');
-                return;
-            }
-
-            foreach ($to as $email => $name) {
-                // 检查是否已存在(避免重复)
-                $exists = EmailLog::where('recipient_email', $email)
-                    ->where('subject', $message->getSubject() ?? '')
-                    ->where('created_at', '>', now()->subMinutes(5))
-                    ->exists();
-
-                if ($exists) {
-                    Log::info('Email log already exists, skipping', ['email' => $email]);
-                    continue;
-                }
-
-                $log = EmailLog::create([
-                    'recipient_email' => $email,
-                    'recipient_name' => is_string($name) ? $name : null,
-                    'subject' => $message->getSubject() ?? '',
-                    'content' => $this->extractContentFromMessage($message),
-                    'template' => $this->detectTemplateFromMessage($message),
-                    'status' => $status,
-                    'metadata' => json_encode([
-                        'from' => $this->formatAddresses($message->getFrom()),
-                        'cc' => $this->formatAddresses($message->getCc()),
-                        'bcc' => $this->formatAddresses($message->getBcc()),
-                        'reply_to' => $this->formatAddresses($message->getReplyTo()),
-                        'message_id' => $message->getMessageId(),
-                    ]),
-                ]);
-
-                Log::info('Email log created from message', [
-                    'log_id' => $log->id,
-                    'email' => $email,
-                    'status' => $status,
-                ]);
-            }
-        } catch (\Exception $e) {
-            Log::error('Failed to log email from message: ' . $e->getMessage(), [
-                'trace' => $e->getTraceAsString(),
-            ]);
-        }
-    }
-
-    /**
-     * 从 Mailable 对象记录邮件
-     */
-    protected function logEmailFromMailable($mailable, string $status, ?string $jobId = null): void
-    {
-        try {
-            // 提取收件人
-            $to = $this->extractRecipientsFromMailable($mailable);
-
-            if (empty($to)) {
-                Log::warning('No recipients in mailable');
                 return;
                 return;
             }
             }
 
 
-            $subject = $this->extractSubjectFromMailable($mailable);
+            $subject = $message->getSubject() ?? '';
 
 
-            foreach ($to as $recipient) {
-                $email = is_array($recipient) ? ($recipient['address'] ?? $recipient['email'] ?? null) : $recipient;
-                $name = is_array($recipient) ? ($recipient['name'] ?? null) : null;
+            foreach ($to as $address) {
+                $email = $address instanceof \Symfony\Component\Mime\Address
+                    ? $address->getAddress()
+                    : (string) $address;
 
 
-                if (!$email) {
+                if (! $email) {
                     continue;
                     continue;
                 }
                 }
 
 
-                // 检查是否已存在
+                $name = $address instanceof \Symfony\Component\Mime\Address ? $address->getName() : null;
+
+                // 检查是否已存在(避免重复)
                 $exists = EmailLog::where('recipient_email', $email)
                 $exists = EmailLog::where('recipient_email', $email)
                     ->where('subject', $subject)
                     ->where('subject', $subject)
                     ->where('created_at', '>', now()->subMinutes(5))
                     ->where('created_at', '>', now()->subMinutes(5))
@@ -422,467 +70,77 @@ class EventServiceProvider extends ServiceProvider
                     continue;
                     continue;
                 }
                 }
 
 
-                $log = EmailLog::create([
+                EmailLog::create([
                     'recipient_email' => $email,
                     'recipient_email' => $email,
-                    'recipient_name' => $name,
+                    'recipient_name' => $name ?: null,
                     'subject' => $subject,
                     'subject' => $subject,
-                    'content' => $this->extractContentFromMailable($mailable),
-                    'template' => $this->detectTemplateFromMailable($mailable),
-                    'status' => $status,
+                    'content' => $this->extractContentFromMessage($message),
+                    'template' => $this->detectTemplateFromMessage($message),
+                    'status' => 'sent',
+                    'sent_at' => now(),
                     'metadata' => json_encode([
                     'metadata' => json_encode([
-                        'job_id' => $jobId,
-                        'mailable_class' => get_class($mailable),
-                        'cc' => $this->extractCcFromMailable($mailable),
-                        'bcc' => $this->extractBccFromMailable($mailable),
+                        'from'       => $this->formatAddresses($message->getFrom()),
+                        'cc'         => $this->formatAddresses($message->getCc()),
+                        'bcc'        => $this->formatAddresses($message->getBcc()),
+                        'reply_to'   => $this->formatAddresses($message->getReplyTo()),
+                        'message_id' => $message->getMessageId(),
                     ]),
                     ]),
                 ]);
                 ]);
-
-                Log::info('Email log created from mailable', [
-                    'log_id' => $log->id,
-                    'email' => $email,
-                    'status' => $status,
-                ]);
             }
             }
-        } catch (\Exception $e) {
-            Log::error('Failed to log email from mailable: ' . $e->getMessage(), [
+        } catch (\Throwable $e) {
+            Log::error('Failed to log sent email: '.$e->getMessage(), [
                 'trace' => $e->getTraceAsString(),
                 'trace' => $e->getTraceAsString(),
             ]);
             ]);
         }
         }
     }
     }
 
 
     /**
     /**
-     * 更新邮件状态
-     */
-    protected function updateEmailStatus($message, string $status): void
-    {
-        try {
-            $to = $message->getTo();
-
-            if (empty($to)) {
-                return;
-            }
-
-            foreach ($to as $email => $name) {
-                $updated = EmailLog::where('recipient_email', $email)
-                    ->where('status', 'pending')  // 只更新 pending 状态
-                    ->orderBy('created_at', 'desc')
-                    ->limit(1)
-                    ->update([
-                        'status' => $status,
-                        'sent_at' => now(),
-                    ]);
-
-                if ($updated) {
-                    Log::info('Email status updated', [
-                        'email' => $email,
-                        'status' => $status,
-                    ]);
-                }
-            }
-        } catch (\Exception $e) {
-            Log::error('Failed to update email status: ' . $e->getMessage());
-        }
-    }
-
-    /**
-     * 处理队列任务
-     */
-    protected function handleQueueJob($job, string $status): void
-    {
-        try {
-            $payload = $job->payload();
-
-            if (!isset($payload['data']['command'])) {
-                return;
-            }
-
-            $command = @unserialize($payload['data']['command']);
-
-            if (!$command || !property_exists($command, 'mailable')) {
-                return;
-            }
-
-            $mailable = $command->mailable;
-
-            if (!$mailable instanceof \Illuminate\Mail\Mailable) {
-                return;
-            }
-
-            $this->logEmailFromMailable($mailable, $status, $job->getJobId());
-
-        } catch (\Exception $e) {
-            Log::error('Handle queue job error: ' . $e->getMessage());
-        }
-    }
-
-    /**
-     * 处理队列任务完成后
-     */
-    protected function handleQueueJobAfter($job): void
-    {
-        try {
-            $payload = $job->payload();
-
-            if (!isset($payload['data']['command'])) {
-                return;
-            }
-
-            $command = @unserialize($payload['data']['command']);
-
-            if (!$command || !property_exists($command, 'mailable')) {
-                return;
-            }
-
-            $mailable = $command->mailable;
-
-            if (!$mailable instanceof \Illuminate\Mail\Mailable) {
-                return;
-            }
-
-            $to = $this->extractRecipientsFromMailable($mailable);
-            $subject = $this->extractSubjectFromMailable($mailable);
-
-            Log::info('Queue job completed, updating status', [
-                'job_id' => $job->getJobId(),
-                'recipients_count' => count($to),
-                'subject' => $subject,
-            ]);
-
-            foreach ($to as $recipient) {
-                $email = is_array($recipient) ? ($recipient['address'] ?? $recipient['email'] ?? null) : $recipient;
-
-                if (!$email) {
-                    continue;
-                }
-
-                $updated = EmailLog::where('recipient_email', $email)
-                    ->where('subject', $subject)
-                    ->where('status', 'pending')  // 只更新 pending 状态的记录
-                    ->orderBy('created_at', 'desc')
-                    ->limit(1)
-                    ->update([
-                        'status' => 'sent',
-                        'sent_at' => now(),
-                    ]);
-
-                if ($updated) {
-                    Log::info('Email status updated to sent', [
-                        'email' => $email,
-                        'subject' => $subject,
-                    ]);
-                } else {
-                    Log::warning('Failed to update email status', [
-                        'email' => $email,
-                        'subject' => $subject,
-                    ]);
-                }
-            }
-
-        } catch (\Exception $e) {
-            Log::error('Handle queue job after error: ' . $e->getMessage(), [
-                'trace' => $e->getTraceAsString(),
-            ]);
-        }
-    }
-
-
-    /**
-     * 处理队列任务失败
-     */
-    protected function handleQueueJobFailed($job, $exception): void
-    {
-        try {
-            $payload = $job->payload();
-
-            if (!isset($payload['data']['command'])) {
-                return;
-            }
-
-            $command = @unserialize($payload['data']['command']);
-
-            if (!$command || !property_exists($command, 'mailable')) {
-                return;
-            }
-
-            $mailable = $command->mailable;
-
-            if (!$mailable instanceof \Illuminate\Mail\Mailable) {
-                return;
-            }
-
-            $to = $this->extractRecipientsFromMailable($mailable);
-            $subject = $this->extractSubjectFromMailable($mailable);
-
-            foreach ($to as $recipient) {
-                $email = is_array($recipient) ? ($recipient['address'] ?? $recipient['email'] ?? null) : $recipient;
-
-                if (!$email) {
-                    continue;
-                }
-
-                EmailLog::where('recipient_email', $email)
-                    ->where('subject', $subject)
-                    ->where('status', 'pending')  // 只更新 pending 状态的记录
-                    ->orderBy('created_at', 'desc')
-                    ->limit(1)
-                    ->update([
-                        'status' => 'failed',
-                        'error_message' => $exception->getMessage(),
-                    ]);
-            }
-
-        } catch (\Exception $e) {
-            Log::error('Handle queue job failed error: ' . $e->getMessage());
-        }
-    }
-
-
-
-    /**
-     * 从 Mailable 提取收件人
-     */
-    protected function extractRecipientsFromMailable($mailable): array
-    {
-        try {
-            Log::info('Extracting recipients from mailable', [
-                'class' => get_class($mailable),
-            ]);
-
-            // 方法1: 尝试直接访问 to 属性
-            if (property_exists($mailable, 'to')) {
-                $to = $mailable->to;
-                Log::info('Found to property', ['to' => $to]);
-                return $to ?? [];
-            }
-
-            // 方法2: 尝试反射获取 to 属性
-            $reflection = new \ReflectionClass($mailable);
-
-            if ($reflection->hasProperty('to')) {
-                $property = $reflection->getProperty('to');
-                $property->setAccessible(true);
-                $to = $property->getValue($mailable);
-                Log::info('Extracted to via reflection', ['to' => $to]);
-                return $to ?? [];
-            }
-
-            // 方法3: 检查常见属性(customer, subscribersList, order 等)
-            $recipientProperties = ['customer', 'subscribersList', 'order', 'invoice', 'shipment', 'refund'];
-
-            foreach ($recipientProperties as $propName) {
-                if ($reflection->hasProperty($propName)) {
-                    $property = $reflection->getProperty($propName);
-                    $property->setAccessible(true);
-                    $obj = $property->getValue($mailable);
-
-                    if ($obj && isset($obj->email)) {
-                        $recipients = [[
-                            'address' => $obj->email,
-                            'name' => $obj->name ?? $obj->first_name ?? $obj->customer_full_name ?? null,
-                        ]];
-                        Log::info("Extracted recipient from mailable->{$propName}", ['recipients' => $recipients]);
-                        return $recipients;
-                    }
-                }
-            }
-
-            // 方法4: 检查 toAddresses 属性
-            if (property_exists($mailable, 'toAddresses')) {
-                $toAddresses = $mailable->toAddresses;
-                if (!empty($toAddresses)) {
-                    Log::info('Found toAddresses property', ['toAddresses' => $toAddresses]);
-                    return $toAddresses;
-                }
-            }
-
-            // 方法5: 通过反射查找所有可能的邮箱字段
-            $properties = $reflection->getProperties();
-
-            foreach ($properties as $property) {
-                $property->setAccessible(true);
-                $value = $property->getValue($mailable);
-
-                // 如果属性值是字符串且看起来像邮箱
-                if (is_string($value) && filter_var($value, FILTER_VALIDATE_EMAIL)) {
-                    $recipients = [[
-                        'address' => $value,
-                        'name' => null,
-                    ]];
-                    Log::info("Extracted email from property {$property->getName()}", ['recipients' => $recipients]);
-                    return $recipients;
-                }
-
-                // 如果属性是对象且有 email 字段
-                if (is_object($value) && isset($value->email) && filter_var($value->email, FILTER_VALIDATE_EMAIL)) {
-                    $recipients = [[
-                        'address' => $value->email,
-                        'name' => $value->name ?? $value->first_name ?? null,
-                    ]];
-                    Log::info("Extracted email from object property {$property->getName()}", ['recipients' => $recipients]);
-                    return $recipients;
-                }
-            }
-
-            Log::warning('Could not extract recipients from mailable', [
-                'class' => get_class($mailable),
-                'properties' => array_keys(get_object_vars($mailable)),
-            ]);
-
-            return [];
-        } catch (\Exception $e) {
-            Log::error('Extract recipients error: ' . $e->getMessage(), [
-                'trace' => $e->getTraceAsString(),
-            ]);
-            return [];
-        }
-    }
-
-
-    /**
-     * 从 Mailable 提取主题
-     */
-    protected function extractSubjectFromMailable($mailable): string
-    {
-        try {
-            Log::info('Extracting subject from mailable', [
-                'class' => get_class($mailable),
-            ]);
-
-            // 方法1: 尝试直接访问 subject 属性
-            if (property_exists($mailable, 'subject') && !empty($mailable->subject)) {
-                $subject = $mailable->subject;
-                Log::info('Found subject property', ['subject' => $subject]);
-                return $subject;
-            }
-
-            // 方法2: 尝试反射获取 subject 属性
-            $reflection = new \ReflectionClass($mailable);
-
-            if ($reflection->hasProperty('subject')) {
-                $property = $reflection->getProperty('subject');
-                $property->setAccessible(true);
-                $subject = $property->getValue($mailable);
-
-                if (!empty($subject)) {
-                    Log::info('Extracted subject via reflection', ['subject' => $subject]);
-                    return $subject;
-                }
-            }
-
-            // 方法3: 从类名推断并翻译
-            $className = class_basename($mailable);
-            $defaultSubject = str_replace(['Notification', 'Mail'], '', $className);
-
-            // 尝试从语言文件获取翻译
-            $translationKey = 'shop::app.emails.' . strtolower($className) . '.subject';
-            $translatedSubject = trans($translationKey);
-
-            if ($translatedSubject && $translatedSubject !== $translationKey) {
-                Log::info('Using translated subject', ['subject' => $translatedSubject]);
-                return $translatedSubject;
-            }
-
-            // 方法4: 根据邮件类型返回默认主题
-            $subjectMap = [
-                'RegistrationNotification' => trans('shop::app.emails.customers.registration.subject'),
-                'SubscriptionNotification' => trans('shop::app.emails.customers.subscribed.subject'),
-                'EmailVerificationNotification' => trans('shop::app.emails.customers.verification.subject'),
-                'UpdatePasswordNotification' => trans('shop::app.emails.customers.update-password.subject'),
-                'ResetPasswordNotification' => trans('shop::app.emails.customers.reset-password.subject'),
-            ];
-
-            if (isset($subjectMap[$className])) {
-                Log::info('Using mapped subject', ['subject' => $subjectMap[$className]]);
-                return $subjectMap[$className];
-            }
-
-            Log::info('Using default subject from class name', ['subject' => $defaultSubject]);
-            return $defaultSubject;
-        } catch (\Exception $e) {
-            Log::error('Extract subject error: ' . $e->getMessage());
-            return 'Email Notification';
-        }
-    }
-
-
-
-    /**
-     * 从 Mailable 提取密送
-     */
-    protected function extractBccFromMailable($mailable): array
-    {
-        try {
-            return property_exists($mailable, 'bcc') ? ($mailable->bcc ?? []) : [];
-        } catch (\Exception $e) {
-            return [];
-        }
-    }
-
-    /**
-     * 从 Message 提取内容
+     * 从 Symfony Email 提取内容(优先 HTML,回退纯文本)
      */
      */
     protected function extractContentFromMessage($message): string
     protected function extractContentFromMessage($message): string
     {
     {
         try {
         try {
-            $body = $message->getBody();
-            return $body ? ($body->toString() ?? '') : '';
-        } catch (\Exception $e) {
-            return '';
-        }
-    }
+            $html = method_exists($message, 'getHtmlBody') ? $message->getHtmlBody() : null;
+            $text = method_exists($message, 'getTextBody') ? $message->getTextBody() : null;
 
 
-    /**
-     * 从 Mailable 提取内容
-     */
-    protected function extractContentFromMailable($mailable): string
-    {
-        try {
-            if (method_exists($mailable, 'render')) {
-                $content = $mailable->render();
-                return substr($content, 0, 65535);
-            }
-            return '';
-        } catch (\Exception $e) {
+            $content = $html ?: $text ?: '';
+
+            return substr((string) $content, 0, 65535);
+        } catch (\Throwable $e) {
             return '';
             return '';
         }
         }
     }
     }
 
 
     /**
     /**
-     * 从 Message 检测模板
+     * 从 Symfony Email 检测模板
      */
      */
     protected function detectTemplateFromMessage($message): ?string
     protected function detectTemplateFromMessage($message): ?string
     {
     {
         try {
         try {
+            // Symfony Mailer 的 header 读取
             $headers = $message->getHeaders();
             $headers = $message->getHeaders();
 
 
-            if ($headers && $headers->has('X-Template-Name')) {
-                return $headers->get('X-Template-Name')->getFieldBody();
+            if ($headers && method_exists($headers, 'get')) {
+                $header = $headers->get('X-Template-Name');
+                if ($header) {
+                    return $header->getBodyAsString();
+                }
             }
             }
 
 
             $subject = $message->getSubject();
             $subject = $message->getSubject();
+
             return $this->detectTemplateBySubject($subject);
             return $this->detectTemplateBySubject($subject);
-        } catch (\Exception $e) {
+        } catch (\Throwable $e) {
             return null;
             return null;
         }
         }
     }
     }
 
 
-    /**
-     * 从 Mailable 检测模板
-     */
-    protected function detectTemplateFromMailable($mailable): ?string
-    {
-        $className = get_class($mailable);
-        $subject = $this->extractSubjectFromMailable($mailable);
-
-        return $this->detectTemplateBySubject($subject, $className);
-    }
-
     /**
     /**
      * 根据主题检测模板类型
      * 根据主题检测模板类型
      */
      */
     protected function detectTemplateBySubject(?string $subject, ?string $className = ''): ?string
     protected function detectTemplateBySubject(?string $subject, ?string $className = ''): ?string
     {
     {
-        $text = strtolower(($subject ?? '') . ' ' . ($className ?? ''));
+        $text = strtolower(($subject ?? '').' '.($className ?? ''));
 
 
         if (str_contains($text, 'registration')) {
         if (str_contains($text, 'registration')) {
             return 'customer_registration';
             return 'customer_registration';
@@ -913,7 +171,7 @@ class EventServiceProvider extends ServiceProvider
     }
     }
 
 
     /**
     /**
-     * 格式化地址
+     * 格式化地址(兼容 Symfony Address 对象数组)
      */
      */
     protected function formatAddresses($addresses): array
     protected function formatAddresses($addresses): array
     {
     {
@@ -922,11 +180,19 @@ class EventServiceProvider extends ServiceProvider
         }
         }
 
 
         $result = [];
         $result = [];
-        foreach ($addresses as $email => $name) {
-            $result[] = [
-                'email' => $email,
-                'name' => is_string($name) ? $name : null,
-            ];
+
+        foreach ($addresses as $key => $address) {
+            if ($address instanceof \Symfony\Component\Mime\Address) {
+                $result[] = [
+                    'email' => $address->getAddress(),
+                    'name'  => $address->getName(),
+                ];
+            } else {
+                $result[] = [
+                    'email' => is_string($address) ? $address : $key,
+                    'name'  => is_string($address) ? null : $address,
+                ];
+            }
         }
         }
 
 
         return $result;
         return $result;

+ 37 - 8
packages/Webkul/BagistoApi/src/Models/AttributeOption.php

@@ -8,6 +8,8 @@ use ApiPlatform\Metadata\Get;
 use ApiPlatform\Metadata\GetCollection;
 use ApiPlatform\Metadata\GetCollection;
 use ApiPlatform\OpenApi\Model;
 use ApiPlatform\OpenApi\Model;
 use Illuminate\Database\Eloquent\Model as EloquentModel;
 use Illuminate\Database\Eloquent\Model as EloquentModel;
+use Symfony\Component\TypeInfo\Type\BuiltinType;
+use Symfony\Component\TypeInfo\TypeIdentifier;
 
 
 #[ApiResource(
 #[ApiResource(
     shortName: 'AttributeOption',
     shortName: 'AttributeOption',
@@ -42,23 +44,50 @@ use Illuminate\Database\Eloquent\Model as EloquentModel;
         ),
         ),
     ],
     ],
 )]
 )]
+#[ApiProperty(property: 'product_count', writable: false, readable: true, nativeType: new BuiltinType(TypeIdentifier::INT))]
 class AttributeOption extends \Webkul\Attribute\Models\AttributeOption
 class AttributeOption extends \Webkul\Attribute\Models\AttributeOption
 {
 {
     /**
     /**
-     * Number of visible products associated with this option within the
-     * current category context. Populated by FilterableAttributesProvider.
+     * Static cache of product counts resolved by FilterableAttributesProvider.
+     *
+     * Keyed by attribute id, then option id. The provider computes the counts
+     * once per request and stores them here so the GraphQL "options" relation
+     * (which re-queries options through a separate cursor-connection provider
+     * and therefore produces fresh model instances) can still surface the
+     * correct product count.
+     *
+     * @var array<int, array<int, int>>
      */
      */
-    #[ApiProperty(writable: false, readable: true)]
-    public int $product_count = 0;
+    private static array $resolvedProductCounts = [];
 
 
     /**
     /**
-     * Laravel accessor for the product_count virtual attribute. The accessor
-     * naming convention makes API Platform discover it as a virtual attribute,
-     * while the public property above lets PropertyInfo infer the int type.
+     * Store the resolved product counts for the given attribute's options.
+     *
+     * @param  array<int, int>  $counts  option id => count
+     */
+    public static function setResolvedProductCounts(int $attributeId, array $counts): void
+    {
+        static::$resolvedProductCounts[$attributeId] = $counts;
+    }
+
+    /**
+     * Number of visible products associated with this option within the
+     * current category context.
+     *
+     * Prefer the value computed and cached by FilterableAttributesProvider;
+     * this keeps the count correct even when the option is fetched through
+     * the GraphQL cursor-connection relation instead of the eager-loaded
+     * collection.
      */
      */
     public function getProductCountAttribute(): int
     public function getProductCountAttribute(): int
     {
     {
-        return (int) ($this->product_count ?? 0);
+        $attributeId = (int) ($this->attribute_id ?? 0);
+
+        if (isset(static::$resolvedProductCounts[$attributeId][(int) $this->id])) {
+            return (int) static::$resolvedProductCounts[$attributeId][(int) $this->id];
+        }
+
+        return 0;
     }
     }
 
 
     #[ApiProperty(identifier: true, writable: false)]
     #[ApiProperty(identifier: true, writable: false)]

+ 54 - 0
packages/Webkul/BagistoApi/src/Models/CategoryProducts.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Resolver\CategoryProductsResolver;
+
+#[ApiResource(
+    shortName: 'CategoryProducts',
+    operations: [],
+    graphQlOperations: [
+        new Query(
+            resolver: CategoryProductsResolver::class,
+            args: [
+                'slug' => [
+                    'type'        => 'String!',
+                    'description' => 'Category slug to list products for.',
+                ],
+                'filter' => [
+                    'type'        => 'String',
+                    'description' => 'JSON filter object containing attribute filters. Example: {"color":{"match":"2"},"size":{"match":"M"}}',
+                ],
+                'first'   => ['type' => 'Int'],
+                'last'    => ['type' => 'Int'],
+                'after'   => ['type' => 'String'],
+                'before'  => ['type' => 'String'],
+                'locale'  => ['type' => 'String'],
+                'channel' => ['type' => 'String'],
+            ],
+            read: false,
+            paginationEnabled: false,
+            description: 'List products in a category with pagination and filters. Use categoryAttributeFilters for the filter/facet data.',
+        ),
+    ],
+    normalizationContext: ['skip_null_values' => false],
+)]
+class CategoryProducts
+{
+    /**
+     * Total number of products matching the current filters.
+     */
+    public int $total_count = 0;
+
+    /**
+     * Product list for the current page.
+     *
+     * @var list<ProductSearchEdgeDto>
+     */
+    #[ApiProperty(readableLink: true)]
+    public array $products = [];
+}

+ 2 - 1
packages/Webkul/BagistoApi/src/Models/Filter/Attribute.php

@@ -16,7 +16,8 @@ use Webkul\BagistoApi\State\FilterableAttributesProvider;
         new QueryCollection(
         new QueryCollection(
             provider: FilterableAttributesProvider::class,
             provider: FilterableAttributesProvider::class,
             args: [
             args: [
-                'categorySlug' => ['type' => 'String', 'required' => false],
+                'slug'         => ['type' => 'String', 'required' => false],
+                'filter'       => ['type' => 'String', 'description' => 'JSON attribute filters to recompute option product counts against (faceted search).'],
                 'first'        => ['type' => 'Int', 'description' => 'Number of items to return from the start'],
                 'first'        => ['type' => 'Int', 'description' => 'Number of items to return from the start'],
                 'last'         => ['type' => 'Int', 'description' => 'Number of items to return from the end'],
                 'last'         => ['type' => 'Int', 'description' => 'Number of items to return from the end'],
                 'after'        => ['type' => 'String', 'description' => 'Cursor to start pagination after'],
                 'after'        => ['type' => 'String', 'description' => 'Cursor to start pagination after'],

+ 2 - 0
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -25,6 +25,7 @@ use Webkul\BagistoApi\Repositories\GuestCartTokensRepository;
 use Webkul\BagistoApi\Resolver\BaseQueryItemResolver;
 use Webkul\BagistoApi\Resolver\BaseQueryItemResolver;
 use Webkul\BagistoApi\Resolver\CategoryCollectionResolver;
 use Webkul\BagistoApi\Resolver\CategoryCollectionResolver;
 use Webkul\BagistoApi\Resolver\CustomerQueryResolver;
 use Webkul\BagistoApi\Resolver\CustomerQueryResolver;
+use Webkul\BagistoApi\Resolver\CategoryProductsResolver;
 use Webkul\BagistoApi\Resolver\Factory\ProductRelationResolverFactory;
 use Webkul\BagistoApi\Resolver\Factory\ProductRelationResolverFactory;
 use Webkul\BagistoApi\Resolver\ProductCollectionResolver;
 use Webkul\BagistoApi\Resolver\ProductCollectionResolver;
 use Webkul\BagistoApi\Resolver\ProductSearchResolver;
 use Webkul\BagistoApi\Resolver\ProductSearchResolver;
@@ -564,6 +565,7 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(CustomerQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(CustomerQueryResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(PageByUrlKeyResolver::class, QueryCollectionResolverInterface::class);
         $this->app->tag(PageByUrlKeyResolver::class, QueryCollectionResolverInterface::class);
         $this->app->tag(ProductSearchResolver::class, QueryItemResolverInterface::class);
         $this->app->tag(ProductSearchResolver::class, QueryItemResolverInterface::class);
+        $this->app->tag(CategoryProductsResolver::class, QueryItemResolverInterface::class);
 
 
         $this->app->extend(ResolverFactoryInterface::class, function ($resolverFactory, $app) {
         $this->app->extend(ResolverFactoryInterface::class, function ($resolverFactory, $app) {
             return new ProductRelationResolverFactory(
             return new ProductRelationResolverFactory(

+ 121 - 0
packages/Webkul/BagistoApi/src/Resolver/CategoryProductsResolver.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace Webkul\BagistoApi\Resolver;
+
+use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
+use ApiPlatform\State\Pagination\PaginatorInterface;
+use Illuminate\Support\Facades\DB;
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
+use Webkul\BagistoApi\Models\CategoryProducts;
+use Webkul\BagistoApi\State\ProductGraphQLProvider;
+
+class CategoryProductsResolver implements QueryItemResolverInterface
+{
+    public function __construct(
+        private readonly ProductGraphQLProvider $productProvider,
+    ) {}
+
+    public function __invoke(?object $item, array $context): object
+    {
+        $args = $context['args'] ?? [];
+
+        $categorySlug = $args['slug'] ?? null;
+
+        // When the "products" collection field is empty, API Platform's
+        // ResolverFactory treats the empty array as "not yet fetched" and
+        // re-invokes the item resolver for that sub-field. Guard against this by
+        // returning an empty result for non-root field names.
+        $fieldName = $context['info']->fieldName ?? 'categoryProducts';
+        if (! $categorySlug || $fieldName !== 'categoryProducts') {
+            return new CategoryProducts;
+        }
+
+        $categoryId = DB::table('category_translations')
+            ->where('slug', $categorySlug)
+            ->value('category_id');
+
+        if (! $categoryId) {
+            throw new \RuntimeException("Category not found for slug: {$categorySlug}");
+        }
+
+        $filters = $this->parseFilter($args['filter'] ?? null);
+        $filters['category_id'] = (int) $categoryId;
+
+        $productArgs = $args;
+        $productArgs['filter'] = json_encode($filters);
+        // Default sort: cheapest first (sortKey PRICE, ascending).
+        if (! isset($productArgs['sortKey'])) {
+            $productArgs['sortKey'] = 'PRICE';
+            $productArgs['reverse'] = false;
+        }
+
+        $paginator = $this->productProvider->provide(
+            $context['operation'],
+            [],
+            ['args' => $productArgs]
+        );
+
+        if (! $paginator instanceof PaginatorInterface) {
+            throw new \UnexpectedValueException('Product provider must return a paginator.');
+        }
+
+        $result = new CategoryProducts;
+        $result->total_count = (int) $paginator->getTotalItems();
+
+        $offset = $this->resolveOffset($args, $result->total_count);
+
+        foreach ($paginator as $index => $product) {
+            $edge = new ProductSearchEdgeDto;
+            $edge->cursor = base64_encode((string) ($offset + $index));
+            $edge->node = $product;
+            $result->products[] = $edge;
+        }
+
+        return $result;
+    }
+
+    /**
+     * Resolve the current page offset from Relay cursor args.
+     */
+    private function resolveOffset(array $args, int $total): int
+    {
+        $limit = max(1, (int) ($args['first'] ?? $args['last'] ?? 30));
+        $offset = 0;
+
+        if (! empty($args['after'])) {
+            $decoded = base64_decode($args['after'], true);
+            $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
+        }
+
+        if (! empty($args['before'])) {
+            $decoded = base64_decode($args['before'], true);
+            $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
+            $offset = max(0, $cursor - $limit);
+        }
+
+        if ($offset > $total) {
+            $offset = max(0, $total - $limit);
+        }
+
+        return $offset;
+    }
+
+    /**
+     * Parse the `filter` arg (JSON string or already-decoded array) into an
+     * associative array of filters keyed by attribute code.
+     */
+    private function parseFilter(mixed $filter): array
+    {
+        if (empty($filter)) {
+            return [];
+        }
+
+        if (is_string($filter)) {
+            $decoded = json_decode($filter, true);
+
+            return is_array($decoded) ? $decoded : [];
+        }
+
+        return is_array($filter) ? $filter : [];
+    }
+}

+ 259 - 12
packages/Webkul/BagistoApi/src/State/FilterableAttributesProvider.php

@@ -13,6 +13,8 @@ use Webkul\BagistoApi\Models\Product;
 
 
 class FilterableAttributesProvider implements ProviderInterface
 class FilterableAttributesProvider implements ProviderInterface
 {
 {
+    private ?array $attributeTypeCache = null;
+
     public function __construct(
     public function __construct(
         private readonly Pagination $pagination
         private readonly Pagination $pagination
     ) {}
     ) {}
@@ -23,7 +25,12 @@ class FilterableAttributesProvider implements ProviderInterface
         $args = $context['args'] ?? [];
         $args = $context['args'] ?? [];
         $info = $context['info'] ?? null;
         $info = $context['info'] ?? null;
 
 
-        $categorySlug = $args['categorySlug'] ?? null;
+        $categorySlug = $args['slug'] ?? $args['categorySlug'] ?? null;
+
+        // Active attribute filters (faceted search). When a filter is already
+        // selected for one attribute, the product counts of every other
+        // attribute are recomputed against the remaining product set.
+        $activeFilters = $this->parseActiveFilters($args['filter'] ?? null);
 
 
         $first = isset($args['first']) ? (int) $args['first'] : null;
         $first = isset($args['first']) ? (int) $args['first'] : null;
         $last = isset($args['last']) ? (int) $args['last'] : null;
         $last = isset($args['last']) ? (int) $args['last'] : null;
@@ -53,10 +60,20 @@ class FilterableAttributesProvider implements ProviderInterface
             ? DB::table('category_translations')->where('slug', $categorySlug)->select('category_id')->pluck('category_id')->first()
             ? DB::table('category_translations')->where('slug', $categorySlug)->select('category_id')->pluck('category_id')->first()
             : null;
             : null;
 
 
+        // Filterable attributes are derived from the products in the category
+        // (their product_attribute_values) rather than the admin-configured
+        // category_filterable_attributes table, so every attribute actually used
+        // by the category's products is surfaced automatically.
         if ($categoryId) {
         if ($categoryId) {
             $query
             $query
-                ->leftJoin('category_filterable_attributes as cfa', 'cfa.attribute_id', '=', 'attributes.id')
-                ->where('cfa.category_id', $categoryId);
+                ->where('is_filterable', 1)
+                ->whereIn('attributes.id', function ($sub) use ($categoryId) {
+                    $sub->select('pav.attribute_id')
+                        ->from('product_attribute_values as pav')
+                        ->join('product_categories as pc', 'pc.product_id', '=', 'pav.product_id')
+                        ->where('pc.category_id', $categoryId)
+                        ->distinct();
+                });
         } else {
         } else {
             $query->where('is_filterable', 1);
             $query->where('is_filterable', 1);
         }
         }
@@ -67,7 +84,10 @@ class FilterableAttributesProvider implements ProviderInterface
         // TODO: change to use customer group from active customer when auth is implemented
         // TODO: change to use customer group from active customer when auth is implemented
         $customerGroup = core()->getGuestCustomerGroup();
         $customerGroup = core()->getGuestCustomerGroup();
 
 
-        $maxPriceQuery = Product::query()
+        // Price range for the "price" filter is derived from the products in the
+        // category (product_price_indices already aggregates variant prices into
+        // the parent product's min_price / max_price for configurable products).
+        $priceRangeQuery = Product::query()
             ->leftJoin('product_price_indices', function ($join) {
             ->leftJoin('product_price_indices', function ($join) {
                 $join->on('products.id', '=', 'product_price_indices.priceable_id')
                 $join->on('products.id', '=', 'product_price_indices.priceable_id')
                     ->where('product_price_indices.priceable_type', '=', \Webkul\Product\Models\Product::class);
                     ->where('product_price_indices.priceable_type', '=', \Webkul\Product\Models\Product::class);
@@ -76,10 +96,11 @@ class FilterableAttributesProvider implements ProviderInterface
             ->where('product_price_indices.customer_group_id', $customerGroup->id);
             ->where('product_price_indices.customer_group_id', $customerGroup->id);
 
 
         if ($categoryId) {
         if ($categoryId) {
-            $maxPriceQuery->where('product_categories.category_id', $categoryId);
+            $priceRangeQuery->where('product_categories.category_id', $categoryId);
         }
         }
 
 
-        $maxPrice = $maxPriceQuery->max('min_price') ?? 0;
+        $minPrice = (float) ($priceRangeQuery->min('product_price_indices.min_price') ?? 0);
+        $maxPrice = (float) ($priceRangeQuery->max('product_price_indices.max_price') ?? 0);
 
 
         $total = (clone $query)->count();
         $total = (clone $query)->count();
 
 
@@ -92,15 +113,20 @@ class FilterableAttributesProvider implements ProviderInterface
             ->limit($perPage)
             ->limit($perPage)
             ->get();
             ->get();
 
 
-        $items = $items->map(function ($item) use ($maxPrice, $categoryId) {
-            $item->maxPrice = (float) $maxPrice;
-            $item->minPrice = 0.0;
+        $items = $items->map(function ($item) use ($minPrice, $maxPrice, $categoryId, $activeFilters) {
+            $item->minPrice = $minPrice;
+            $item->maxPrice = $maxPrice;
 
 
-            $this->attachProductCounts($item, $categoryId);
+            $this->attachProductCounts($item, $categoryId, $activeFilters);
 
 
             return $item;
             return $item;
         });
         });
 
 
+        // Always expose a "price" filter, regardless of whether the admin has
+        // configured it in category_filterable_attributes. Its min/max bounds are
+        // derived from the products in the category (computed above).
+        $items = $this->ensurePriceFilter($items, $minPrice, $maxPrice);
+
         $currentPage = $total > 0 ? (int) floor($offset / $perPage) + 1 : 1;
         $currentPage = $total > 0 ? (int) floor($offset / $perPage) + 1 : 1;
 
 
         return new Paginator(
         return new Paginator(
@@ -120,8 +146,15 @@ class FilterableAttributesProvider implements ProviderInterface
      *
      *
      * Visibility mirrors ProductGraphQLProvider: a product is counted only when
      * Visibility mirrors ProductGraphQLProvider: a product is counted only when
      * it has status=1 (attribute id 8) and visible_individually=1 (attribute id 7).
      * it has status=1 (attribute id 8) and visible_individually=1 (attribute id 7).
+     *
+     * Faceted search: when other attributes already have an active filter, the
+     * counts are restricted to the products matching those filters, so each
+     * option count reflects the remaining result set (excluding the current
+     * attribute's own filter so its options stay selectable).
+     *
+     * @param  array<string, array<string, mixed>>  $activeFilters  attribute code => filter spec
      */
      */
-    private function attachProductCounts($item, ?int $categoryId): void
+    private function attachProductCounts($item, ?int $categoryId, array $activeFilters = []): void
     {
     {
         $options = $item->options ?? $item->options()->get();
         $options = $item->options ?? $item->options()->get();
 
 
@@ -158,6 +191,8 @@ class FilterableAttributesProvider implements ProviderInterface
             });
             });
         }
         }
 
 
+        $this->applyActiveFilters($countQuery, $activeFilters, $item->code);
+
         $optionIds = $options->pluck('id')->map(fn ($id) => (string) $id)->all();
         $optionIds = $options->pluck('id')->map(fn ($id) => (string) $id)->all();
 
 
         if ($column === 'integer_value') {
         if ($column === 'integer_value') {
@@ -171,9 +206,14 @@ class FilterableAttributesProvider implements ProviderInterface
             $counts = $this->countMultiSelectOptions(clone $countQuery, $optionIds, $alias);
             $counts = $this->countMultiSelectOptions(clone $countQuery, $optionIds, $alias);
         }
         }
 
 
+        $normalized = [];
+
         foreach ($options as $option) {
         foreach ($options as $option) {
-            $option->product_count = (int) ($counts[(string) $option->id] ?? 0);
+            $count = (int) ($counts[(string) $option->id] ?? 0);
+            $normalized[(int) $option->id] = $count;
         }
         }
+
+        \Webkul\BagistoApi\Models\AttributeOption::setResolvedProductCounts((int) $attributeId, $normalized);
     }
     }
 
 
     /**
     /**
@@ -202,4 +242,211 @@ class FilterableAttributesProvider implements ProviderInterface
 
 
         return $counts;
         return $counts;
     }
     }
+
+    /**
+     * Parse the `filter` arg (JSON string or array) into a map of active filters.
+     *
+     * Attribute filters are keyed by attribute code; the price range is stored
+     * under the special "__price__" key so it can be applied to facet counts too.
+     * Matches the format used by ProductGraphQLProvider:
+     *   {"color":{"match":"2"},"price_from":10,"price_to":200}
+     *
+     * @return array<string, array<string, mixed>>
+     */
+    private function parseActiveFilters(mixed $filter): array
+    {
+        if (empty($filter)) {
+            return [];
+        }
+
+        if (is_string($filter)) {
+            $decoded = json_decode($filter, true);
+
+            $filter = is_array($decoded) ? $decoded : [];
+        }
+
+        if (! is_array($filter)) {
+            return [];
+        }
+
+        // Non-attribute keys handled elsewhere (category, pagination, etc.) must
+        // not be treated as attribute filters for facets.
+        $ignored = ['category_id', 'type', 'sku', 'new', 'featured', 'pageSize', 'first', 'last', 'after', 'before'];
+
+        $active = [];
+
+        $priceFrom = isset($filter['price_from']) ? (float) $filter['price_from'] : null;
+        $priceTo = isset($filter['price_to']) ? (float) $filter['price_to'] : null;
+
+        if ($priceFrom !== null || $priceTo !== null) {
+            $active['__price__'] = ['from' => $priceFrom, 'to' => $priceTo];
+        }
+
+        foreach ($filter as $code => $spec) {
+            if (in_array($code, $ignored, true) || $code === 'price_from' || $code === 'price_to') {
+                continue;
+            }
+
+            if (is_array($spec)) {
+                if (isset($spec['match'])) {
+                    $active[$code] = [
+                        'match'      => $spec['match'],
+                        'match_type' => strtoupper($spec['match_type'] ?? ''),
+                    ];
+                } elseif (array_is_list($spec)) {
+                    $active[$code] = ['match' => implode(',', $spec), 'match_type' => ''];
+                }
+            } else {
+                $active[$code] = ['match' => (string) $spec, 'match_type' => ''];
+            }
+        }
+
+        return $active;
+    }
+
+    /**
+     * Apply the active filters of other attributes to a facet count query.
+     *
+     * For each active attribute filter (excluding the attribute currently being
+     * counted) and the price range, add an EXISTS sub-query restricting to
+     * products that match. This keeps the counts consistent with the product
+     * listing that used the same filters.
+     *
+     * @param  array<string, array<string, mixed>>  $activeFilters
+     */
+    private function applyActiveFilters($countQuery, array $activeFilters, ?string $currentCode): void
+    {
+        if (empty($activeFilters)) {
+            return;
+        }
+
+        $attributeTypes = $this->getAttributeTypeCache();
+
+        // Price range (always applied, regardless of the attribute being counted).
+        if (isset($activeFilters['__price__'])) {
+            $this->applyPriceFilter($countQuery, $activeFilters['__price__']);
+        }
+
+        foreach ($activeFilters as $code => $spec) {
+            if ($code === '__price__') {
+                continue;
+            }
+
+            // Exclude the current attribute so its own options remain selectable.
+            if ($code === $currentCode) {
+                continue;
+            }
+
+            $attributeType = $attributeTypes[$code] ?? 'text';
+            $column = $this->columnForType($attributeType);
+            $term = (string) $spec['match'];
+            $matchType = $spec['match_type'] ?? '';
+
+            $countQuery->whereIn('pav.product_id', function ($sub) use ($code, $column, $term, $matchType) {
+                $sub->select('product_id')
+                    ->from('product_attribute_values as pav_facet')
+                    ->where('pav_facet.attribute_id', function ($q) use ($code) {
+                        $q->select('id')->from('attributes')->where('code', $code);
+                    });
+
+                if ($matchType === 'PARTIAL') {
+                    $sub->where('pav_facet.'.$column, 'like', "%{$term}%");
+                } elseif (str_contains($term, ',')) {
+                    $values = array_values(array_filter(array_map('trim', explode(',', $term))));
+                    $sub->whereIn('pav_facet.'.$column, $values);
+                } else {
+                    $sub->where('pav_facet.'.$column, $term);
+                }
+            });
+        }
+    }
+
+    /**
+     * Apply the price range to a facet count query. Mirrors the product listing
+     * price filter: products whose price attribute (id 11) float_value falls in
+     * the requested range.
+     *
+     * @param  array{from: ?float, to: ?float}  $price
+     */
+    private function applyPriceFilter($countQuery, array $price): void
+    {
+        $from = $price['from'] ?? null;
+        $to = $price['to'] ?? null;
+
+        $countQuery->whereIn('pav.product_id', function ($sub) use ($from, $to) {
+            $sub->select('product_id')
+                ->from('product_attribute_values as pav_price')
+                ->where('pav_price.attribute_id', 11);
+
+            if ($from !== null && $to !== null) {
+                $sub->whereBetween('pav_price.float_value', [$from, $to]);
+            } elseif ($from !== null) {
+                $sub->where('pav_price.float_value', '>=', $from);
+            } elseif ($to !== null) {
+                $sub->where('pav_price.float_value', '<=', $to);
+            }
+        });
+    }
+
+    /**
+     * Get the attribute code => type map, cached for the request.
+     *
+     * @return array<string, string>
+     */
+    private function getAttributeTypeCache(): array
+    {
+        if ($this->attributeTypeCache === null) {
+            $this->attributeTypeCache = DB::table('attributes')
+                ->pluck('type', 'code')
+                ->toArray();
+        }
+
+        return $this->attributeTypeCache;
+    }
+
+    /**
+     * Map an attribute type to the product_attribute_values column holding its value.
+     */
+    private function columnForType(string $attributeType): string
+    {
+        return match ($attributeType) {
+            'text', 'textarea'  => 'text_value',
+            'select', 'multiselect', 'dropdown' => 'integer_value',
+            'decimal', 'price' => 'float_value',
+            'integer'  => 'integer_value',
+            'boolean'  => 'boolean_value',
+            'datetime' => 'datetime_value',
+            'date'     => 'date_value',
+            'json'     => 'json_value',
+            default    => 'text_value',
+        };
+    }
+
+    /**
+     * Ensure the collection contains a "price" filter attribute, prepending one
+     * (loaded from the attributes table) when the admin has not configured it for
+     * the category. The min/max bounds are computed from the category's products.
+     *
+     * @param  \Illuminate\Support\Collection  $items
+     * @return \Illuminate\Support\Collection
+     */
+    private function ensurePriceFilter($items, float $minPrice, float $maxPrice)
+    {
+        if ($items->contains(fn ($item) => $item->code === 'price')) {
+            return $items;
+        }
+
+        $priceAttribute = Attribute::query()
+            ->where('code', 'price')
+            ->first();
+
+        if (! $priceAttribute) {
+            return $items;
+        }
+
+        $priceAttribute->minPrice = $minPrice;
+        $priceAttribute->maxPrice = $maxPrice;
+
+        return $items->prepend($priceAttribute);
+    }
 }
 }

+ 2 - 1
packages/Webkul/BagistoApi/src/State/ProductGraphQLProvider.php

@@ -144,7 +144,8 @@ class ProductGraphQLProvider implements ProviderInterface
                             ->findOneByField('code', 'guest');
                             ->findOneByField('code', 'guest');
 
 
                     $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
                     $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
-                        $join->on('products.id', '=', 'product_price_indices.product_id')
+                        $join->on('products.id', '=', 'product_price_indices.priceable_id')
+                            ->where('product_price_indices.priceable_type', '=', \Webkul\Product\Models\Product::class)
                             ->where('product_price_indices.customer_group_id', $customerGroup->id);
                             ->where('product_price_indices.customer_group_id', $customerGroup->id);
                     })
                     })
                         ->orderBy('product_price_indices.min_price', $direction)
                         ->orderBy('product_price_indices.min_price', $direction)