309 lines
13 KiB
PHP
309 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Exceptions\Advertising\AudienceTooSmallException;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AdCampaign;
|
|
use App\Models\AdCampaignAd;
|
|
use App\Models\AdWalletTransaction;
|
|
use App\Services\Advertising\CampaignAudienceBuilder;
|
|
use App\Services\Advertising\CampaignLauncher;
|
|
use App\Services\Advertising\CreativeValidator;
|
|
use App\Services\Advertising\YandexDirectClient;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* HTTP API кампаний Директа для клиентского портала (Часть A, Task 12) — тонкий
|
|
* слой над готовыми сервисами CampaignAudienceBuilder / CampaignLauncher /
|
|
* CreativeValidator / YandexDirectClient. Фронт (Часть B2) — отдельная задача.
|
|
*
|
|
* tenant_id всегда из $request->user()->tenant_id; все запросы к ad_* — с явным
|
|
* ->where('tenant_id', ...) поверх RLS (defense-in-depth, в тестах PG superuser
|
|
* BYPASSRLS) — паттерн из AdvertisingWalletController/TenantChargesController.
|
|
*/
|
|
class AdvertisingCampaignController extends Controller
|
|
{
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
$campaigns = AdCampaign::where('tenant_id', $tenantId)
|
|
->orderByDesc('id')
|
|
->get(['id', 'name', 'status', 'weekly_budget_rub', 'audience_days', 'launched_at']);
|
|
|
|
return response()->json(['data' => $campaigns]);
|
|
}
|
|
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
$data = $request->validate([
|
|
'name' => ['required', 'string', 'max:255'],
|
|
// TODO(бизнес): уточнить минимальный недельный бюджет клиента (после ÷1.3
|
|
// у Яндекса — фактический минимум площадки), Р8/В-open — НЕ хардкодить
|
|
// выдуманное число. Пока только "> 0".
|
|
'audience_days' => ['required', 'integer', 'min:1', 'max:90'],
|
|
'use_uploaded_list' => ['boolean'],
|
|
'weekly_budget_rub' => ['required', 'numeric', 'min:1'],
|
|
'daily_budget_rub' => ['nullable', 'numeric', 'min:1'],
|
|
'click_bid_rub' => ['nullable', 'numeric', 'min:1'],
|
|
]);
|
|
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $tenantId,
|
|
'status' => AdCampaign::STATUS_DRAFT,
|
|
'name' => $data['name'],
|
|
'audience_days' => $data['audience_days'],
|
|
'use_uploaded_list' => $data['use_uploaded_list'] ?? false,
|
|
'weekly_budget_rub' => $data['weekly_budget_rub'],
|
|
'daily_budget_rub' => $data['daily_budget_rub'] ?? null,
|
|
'click_bid_rub' => $data['click_bid_rub'] ?? null,
|
|
]);
|
|
|
|
return response()->json($campaign, 201);
|
|
}
|
|
|
|
public function show(Request $request, int $id): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
|
|
$ads = AdCampaignAd::where('tenant_id', $tenantId)->where('campaign_id', $campaign->id)->get();
|
|
|
|
$chargeAmounts = AdWalletTransaction::where('tenant_id', $tenantId)
|
|
->where('type', AdWalletTransaction::TYPE_CHARGE)
|
|
->where('channel', 'yandex')
|
|
->where('related_type', 'campaign')
|
|
->where('related_id', $campaign->id)
|
|
->pluck('amount_rub');
|
|
|
|
$spentRub = '0.00';
|
|
foreach ($chargeAmounts as $amount) {
|
|
$amount = (string) $amount;
|
|
$abs = bccomp($amount, '0', 2) < 0 ? bcmul($amount, '-1', 2) : $amount;
|
|
$spentRub = bcadd($spentRub, $abs, 2);
|
|
}
|
|
|
|
return response()->json([
|
|
'campaign' => $campaign,
|
|
'ads' => $ads,
|
|
'spent_rub' => $spentRub,
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, int $id): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
|
|
|
|
$data = $request->validate([
|
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
|
'audience_days' => ['sometimes', 'required', 'integer', 'min:1', 'max:90'],
|
|
'use_uploaded_list' => ['sometimes', 'boolean'],
|
|
'weekly_budget_rub' => ['sometimes', 'required', 'numeric', 'min:1'],
|
|
'daily_budget_rub' => ['sometimes', 'nullable', 'numeric', 'min:1'],
|
|
'click_bid_rub' => ['sometimes', 'nullable', 'numeric', 'min:1'],
|
|
]);
|
|
|
|
// Правка аудитории/списка (audience_days, use_uploaded_list) применяется на
|
|
// следующий день ночным replace-джобом (Р30) — здесь только сохраняем поле,
|
|
// текущий прогон кампании её не подхватывает. Бюджет — применяется сразу.
|
|
$campaign->update($data);
|
|
|
|
return response()->json($campaign->fresh());
|
|
}
|
|
|
|
public function audienceSize(Request $request, int $id, CampaignAudienceBuilder $builder): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
|
|
|
|
$data = $request->validate([
|
|
'days' => ['nullable', 'integer', 'min:1', 'max:90'],
|
|
]);
|
|
|
|
// Живой счётчик (Р22-Р23): временно проставляем audience_days на инстансе,
|
|
// НЕ сохраняя в БД (нет вызова ->save()).
|
|
$campaign->audience_days = $data['days'] ?? $campaign->audience_days;
|
|
|
|
$size = $builder->size($campaign);
|
|
$enough = $size >= 100;
|
|
|
|
return response()->json([
|
|
'size' => $size,
|
|
'min' => 100,
|
|
'enough' => $enough,
|
|
'hint' => $enough ? null : 'Аудитория меньше 100 — увеличьте число дней или добавьте свой список',
|
|
]);
|
|
}
|
|
|
|
public function launch(Request $request, int $id, CampaignLauncher $launcher): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
|
|
|
|
try {
|
|
$launcher->launch($campaign);
|
|
} catch (AudienceTooSmallException $e) {
|
|
return response()->json([
|
|
'message' => "Аудитория слишком мала: {$e->size}. Нужно минимум 100 — увеличьте дни или добавьте свой список.",
|
|
], 422);
|
|
} catch (RuntimeException $e) {
|
|
return response()->json(['message' => $e->getMessage()], 409);
|
|
}
|
|
|
|
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;
|
|
|
|
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
|
|
|
|
$data = $request->validate([
|
|
'title' => ['required', 'string', 'max:56'],
|
|
'text' => ['required', 'string', 'max:120'],
|
|
'href' => ['required', 'url', 'max:1024'],
|
|
'title2' => ['nullable', 'string', 'max:60'],
|
|
]);
|
|
|
|
$errors = $validator->validateText($data['title'], $data['text'], $data['title2'] ?? null);
|
|
if ($errors !== []) {
|
|
return response()->json(['errors' => $errors], 422);
|
|
}
|
|
|
|
$ad = AdCampaignAd::create([
|
|
'tenant_id' => $tenantId,
|
|
'campaign_id' => $campaign->id,
|
|
'title' => $data['title'],
|
|
'text' => $data['text'],
|
|
'href' => $data['href'],
|
|
'title2' => $data['title2'] ?? null,
|
|
'moderation_status' => 'draft',
|
|
]);
|
|
|
|
return response()->json($ad, 201);
|
|
}
|
|
|
|
public function uploadAdImage(Request $request, int $id, int $adId, CreativeValidator $validator): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->user()->tenant_id;
|
|
|
|
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
|
|
$ad = AdCampaignAd::where('tenant_id', $tenantId)
|
|
->where('campaign_id', $campaign->id)
|
|
->where('id', $adId)
|
|
->firstOrFail();
|
|
|
|
$request->validate([
|
|
'file' => ['required', 'image', 'max:10240'],
|
|
]);
|
|
|
|
/** @var UploadedFile $file */
|
|
$file = $request->file('file');
|
|
$path = $file->getRealPath() ?: $file->getPathname();
|
|
|
|
$size = @getimagesize($path);
|
|
$width = (int) ($size[0] ?? 0);
|
|
$height = (int) ($size[1] ?? 0);
|
|
|
|
$errors = $validator->validateImage($width, $height, (string) $file->getMimeType(), (int) $file->getSize());
|
|
if ($errors !== []) {
|
|
return response()->json(['errors' => $errors], 422);
|
|
}
|
|
|
|
if (config('services.yandex_direct.enabled') === false) {
|
|
return response()->json(['message' => 'Яндекс.Директ выключен — картинку пока не загрузить.'], 409);
|
|
}
|
|
|
|
$direct = new YandexDirectClient(
|
|
(string) config('services.yandex_direct.base_url'),
|
|
(string) config('services.yandex_direct.token'),
|
|
);
|
|
|
|
$base64 = base64_encode((string) file_get_contents($path));
|
|
$hash = $direct->uploadAdImage($file->getClientOriginalName(), $base64);
|
|
|
|
$ad->update(['image_normal_hash' => $hash]);
|
|
|
|
return response()->json(['hash' => $hash]);
|
|
}
|
|
}
|