Написание консольных команд и заданий cron

В процессе создания приложения возникла потребность реализации функционала очистки заброшенных корзин, так как количество ключей в реализуемом магазине ограничено, а при добавлении игры в корзину ключ резервируется и становится недоступным для попки другими пользователями.

В предыдущих пунктах были реализованы методы для удаления таких корзин. Но чтобы это удаление происходило не вручную, а автоматически была реализована консольная команда, удаляющая корзины, ожидающие оплаты (по умолчанию старее трёх суток), а также неоплаченные корзины (по умолчанию одни сутки). Программный код данной команды представлен в приложении G. После ввода «php bin/console difuks:dazzle:basket:clear-old» заброшенные корзины, если они существуют, удаляются, а в консоли выводится количество удалённых корзин. Тем не менее, данный процесс всё ещё не автоматизирован. Для того, чтобы команда выполнялась автоматически, необходимо добавит её в задания cron [26]. Для систем Debian (в данном случае Ubuntu) это реализуется командой в терминале: «echo "0 0 * * * root cd /path/to/project/ && bin/console difuks:dazzle:basket:clear-old >> var/logs/cron.log" >> /etc/crontab». После ввода данной команды очистка заброшенных корзин будет производиться каждые сутки в 00:00 по серверному времени.

Также необходимо автоматизировать отправку почты, сохранённой во временный файл и ожидающей отправки с помощью команды в терминале «echo "0 * * * * root cd /path/to/project/ && bin/console swiftmailer:spool:send --message-limit=5 --env=prod >> var/logs/cron.log" >> /etc/crontab». Отправка почты будет производится каждый час по 5 email максимум. Это гарантирует защиту от блокировки за рассылку спама.

Тестирование

Тестирование осуществлялось с помощью встроенных механизмов Symfony, расширяющих возможности PHPUnit [27]. Были реализованы функциональные тесты, осуществляющие проверку корректности выполнения основных функций реализуемого веб-приложения, а именно:

· Корректное осуществление авторизации;

· Корректная деавторизация;

· Корректный статус ответа всех страниц публичной части;

· Осуществление перенаправления неавторизованного пользователя на страницу авторизации при попытке входа в административную панель, а также на страницу корзины; корректное отображения этих страниц для авторизованного пользователя;

· Корректное осуществление добавления игры в корзину (существование хотя бы одной доступной для покупки игры, невозможность покупки неавторизованному пользователю).

Программный код последнего из указанных тестов и конфигурация PHPUnit представлена в приложении G. Для запуска тестов необходимо выполнить команду: «phpunit», находясь в корневой директории проекта. В случае неуспешного прохождения какого-либо из тестов в консоли отобразиться описание ошибки и дополнительная информация по ней.

Перенос проекта на production-сервер

Перенос проекта на production-сервер состоял из следующих этапов:

· Приведения всего программного кода к стандартам Symfony (расширение стандартов PSR-2) с помощью утилиты php-cs-fixer [28].

· Отправка всех изменений на сервере разработки в систему контроля версий.

· Аренда виртуального сервера на хостинге FirstVDS. Характеристики арендованного сервера: Процессор Intel Xeon 2,4 ГГц (1 ядро), оперативная память 1 ГБ, диск HDD+SSD 30 ГБ, операционная система Ubuntu 16.04.

· Регистрация домена difk.ru и изменение серверов имён на серверы имён FirstVDS для доступа к серверу по адресу.

· Подключение по ssh к удалённому серверу.

· Установка PHP 7.1, PostgreSQL, git, composer, npm, webpack, bower, phpunit.

· Настройка PHP, создание базы данных PostgreSQL (процесс аналогичен таковому на сервере разработки).

· Клонирования репозитория из системы контроля версий.

· Установка зависимостей composer, npm, bower. Запуск сборщика webpack для production-среды (NODE_ENV=production webpack).

· Миграция базы данных.

· Запуск тестов PHPUnit.

· Добавление заданий cron.

· Настройка виртуального хоста apache2 (для доступа из сети по адресу difk.ru к папке web приложения).

· Перезапуск apache2.

После всех выполненных действий веб-приложение Symfony готово к работе и доступно по адресу http://difk.ru.

ЗАКЛЮЧЕНИЕ

Современный этап развития технологий в области веб-проектирования предоставляет возможности для разрешения проблем, являющихся актуальными и глобальными. В ходе выполнения выпускной квалификационной работы были поставлены и решены следующие задачи.

В ходе анализа существующих современных технологий и моделей в области веб были обозначены проблемы в области веб-разработки и приведены некоторые типовые решения, способствующие устранению обозначенных проблем и противоречий. В качестве методологии в области веб-проектирования был обозначен шаблон проектирования MVC, реализующий ООП-подход в области веб.

Технологии Symfony Framework отвечают современным требованиям и стандартам, поддерживают шаблон проектирования MVC, обеспечивают безопасность доступа к данным, увеличивают быстродействие запросов, обладают высоким уровнем масштабируемости. Основные компоненты Symfony Framework: бандлы, Doctrine, маршрутизация, контроллеры, шаблонизатор Twig, сервисы и т.д. – предоставляют инструментарий для разработки сложно структурируемых веб-приложений в соответствии с принятыми стандартами в области веб. Отсутствие официальной русскоязычной документации по Symfony Framework поставило дополнительную задачу по адаптации официальной документации разработчика на русский язык.

На основе анализа моделей и подходов в области веб, инструментария и функционала Symfony Framework в качестве демонстрации программной реализации возможностей его компонентов и подходов был реализован интернет-магазин, иллюстрирующий типовой процесс разработки веб-приложения, отвечающего современным требованиям. В работе большое внимание было уделено техническим вопросам по настройке и развертыванию проекта на production-сервере.

Для разработанного приложения были написаны функциональные автоматизированные тесты, на основании которых осуществлено тестирование приложения.

Таким образом цель выпускной квалификационной работы – выполнить анализ возможностей технологий Symfony Framework и реализовать приложение интернет-магазина компьютерных игр на основе современных подходов в области веб-разработки – была достигнута.

Результаты выпускной квалификационной работы могут быть использованы как теоретическое пособие по Symfony Framework, а практическая разработка - в качестве модели проектирования и реализации веб-приложения. При соответствующей адаптации к предметной области приложение может быть использовано как полноценный интернет-магазин.

СПИСОК ЛИТЕРАТУРЫ

1. History of the Web. URL: http://webfoundation.org/about/vision/history-of-the-web/ (дата обращения: 13.05.2016)

2. Internet Live Stats. URL: http://www.internetlivestats.com/ (дата обращения: 15.04.2017)

3. Selmanovic D.. The 10 Most Common Mistakes Web Developers Make. URL: https://www.toptal.com/web/top-10-mistakes-that-web-developers-make (дата обращения: 15.04.2017)

4. Helmke M., Joseph E., Rey J., Ballew P., Hill B. The Official Ubuntu Book. Upper Saddle River, NJ: Prentice Hall, 2014. 368 c.

5. Simpson K. You Don't Know JS: ES6 & Beyond. Sebastopol, CA: O'Reilly Media, 2015. 280 с.

6. Composer. URL: https://getcomposer.org/doc/ (дата обращения 19.04.2016)

7. Npm Documentation. URL: https://docs.npmjs.com/ (дата обращения 19.04.2017)

8. Potencier F. The Twig Book. Paris: SensioLabs, 2017. 156 с.

9. Romer M. PHP Persistence: Concepts, Techniques and Practical Solutions with Doctrine. New York City, NA: Apress, 2016. 107 с.

10. Chacon S., Straub B. Pro Git. New York City, NA: Apress, 2016. 456 с.

11. Chaudhary M., Kumar A. Birmingham. PhpStorm Cookbook. Birmingham: Packt Publishing, 2014. 256 с.

12. Webpack. URL: https://webpack.js.org/ (дата обращения 10.04.2017)

13. Material Design Light. URL: https://getmdl.io/ (дата обращения 21.04.2017)

14. Less.js: Getting started. URL: http://lesscss.org/ (дата обращения 21.04.2017)

15. Potencier F. Symfony. The Book. Paris: SensioLabs, 2016. 219 с.

16. Stones R. Beginning Databases with PostgreSQL: From Novice to Professional. New York City, NA: Apress, 2016. 664 с.

17. Hopkins C. The MVC Pattern and PHP. URL: https://www.sitepoint.com/the-mvc-pattern-and-php-1/ (дата обращения 11.04.2017)

18. Bierer Doug. PHP 7 Programming Cookbook. Birmingham: Packt, 2016. 610 с.

19. A Framework or a CMS? What is better to choose? URL: http://www.web-and-development.com/a-framework-or-a-cms-what-is-better-to-choose/ (дата обращения 11.04.2017)

20. Symfony versus Flat PHP. URL: https://symfony.com/doc/current/introduction/from_flat_php_to_symfony2.html (дата обращения 11.04.2017)

21. Symfony. URL: https://www.drupal.org/project/symfony (дата обращения 11.04.2017)

22. FOSUserBundle. URL: http://knpbundles.com/FriendsOfSymfony/FOSUserBundle (дата обращения 11.04.2017)

23. SonataAdminBundle. URL: https://sonata-project.org/bundles/admin/3-x/doc/index.html (дата обращения 11.04.2017)

24. Methodology BEM. URL: https://en.bem.info/methodology/ (дата обращения 20.04.2017)

25. Robokassa user manual. URL: https://docs.robokassa.ru/en/ (дата обращения 25.04.2017)

26. CronHowto. URL: https://help.ubuntu.com/community/CronHowto (дата обращения 25.04.2017)

27. Bergmann S. PHPUnit Manual. Siegburg: Sebastian Bergmann, 2017. 175 с.

28. PHP Coding Standards Fixer. URL: http://cs.sensiolabs.org/ (дата обращения 10.06.2017)

ПРИЛОЖЕНИЯ

Приложение A. Конфигурационные файлы

/app/config/parameters.yml

/.idea/

/var/

/vendor/

/web/assets/

/web/cache/

/web/upload/

/node_modules/

/bower_components/

.php_cs.cache

/web/bundles/

Листинг 19. Содержимое файла .gitignore

{

"name": "symfony-game-shop",

"version": "0.0.1",

"dependencies": {

"assets-webpack-plugin": "^3.2.0",

"autoprefixer": "^6.3.6",

"babel-core": "^6.17.0",

"babel-loader": "^6.2.5",

"babel-preset-es2015": "^6.16.0",

"bower": "^1.7.2",

"clean-webpack-plugin": "^0.1.8",

"clndr": "^1.4.6",

"css-loader": "^0.23.1",

"css-mqpacker": "^4.0.1",

"exports-loader": "^0.6.2",

"extract-text-webpack-plugin": "^1.0.1",

"file-loader": "^0.8.5",

"getmdl-select": "^1.0.4",

"imports-loader": "^0.6.5",

"jquery": "^1.11.3",

"jquery-mousewheel": "^3.1.13",

"jquery-ui": "^1.10.5",

"jquery.dotdotdot": "^1.7.4",

"less": "^2.3.1",

"less-loader": "^2.2.2",

"material-design-lite": "^1.3.0",

"moment": "^2.15.1",

"normalize.css": "^4.1.1",

"nouislider": "^9.2.0",

"picturefill": "^3.0.2",

"postcss-loader": "^0.8.2",

"resolve-url-loader": "^1.4.3",

"slick-carousel": "^1.6.0",

"style-loader": "^0.13.0",

"underscore": "^1.8.3",

"url-loader": "^0.5.7",

"webpack": "^1.12.11"

}

}

Листинг 20. Содержимое файла package.json

Приложение B. Класс сущности Game

<?php

declare(strict_types=1);

namespace Difuks\DazzleBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

use Doctrine\Common\Collections\ArrayCollection;

/**

* Game.

*

* @ORM\Table(name="game")

* @ORM\Entity(repositoryClass="Difuks\DazzleBundle\Repository\GameRepository")

*/

Class Game

{

/**

* @var int

*

* @ORM\Column(name="id", type="integer")

* @ORM\Id

* @ORM\GeneratedValue(strategy="AUTO")

*/

private $id;

/**

* @var string

*

* @ORM\Column(name="name", type="string", length=255)

*/

private $name;

/**

* @var string

*

* @ORM\Column(name="code", type="string", length=255, unique=true)

*/

private $code;

/**

* @var Image

*

* @ORM\ManyToOne(targetEntity="Image", cascade={"persist"})

* @ORM\JoinColumn(nullable=true)

*/

private $logo;

/**

* @var \DateTime

*

* @ORM\Column(name="release_date", type="datetime")

*/

private $releaseDate;

/**

* @var string

*

* @ORM\Column(name="site", type="string", length=255)

*/

private $site;

/**

* @var string

*

* @ORM\Column(name="video", type="string", length=255)

*/

private $video;

/**

* @var int

*

* @ORM\Column(name="age_restrictions", type="integer")

*/

private $ageRestrictions;

/**

* @var string

*

* @ORM\Column(name="description", type="text")

*/

private $description;

/**

* @var string

*

* @ORM\Column(name="system_requirements", type="text")

*/

private $systemRequirements;

/**

* @var float

*

* @ORM\Column(name="price", type="float")

*/

private $price;

/**

* @var Genre[]|ArrayCollection

*

* @ORM\ManyToMany(targetEntity="Genre", inversedBy="games", cascade={"persist"})

* @ORM\JoinTable(name="game_genre")

*/

private $genres;

/**

* @var Developer

*

* @ORM\ManyToOne(targetEntity="Developer", cascade={"persist"}, inversedBy="games")

* @ORM\JoinColumn(nullable=false)

*/

private $developer;

/**

* @var Publisher

*

* @ORM\ManyToOne(targetEntity="Publisher", cascade={"persist"})

* @ORM\JoinColumn(nullable=false)

*/

private $publisher;

/**

* @var Image[]|ArrayCollection

*

* @ORM\ManyToMany(targetEntity="Image", inversedBy="games", cascade={"persist"})

* @ORM\JoinTable(name="game_screenshots")

*/

private $screenshots;

/**

* @var Key[]|ArrayCollection

*

* @ORM\OneToMany(

* targetEntity="Key",

* mappedBy="game",

* orphanRemoval=true,

* cascade={"persist"}

* )

*/

private $keys;

/**

* @var bool

*

* @ORM\Column(name="multiplayer", type="boolean")

*/

private $multiplayer;

/**

* @var Review[]|ArrayCollection

*

* @ORM\OneToMany(

* targetEntity="Review",

* mappedBy="game",

* orphanRemoval=true,

* cascade={"persist"}

* )

*/

private $reviews;

/**

* @var Discount

*

* @ORM\ManyToOne(targetEntity="Discount", inversedBy="games", cascade={"persist"})

* @ORM\JoinColumn(nullable=true, onDelete="SET NULL")

*/

private $discount;

/**

* @var BasketProduct[]|ArrayCollection

*

* @ORM\OneToMany(

* targetEntity="BasketProduct",

* mappedBy="game",

* orphanRemoval=true

* )

*/

private $basketProducts;

/**

* @var int

*

* @ORM\Column(name="buy_count", type="integer", nullable=true)

*/

private $buyCount;

/**

* @var \DateTime

*

* @ORM\Column(name="last_buy", type="datetime", nullable=true)

*/

private $lastBuy;

/**

* @var float

*

* @ORM\Column(name="rate", type="float", nullable=true)

*/

private $rate;

/**

* @var bool

*

* @ORM\Column(name="is_released", type="boolean", options={"default" : true})

*/

private $isReleased;

/**

* @var bool

*

* @ORM\Column(name="is_rus", type="boolean", options={"default" : true})

*/

private $isRus;

public function __construct()

{

$this->releaseDate = new \DateTime();

$this->genres = new ArrayCollection();

$this->keys = new ArrayCollection();

$this->screenshots = new ArrayCollection();

$this->reviews = new ArrayCollection();

$this->basketProducts = new ArrayCollection();

$this->buyCount = 0;

$this->rate = 0;

}

public function __toString()

{

return (string) $this->getName();

}

public function getId()

{

return $this->id;

}

public function setName(string $name)

{

$this->name = $name;

return $this;

}

public function getName()

{

return $this->name;

}

public function setCode(string $code)

{

$this->code = $code;

return $this;

}

public function getCode()

{

return $this->code;

}

public function setLogo(Image $logo)

{

$this->logo = $logo;

return $this;

}

public function getLogo()

{

return $this->logo;

}

public function setReleaseDate(\DateTime $releaseDate)

{

$this->releaseDate = $releaseDate;

return $this;

}

public function getReleaseDate()

{

return $this->releaseDate;

}

public function setSite(string $site)

{

$this->site = $site;

return $this;

}

public function getSite()

{

return $this->site;

}

public function setVideo(string $video)

{

$this->video = $video;

return $this;

}

public function getVideo()

{

return $this->video;

}

public function setAgeRestrictions(int $ageRestrictions)

{

$this->ageRestrictions = $ageRestrictions;

return $this;

}

public function getAgeRestrictions()

{

return $this->ageRestrictions;

}

public function setDescription(string $description)

{

$this->description = $description;

return $this;

}

public function getDescription()

{

return $this->description;

}

public function setSystemRequirements(string $systemRequirements)

{

$this->systemRequirements = $systemRequirements;

return $this;

}

public function getSystemRequirements()

{

return $this->systemRequirements;

}

public function setPrice(float $price)

{

$this->price = $price;

return $this;

}

public function getPrice()

{

return (int) $this->price;

}

public function addGenre(Genre $genre)

{

$this->genres->add($genre);

return $this;

}

public function removeGenre(Genre $genre)

{

$this->genres->removeElement($genre);

return $this;

}

public function getGenres()

{

return $this->genres;

}

public function setGenres(ArrayCollection $genres)

{

$this->genres = $genres;

}

public function addScreenshot(Image $screenshot)

{

$this->screenshots->add($screenshot);

return $this;

}

public function removeScreenshot(Image $screenshot)

{

$this->screenshots->removeElement($screenshot);

return $this;

}

public function getScreenshots()

{

return $this->screenshots;

}

public function setScreenshots(ArrayCollection $screenshots)

{

$this->screenshots = $screenshots;

}

public function getDeveloper()

{

return $this->developer;

}

public function setDeveloper(Developer $developer)

{

$this->developer = $developer;

return $this;

}

public function getPublisher()

{

return $this->publisher;

}

public function setPublisher(Publisher $publisher)

{

$this->publisher = $publisher;

return $this;

}

public function getKeys()

{

$unPayedKeys = new ArrayCollection();

foreach ($this->keys as $key) {

if ($key->getStatus() == 0) {

$unPayedKeys->add($key);

}

}

return $unPayedKeys;

}

public function addKey(Key $key)

{

$this->keys->add($key);

$key->setGame($this);

return $this;

}

public function removeKey(Key $key)

{

$this->keys->removeElement($key);

return $this;

}

public function getReviews()

{

return $this->reviews;

}

public function addReview(Review $review)

{

$this->reviews->add($review);

$review->setGame($this);

$rate = $review->getRate();

$count = 1;

foreach ($this->getReviews() as $newReview) {

$rate += $newReview->getRate();

++$count;

}

$rate = round($rate / $count, 1);

$this->setRate($rate);

return $this;

}

public function removeReview(Review $review)

{

$this->reviews->removeElement($review);

return $this;

}

public function getRate()

{

return $this->rate;

}

public function setRate(float $rate)

{

$this->rate = $rate;

return $this;

}

public function getIntRate()

{

return (int) $this->getRate();

}

public function setMultiplayer(bool $multiplayer)

{

$this->multiplayer = $multiplayer;

return $this;

}

public function getMultiplayer()

{

return $this->multiplayer;

}

public function getDiscount()

{

return $this->discount;

}

public function setDiscount($discount)

{

$this->discount = $discount;

return $this;

}

public function removeDiscount()

{

$this->discount = null;

return $this;

}

public function getDiscountPrice()

{

if ($this->getDiscount()) {

$discountPrice = $this->getPrice() - $this->discount->getValue() * $this->getPrice() / 100;

} else {

$discountPrice = $this->getPrice();

}

return (int) $discountPrice;

}

public function getIsDiscount()

{

return $this->getDiscountPrice() != $this->getPrice();

}

public function getBasketProducts()

{

return $this->basketProducts;

}

public function getBuyCount()

{

return $this->buyCount;

}

public function setBuyCount($buyCount)

{

$this->buyCount = $buyCount;

return $this;

}

public function getLastBuy()

{

return $this->lastBuy;

}

public function setLastBuy(\DateTime $lastBuy)

{

$this->lastBuy = $lastBuy;

return $this;

}

public function getIsReleased()

{

return $this->isReleased;

}

public function setIsReleased(bool $isReleased)

{

$this->isReleased = $isReleased;

return $this;

}

public function getIsFavorite(User $user = null)

{

if ($user == null) {

return false;

}

$favorites = $user->getFavoriteGames();

foreach ($favorites as $favorite) {

if ($favorite->getId() == $this->getId()) {

return true;

}

}

return false;

}

public function getReviewCount()

{

return $this->reviews->count();

}

public function getIsRus()

{

return $this->isRus;

}

public function setIsRus(bool $isRus)

{

$this->isRus = $isRus;

return $this;

}

public function getKeysCount()

{

return $this->getKeys()->count();

}

}

Листинг 21. Класс сущности Game

Приложение C. Репозиторий GameRepository сущности Game

<?

namespace Difuks\DazzleBundle\Repository;

use Doctrine\ORM\EntityRepository;

use Doctrine\ORM\Query\Expr\Join;

class GameRepository extends EntityRepository

{

public function count(): int

{

$db = $this->createQueryBuilder('t');

return $db

->select('count(t.id)')

->getQuery()

->getSingleScalarResult();

}

public function maxPrice()

{

$db = $this->createQueryBuilder('t');

return $db

->select('MAX(t.price)')

->getQuery()

->getSingleScalarResult();

}

public function minPrice()

{

$minPrice = $this->getEntityManager()

->createQuery(

'SELECT

round(MIN(CASE WHEN g.discount IS NULL THEN g.price ELSE (g.price - g.price * d.value / 100) END),1)

FROM DifuksDazzleBundle:Game g

LEFT JOIN DifuksDazzleBundle:Discount d

WHERE g.discount = d'

)

->getSingleScalarResult();

return $minPrice;

}

public function maxAgeRest()

{

$db = $this->createQueryBuilder('t');

return $db

->select('MAX(t.ageRestrictions)')

->getQuery()

->getSingleScalarResult();

}

public function minAgeRest()

{

$db = $this->createQueryBuilder('t');

return $db

->select('MIN(t.ageRestrictions)')

->getQuery()

->getSingleScalarResult();

}

public function findByFilter(array $filter = [], array $sort = [], int $page = 1, $count = 9)

{

$query = $this->createQueryBuilder('g');

$actualPrice = 'CASE WHEN g.discount IS NULL THEN g.price ELSE (g.price - g.price * d.value / 100) END';

$query->select("g, $actualPrice AS HIDDEN price");

$query

->leftJoin('DifuksDazzleBundle:Discount', 'd', Join::WITH, 'g.discount = d');

if (isset($filter['price']['min'])) {

$minPrice = $filter['price']['min'];

$query->andWhere("$actualPrice >= $minPrice");

}

if (isset($filter['price']['max'])) {

$maxPrice = $filter['price']['max'];

$query->andWhere("$actualPrice <= $maxPrice");

}

if (isset($filter['age']['min'])) {

$ageMin = $filter['age']['min'];

$query->andWhere("g.ageRestrictions >= $ageMin");

}

if (isset($filter['age']['max'])) {

$ageMax = $filter['age']['max'];

$query->andWhere("g.ageRestrictions <= $ageMax");

}

if (isset($filter['rate']['min'])) {

$rateMin = $filter['rate']['min'];

$query->andWhere("g.rate >= $rateMin");

}

if (isset($filter['rate']['max'])) {

$rateMax = $filter['rate']['max'];

$query->andWhere("g.rate <= $rateMax");

}

if (isset($filter['isReleased'])) {

$query->andWhere('g.isReleased = TRUE');

}

if (isset($filter['isDiscount'])) {

$query->andWhere('g.discount IS NOT NULL');

}

if (isset($filter['genre'])) {

$query->andWhere(':genres MEMBER OF g.genres');

$query->setParameter('genres', $filter['genre']);

}

$order = $sort['order'];

$by = $sort['by'];

if ($order == 'price') {

$query->addOrderBy('price', $by);

} else {

$query->addOrderBy("g.$order", $by);

}

$games = $query->getQuery()->setMaxResults($count * $page)->setFirstResult(($page - 1) * $count)->getResult();

$totalCount = count($query->select('g.id')->orderBy('g.id')->getQuery()->getResult());

return [

'elements' => $games,

'page' => [

'count' => ceil($totalCount / $count),

'current' => $page,

],

];

}

public function getDisocuntGames()

{

$db = $this->createQueryBuilder('g');

$db->where('g.discount IS NOT NULL')->setFirstResult(0)->setMaxResults(10);

return $db->getQuery()->getResult();

}

}

Листинг 22. Репозиторий GameRepository сущности Game

Приложение D. Класс генерации формы на основе сущности

<?

namespace Difuks\DazzleBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

use Symfony\Component\Validator\Constraints as Assert;

/**

* Feedback.

*

* @ORM\Table(name="feedback")

* @ORM\Entity()

*/

Class Feedback

{

/**

* @var int

*

* @ORM\Column(name="id", type="integer")

* @ORM\Id

* @ORM\GeneratedValue(strategy="AUTO")

*/

private $id;

/**

* @var string

*

* @Assert\NotBlank()

* @ORM\Column(name="name", type="string", length=255, nullable=false)

*/

private $name;

/**

* @var string

*

* @Assert\NotBlank()

* @Assert\Email()

* @ORM\Column(name="email", type="string", length=255, nullable=false)

*/

private $email;

/**

* @var string

*

* @Assert\NotBlank()

* @ORM\Column(name="text", type="text", length=255, nullable=false)

*/

private $text;

/**

* @var \DateTime

*

* @ORM\Column(name="date", type="datetime")

*/

private $date;

public function __construct()

{

$this->date = new \DateTime();

}

public function getId()

{

return $this->id;

}

public function getName()

{

return $this->name;

}

public function setName(string $name)

{

$this->name = $name;

}

public function getEmail()

{

return $this->email;

}

public function setEmail(string $email)

{

$this->email = $email;

}

public function getText()

{

return $this->text;

}

public function setText(string $text)

{

$this->text = $text;

}

public function getDate()

{

return $this->date;

}

public function setDate(\DateTime $date)

{

$this->date = $date;

}

}

Листинг 22. Сущность Feedback

<?

namespace Difuks\DazzleBundle\Form;

use Difuks\DazzleBundle\Entity\Feedback;

use Symfony\Component\Form\AbstractType;

use Symfony\Component\Form\Extension\Core\Type\EmailType;

use Symfony\Component\Form\Extension\Core\Type\TextType;

use Symfony\Component\Form\Extension\Core\Type\TextareaType;

use Symfony\Component\Form\FormBuilderInterface;

use Symfony\Component\OptionsResolver\OptionsResolver;

class FeedbackType extends AbstractType

{

public function buildForm(FormBuilderInterface $builder, array $options)

{

$builder

->add('name', TextType::class, [

'label' => 'Имя',

'required' => false,

'mapped' => true,

])

->add('email', EmailType::class, [

'label' => 'Email',

'required' => false,

])

->add('text', TextareaType::class, [

'label' => 'Текст обращения',

'required' => false,

])

;

}

public function configureOptions(OptionsResolver $resolver)

{

$resolver->setDefaults([

'data_class' => Feedback::class,

]);

}

}

Листинг 23. Класс генерации формы обратной связи

Приложение E. Маршруты и контроллер публичной части сайта

index:

path: /

defaults: { _controller: DifuksDazzleBundle:Public:index }

genres:

path: /genres/

defaults: { _controller: DifuksDazzleBundle:Public:genres }

catalog_all:

path: /catalog/

defaults: { _controller: DifuksDazzleBundle:Public:catalog }

catalog:

path: /catalog/{code}

defaults: { _controller: DifuksDazzleBundle:Public:catalog }

product:

path: /product/{code}

defaults: { _controller: DifuksDazzleBundle:Public:product, code: default }

basket:

path: /basket/

defaults: { _controller: DifuksDazzleBundle:Public:basket }

feedback:

path: /feedback/

defaults: { _controller: DifuksDazzleBundle:Public:feedback }

unsubscribe:

path: /unsubscribe/{hash}

defaults: { _controller: DifuksDazzleBundle:Public:unsubscribe }

Листинг 24. Конфигурация маршрутов публичной части

<?

declare(strict_types=1);

namespace Difuks\DazzleBundle\Controller;

use Difuks\DazzleBundle\Entity\Feedback;

use Difuks\DazzleBundle\Entity\Game;

use Difuks\DazzleBundle\Entity\Genre;

use Difuks\DazzleBundle\Entity\Subscribes;

use Difuks\DazzleBundle\Form\FeedbackType;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

use Symfony\Component\HttpFoundation\Response;

class PublicController extends Controller

{

public function indexAction(): Response

{

return $this->render('DifuksDazzleBundle:Public:index.html.twig');

}

public function genresAction(): Response

{

$genres = $this->getDoctrine()->getRepository(Genre::class)->findBy([], ['id' => 'ASC']);

$totalCount = $this->getDoctrine()->getRepository(Game::class)->count();

return $this->render('DifuksDazzleBundle:Public:genres.html.twig', ['genres' => $genres, 'totalCount' => $totalCount]);

}

public function catalogAction(Genre $genre = null): Response

{

return $this->render('DifuksDazzleBundle:Public:catalog.html.twig', ['genre' => $genre]);

}

public function productAction(Game $game): Response

{

return $this->render('DifuksDazzleBundle:Public:product.html.twig', ['game' => $game]);

}

public function basketAction(): Response

{

return $this->render('@DifuksDazzle/Public/basket.html.twig');

}

public function feedbackAction(): Response

{

$form = $this->createForm(FeedbackType::class);

return $this->render('@DifuksDazzle/Public/feedback.html.twig', ['form' => $form->createView()]);

}

public function unsubscribeAction(Subscribes $subscribe): Response

{

$this->get('difuks.dazzle.social_service')->unSubscribe($subscribe);

return $this->render('@DifuksDazzle/Public/unsubscribe.html.twig');

}

}

Листинг 25. Контроллер публичной части сайта

Приложение F. Сервис для работы с корзиной и заказами. Настройка сервисов

<?

namespace Difuks\DazzleBundle\Services;

use Difuks\DazzleBundle\Entity\Basket;

use Difuks\DazzleBundle\Entity\BasketProduct;

use Difuks\DazzleBundle\Entity\Game;

use Doctrine\ORM\EntityManager;

use Symfony\Component\DependencyInjection\Container;

Class OrderService

{

protected $container;

protected $pass;

protected $pass2;

protected $login;

protected $em;

protected $mailer;

public function __construct(Container $container, EntityManager $em, \Swift_Mailer $mailer)

{

$this->container = $container;

$this->mailer = $mailer;

$this->em = $em;

$this->login = $container->getParameter('robokassa.login');

$this->pass = $container->getParameter('robokassa.password');

$this->pass2 = $container->getParameter('robokassa.password2');

}

/**

* Получает url для перевода в систему оплаты.

*

* @param int $id id заказа

* @param float $sum сумма заказа

*

* @return string url

*/

public function getUrl(int $id, float $sum): string

{

$descr = 'Оформление заказа №'.$id;

$crc = md5("$this->login:$sum:$id:$this->pass");

$url = "https://auth.robokassa.ru/Merchant/Index.aspx?MrchLogin=$this->login&".

"OutSum=$sum&InvId=$id&Description=$descr&SignatureValue=$crc&IsTest=1";

return $url;

}

/**

* Обрабатывает результат запроса от службы оплаты.

*

* @param int $id id заказа

* @param float $sum сумма заказа

* @param string $crc хэш

*

* @throws \Exception в случае несовпадения хэша

*/

public function setResult(int $id, float $sum, string $crc): void

{

$crc = strtoupper($crc);

$myCrc = strtoupper(md5("$sum:$id:$this->pass2"));

if ($myCrc != $crc) {

throw new \Exception('Неверные данные оплаты. Хеш '.$myCrc.' и '.$crc.' не совпадают');

}

$basket = $this->em->getRepository(Basket::class)->find($id);

$fromEmail = $this->container->getParameter('mailer_user');

if ($basket->getPaymentState() != 2) {

$this->sendEmailAboutPay($basket, $fromEmail);

$this->refreshGamesRate($basket);

$basket->setPaymentState(2);

$this->em->persist($basket);

$this->em->flush();

}

}

/**

* Отправляет email об успешной оплате.

*

* @param Basket $basket

* @param string $email

*/

protected function sendEmailAboutPay(Basket $basket, string $email): void

{

$body = [];

$keys = $basket->getKeys();

/*

* @var Key

*/

foreach ($keys as $key) {

$body[$key->getGame()->getName()][] = $key->getKey();

}

$message = \Swift_Message::newInstance()

->setSubject('Покупка игр')

->setFrom($email)

->setTo($basket->getUser()->getEmail())

->setBody($this->container->get('templating')->render(

'@DifuksDazzle/Email/keys.send.html.twig',

['body' => $body]

),

'text/html');

$this->mailer->send($message);

}

/**

* Обновляет количество покупок игры, а так же дату последней покупки.

*

* @param Basket $basket

*/

protected function refreshGamesRate(Basket $basket): void

{

$products = $basket->getProducts();

foreach ($products as $product) {

$game = $product->getGame();

$currentCount = ($game->getBuyCount()) ?: 0;

$game->setBuyCount($currentCount + $product->getQuantity());

$game->setLastBuy(new \DateTime());

$this->em->persist($game);

$this->em->flush();

}

}

/**

* Обрабатывает запрос на странице завершения оплаты.

*

* @param int $id id заказа

* @param float $sum сумма заказа

* @param string $crc хэш

*

* @throws \Exception в случае несовпадения хэша

*

* @return string текст с результатом

*/

public function getDone(int $id, float $sum, string $crc): string

{

$crc = strtoupper($crc);

$myCrc = strtoupper(md5("$sum:$id:$this->pass"));

if ($myCrc != $crc) {

throw new \Exception('Неверные данные оплаты. Хеш '.$myCrc.' и '.$crc.' не совпадают');

}

$basket = $this->em->getRepository(Basket::class)->find($id);

if (isset($basket)) {

if ($basket->getPaymentState() != 2) {

$basket->setPaymentState(1);

$this->em->persist($basket);

$this->em->flush();

}

return 'Операция прошла успешно. После проведения оплаты ключи отправят вам на email. Спасибо за покупку!';

} else {

return 'Нет заказа с таким номером';

}

}

/**

* Получает текущую корзину пользователя.

*

* @return Basket

*/

public function getCurrentBasket(): Basket

{

$user = $this->container->get('security.token_storage')->getToken()->getUser();

$basket = $this->em->getRepository(Basket::class)->getCurrentBasketByUser($user);

return $basket;

}

/**

* Добавляет необходимое количество игр в корзину. Возвращает оставшееся количество ключей.

*

* @param Basket $basket

* @param Game $game

* @param int $quantity

*

* @return int

*/

public function addToBasket(Basket $basket, Game $game, int $quantity): int

{

$basket->addGame($game, $quantity);

$this->em->persist($basket);

$this->em->flush();

return $game->getKeysCount();

}

/**

* Изменят количество находящейся в корзине игр

*

* @param BasketProduct $basketProduct

* @param int $quantity

*/

public function changeBasketProductCount(BasketProduct $basketProduct, int $quantity): void

{

$basketProduct->getBasket()->changeGameCount($basketProduct->getGame(), $quantity);

$this->em->persist($basketProduct);

$this->em->flush();

}

/**

* Удаляет игру из корзины.

*

* @param BasketProduct $basketProduct

*/

public function deleteBasketProduct(BasketProduct $basketProduct): void

{

$basket = $basketProduct->getBasket();

$basket->removeProduct($basketProduct);

if ($basket->getProductCount() == 0) {

$this->em->remove($basket);

} else {

$this->em->persist($basket);

}

$this->em->flush();

}

/**

* Удаляет старые корзины.

*

* @param int $notPayDay количество дней для удаления корзин в статусе неоплачено

* @param int $payDay количество дней для удаления корзин в статусе ожидания оплаты

*

* @return int число удалённых корзин

*/

public function clearOldBasket(int $notPayDay, int $payDay): int

{

$oldBaskets = $this->em

->getRepository(Basket::class)

->getOldBaskets(

$notPayDay,

$payDay

);

$count = count($oldBaskets);

foreach ($oldBaskets as $basket) {

$this->em->remove($basket);

}

$this->em->flush();

return $count;

}

}

Листинг 26. Сервис для работы с корзиной и заказами

services:

difuks.dazzle.file.twig.extension:

class: Difuks\DazzleBundle\Extension\Twig\FileExtension

arguments: ['@service_container']

tags:

- { name: twig.extension }

difuks.dazzle.flush_handler:

class: Difuks\DazzleBundle\EventHandler\FlushHandler

arguments: ['@swiftmailer.mailer.cron', '@service_container']

tags:

- { name: doctrine.event_listener, event: onFlush }

difuks.dazzle.authentication_handler:

class: Difuks\DazzleBundle\EventHandler\AuthenticationHandler

arguments: ['@router', '@security.authorization_checker']

difuks.dazzle.order_service:

class: Difuks\DazzleBundle\Services\OrderService

arguments: ['@service_container', '@doctrine.orm.entity_manager', '@swiftmailer.mailer.moment']

difuks.dazzle.social_service:

class: Difuks\DazzleBundle\Services\SocialService

arguments: ['@service_container', '@doctrine.orm.entity_manager']

difuks.dazzle.form.registration:

class: Difuks\DazzleBundle\Form\RegistrationType

tags:

- { name: form.type, alias: difuks_dazzle_user_registration }

difuks.dazzle.form.profile:

class: Difuks\DazzleBundle\Form\ProfileType

tags:

- { name: form.type, alias: difuks_dazzle_user_profile }

Листинг 27. Файл настройки собственных сервисов

Приложение F. Класс консольной команды очистки заброшенных корзин

<?

declare(strict_types=1);

namespace Difuks\DazzleBundle\Command;

use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;

use Symfony\Component\Console\Input\InputInterface;

use Symfony\Component\Console\Input\InputOption;

use Symfony\Component\Console\Output\OutputInterface;

class ClearOldBasketCommand extends ContainerAwareCommand

{

protected function configure()

{

$this->addOption('not-pay-day', null, InputOption::VALUE_REQUIRED, 'Количество дней для удаление корзин со статусом 0', 1);

$this->addOption('pay-day', null, InputOption::VALUE_REQUIRED, 'Количество дней для удаление корзин со статусом 1', 3);

$this

->setName('difuks:dazzle:basket:clear-old')

->setDescription('Очищает старые корзины')

->setHelp('Очищает корзины со статусом 0 (старее одного дня) и 1 (старее трёх дней)');

}

protected function execute(InputInterface $input, OutputInterface $output)

{

$notPayDay = (int) $input->getOption('not-pay-day');

$payDay = (int) $input->getOption('pay-day');

$count = $this->getContainer()->get('difuks.dazzle.order_service')->clearOldBasket($notPayDay, $payDay);

$output->writeln((new \DateTime())->format('d.m.Y H:i:s')." Remove $count baskets");

}

}

Листинг 28. Класс консольной команды очистки заброшенных корзин

Приложение G. Функциональный тест виджета добавления в корзину и конфигурация PHPUnit

<?

namespace Difuks\DazzleBundle\Tests\Functional\Controller;

use Difuks\DazzleBundle\Tests\Functional\BaseControllerTest;

class WidgetControllerTest extends BaseControllerTest

{

public function testProductSliderWidget()

{

$crawler = $this->client->request('GET', '/');

$gameButtons = $crawler->filter('button:contains("В корзину")');

$this->assertTrue($gameButtons->count() > 0, 'Ни одной доступной для покупки игры');

if ($gameButtons->count()) {

$this->checkUrl('/ajax/add-to-basket/'.$gameButtons->first()->attr('data-id'));

$answer = json_decode($this->client->getResponse()->getContent(), true);

$this->assertTrue(isset($answer['error']) && $answer['error'] == true, 'Добавление в корзину доступно неавторизованному пользователю');

$this->logIn();

$this->checkUrl('/ajax/add-to-basket/'.$gameButtons->first()->attr('data-id'));

$this->logout();

}

}

}

Листинг 29. Функциональный тест виджета добавления в корзину

<?xml version="1.0" encoding="UTF-8"?>

<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->

<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"

backupGlobals="false"

colors="true"

bootstrap="app/autoload.php"

>

<php>

<ini name="error_reporting" value="-1" />

<server name="KERNEL_DIR&qu

Наши рекомендации