feat(реклама): Часть 3b-1 — набор баннеров кампании (хранение + генерация)

Часть 3b-1 из 6. Из одной картинки клиента генерируется и сохраняется полный
набор баннеров кампании (15 размеров Яндекса).

- Таблица ad_campaign_banners (RLS tenant_isolation, GRANT crm_app_user SELECT/INSERT/DELETE,
  клиентская — srv_bypass не нужен). Модель AdCampaignBanner.
- CampaignBannerService::generate — прогон исходной картинки по BannerSizes через
  BannerGenerator, файлы на приватный диск local, строки в БД; перегенерация заменяет набор.
- CHANGELOG_schema v8.97 + предупреждение для Части 4 (джоб под crm_supplier_worker
  потребует GRANT + srv_bypass re-run, иначе тихий ноль).

Тесты: 9/9 зелёные (Storage::fake). rls-reviewer: PASS.
Spec: docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md §4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-26 11:35:31 +03:00
parent ec8f189b07
commit 8e91be53c3
6 changed files with 223 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Один сгенерированный баннер набора кампании (точный размер блока Яндекса), файл на
* приватном диске 'local'. Tenant-aware с RLS. Источник: db/schema.sql, ad_campaign_banners.
*/
class AdCampaignBanner extends Model
{
protected $fillable = [
'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'width' => 'integer',
'height' => 'integer',
'bytes' => 'integer',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use Illuminate\Support\Facades\Storage;
/**
* Генерация полного набора баннеров кампании из ОДНОЙ картинки клиента: прогон по всем
* размерам BannerSizes через BannerGenerator, файлы на приватном диске 'local', строки в БД.
* Повторная генерация заменяет прежний набор (файлы + строки).
*/
final class CampaignBannerService
{
private const DISK = 'local';
public function __construct(private readonly BannerGenerator $generator) {}
/** @return int число сгенерированных баннеров (= число размеров). */
public function generate(AdCampaign $campaign, string $sourceBinary): int
{
$disk = Storage::disk(self::DISK);
$old = AdCampaignBanner::where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)->get();
foreach ($old as $banner) {
$disk->delete($banner->path);
$banner->delete();
}
$count = 0;
foreach (BannerSizes::all() as [$w, $h]) {
$jpeg = $this->generator->coverJpeg($sourceBinary, $w, $h);
$path = "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$w}x{$h}.jpg";
$disk->put($path, $jpeg);
AdCampaignBanner::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'width' => $w,
'height' => $h,
'path' => $path,
'bytes' => strlen($jpeg),
]);
$count++;
}
return $count;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_campaign_banners', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
$table->unsignedSmallInteger('width');
$table->unsignedSmallInteger('height');
$table->string('path');
$table->unsignedInteger('bytes');
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
});
DB::statement('ALTER TABLE ad_campaign_banners ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE ad_campaign_banners FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_banners');
DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_banners USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, DELETE ON ad_campaign_banners TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('ad_campaign_banners');
}
};
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Schema;
it('создаёт таблицу ad_campaign_banners с нужными колонками', function () {
expect(Schema::hasTable('ad_campaign_banners'))->toBeTrue();
foreach (['id', 'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes', 'created_at'] as $col) {
expect(Schema::hasColumn('ad_campaign_banners', $col))->toBeTrue("нет колонки {$col}");
}
});
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\Tenant;
use App\Services\Advertising\CampaignBannerService;
use Illuminate\Support\Facades\Storage;
function bannerSrcJpeg(int $w, int $h): string
{
$img = imagecreatetruecolor($w, $h);
imagefill($img, 0, 0, imagecolorallocate($img, 15, 110, 86));
ob_start();
imagejpeg($img, null, 90);
$bin = (string) ob_get_clean();
imagedestroy($img);
return $bin;
}
it('генерирует полный набор из 15 баннеров с файлами на диске', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false,
]);
$count = app(CampaignBannerService::class)->generate($campaign, bannerSrcJpeg(1500, 1500));
expect($count)->toBe(15)
->and(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(15);
$b = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 300)->where('height', 250)->first();
expect($b)->not->toBeNull();
Storage::disk('local')->assertExists($b->path);
$info = getimagesizefromstring((string) Storage::disk('local')->get($b->path));
expect($info[0])->toBe(300)->and($info[1])->toBe(250)->and($b->bytes)->toBeGreaterThan(0);
});
it('перегенерация заменяет прежний набор (снова 15, старые файлы удалены)', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false,
]);
$svc = app(CampaignBannerService::class);
$svc->generate($campaign, bannerSrcJpeg(1500, 1500));
$firstPaths = AdCampaignBanner::where('campaign_id', $campaign->id)->pluck('path')->all();
$svc->generate($campaign, bannerSrcJpeg(1200, 1200));
expect(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(15);
expect(count($firstPaths))->toBe(15);
});
+27
View File
@@ -8,6 +8,33 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
## v8.97 (2026-07-26) — Реклама «за показы», Часть 3b-1 — набор баннеров кампании
Новая таблица `ad_campaign_banners` — сгенерированные из ОДНОЙ картинки клиента баннеры
точных размеров блоков Яндекса (медийная). Колонки: `id`, `tenant_id` (FK tenants, cascade),
`campaign_id` (FK ad_campaigns, cascade), `width`/`height` (`SMALLINT` unsigned), `path` (файл на
приватном диске `local` = `storage/app/private`), `bytes` (`INT` unsigned), `timestamps`; индекс
`(tenant_id, campaign_id)`.
RLS: `ENABLE`+`FORCE ROW LEVEL SECURITY`, политика `tenant_isolation`
(`tenant_id = current_setting('app.current_tenant_id')`) — как у `ad_campaigns`. GRANT
`SELECT, INSERT, DELETE` только `crm_app_user` (клиент). Таблица **клиентская** — служебные роли
(`crm_admin_user`/`crm_supplier_worker`) её не читают, поэтому **`srv_bypass` перезапускать НЕ
нужно** (нет кросс-тенантного доступа служебных ролей). Миграция
`app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php`, прогнана на
`liderra_testing`.
Наполняет `App\Services\Advertising\CampaignBannerService::generate()` — прогон исходной картинки
по `BannerSizes` через `BannerGenerator` (Часть 3a), файлы на диск `local`, строки в БД;
перегенерация заменяет прежний набор. `schema.sql` (снимок) — регенерировать при завершении фичи.
⚠️ **Предупреждение для Части 4 (джоб загрузки баннеров в Яндекс, вердикт rls-reviewer):** Директ-
джобы бегут под `pgsql_supplier` (`crm_supplier_worker`, на кластере НЕ BYPASSRLS — ср. хотфикс v8.94
для `ad_campaigns`). Когда появится джоб, читающий `ad_campaign_banners` под этой ролью, ему
понадобится `GRANT SELECT ON ad_campaign_banners TO crm_supplier_worker` + перезапуск
`db/03_service_bypass_policies.sql` (srv_bypass), иначе «тихий ноль». Здесь (клиентская генерация)
это не нужно.
## v8.96 (2026-07-26) — Реклама «за показы», Часть 1 — денежная модель кампании (CPM)
Перевод рекламного модуля с «за клики» на «за показы» (медийная кампания). Часть 1 из 6