feat: replace chb with consent groups and preserve runtime config (api#317, #17)
deploy-preland / deploy zoomstage.online (push) Successful in 42s

This commit is contained in:
Panfilov Konstantin committed 2026-10-24 04:35:44 +03:00
1 parent 188b78a115
commit 89cc16eb4a
12 files changed
+208 -9

No files matched your search

+26
View File
@@ -129,6 +129,32 @@ jobs:
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
echo "залито файлов: $(wc -l < "$files")"
# Сначала совместимый PHP-код: он понимает старый host-config без chb.
# Затем меняем только согласованные consent-ключи, не заменяя весь config снимком Git.
- name: Migrate consent UTM in preserved host config
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }}
SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }}
run: |
set -eu
umask 077
known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp)
trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
changed=0
php bin/ensure-cons-config.php "$config" || changed=$?
case "$changed" in
0) ;;
10) revision=$(git rev-parse HEAD)
printf '%s\n' "put -p $config $REMOTE_DIR/config.json.upload-$revision" "rename $REMOTE_DIR/config.json.upload-$revision $REMOTE_DIR/config.json" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST" ;;
*) exit "$changed" ;;
esac
- name: Check the page
run: |
set -eu
+1 -1
View File
@@ -31,7 +31,7 @@ Preland всегда формирует полный resolved-набор сво
| `degr` | пресет ухудшения превью «До» | сайт (#42) |
| `registration` | `before` — PIN до оплаты, `after` — после | сайт, страница результата |
| `bchoice` | попап выбора банка (`a` — показать) | сайт, оплата (#44) |
| `chb` | галочки согласия на странице оплаты (`no` — не обязательны) | сайт, оплата |
| `cons` | номер группы согласий на Join; `rnd` выбирает группу по весам, исходно 1 с вероятностью 100%, default — 2 | сайт, оплата |
| `tp` | группа тарифов | сайт, cpanel «Группы тарифов (tp)» |
| `focus` | первоначальная область лупы Join: `center`/`face` | сайт, cpanel после `tp` |
+36
View File
@@ -0,0 +1,36 @@
<?php
/** preland#17: миграция только utm.chb → utm.cons; остальные значения владельца сохраняются. */
declare(strict_types=1);
if ($argc !== 2) {
fwrite(STDERR, "Использование: php bin/ensure-cons-config.php /path/to/config.json\n");
exit(2);
}
try {
$path = $argv[1];
$raw = @file_get_contents($path);
if ($raw === false) throw new RuntimeException('Не удалось прочитать config.json.');
// Object decode сохраняет типы соседних {} и объектов с числовыми ключами.
$config = json_decode($raw, false, 512, JSON_THROW_ON_ERROR);
if (!$config instanceof stdClass || !($config->utm ?? null) instanceof stdClass) throw new RuntimeException('В config.json отсутствует объект utm.');
$hasCons = property_exists($config->utm, 'cons');
$cons = $hasCons ? $config->utm->cons : (object) ['weights' => (object) ['1' => 100, '2' => 0], 'default' => '2'];
if (!$cons instanceof stdClass || !(is_array($cons->weights ?? null) || ($cons->weights ?? null) instanceof stdClass)
|| !(is_string($cons->default ?? null) || is_int($cons->default ?? null))
|| !preg_match('/^[1-9][0-9]*$/D', (string) $cons->default)
|| (float) $cons->default > 2147483647) {
throw new RuntimeException('Существующий utm.cons некорректен; автоматическая перезапись запрещена.');
}
if (!property_exists($config->utm, 'chb') && $hasCons) {
fwrite(STDOUT, "cons уже настроен: config не изменён.\n");
exit(0);
}
unset($config->utm->chb);
$config->utm->cons = $cons;
$encoded = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
if (file_put_contents($path, $encoded, LOCK_EX) === false) throw new RuntimeException('Не удалось записать config.json.');
fwrite(STDOUT, "Мигрированы только utm.chb/cons; остальные поля и существующие настройки cons сохранены.\n");
exit(10);
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . "\n");
exit(2);
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/** Собственный контракт cons Preland; существование группы проверяет основной API. */
declare(strict_types=1);
/** Переходный runtime-конфиг: новый код работает и до точечного обновления host config. */
function prelandNormalizeConsConfig(array $config): array
{
$config['utm'] = is_array($config['utm'] ?? null) ? $config['utm'] : [];
unset($config['utm']['chb']);
if (!is_array($config['utm']['cons'] ?? null)) {
$config['utm']['cons'] = ['weights' => ['1' => 100, '2' => 0], 'default' => '2'];
}
return $config;
}
/** Concrete номера не ограничены random-весами: новые группы создаются в CPanel API. */
function prelandResolveCons(mixed $value, array $settings): string
{
$valid = static fn (mixed $candidate): bool => (is_string($candidate) || is_int($candidate))
&& preg_match('/^[1-9][0-9]{0,9}$/D', (string) $candidate) === 1
&& (float) $candidate <= 2147483647;
$default = $valid($settings['default'] ?? null) ? (string) $settings['default'] : '2';
if ($valid($value)) return (string) $value;
if ($value !== 'rnd') return $default;
$weights = [];
foreach ((array) ($settings['weights'] ?? []) as $number => $weight) {
if ($valid($number) && is_numeric($weight) && is_finite((float) $weight) && (float) $weight > 0) {
$weights[(string) $number] = (float) $weight;
}
}
$total = array_sum($weights);
if ($total <= 0 || !is_finite($total)) return $default;
$point = (mt_rand() / (mt_getrandmax() + 1.0)) * $total;
foreach ($weights as $number => $weight) {
if ($point < $weight) return (string) $number;
$point -= $weight;
}
return (string) array_key_last($weights);
}
/** Legacy chb не должен проходить как неизвестный внешний рекламный параметр. */
function prelandNormalizeConsQuery(array $query, array $settings): array
{
unset($query['chb']);
$query['cons'] = prelandResolveCons($query['cons'] ?? null, $settings);
return $query;
}
+12 -1
View File
@@ -72,6 +72,16 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
const utmData = <?= $utmJson ?>;
const redirectUrlBase = <?= json_encode($redirectUrlBase, JSON_UNESCAPED_UNICODE) ?>;
const redirectUrlUtm = <?= json_encode($redirectUrlUtm, JSON_UNESCAPED_UNICODE) ?>;
// Сохранённая до перехода на cons загрузка не должна вернуть legacy chb.
const normalizeConsentRedirect = query => {
const params = new URLSearchParams(query || '');
params.delete('chb');
const value = params.get('cons');
if (!/^[1-9][0-9]{0,9}$/.test(value || '') || Number(value) > 2147483647) {
params.set('cons', String(utmData.cons || '2'));
}
return params.toString();
};
const createApiUrl = <?= json_encode($createApiUrl, JSON_UNESCAPED_UNICODE) ?>;
const uploadPolicyApiUrl = <?= json_encode($uploadPolicyApiUrl, JSON_UNESCAPED_UNICODE) ?>;
@@ -202,7 +212,8 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
function redirectToTour(record) {
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm;
const redirectQuery = normalizeConsentRedirect(record.meta.redirectUtm);
if (redirectQuery) targetUrl += '&' + redirectQuery;
const shownTheme = window.ZoomzillaTheme.current();
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
+4 -4
View File
@@ -39,12 +39,12 @@
},
"default": "a"
},
"chb": {
"cons": {
"weights": {
"a": 1,
"b": 0
"1": 100,
"2": 0
},
"default": "b"
"default": "2"
},
"tp": {
"weights": {
+4 -1
View File
@@ -4,6 +4,7 @@ require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/components/asset-url.php';
require_once __DIR__ . '/components/site-domain.php';
require_once __DIR__ . '/components/theme-preference.php';
require_once __DIR__ . '/components/cons-utm.php';
$configFile = __DIR__ . '/config.json';
$config = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : [];
@@ -13,6 +14,7 @@ if (json_last_error() !== JSON_ERROR_NONE) {
}
Yii::init($config['default_language'] ?? 'ru');
$config = prelandNormalizeConsConfig($config);
function getWeightedRandom($weights)
{
@@ -40,10 +42,11 @@ function getWeightedRandom($weights)
return (string)end($keys);
}
$get_params = $_GET;
$get_params = prelandNormalizeConsQuery($_GET, $config['utm']['cons']);
if (isset($config['utm']) && is_array($config['utm'])) {
foreach ($get_params as $key => $value) {
if ($key === 'cons') continue;
if (!isset($config['utm'][$key])) {
continue;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/** preland#17: точечный, идемпотентный patch только chb/cons и его defaults/weights. */
declare(strict_types=1);
$script=dirname(__DIR__).'/bin/ensure-cons-config.php';
if(!is_file($script)){fwrite(STDERR,"FAIL: patch cons ещё не реализован.\n");exit(1);}
$fixture=['host'=>'keep.example','requisites'=>['Не менять'],'utm'=>['theme'=>['default'=>'dark','weights'=>['light'=>7]],'chb'=>['default'=>'b','weights'=>['a'=>1,'b'=>0]],'focus'=>['default'=>'face']]];
$path=tempnam(sys_get_temp_dir(),'cons-config-');
try{
file_put_contents($path,json_encode($fixture,JSON_THROW_ON_ERROR));
exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code);
$actual=json_decode(file_get_contents($path),true,512,JSON_THROW_ON_ERROR);
if($code!==10||isset($actual['utm']['chb'])||($actual['utm']['cons']??null)!==['weights'=>['1'=>100,'2'=>0],'default'=>'2'])throw new RuntimeException('Patch должен заменить только согласованный ключ chb на cons.');
$comparison=$actual;unset($comparison['utm']['cons']);$old=$fixture;unset($old['utm']['chb']);
if($comparison!==$old)throw new RuntimeException('Patch изменил соседние поля runtime config.');
$before=file_get_contents($path);exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code);
if($code!==0||file_get_contents($path)!==$before)throw new RuntimeException('Повторный patch должен оставить байты без изменений.');
$custom=$actual;$custom['utm']['cons']=['weights'=>['1'=>20,'3'=>80],'default'=>'3'];
file_put_contents($path,json_encode($custom,JSON_THROW_ON_ERROR));$before=file_get_contents($path);
exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code);
if($code!==0||file_get_contents($path)!==$before)throw new RuntimeException('Следующий deploy не должен сбросить настроенный владельцем cons.');
foreach([[],true,1.5,'2147483648','0']as$invalid){
$broken=$custom;$broken['utm']['cons']['default']=$invalid;
file_put_contents($path,json_encode($broken,JSON_THROW_ON_ERROR));$before=file_get_contents($path);
exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path).' 2>/dev/null',$output,$code);
if($code!==2||file_get_contents($path)!==$before)throw new RuntimeException('Некорректный существующий cons должен остановить deploy без перезаписи config.');
}
$objectFixture='{"host":"keep","extra":{},"map":{"0":"a","1":"b"},"list":[],"utm":{"chb":{"default":"a"},"other":{"weights":{}}}}';
file_put_contents($path,$objectFixture);exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code);
$beforeObject=json_decode($objectFixture,false,512,JSON_THROW_ON_ERROR);$afterObject=json_decode(file_get_contents($path),false,512,JSON_THROW_ON_ERROR);
unset($beforeObject->utm->chb,$afterObject->utm->cons);
if($code!==10||json_encode($beforeObject)!==json_encode($afterObject))throw new RuntimeException('Patch должен сохранять типы соседних пустых и numeric-key JSON объектов.');
echo "PASS: cons config migration preserves other fields and JSON types, idempotent\n";
}finally{@unlink($path);}
+10
View File
@@ -0,0 +1,10 @@
<?php
/** preland#17: consent-конфиг мигрируется после совместимого кода, без замены runtime-снимком. */
declare(strict_types=1);
$root=dirname(__DIR__);$workflow=file_get_contents($root.'/.gitea/workflows/deploy.yml');
$upload=strpos($workflow,'- name: Upload over SFTP');$patch=strpos($workflow,'- name: Migrate consent UTM in preserved host config');
if($upload===false||$patch===false||$patch<$upload)throw new RuntimeException('Consent config patch должен выполняться после совместимого кода.');
$section=substr($workflow,$patch,strpos($workflow,'- name: Check the page',$patch)-$patch);
foreach(['get -p $REMOTE_DIR/config.json','ensure-cons-config.php','config.json.upload-$revision','rename $REMOTE_DIR/config.json.upload-$revision']as$expected){if(!str_contains($section,$expected))throw new RuntimeException('Отсутствует безопасный этап: '.$expected);}
if(str_contains($section,'put -p config.json '))throw new RuntimeException('Снимок Git не должен заменять runtime config.');
echo "PASS: consent config patch follows compatible code and uses atomic targeted replacement\n";
+32
View File
@@ -0,0 +1,32 @@
<?php
/** preland#17: concrete cons независим от random-весов, старый chb не управляет переходом. */
declare(strict_types=1);
$file = dirname(__DIR__) . '/components/cons-utm.php';
if (!is_file($file)) {
fwrite(STDERR, "FAIL: нормализация cons ещё не реализована.\n");
exit(1);
}
require $file;
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) throw new RuntimeException($message);
$checks++;
};
$fixture = ['host'=>'api.example.test','utm'=>['theme'=>['default'=>'dark','weights'=>['dark'=>3]],'chb'=>['default'=>'a','weights'=>['a'=>1]]]];
$normalized = prelandNormalizeConsConfig($fixture);
$expect(!isset($normalized['utm']['chb']), 'Старый chb должен быть удалён из runtime-копии конфига.');
$expect($normalized['host'] === $fixture['host'] && $normalized['utm']['theme'] === $fixture['utm']['theme'], 'Соседние поля должны сохраняться.');
$expect($fixture['utm']['chb']['default'] === 'a', 'Нормализация не должна менять исходный массив.');
$settings = $normalized['utm']['cons'];
$expect(prelandResolveCons(null, $settings) === '2', 'Отсутствующее значение должно получить default2.');
foreach (['1','2','3','99'] as $value) $expect(prelandResolveCons($value, $settings) === $value, 'Concrete cons должен приниматься независимо от весов: '.$value);
foreach (['0','-1','a','1x','',[],['1']] as $value) $expect(prelandResolveCons($value, $settings) === '2', 'Неверное значение должно получить default2.');
for ($i=0; $i<1000; $i++) $expect(prelandResolveCons('rnd', $settings) === '1', 'rnd обязан выбрать только группу1.');
$settings = ['default'=>'2','weights'=>['2'=>0,'1'=>100]];
for ($i=0; $i<1000; $i++) $expect(prelandResolveCons('rnd', $settings) === '1', 'Нулевой вес не должен случайно попасть в выборку.');
$query = prelandNormalizeConsQuery(['chb'=>'a','cons'=>'3','utm_source'=>'ad','subid'=>'42'], $settings);
$expect($query === ['cons'=>'3','utm_source'=>'ad','subid'=>'42'], 'Query должен удалить только chb и нормализовать cons.');
$query = prelandNormalizeConsQuery(['chb'=>['b'],'utm_source'=>'ad'], $settings);
$expect(!isset($query['chb']) && $query['cons'] === '2' && $query['utm_source'] === 'ad', 'Array legacy chb не должен влиять на fallback.');
echo "PASS: cons resolver {$checks} checks\n";
+1 -1
View File
@@ -37,7 +37,7 @@ $expected = [
'degr' => '1',
'registration' => 'after',
'bchoice' => 'a',
'chb' => 'b',
'cons' => '2',
'tp' => $config['utm']['tp']['default'] ?? null,
'focus' => 'face',
'utm_source' => 'stage-ad',
+1 -1
View File
@@ -27,7 +27,7 @@ if (!is_array($config)) {
$failures[] = 'Рабочий config.json должен фиксировать явно согласованный focus=face.';
}
foreach (['indpic', 'theme', 'degr', 'registration', 'bchoice', 'chb', 'tp'] as $key) {
foreach (['indpic', 'theme', 'degr', 'registration', 'bchoice', 'cons', 'tp'] as $key) {
$setting = $config['utm'][$key] ?? null;
if (!is_array($setting) || !is_array($setting['weights'] ?? null) || !is_string($setting['default'] ?? null)) {
$failures[] = "Рабочий config.json должен хранить валидный stage-снимок UTM {$key}.";