Files
portal/app/tests/Feature/Obzvon/AdWalletMinusRaceTest.php
T
Дмитрий 42ebbe769c feat: обзвон — кошелёк уходит в минус только по разрешению канала
Разговор обзвона, начатый при пустом счёте, теперь списывается целиком, счёт
уходит в минус, и долг остаётся за клиентом — решение владельца Р38, требование
Т61а. Числа из спеки: было 12 рублей, списали 102 — на счету минус 90, а в
летописи одна строка на 102.

Разрешение выдано поимённо, белым списком: только ai_call. У яндекса, СМС и
телеграма поведение не изменилось ни на копейку — списание по-прежнему упирается
в ноль. Доказано A/B на одном дереве: 424, 425 и 330 тестов трёх каналов дали
ровно те же числа и с правкой, и без неё.

Проверка платёжеспособности научилась называть причину остановки: долг по
обзвону и непокрытые заморозки — разные вещи. Сам вердикт isSolvent не изменён
ни байтом: по решению Р56 минус гасит клиенту всю рекламу, и письмо задачи З-1.7
обязано назвать обе остановки сразу. Различать нужно, чтобы назвать, а не чтобы
смягчить приговор.

Попутно, и это касается уже работающих денег рекламы: возврат refund был
единственным из пяти методов, кто не ставил контекст клиента. Замерено запуском,
а не чтением — под боевой ролью без контекста метод падал ModelNotFoundException,
а не молчал. Живым деньгам не грозило: единственный зовущий, PollClientSmsDelivery
Command, ставит контекст сам во внешней транзакции. Теперь ставит и сам метод.

Обрезка нулём для трёх старых каналов сохранена намеренно, но перестала быть
молчаливой: на ней пишется предупреждение ad_wallet.charge_clamped_to_zero. До
сих пор съеденная разница между летописью и остатком пропадала без следа.

Сторожа: tests/Feature/Obzvon/AdWalletMinusTest.php и AdWalletMinusRaceTest.php.
Все показаны красными пятью поломками. Гонка проверена параллельным прогоном
шести и четырёх настоящих процессов, а не рассуждением: без замка по строке
кошелька теряется пять списаний из шести.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:10:37 +03:00

163 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
* З-1.2, проверка 6: два одновременных списания по одному клиенту не задваивают и
* не теряют. 🔴 Гонка проверяется ПАРАЛЛЕЛЬНЫМ ПРОГОНОМ, а не рассуждением.
*
* Здесь НЕТ `DatabaseTransactions`: настоящим процессам нужны настоящие,
* закоммиченные данные — из чужой незавершённой транзакции они не увидят ничего.
* Поэтому за собой убираем руками, в finally.
*
* `pcntl_fork` на Windows нет (см. AuditChainRaceConditionTest), поэтому работников
* запускаем как отдельные процессы `php`, каждый со своим соединением к базе, и
* сводим их к общему мгновению старта: без общего мгновения второй успевал бы
* закончить раньше, чем стартовал первый, и гонки бы не было вовсе.
*/
function z12RabotnikSkript(): string
{
$base = str_replace('\\', '/', base_path());
$skript = <<<PHP
<?php
// argv: 1=база 2=мгновение старта 3=тенант 4=сумма 5=ключ 6=номер звонка
putenv('DB_DATABASE='.\$argv[1]);
\$_ENV['DB_DATABASE'] = \$argv[1];
\$_SERVER['DB_DATABASE'] = \$argv[1];
require '{$base}/vendor/autoload.php';
\$app = require '{$base}/bootstrap/app.php';
\$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
// Общее мгновение старта: все работники ждут одну и ту же отметку времени.
\$ozhidanie = ((float) \$argv[2]) - microtime(true);
if (\$ozhidanie > 0) {
usleep((int) (\$ozhidanie * 1000000));
}
try {
app(App\Services\Advertising\AdWalletService::class)->charge(
(int) \$argv[3], 'ai_call', 'obzvon_call', (int) \$argv[6], \$argv[4], \$argv[5],
);
\$ishod = 'OK';
} catch (Throwable \$e) {
\$ishod = 'УПАЛ: '.get_class(\$e).' '.\$e->getMessage();
}
// Отчитываемся, к КАКОЙ базе сходили: иначе работник мог бы молча уйти в чужую.
echo config('database.connections.pgsql.database').'|'.\$ishod."\\n";
PHP;
$put = sys_get_temp_dir().'/z12_rabotnik_'.getmypid().'.php';
file_put_contents($put, $skript);
return $put;
}
/**
* Запускает работников ОДНОВРЕМЕННО и возвращает их отчёты.
*
* @param list<array{0:string,1:string,2:int}> $zadaniya [сумма, ключ, номер звонка]
* @return list<string>
*/
function z12ParallelnyyProgon(string $skript, int $tenantId, array $zadaniya): array
{
$baza = config('database.connections.pgsql.database');
$start = microtime(true) + 6.0; // запас на раскрутку Laravel в каждом процессе
$php = PHP_BINARY;
$processy = [];
foreach ($zadaniya as $z) {
$cmd = escapeshellarg($php).' '.escapeshellarg($skript).' '
.escapeshellarg((string) $baza).' '.escapeshellarg((string) $start).' '
.escapeshellarg((string) $tenantId).' '.escapeshellarg($z[0]).' '
.escapeshellarg($z[1]).' '.escapeshellarg((string) $z[2]);
$pipes = [];
$p = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
$processy[] = [$p, $pipes];
}
$otchety = [];
foreach ($processy as [$p, $pipes]) {
$out = trim((string) stream_get_contents($pipes[1]));
$err = trim((string) stream_get_contents($pipes[2]));
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($p);
$otchety[] = $out !== '' ? $out : ('ПУСТО, stderr: '.$err);
}
return $otchety;
}
function z12UbratZaSoboy(int $tenantId): void
{
DB::table('ad_wallet_transactions')->where('tenant_id', $tenantId)->delete();
DB::table('ad_wallet_holds')->where('tenant_id', $tenantId)->delete();
DB::table('ad_wallets')->where('tenant_id', $tenantId)->delete();
DB::table('tenants')->where('id', $tenantId)->delete();
}
it('шесть одновременных списаний разными ключами не теряют ни копейки и доводят счёт до минуса', function () {
$tenant = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3.00', 'frozen_rub' => '0.00']);
$skript = z12RabotnikSkript();
try {
$zadaniya = [];
for ($i = 1; $i <= 6; $i++) {
$zadaniya[] = ['1.00', 'z12:race:raznye:'.$i, $i];
}
$otchety = z12ParallelnyyProgon($skript, $tenant->id, $zadaniya);
// Работники обязаны отчитаться ИМЕНЕМ базы: чужая база обесценила бы замер.
$baza = config('database.connections.pgsql.database');
foreach ($otchety as $o) {
expect($o)->toBe($baza.'|OK');
}
// 3 ₽ было, 6 ₽ списали шестью независимыми событиями → ровно минус 3 ₽.
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('-3.00')
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(6);
} finally {
z12UbratZaSoboy($tenant->id);
@unlink($skript);
}
});
it('четыре одновременных списания с ОДНИМ ключом двигают деньги ровно один раз', function () {
$tenant = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '12.00', 'frozen_rub' => '0.00']);
$skript = z12RabotnikSkript();
try {
$zadaniya = [];
for ($i = 1; $i <= 4; $i++) {
$zadaniya[] = ['102.00', 'z12:race:odin-klyuch', 1];
}
$otchety = z12ParallelnyyProgon($skript, $tenant->id, $zadaniya);
$baza = config('database.connections.pgsql.database');
foreach ($otchety as $o) {
expect($o)->toBe($baza.'|OK');
}
// Числа из спеки, но списанные вчетвером разом: минус 90 ₽, а не минус 396 ₽.
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('-90.00')
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
} finally {
z12UbratZaSoboy($tenant->id);
@unlink($skript);
}
});