feat реклама за показы: сервис ленты сообщений и защита от повторов одной и той же причины

This commit is contained in:
Дмитрий
2026-07-28 09:12:55 +03:00
parent 9c332cc26e
commit 83753fc049
2 changed files with 134 additions and 0 deletions
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdCampaign;
use App\Models\AdCampaignMessage;
/**
* Единственное место, где создаются сообщения ленты кампании. Здесь же защита от дублей
* и отправка уведомлений чтобы «положить в ленту, но забыть сказать клиенту» стало
* невозможно по построению.
*/
class CampaignMessageService
{
/**
* Слова Яндекса. Дубль по паре «кампания + баннер» не пишем: опрос модерации бежит
* по расписанию и до перемены решения присылает одну и ту же причину при каждом
* обходе иначе за сутки лента превратится в сотню одинаковых строк, а клиент
* получит сотню писем.
*
* Работаем на том же соединении, что и переданная кампания: джоб модерации ходит
* через `pgsql_supplier`, и сообщение обязано лечь тем же каналом, иначе на бою
* RLS без tenant-контекста молча не даст ни прочитать, ни записать.
*/
public function postFromYandex(AdCampaign $campaign, ?int $bannerId, string $body): ?AdCampaignMessage
{
$body = trim($body);
if ($body === '') {
return null;
}
$connection = $campaign->getConnectionName();
$last = AdCampaignMessage::on($connection)
->where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)
->where('author', AdCampaignMessage::AUTHOR_YANDEX)
->where(fn ($q) => $bannerId === null ? $q->whereNull('banner_id') : $q->where('banner_id', $bannerId))
->orderByDesc('id')
->first();
if ($last !== null && $last->body === $body) {
return null;
}
$message = new AdCampaignMessage([
'tenant_id' => (int) $campaign->tenant_id,
'campaign_id' => (int) $campaign->id,
'banner_id' => $bannerId,
'author' => AdCampaignMessage::AUTHOR_YANDEX,
'body' => $body,
]);
$message->setConnection($connection);
$message->save();
return $message;
}
}
@@ -3,8 +3,10 @@
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCampaignMessage;
use App\Models\Tenant;
use App\Services\Advertising\CampaignMessageService;
function messageCampaign(): array
{
@@ -32,3 +34,75 @@ it('текст сообщения хранится целиком, длинне
expect(mb_strlen($msg->refresh()->body))->toBe(mb_strlen($long));
});
it('пояснение Яндекса ложится в ленту', function () {
[$tenant, $campaign] = messageCampaign();
app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не соответствует требованиям');
$messages = AdCampaignMessage::where('campaign_id', $campaign->id)->get();
expect($messages)->toHaveCount(1)
->and($messages->first()->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX)
->and($messages->first()->body)->toBe('Изображение не соответствует требованиям');
});
/**
* Опрос модерации бежит по расписанию. Пока Яндекс не передумал, он присылает ОДНУ И ТУ ЖЕ
* причину при каждом обходе. Без защиты лента за сутки превратится в сотню одинаковых строк,
* и клиенту прилетит сотня писем.
*/
it('тот же текст по тому же баннеру второй раз в ленту не попадает', function () {
[$tenant, $campaign] = messageCampaign();
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true,
'path' => 'ad-banners/a/300x250.jpg',
]);
$service = app(CampaignMessageService::class);
$service->postFromYandex($campaign, $banner->id, 'Одно и то же');
$second = $service->postFromYandex($campaign, $banner->id, 'Одно и то же');
expect($second)->toBeNull()
->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(1);
});
/** Другой размер блока — другая беда, её надо показать отдельно. */
it('тот же текст по ДРУГОМУ баннеру в ленту попадает', function () {
[$tenant, $campaign] = messageCampaign();
$slot = [
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'bytes' => 1000, 'included' => true,
];
$a = AdCampaignBanner::create($slot + ['width' => 300, 'height' => 250, 'path' => 'ad-banners/a/300x250.jpg']);
$b = AdCampaignBanner::create($slot + ['width' => 728, 'height' => 90, 'path' => 'ad-banners/a/728x90.jpg']);
$service = app(CampaignMessageService::class);
$service->postFromYandex($campaign, $a->id, 'Одно и то же');
$service->postFromYandex($campaign, $b->id, 'Одно и то же');
expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
});
/** Яндекс передумал и написал другое — это новость, её показываем. */
it('изменившийся текст по тому же баннеру в ленту попадает', function () {
[$tenant, $campaign] = messageCampaign();
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true,
'path' => 'ad-banners/a/300x250.jpg',
]);
$service = app(CampaignMessageService::class);
$service->postFromYandex($campaign, $banner->id, 'Первая претензия');
$service->postFromYandex($campaign, $banner->id, 'Вторая претензия');
expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
});
it('пустой текст в ленту не кладём', function () {
[, $campaign] = messageCampaign();
expect(app(CampaignMessageService::class)->postFromYandex($campaign, null, ' '))->toBeNull()
->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0);
});