diff --git a/app/app/Models/AdCampaignBanner.php b/app/app/Models/AdCampaignBanner.php new file mode 100644 index 00000000..bc538c53 --- /dev/null +++ b/app/app/Models/AdCampaignBanner.php @@ -0,0 +1,36 @@ + 'integer', + 'campaign_id' => 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'bytes' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } +} diff --git a/app/app/Services/Advertising/CampaignBannerService.php b/app/app/Services/Advertising/CampaignBannerService.php new file mode 100644 index 00000000..f2ac0278 --- /dev/null +++ b/app/app/Services/Advertising/CampaignBannerService.php @@ -0,0 +1,53 @@ +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; + } +} diff --git a/app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php b/app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php new file mode 100644 index 00000000..c94ad66a --- /dev/null +++ b/app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/app/tests/Feature/Advertising/AdCampaignBannerMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignBannerMigrationTest.php new file mode 100644 index 00000000..1ab12b08 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignBannerMigrationTest.php @@ -0,0 +1,12 @@ +toBeTrue(); + foreach (['id', 'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes', 'created_at'] as $col) { + expect(Schema::hasColumn('ad_campaign_banners', $col))->toBeTrue("нет колонки {$col}"); + } +}); diff --git a/app/tests/Feature/Advertising/CampaignBannerServiceTest.php b/app/tests/Feature/Advertising/CampaignBannerServiceTest.php new file mode 100644 index 00000000..36cc44f1 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignBannerServiceTest.php @@ -0,0 +1,57 @@ +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); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 9e9ae4ca..84e72104 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -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