diff --git a/app/app/Http/Controllers/Api/TgRobotController.php b/app/app/Http/Controllers/Api/TgRobotController.php index 3284efed..1a95024b 100644 --- a/app/app/Http/Controllers/Api/TgRobotController.php +++ b/app/app/Http/Controllers/Api/TgRobotController.php @@ -5,8 +5,11 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\ClientTg\RobotJob; +use App\Services\ClientTg\TelegramAudienceService; use App\Services\ClientTg\TelegramRobotQueue; use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\Response; /** * Служебный канал телеграм-робота кабинета МТС. @@ -41,10 +44,28 @@ class TgRobotController extends Controller ]]); } - /** Заглушка до задачи 7. */ - public function phones(int $jobId): JsonResponse + /** + * Отдать роботу номера кампании — построчно, обычным текстом. + * + * 🔴 ПДн. Отдаём ТОЛЬКО по заданию, которое сейчас в работе, и только по тому номеру + * задания, который робот прислал в адресе. Иначе утёкший токен позволил бы перебором + * номеров вычерпать клиентские базы. Проверка держится на самом запросе, а не на + * внешнем условии «в работе кто-то один». + */ + public function phones(int $jobId, TelegramAudienceService $audience): Response { - abort(501, 'Выдача номеров ещё не сделана (задача 7).'); + $job = RobotJob::where('id', $jobId) + ->where('status', RobotJob::STATUS_TAKEN) + ->first(); + + abort_if($job === null, 404, 'Задание не в работе — номера по нему не выдаются.'); + + $phones = $audience->phonesForCampaign($job->tenant_id, $job->campaign_id); + + return response(implode("\n", $phones), 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store', + ]); } /** Заглушка до задачи 8. */ diff --git a/app/app/Services/ClientTg/TelegramAudienceService.php b/app/app/Services/ClientTg/TelegramAudienceService.php index bea7ef8b..6b4e1546 100644 --- a/app/app/Services/ClientTg/TelegramAudienceService.php +++ b/app/app/Services/ClientTg/TelegramAudienceService.php @@ -42,6 +42,23 @@ final class TelegramAudienceService }); } + /** + * Номера кандидатов кампании — для выдачи роботу. + * + * Отдельный вход рядом с build(): каналу робота нужен готовый список, а не пересчёт + * состава кампании. Tenant-контекст ставит вызывающая сторона. + * + * @return list + */ + public function phonesForCampaign(int $tenantId, int $campaignId): array + { + $campaign = Campaign::where('tenant_id', $tenantId) + ->where('id', $campaignId) + ->firstOrFail(); + + return $this->build($campaign)->phones; + } + /** @return list */ private function fromDeals(Campaign $campaign, int $tenantId): array { diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 538f05f8..a8f1ac0f 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3072,6 +3072,24 @@ parameters: count: 2 path: tests/Feature/ClientTg/TgRobotNextTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$campaign\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/ClientTg/TgRobotPhonesTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/ClientTg/TgRobotPhonesTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/ClientTg/TgRobotPhonesTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound diff --git a/app/tests/Feature/ClientTg/TgRobotPhonesTest.php b/app/tests/Feature/ClientTg/TgRobotPhonesTest.php new file mode 100644 index 00000000..9f1bc100 --- /dev/null +++ b/app/tests/Feature/ClientTg/TgRobotPhonesTest.php @@ -0,0 +1,71 @@ +create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_RUNNING, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'created_by' => 1, + ]); +} + +beforeEach(function () { + config()->set('services.tg_robot.token', 'секрет'); + $this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->campaign = tgPhonesCampaign($this->tenant->id); +}); + +it('отдаёт номера построчно только по заданию в работе', function () { + $job = app(TelegramRobotQueue::class)->enqueue( + $this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, [], + ); + app(TelegramRobotQueue::class)->takeNext(); + + // Номера кладём НАСТОЯЩИЕ, а не подменяем сервис аудитории: он объявлен final и + // подмене не поддаётся, а живые строки заодно проверяют всю связку канал → сервис → база. + CampaignPhone::create([ + 'tenant_id' => $this->tenant->id, 'campaign_id' => $this->campaign->id, + 'phone' => '79000000001', 'expires_at' => null, + ]); + CampaignPhone::create([ + 'tenant_id' => $this->tenant->id, 'campaign_id' => $this->campaign->id, + 'phone' => '79000000002', 'expires_at' => null, + ]); + + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->get("/api/tg-robot/jobs/{$job->id}/phones") + ->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') + ->assertSee('79000000001') + ->assertSee('79000000002'); +}); + +it('не отдаёт номера по заданию, которое не в работе', function () { + $job = app(TelegramRobotQueue::class)->enqueue( + $this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, [], + ); + + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->get("/api/tg-robot/jobs/{$job->id}/phones") + ->assertStatus(404); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index f7f39b0a..f0f29090 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-31T05:10:55.726Z +Last updated: 2026-07-31T05:16:57.726Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 13.36ч | 14477455.7ч | -| 23936 | Code | 4.01ч | NaNч | -| 4 | System | 2.18ч | 12285989.2ч | +| 3544 | MsMpEng | 13.44ч | NaNч | +| 23936 | Code | 4.06ч | 0.0ч | +| 4 | System | 2.19ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.