Сервис на 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.

Комментарии

 

Alexander Penshin

Почему бы не добавить в модель User статический метод findOverdue()?

public static function findOverdue(){
return static::find()
    ->where(['status' => User::STATUS_WAIT])
    ->andWhere(['<', 'created_at', time() - Yii::$app->params['user.emailConfirmTokenExpire']]);
}
Ответить

 

Дмитрий Елисеев

Статические методы не получится совмещать. А с ActiveQuery можно:

Post::find()->published()->forCategory($id)->orderBy('date')
Ответить

 

Alex – owls.kz

Как всегда супер!

подскажите как можно оптимизировать такой экшен консольного контроллера

public function actionChangeStatus()
{
    $date = Yii::$app->formatter->asTimestamp(date('Y-m-d H:i:s'));

    $models = Hosting::find()->where(['not in', 'status', Hosting::STATUS_OFF])->all();

    foreach ($models as $model) {
        if($model->notify_date <= $date) {
            $model->status = Hosting::STATUS_ALERT;
            if($model->save()) {
                $this->sendSms();
            }
        }

        if($model->end_date <= $date) {
            $model->status = Hosting::STATUS_OFF;
            if($model->save()) {
                $this->sendSms();
            }
        }
    }
}
Ответить

 

Дмитрий Елисеев

Если бы не отправка 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. Извините за грубость, не хотел показаться диванным-экспертом.

Ответить

 

Rayzor

Подскажите, плиз как перенести эти настройки в базу данных. Например я хочу настройку включить кеш. Сделал пока так:
записал в табличку настройку, в модуле настроек вытягиваю данные и присваиваю переменным, а в контроллере делаю getModules('settings') и получаю настройки.
Как сделать лучше?

Ответить

 

Дмитрий Елисеев

Перенёс сейчас настройки в поле в классе модуля. А так можно, например, запрашивать их из базы и присваивать, например, в методе init() модуля.

Ответить

 

Akulenok

Подскажите, я хочу все кроны перенести в один контроллер, в app\commands\CronController

Но там не будет работать $this->module->emailConfirmTokenExpire

как мне ее получить из модуля юзера?

Ответить

 

Дмитрий Елисеев
Yii::$app->getModule('user')->emailConfirmTokenExpire
Ответить

 

Sergey Aver

Дмитрий подскажите такую вещь, если у меня в таблице пользователей хранятся md5 пароли, я могу как то пользователей перенести в yii с сохранением их старых паролей? Или надо будет обнулять им пароли и при заходе просить их востановить его, как вообще поступить в такой ситуации?

Ответить

 

Дмитрий Елисеев

Перепишите просто метод validatePassword на поддержку двух вариантов:

public function validatePassword($password)
{
    return md5($password) === $this->password_hash ||
        Yii::$app->security->validatePassword($password, $this->password_hash);
}

и старые пароли останутся работать.

Ответить

 

Sergey Aver

Спасибо за помощь, не перестаю удивляться гибкостью данного фреймворка

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>