feat(admin): независимые именованные UTM-наборы

This commit is contained in:
Panfilov Konstantin committed 2026-10-26 18:38:11 +03:00
1 parent 941a4f01aa
commit 94619432a3
16 files changed
+220 -114

No files matched your search

+12 -17
View File
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace PrelandAdmin;
/** Копирует только сохранённые host и canonical UTM; клиент не передаёт значения config. */
/** Применяет независимый именованный UTM-снимок, сохраняя остальные поля получателя. */
final class Copy
{
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
@@ -13,28 +13,27 @@ final class Copy
public function create(array $data): array
{
if (count($data)!==2 || array_diff(array_keys($data),['source','targets'])) throw new Problem('Передайте только источник и получателей копирования.');
$source=$this->selection($data['source']??null);
if (count($data)!==2 || array_diff(array_keys($data),['preset','targets']) || !is_string($data['preset']??null)) throw new Problem('Передайте только ID набора и получателей копирования.');
$targets=$data['targets']??null;
if (!is_array($targets) || !array_is_list($targets) || !$targets || count($targets)>100) throw new Problem('Выберите от 1 до 100 получателей.');
$selected=[];$seen=[$source['id']=>true];
$selected=[];$seen=[];
foreach ($targets as $entry) {
$target=$this->selection($entry);
if (isset($seen[$target['id']])) throw new Problem('Источник нельзя выбирать получателем; получатели не должны повторяться.');
if (isset($seen[$target['id']])) throw new Problem('Получатели не должны повторяться.');
$seen[$target['id']]=true;$this->available($target['id']);$selected[]=$target;
}
$target=$this->available($source['id']);
$read=(new Transport($this->store,$target))->readConfig();
if (!hash_equals($read['version'],$source['version'])) throw new Problem('Настройки источника изменились. Перечитайте источник перед копированием.',409);
$changes=self::project($read['config']);
if (!$changes) throw new Problem('В источнике нет host или UTM для копирования.');
return $this->store->locked('utm-presets',function()use($data,$selected){
$preset=(new UtmPresets($this->store))->get($data['preset']);
$changes=self::project((object)['utm'=>$preset['utm']]);
if (!$changes) throw new Problem('В наборе нет UTM для копирования.');
// Jobs сериализует каждое значение отдельно: JSON-объекты и массивы сохраняют типы.
return (new Jobs($this->store))->create('copy',['source'=>$source,'targets'=>$selected,'changes'=>$changes]);
return (new Jobs($this->store))->create('copy',['preset'=>['id'=>$preset['id'],'name'=>$preset['name']],'targets'=>$selected,'changes'=>$changes]);
});
}
private function selection(mixed $value): array
{
if (!$value instanceof \stdClass) throw new Problem('Источник и получатель должны быть объектами с ID и версией.');
if (!$value instanceof \stdClass) throw new Problem('Получатель должен быть объектом с ID и версией.');
$value=get_object_vars($value);
if (count($value)!==2 || array_diff(array_keys($value),['id','version']) || !is_string($value['id']??null) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$value['id']) || !is_string($value['version']??null) || !preg_match('/^[a-f0-9]{64}$/D',$value['version'])) throw new Problem('Укажите корректные ID и версию сохранённых настроек.');
return $value;
@@ -59,14 +58,10 @@ final class Copy
}
}
private static function project(\stdClass $config): array
public static function project(\stdClass $config): array
{
self::assertAliases($config);
$projection=new \stdClass();$changes=[];
if (property_exists($config,'host')) {
if (!is_string($config->host)) throw new Problem('Адрес сайта источника должен быть строкой.');
$projection->host=$config->host;$changes[]=['path'=>['host'],'value'=>$config->host];
}
$projection->utm=new \stdClass();
foreach (self::GROUPS as $canonical) {
$group=null;$present=false;
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Независимые снимки UTM: без доменов, подключений, заметок и происхождения. */
final class UtmPresets
{
private const GROUPS=['indpic','theme','degr','reg','banch','tp','cons','focus'];
public function __construct(private Store $store) {}
public function all(): array {
return array_values(array_map(static fn($p)=>['id'=>$p['id'],'name'=>$p['name']],$this->store->read('utm-presets.json')));
}
public function get(string $id): array {
if(!preg_match('/^[a-f0-9]{24}$/D',$id))throw new Problem('Некорректный ID набора.');
$presets=$this->store->read('utm-presets.json');if(!isset($presets[$id]))throw new Problem('Набор удалён или не найден. Обновите список.',404);
$p=$presets[$id];$p['utm']=Config::decode(json_encode($p['utm'],JSON_THROW_ON_ERROR));return $p;
}
public function create(array $data): array {
if(count($data)!==2||array_diff(array_keys($data),['name','utm'])||!is_string($data['name']??null)||!(($data['utm']??null) instanceof \stdClass))throw new Problem('Передайте только название и объект UTM.');
$name=trim($data['name']);if($name===''||!preg_match('/^[^\x00-\x1f\x7f]{1,120}$/uD',$name))throw new Problem('Укажите название от 1 до 120 символов без управляющих знаков.');
$utm=$data['utm'];if(!get_object_vars($utm)||array_diff(array_keys(get_object_vars($utm)),self::GROUPS))throw new Problem('Набор должен содержать только редактируемые группы UTM.');
foreach(get_object_vars($utm) as $group){if(!$group instanceof \stdClass||count(get_object_vars($group))!==2||array_diff(array_keys(get_object_vars($group)),['default','weights']))throw new Problem('В группе разрешены только default и weights, без заметок.');}
// Канонизация использует тот же контракт, что применение на домен.
$changes=Copy::project((object)['utm'=>$utm]);$config=Config::patch((object)[], $changes);Config::validate($config);
return $this->store->locked('utm-presets',function()use($name,$config){
$presets=$this->store->read('utm-presets.json');foreach($presets as $p)if(preg_match('/^'.preg_quote($name,'/').'$/iuD',$p['name']))throw new Problem('Набор с таким названием уже существует. Укажите другое имя.',409);
$id=bin2hex(random_bytes(12));$record=['id'=>$id,'name'=>$name,'utm'=>$config->utm];$presets[$id]=$record;$this->store->write('utm-presets.json',$presets);return $record;
});
}
public function delete(string $id): void {
$this->store->locked('utm-presets',function()use($id){$this->get($id);$presets=$this->store->read('utm-presets.json');unset($presets[$id]);$this->store->write('utm-presets.json',$presets);});
}
}