feat(реклама): пауза/возобновление кампании клиента (Директ suspend/resume)

This commit is contained in:
Дмитрий
2026-07-25 10:26:08 +03:00
parent b19579937e
commit bdc1d09475
3 changed files with 261 additions and 0 deletions
@@ -16,6 +16,7 @@ use App\Services\Advertising\YandexDirectClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
@@ -165,6 +166,71 @@ class AdvertisingCampaignController extends Controller
return response()->json(['status' => $campaign->fresh()->status]);
}
public function pause(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
if (! in_array($campaign->status, [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION], true)) {
return response()->json([
'message' => 'Кампанию нельзя поставить на паузу из текущего состояния.',
], 409);
}
$this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->suspendCampaign($yandexCampaignId));
$campaign->update(['status' => AdCampaign::STATUS_PAUSED]);
return response()->json(['status' => $campaign->fresh()->status]);
}
public function resume(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
if ($campaign->status !== AdCampaign::STATUS_PAUSED) {
return response()->json([
'message' => 'Возобновить можно только кампанию на паузе.',
], 409);
}
$this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->resumeCampaign($yandexCampaignId));
$campaign->update(['status' => AdCampaign::STATUS_RUNNING]);
return response()->json(['status' => $campaign->fresh()->status]);
}
/**
* Вызывает Директ (suspend/resume) под рубильником, если у кампании уже есть
* yandex_campaign_id. Деньги не трогает. Если Директ недоступен логируем и
* всё равно продолжаем менять локальный статус (клиент ждёт паузу/возобновление
* здесь и сейчас, синхронизация с Директом не блокер).
*/
private function callDirect(AdCampaign $campaign, callable $action): void
{
if (config('services.yandex_direct.enabled') !== true || $campaign->yandex_campaign_id === null) {
return;
}
$direct = new YandexDirectClient(
(string) config('services.yandex_direct.base_url'),
(string) config('services.yandex_direct.token'),
);
try {
$action($direct, (int) $campaign->yandex_campaign_id);
} catch (RuntimeException $e) {
Log::warning('advertising.campaign_direct_call_failed', [
'campaign_id' => $campaign->id,
'error' => $e->getMessage(),
]);
}
}
public function storeAd(Request $request, int $id, CreativeValidator $validator): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
+2
View File
@@ -389,6 +389,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group
Route::patch('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@update')->whereNumber('id');
Route::get('/campaigns/{id}/audience-size', 'App\Http\Controllers\Api\AdvertisingCampaignController@audienceSize')->whereNumber('id');
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id');
Route::post('/campaigns/{id}/pause', 'App\Http\Controllers\Api\AdvertisingCampaignController@pause')->whereNumber('id');
Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\AdvertisingCampaignController@resume')->whereNumber('id');
Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id');
Route::post('/campaigns/{id}/ads/{adId}/image', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadAdImage')->whereNumber(['id', 'adId']);
});
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Http;
/**
* Пауза/возобновление кампании клиента (Директ suspend/resume).
*
* Мирроим auth/tenant setup из AdvertisingCampaignEndpointTest.php без RefreshDatabase,
* каждый тест создаёт свой Tenant::factory()->create().
*/
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
it('returns 401 for pause without auth', function () {
auth()->logout();
$this->postJson('/api/advertising/campaigns/1/pause')->assertStatus(401);
});
it('returns 401 for resume without auth', function () {
auth()->logout();
$this->postJson('/api/advertising/campaigns/1/resume')->assertStatus(401);
});
it('pauses a running campaign without calling Direct when disabled', function () {
config(['services.yandex_direct.enabled' => false]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания на паузу',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_RUNNING,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
$response->assertOk()->assertJsonPath('status', 'paused');
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'status' => AdCampaign::STATUS_PAUSED,
]);
});
it('pauses a pending_moderation campaign and calls Direct suspend when enabled', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 555]]]])]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания на модерации',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 555,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
$response->assertOk()->assertJsonPath('status', 'paused');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/json/v5/campaigns')
&& ($request['method'] ?? null) === 'suspend'
&& ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 555;
});
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'status' => AdCampaign::STATUS_PAUSED,
]);
});
it('rejects pausing a draft campaign with 409', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Черновик',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_DRAFT,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
$response->assertStatus(409)
->assertJsonPath('message', 'Кампанию нельзя поставить на паузу из текущего состояния.');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
});
it('returns 404 pausing another tenant campaign', function () {
$tenantB = Tenant::factory()->create();
$campaignB = AdCampaign::create([
'tenant_id' => $tenantB->id,
'name' => 'Чужая кампания',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_RUNNING,
]);
$this->postJson("/api/advertising/campaigns/{$campaignB->id}/pause")->assertStatus(404);
});
it('resumes a paused campaign and calls Direct resume when enabled', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['ResumeResults' => [['Id' => 777]]]])]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания на паузе',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PAUSED,
'yandex_campaign_id' => 777,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$response->assertOk()->assertJsonPath('status', 'running');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/json/v5/campaigns')
&& ($request['method'] ?? null) === 'resume'
&& ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 777;
});
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'status' => AdCampaign::STATUS_RUNNING,
]);
});
it('resumes a paused campaign without calling Direct when disabled', function () {
config(['services.yandex_direct.enabled' => false]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания без Директа',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PAUSED,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$response->assertOk()->assertJsonPath('status', 'running');
});
it('rejects resuming a running campaign with 409', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Уже идёт',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_RUNNING,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$response->assertStatus(409)
->assertJsonPath('message', 'Возобновить можно только кампанию на паузе.');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
});
it('returns 404 resuming another tenant campaign', function () {
$tenantB = Tenant::factory()->create();
$campaignB = AdCampaign::create([
'tenant_id' => $tenantB->id,
'name' => 'Чужая кампания на паузе',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PAUSED,
]);
$this->postJson("/api/advertising/campaigns/{$campaignB->id}/resume")->assertStatus(404);
});