feat(admin): независимые именованные UTM-наборы
This commit is contained in:
1 parent
941a4f01aa
commit
94619432a3
16 files changed
+220
-114
No files matched your search
+12
-17
@@ -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;
|
||||
|
||||
@@ -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);});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user