Написание консольных команд и заданий 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