Сервис на Yii2: Автоудаление неактивных пользователей
В прошлой части мы немного изменили структуру нашей системы. Фактически это были банальные операции по переносу файлов и частей кода с места на место. А сейчас добавим что-то новое. В комментариях предложили вычищать из базы пользователей, не активировавших свой адрес при регистрации. Не планировалось, но почему бы и нет? Рассмотрим. как это можно осуществить.
Предыдущие части
Исходники проекта на GitHub
Первым делом, нужно добавить параметр, в котором мы будем хранить срок истечения регистрации. Для этого открываем класс модуля и добавляем параметр emailConfirmTokenExpire
для таймаута сроком в три дня:
namespace app\modules\user; use yii\console\Application as ConsoleApplication; use Yii; class Module extends \yii\base\Module { /** * @var int */ public $emailConfirmTokenExpire = 259200; // 3 days /** * @var int */ public $passwordResetTokenExpire = 3600; ... }
Теперь «просроченных» неактивированных пользователей в контроллере мы можем получить по такому запросу:
$query = User::find() ->where(['status' => User::STATUS_WAIT]) ->andWhere(['<', 'created_at', time() - $this->module->emailConfirmTokenExpire]);
Это все пользователи, ожидающие активацию и зарегистрированные уже давно.
Но мы достаточно «ленивы», чтобы каждый раз напрягать свой или чужой мозг такими грудами условий. При копировании условий из файла в файл в какой-то момент можно где-то допустить ошибку. Лучше эту логику инкапсулировать в какой-нибудь отдельный метод и вызывать уже его.
Куда же это спрятать? Видимо в тот код, который отвечает за поиск. Ищет модели не сама модель. Если заглянуть в класс ActiveRecord
, то можно увидеть, что при запуске User::find()
нам возвращается экземпляр класса ActiveQuery
:
namespace yii\db; ... class ActiveRecord extends BaseActiveRecord { ... /** * @return ActiveQuery the newly created [[ActiveQuery]] instance. */ public static function find() { return Yii::createObject(ActiveQuery::className(), [get_called_class()]); } ... }
То есть строка:
$query = User::find();
по сути аналогична такой:
$query = new ActiveQuery('\app\modules\user\models\User`);
или такой:
$query = new ActiveQuery(User::className());
И уже из экземпляра ActiveQuery
мы вызываем where(...)
, andWhere(...)
и прочие методы.
Было бы неплохо добавить в этот класс свой метод overdue()
, который применял бы эти условия:
class ActiveQuery extends Query implements ActiveQueryInterface { ... public function overdue($timeout) { return $this ->andWhere(['status' => User::STATUS_WAIT]) ->andWhere(['<', 'created_at', time() - $timeout]); } }
Но вставлять свой код в класс фреймворка мы не хотим. Поэтому можем спокойно отнаследоваться от него в своём классе UserQuery
, который поместить в поддиректорию models/query
нашего модуля пользователя:
namespace app\modules\user\models\query; use app\modules\user\models\User; use yii\db\ActiveQuery; use Yii; class UserQuery extends ActiveQuery { public function overdue($timeout) { return $this ->andWhere(['status' => User::STATUS_WAIT]) ->andWhere(['<', 'created_at', time() - $timeout]); } }
Теперь нужно сделать так, чтобы по запросу User::find()
вместо оригинала возвращался наш класс. Просто переопределим метод find()
в нашей модели:
namespace app\modules\user\models; use yii\db\ActiveRecord; use yii\web\IdentityInterface; use app\modules\user\models\query\UserQuery; class User extends ActiveRecord implements IdentityInterface { ... /** * @return UserQuery */ public static function find() { return Yii::createObject(UserQuery::className(), [get_called_class()]); } ... }
Чтобы не создавать такие классы вручную при генерации модели в Gii можно поставить галочку «Generate ActiveQuery». Этот класс создастся в папке
models/query
и пропишется в модель автоматически.
Теперь можем спокойно использовать наш метод overdue($timeout)
для получения списка «просроченных» пользователей:
foreach (User::find()->overdue($timeout)->each() as $user) { /** @var User $user */ echo $user->username . PHP_EOL; }
Теперь для удаления всех неактивированных за три дня пользователей достаточно выполнить простой запрос:
$query = User::find()->overdue($timeout); User::deleteAll($query->where, $query->params);
и фреймворк вычистит таблицу одним SQL-запросом.
Но часто у пользователей к профилю может быть прикреплена фотография и прочие вещи, которые при удалении пользователя тоже нужно удалить. Или нужно записать в лог факт удаления каждого элемента. В таком случае остаётся только пройтись по всему списку и удалить каждый элемент в цикле:
foreach (User::find()->overdue($timeout)->each() as $user) { /** @var User $user */ $user->delete(); }
Но если пользователь только зарегистрировался и ещё не активировал почту, то он, собственно, фотографией не намусорил и можно воспользоваться предыдущим вариантом. Второй вариант более универсальный и будет корректно работать с любыми моделями. В общем, котэ одобряэ:
В итоге, в своём модуле user
мы можем создать ещё одну консольную команду:
namespace app\modules\user\commands; use app\modules\user\models\User; use yii\console\Controller; use yii\helpers\Console; use Yii; /** * Console crontab actions */ class CronController extends Controller { /** * @var \app\modules\user\Module */ public $module; /** * Removes non-activated expired users */ public function actionRemoveOverdue() { foreach (User::find()->overdue($this->module->emailConfirmTokenExpire)->each() as $user) { /** @var User $user */ $this->stdout($user->username); if ($user->delete()) { Yii::info('Remove expired user ' . $user->username); $this->stdout(' OK', Console::FG_GREEN, Console::BOLD); } else { Yii::warning('Cannot remove expired user ' . $user->username); $this->stderr(' FAIL', Console::FG_RED, Console::BOLD); } $this->stdout(PHP_EOL); } $this->stdout('Done!', Console::FG_GREEN, Console::BOLD); $this->stdout(PHP_EOL); } }
Если мы настроили $controllerNamespace
нашего модуля для консольного приложения (это мы делали в предыдущей части), то при запуске
php yii
наш контроллер должен выводиться в списке доступных для выполнения команд:
The following commands are available: ... - user/cron Console crontab actions user/cron/remove-overdue Removes non-activated expired users
Теперь можно вручную попробовать запустить:
php yii user/cron/remove-overdue
и, если в базе есть неактивные пользователи, увидеть процесс их удаления.
Настройка планировщика
Для автоматического удаления по планировщику задач Cron можно в файле /etc/cron.d/myapp
запускать процесс в каждую полночь:
0 0 * * * www-data cd /home/seokeys/htdocs && /usr/bin/php yii user/cron/remove-overdue >/dev/null
или проще, если сделать файл yii
исполняемым для всех:
0 0 * * * www-data /home/seokeys/htdoc/yii user/cron/remove-overdue >/dev/null
или запускать каждую минуту, для подстраховки обернув в flock
, который будет позволять запускать команду только в одном экземпляре:
* * * * * www-data /usr/bin/flock -xn /var/lock/myapp_overdue.lock -c '/home/seokeys/htdocs/yii user/cron/remove-overdue >/dev/null'
Вот и всё. В следующей части надо бы рассказать про автоматические тесты, которые мы обсуждали в нашем эпичном вебинаре. А пока задавайте вопросы в комментариях.
Следующая часть: Тестирование приложения с Codeception.
Почему бы не добавить в модель User статический метод findOverdue()?
Статические методы не получится совмещать. А с ActiveQuery можно:
Как всегда супер!
подскажите как можно оптимизировать такой экшен консольного контроллера
Если бы не отправка SMS каждому, то можно было бы массовыми запросами вроде Hosting::updateAll(...).
когда будет что нибудь посерьезнее чем авто-удаление юзеров?
Группировать параметры через точку ('user.passwordResetTokenExpire') - плохой вариант. Представьте, что у вас таких параметров 100, и везде нужно писать 'user.' =) Как насчет добавить вложенность в массив?
Пихать в консольную команду всякие Yii::info, что в итоге приведет к каше - не есть хорошо. Для этого нужно юзать события, и вешать уже лог на них.
UserQuery(get_called_class()) - еще больший трешь. Yii::createObject вам о чем-нибудь говорит?
> Как насчет добавить вложенность в массив?
Добавляйте. Разрешаю.
> Для этого нужно юзать события, и вешать уже лог на них.
Можно.
И вообще, у вас вроде как модуль это. Тогда какого хера вы вообще лезете в 'params' приложения? Свойства в модуле - вам о чем нибудь говорят?
> Свойства в модуле - вам о чем нибудь говорят?
Говорят о тупейшем нарушении SRP и нагромождении лишних зависимостей, к чему приводит отсутствие хоть какой-то регламентированной и вменяемой системы конфигурирования модулей.
Нет, SPR мы не нарушаем тем, что определяем какие-то свойства для последующего передачи в них значений из конфига. А то что вы взяли и перенесли конфиг модуля в общий - вы успешно размыли границы. Это напоминает Symfony2, где черт ногу сломит при подключении стороннего бандла.
Модуль - это структурная единица приложения. Вы же, надеюсь, не храните настройки в полях контроллера? Ну не придумали отдельного файла настроек для модуля, который бы вливался в общий конфиг и переопределялся при желании локально. Что уже теперь. Хотя можно в бутстрапе это организовать.
А когда модель дергает модуль это уж, извините, феерический бред.
Вы меня не поняли, к сожалению. Я не предлагаю дергать модуль из модели. И как бы, модель ничего не должна знать и о ваших конфигах, и обращаться к ним. Вы же `Yii::$app->getUser()->id` не пихаете туда, верно? Это всего-лишь Query, и дальше своих запросов оно смотреть не должно.
P.S. Извините за грубость, не хотел показаться диванным-экспертом.
Подскажите, плиз как перенести эти настройки в базу данных. Например я хочу настройку включить кеш. Сделал пока так:
записал в табличку настройку, в модуле настроек вытягиваю данные и присваиваю переменным, а в контроллере делаю getModules('settings') и получаю настройки.
Как сделать лучше?
Перенёс сейчас настройки в поле в классе модуля. А так можно, например, запрашивать их из базы и присваивать, например, в методе init() модуля.
Подскажите, я хочу все кроны перенести в один контроллер, в app\commands\CronController
Но там не будет работать $this->module->emailConfirmTokenExpire
как мне ее получить из модуля юзера?
Дмитрий подскажите такую вещь, если у меня в таблице пользователей хранятся md5 пароли, я могу как то пользователей перенести в yii с сохранением их старых паролей? Или надо будет обнулять им пароли и при заходе просить их востановить его, как вообще поступить в такой ситуации?
Перепишите просто метод validatePassword на поддержку двух вариантов:
и старые пароли останутся работать.
Спасибо за помощь, не перестаю удивляться гибкостью данного фреймворка