Files
2026-09-15 10:35:56 +08:00

191 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Esun\ProductInquiry\Model;
use Esun\ProductInquiry\Model\ResourceModel\Inquiry as InquiryResource;
use Esun\ProductInquiry\Model\ResourceModel\Inquiry\CollectionFactory;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\Product\Attribute\Source\Status as ProductStatus;
use Magento\ConfigurableProduct\Model\Product\Type\Configurable;
use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable as ConfigurableResource;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Directory\Model\CountryFactory;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\HTTP\PhpEnvironment\RemoteAddress;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Magento\Store\Model\StoreManagerInterface;
class SubmissionService
{
private const MAX_DAILY_SUBMISSIONS = 3;
private const MAX_DAILY_IP_SUBMISSIONS = 20;
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly ConfigurableResource $configurableResource,
private readonly AvailabilityResolver $availabilityResolver,
private readonly CountryFactory $countryFactory,
private readonly InquiryFactory $inquiryFactory,
private readonly InquiryResource $inquiryResource,
private readonly CollectionFactory $collectionFactory,
private readonly StoreManagerInterface $storeManager,
private readonly CustomerSession $customerSession,
private readonly RemoteAddress $remoteAddress,
private readonly DateTime $dateTime,
private readonly Json $json
) {
}
public function submit(RequestInterface $request): array
{
$data = $this->validateContactData($request);
$store = $this->storeManager->getStore();
$website = $store->getWebsite();
$parent = $this->productRepository->getById((int)$request->getParam('parent_product_id'), false, (int)$store->getId());
$product = $this->productRepository->getById((int)$request->getParam('product_id'), false, (int)$store->getId());
if (!$parent instanceof Product || $parent->getTypeId() !== Configurable::TYPE_CODE) {
throw new LocalizedException(__('The inquiry product is invalid.'));
}
if (!$product instanceof Product || (int)$product->getStatus() !== ProductStatus::STATUS_ENABLED) {
throw new LocalizedException(__('The selected product is unavailable.'));
}
if (!in_array((int)$parent->getId(), array_map('intval', $this->configurableResource->getParentIdsByChild((int)$product->getId())), true)) {
throw new LocalizedException(__('The selected product does not belong to this product.'));
}
$availability = $this->availabilityResolver->resolve($product, $website);
if (!$availability['requires_inquiry']) {
throw new LocalizedException(__('This product is currently available for purchase.'));
}
$messageHash = hash('sha256', $data['email'] . '|' . $product->getId() . '|' . $this->normalizeMessage($data['message']));
$remoteIpHash = $this->getRemoteIpHash();
if ($this->isExactDuplicate($messageHash)) {
return ['duplicate' => true];
}
$this->assertWithinRateLimit($data['email'], $remoteIpHash);
$country = $this->countryFactory->create()->loadByCode($data['country_id']);
if (!$country->getId()) {
throw new LocalizedException(__('Select a valid country.'));
}
$inquiry = $this->inquiryFactory->create();
$inquiry->setData([
'parent_product_id' => (int)$parent->getId(),
'parent_sku' => (string)$parent->getSku(),
'parent_name' => (string)$parent->getName(),
'product_id' => (int)$product->getId(),
'product_sku' => (string)$product->getSku(),
'product_name' => (string)$product->getName(),
'selected_options' => $this->json->serialize($this->getSelectedOptions($parent, $product)),
'store_id' => (int)$store->getId(),
'website_id' => (int)$website->getId(),
'stock_id' => (int)$availability['stock_id'],
'customer_id' => $this->customerSession->isLoggedIn() ? (int)$this->customerSession->getCustomerId() : null,
'name' => $data['name'],
'email' => $data['email'],
'country_id' => (string)$country->getId(),
'country_name' => (string)$country->getName(),
'company' => $data['company'],
'telephone' => $data['telephone'],
'message' => $data['message'],
'status' => Inquiry::STATUS_NEW,
'stock_status' => (int)$availability['stock_status'],
'is_salable' => (int)$availability['is_salable'],
'availability_reason' => (string)$availability['reason'],
'message_hash' => $messageHash,
'remote_ip_hash' => $remoteIpHash,
]);
$this->inquiryResource->save($inquiry);
return ['duplicate' => false, 'inquiry_id' => (int)$inquiry->getId()];
}
private function validateContactData(RequestInterface $request): array
{
if (trim((string)$request->getParam('hideit', '')) !== '') {
throw new LocalizedException(__('Unable to submit the form.'));
}
$data = [
'name' => trim((string)$request->getParam('name', '')),
'email' => mb_strtolower(trim((string)$request->getParam('email', ''))),
'country_id' => strtoupper(trim((string)$request->getParam('country_id', ''))),
'company' => trim((string)$request->getParam('company', '')),
'telephone' => trim((string)$request->getParam('telephone', '')),
'message' => trim((string)$request->getParam('message', '')),
];
if ($data['name'] === '' || $data['message'] === '' || $data['country_id'] === '') {
throw new LocalizedException(__('Complete all required fields.'));
}
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new LocalizedException(__('Enter a valid email address.'));
}
if (mb_strlen($data['name']) > 255 || mb_strlen($data['email']) > 255 || mb_strlen($data['company']) > 255 || mb_strlen($data['telephone']) > 64) {
throw new LocalizedException(__('One or more fields are too long.'));
}
if (mb_strlen($data['message']) > 5000 || strlen($data['country_id']) !== 2) {
throw new LocalizedException(__('The submitted information is invalid.'));
}
return $data;
}
private function getSelectedOptions(Product $parent, Product $product): array
{
$options = [];
foreach ($parent->getTypeInstance()->getConfigurableAttributes($parent) as $configurableAttribute) {
$attribute = $configurableAttribute->getProductAttribute();
$value = $product->getAttributeText((string)$attribute->getAttributeCode());
$options[] = [
'label' => (string)$attribute->getStoreLabel(),
'value' => is_array($value) ? implode(', ', $value) : (string)$value,
];
}
return $options;
}
private function isExactDuplicate(string $messageHash): bool
{
return $this->collectionFactory->create()
->addFieldToFilter('message_hash', $messageHash)
->addFieldToFilter('created_at', ['gteq' => $this->dateTime->gmtDate('Y-m-d H:i:s', time() - 600)])
->getSize() > 0;
}
private function assertWithinRateLimit(string $email, string $remoteIpHash): void
{
$since = $this->dateTime->gmtDate('Y-m-d H:i:s', time() - 86400);
$emailCount = $this->collectionFactory->create()
->addFieldToFilter('email', $email)
->addFieldToFilter('created_at', ['gteq' => $since])
->getSize();
$ipCount = $remoteIpHash === '' ? 0 : $this->collectionFactory->create()
->addFieldToFilter('remote_ip_hash', $remoteIpHash)
->addFieldToFilter('created_at', ['gteq' => $since])
->getSize();
if ($emailCount >= self::MAX_DAILY_SUBMISSIONS || $ipCount >= self::MAX_DAILY_IP_SUBMISSIONS) {
throw new LocalizedException(__('Too many inquiries have been submitted. Please try again later.'));
}
}
private function normalizeMessage(string $message): string
{
return mb_strtolower((string)preg_replace('/\s+/u', ' ', trim($message)));
}
private function getRemoteIpHash(): string
{
$ip = trim((string)$this->remoteAddress->getRemoteAddress());
return $ip === '' ? '' : hash('sha256', $ip);
}
}