Всем привет!

Два месяца назад я и мой знакомый (для краткости, назовем его Илья) запустили свой стартап.
Пффф… Скажите вы. Каждый день кто-то что-то запускает. Кто-то запускает в одиночку. Некоторые кучкуются в команды. У кого-то есть деньги на разработку\маркетинг, кто-то предлагает долю, пост-оплату, опционы. Все крутятся как могут и ищут партнеров также.


У нас не было денег, был лишь опыт и 2 недели до первых продаж.

Под катом я расскажу о том, с чем мы столкнулись и как заработали миллион в кризис

Мне лишь хочется поделиться тем опытом, который приобрели мы.

Точнее поделиться тем опытом, который позволил запустить это все за 2 недели.

Серьезно! Я не шучу. От первого митинга до запуска проекта — 2 недели!

Неделя на разработку и раскрутку в соц. сетях и неделя на продажи.

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

Кто мы?


Когда начался карантин многие сферы бизнеса начали потихоньку проседать. Это затронуло и сферу Ильи, а именно — «танцы и хореография». Он владеет большим количеством танцевальных школ. Такая же беда настигла и фитнес. И там люди начали выкручиваться путем проведения онлайн тренировок. Так у него появилась идея провести танцевальный лагерь онлайн (по полной аналогии с оффлайн лагерями). С чем он и пришел ко мне.

В конечном итоге команда сформировалась только из 2х людей:

  • техническая часть (я)
  • маркетинг + управление (Илья)

Если не лукавить, то этот список можно дополнить наемными сотрудниками (дизайнер, смм, переводчик, юрист), но это уже мелочи жизни. Мы это относим к разовым расходам. И эти расходы небольшие.

Что мы делали?


Смысл танцевального лагеря был прост. Есть 12 преподавателей и мероприятие из 3х дней. По 4 класса в день. Необходимо было создать платформу, где пользователь смог бы зарегистрироваться, купить себе доступ в лагерь. Была также информация о преподавателях, лайнап\расписание, некая форма для обратной связи. Ну и самое интересное — необходимо было организовать стриминг артистов, кто также сидит дома на карантине. Почему мы не выбрали решения типа instagram — бан за фоновую музыку. Решения типа Zoom отмели за ненужностью интерактива + слишком дорого + необходимость установки софта.

Отдельный нюанс в том, что преподаватели из Европы и США также находятся на карантине. Это накладывало определенные ограничения на техническую часть.

И еще один нюанс — это должен быть законченный продукт. Люди должны были видеть нечто красивое и понимать, что они покупают, что они получат. Почему им это нужно.

Чего мы хотели?


Мы хотели сделать MVP ��ля запуска первого лагеря и в дальнейшем проводить их еще и еще. Нужно проверить гипотезу, что у людей есть потребность в данном сервисе. И дело не только в карантине. Как взрослые люди мы предъявляли ряд требований к нему:

  1. Решение должно быть стабильным. Никаких 500-ых ошибок, зависших транзакций. Тикеты в саппорт нам не нужны. Все должно работать без нашего участия
  2. Решение должно быть гибким и масштабируемым. Сегодня у нас 100 человек. Завтра 10000. Письма должны ходить. Стриминг должен выдерживать любую нагрузку. В случае смены платежного шлюза например, не должно быть временного лага в принятии оплаты
  3. Интерфейс должен быть понятен как для пользователей, так и преподаватели сидя дома должны суметь организовать стриминг с мобильного телефона
  4. Мы хотели получить прибыль и в дальнейшем масштабировать наши доходы. Т.о. мы по максимуму минимизировали расходы

Что мы использовали?


  • backend на symfony
  • frontend на jquery + vue
  • упаковка ресурсов через webpack
  • очередь nats
  • небольшой микросервис на Go для отправки писем
  • хранилище Minio для статического контента
  • redis для различных подзадач

Панель администратора была сделана с использованием Sonata Admin Bundle. Настраивается этот зверь быстро, удобен в использовании. Не надо тратить время на разработку какой-либо кастомной панели.

Теперь остановимся чуточку подробнее на разных нюансах.

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

Авторизация


Авторизация через SMS это самый большой пылесос для ваших денег. Если вы запускаете стартап — подумайте 10 раз о том, нужно ли оно вам. Представьте, что к вам пришли 1000 пользователей. А теперь умножьте на среднюю цену SMS в Европе\США и добавьте это в себестоимость вашего проекта. Также вы испытаете ряд проблем по согласовании имени отправителя. Да к тому же это еще и долго (в некоторых странах этот процесс длится от 2-3х недель и более). А от стандартного имени отправителя многие операторы сотовой связи блокируют SMS. Мало приятного. Поэтому мы использовали авторизацию через почту\соц. сети.

Очень быстро авторизацию через соц.сети можно интегрировать, добавив в проект библиотеку HWIOAuthBundle.

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

Она хорошо документирована и есть различные примеры.

Найти ее можно вот тут

Письма мы сразу стали отсылать через очередь. Я считаю, что даже для маленьких проектов это нужно взять за основу. Делается это очень быстро. Да и кто знает, может через месяц ваш стартап выстрелит и там будет х10 пользователей.

Про NATS есть статьи на хабре в том числе.

Есть готовые библиотеки как для php, так и для Go (и множества других языков)
Т.е. получилась связка PHP -> NATS -> GO -> Amazon SES

При помощи docker-compose устанавливаем NATS:

version: "2"

services:
 nats:
  container_name: nats-mq
  image: nats
  networks:
    - "back"
  ports:
    - "127.0.0.1:4222:4222"
    - "127.0.0.1:6222:6222"
    - "127.0.0.1:8222:8222"
  entrypoint: "/gnatsd -c gnatsd.conf --auth SECRET --debug"
  restart: always

networks:
  back:
    driver: "bridge"

Добавляем в composer.json пакет для работы с ним:

...
"repejota/nats": "^0.8.6"
...


Вот пример функции для отправки почты:
public function email($emails, $body, $subject)
{
    if (empty($emails)) {
        return false;
    }

    $prefix = getenv('NATS_PREFIX');
    $encoder = new JSONEncoder();
    $options = new ConnectionOptions([
        'token' => getenv('NATS_TOKEN')
    ]);
    $client = new EncodedConnection($options, $encoder);

    try {
        $client->connect();
        $client->publish(
            $prefix . 'email',
            [
                'Emails' => $emails,
                'HtmlBody' => $body,
                'TextBody' => strip_tags($body),
                'Subject' => $subject,
                'Sender' => 'info@' . getenv('DOMAIN_NAME')
            ]
        );
    } catch (\Exception $exception) {
        return false;
    }

    return true;
}

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

Это добавляет гибкости. А ведь стартап должен придерживаться этого при разработке.
Еще это удобно для отладки.

Ведь можно добавить некий класс Dummy с функцией:

public function email($emails, $body, $subject)
{
    $time = time();
    file_put_contents(SRC_PATH . '../var/dummy/email' . $time . '.html', $body);
    return true;
}

Общий интерфейс оставлю здесь
interface NotificationInterface
{

    /**
     * Инициализируем нотификатор
     *
     * init storage
     * @void
     */
    public function init();

    /**
     * Синхронные ли уведомления
     *
     * @return bool
     */
    public function isSync();

    /**
     * Отправляет СМС
     *
     * @param $phone
     * @param $message
     * @return bool
     */
    public function sms($phone, $message);

    /**
     * Отправляет письмо
     *
     * @param $email
     * @param $body
     * @param $subject
     * @return bool
     */
    public function email($email, $body, $subject);

}



В Go подписаться на очередь можно примерно так:
opts := nats.Options{
	Url:   ...,
	Token: ...,
}

nc, err := opts.Connect()
if err != nil {
	log.Error("error in nats connection", err)
}

c, err := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
if err != nil {
	log.Error("error in nats encoding", err)
}

defer c.Close()
defer nc.Close()

c.Subscribe("email", func(p *EmailInput) {
	p.sendEmail()
})

объявив перед этим:

type EmailInput struct {
	Emails   []*string `json:"Emails"`
	HtmlBody string    `json:"HtmlBody"`
	TextBody string    `json:"TextBody"`
	Subject  string    `json:"Subject"`
	Sender   string    `json:"Sender"`
}

func (email *EmailInput) sendEmail() {
	...
}

Для отправки, как я писал выше, мы использовали Amazon SES.

Почему?

  • администрировать свой почтовый сервер очень накладно и это отнимет время
  • это готовое решение с хорошим бесплатным лимитом
  • легко настраиваются DKIM + SPF (иначе письма будут улетать в спам, конверсия будет падать)

Мануал по настройке SPF здесь

Еще один очень важный нюанс.

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

Теперь ваши пользователи могут БЫСТРО и ЛЕГКО авторизоваться. Самое время получить с них заветные денежки за билеты на ваше мероприятие.

Биллинг


С точки зрения кода система платежей устроена довольно-таки просто. Есть суперкласс для генерации ссылки, и опять же множество фабричных методов. Они умеют сгенерировать ссылку для оплаты, и обработать колбэк. А также запустить пост-продажные хуки (уведомление по почте + добавление доступа к мероприятию).

Общие интерфейсы для хуков и платежных систем оставляю здесь
interface HookInterface
{

    /**
     * @return string
     */
    public function getName();

    /**
     * @param Transaction $transaction
     * @param array $params
     */
    public function run(Transaction $transaction, array $params);

}

interface SystemInterface
{

    /**
     * @return string
     */
    public function getName();

    /**
     * @param Transaction $transaction
     * @param string|null $redirect
     * @return null
     */
    public function createLink(Transaction $transaction, $redirect = null);

    /**
     * @param Transaction $transaction
     * @return string
     */
    public function createWidget(Transaction $transaction);

    /**
     * @return string
     */
    public function createWidgetAssets();

    /**
     * @param $request
     * @param $em
     * @return PayResponse
     */
    public function handleCallback(Request $request, ObjectManager $em);

}


Продукт наш был ориентирован на Европу, поэтому и цены были в Евро.

С какими трудностями же мы столкнулись:

  1. Можно по пальцам пересчитать платежные шлюзы, которые имеют валютные терминалы. Что это? Это такая штука, которая позволяет принимать оплату именно в ЕВРО, а не рублях. Почему это так важно? Да потому что Европейцы настороженно относятся к Русским, очень много мошенников. Рубли при оплате будут снижать конверсию и добавлять тикеты в саппорт, где вам придется объяснять кто вы и что вы.
  2. Согласование валютного терминала занимает время. Готовьтесь, что это будет от 5 дней и выше. На практике около 10. Потому что вас тщательно проверят, да и в карантин Европейцы работали неохотно. Также вопросы возникнут и со стороны нашего отечественного банка, зачем вам это потребовалось
  3. 3d secure никто не отменял (SMS, которая приходит с кодом подтверждения оплаты). Поэтому если у клиента в Европе банк не поддерживает эту возможность, то оплатить он УВЫ не сможет. На этот случай придется билить клиента ручками на PayPal
  4. Согласование сайта происходит только тогда, когда весь контен�� уже присутствует на нем. Т.е. сайт полностью готов, в том числе все юридический документы типа оферты. Т.к. у нас был не интернет-магазин и в целом нетипичное решение — мы обратились к юристам для составления оферты под наш продукт и последующий её перевод

Так получилось, что мы сначала принимали оплаты в рублях. После согласования платежного шлюза — переключились на евро. Это немного снизило конверсию на старте.

Организация стриминга


Если с авторизацией все просто, да и биллинг можно своими силами прикрутить. Такие задачи часто встречаются у средне-статистического разработчика. То вот со стримингом все не так однозначно, как кажется на первый взгляд. Что же не так?

  1. Мы никогда не имели с ним дело. Нужен был сервер + клиент для преподавателей
  2. Картинку необходимо зеркалить, т.е. делать вертикальное отображение (особенности танцевальных видео)
  3. Мы точно не знали сколько будет участников в лагере. И покупать где-либо (в готовых стриминговых решениях) аккаунт на месяц\год\кол-во трафика мы пока не могли. Это сожрало бы всю прибыль
  4. Нужна была защита iframe от воровства контента хотя бы по рефереру

В качестве решения мы выбрали связку larix broadcastr app -> nginx + rtmp -> cdn -> client

В ходе поиска, приложение Larix Broadcaster для вещания RTMP с мобильных устройств оказалось самым надежным и стабильным. На отдельном маленьком виртуальном сервере мы подняли nginx + rtmp для рестриминга дальше в CDN.

Данная схема является самой дешевой. Намного дешевле, чем пользоваться услугами готовых стриминговых платформ. У вас нет необходимости покупать какой-либо премиум\голд\супер аккаунт на месяц\год. Вы платите только за потраченный трафик. Это было идеальным решением в нашей ситуации.

Там же и разворачивали зеркально картинку.

Конфигурация для рестриминга лежит здесь
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    
    keepalive_timeout  65;
    
    include _root.conf;
    include _stat.conf;
}
include _rtmp.conf;

server {

    listen       80;
    server_name  localhost;
    location / {
        root   html;
        index  index.html index.htm;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}

server {
    listen      8080;

    auth_basic "Restricted Area";
    auth_basic_user_file .htpasswd;

    location / {
        root www;
    }

    location /stat {
        rtmp_stat all;
        rtmp_stat_stylesheet stat.xsl;
    }

    location /stat.xsl {
        root www;
    }

}

rtmp {
    server {

        listen 1935;
        chunk_size 4096;

        application your-custom-hash-here {
            live on;
            record off;

            allow publish all;
            allow play all;

            push rtmp://localhost/cdn;
        }

        application cdn {
            allow publish 127.0.0.1;
            deny publish all;

            live on;
            record off;

            exec ffmpeg -i rtmp://localhost/cdn/$name -vf "hflip" -crf 30 -preset ultrafast -acodec aac -strict experimental -ar 44100 -ac 2 -b:a 128k -vcodec libx264 -x264-params keyint=60:no-scenecut=1 -r 30 -b:v 2000k -f flv rtmp://rtmp.cdnhost.abc;
        }

    }
}


На фронт части мы же использовали простой html5 плеер, сконфигурированный с учетом мобильных устройств и их особенностей:

videojs($('#video')[0], {
    controls: true,
    autoplay: false,
    preload: 'auto',
    poster: '/static/splash-en.jpg',
    language: 'en',
    muted: true,
    html5: {
        hls: {
            overrideNative: !videojs.browser.IS_SAFARI
        }
    }
});

Решение оказалось очень стабильным и удобным.

После мероприятия мы подсчитали процент жалоб на качество — это были 2%. И в основном люди, у которых старые стационарные компьютеры или устаревшее ПО (браузеры).

Заключение


Что же в итоге?

Мы провели 2 онлайн лагеря.

Получили только положительный фидбэк от первых пользователей.

В тяжелое время карантина это было именно то, что нужно людям дома.

Общая выручка составила более 1.000.000 рублей.

Если немножечко поговорить о кастдеве, то ориентируясь на фидбэк первого лагеря, мы доработали систему

  1. Сделали возможность покупки билета на один день (а не только полный билет) — это подняло выручку, т.к. многие не хотели покупать полный билет, а хотели купить лишь мастер-класс одного или двух преподавателей
  2. Сделали простой чат около видео-плеера на socket.io — это добавило интерактива в уроки и позволяло получить обратную связь в реальном времени
  3. Т.к. лагерь международный и многие люди путались в часовых поясах — мы добавили возможность изменять таймзону в расписании и смотреть расписание по своему часовому поясу

Какие выводы можно сделать:

  1. Не обязательно иметь стартовые инвестиции и огромную команду, чтобы проверить идею
  2. Ведите разработку гибко, прислушивайтесь к вашим пользователям. Добавляйте только то, что реально нужно людям
  3. Не пишите сразу огромный монолит с кучей функций, он не нужен для проверки гипотезы. Не копите тех. долг. Умейте находить баланс между «плохим» кодом, и «избыточной» функциональностью. Используйте каждый инструмент по назначению
  4. Используйте опыт своих знакомых. Большинство задач ведь уже решено. Достаточно лишь найти нужные инструменты, провести исследования, объединить их в единую систему

Надеюсь, что данная статья кому-нибудь покажется полезной, а кого-нибудь мотивирует.