feat(admin): edit domain settings inline and copy saved configurations
deploy-preland / deploy zoomstage.online (push) Successful in 57s

This commit is contained in:
Panfilov Konstantin committed 2026-10-26 03:06:11 +03:00
1 parent 84f70b1c71
commit dd6d221dd7
19 files changed
+402 -409

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Копирует только сохранённые host и canonical UTM; клиент не передаёт значения config. */
final class Copy
{
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
private const ALIASES = ['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];
private const MAPS = ['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
public function __construct(private Store $store) {}
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);
$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];
foreach ($targets as $entry) {
$target=$this->selection($entry);
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 для копирования.');
// Jobs сериализует каждое значение отдельно: JSON-объекты и массивы сохраняют типы.
return (new Jobs($this->store))->create('copy',['source'=>$source,'targets'=>$selected,'changes'=>$changes]);
}
private function selection(mixed $value): array
{
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;
}
private function available(string $id): array
{
$target=$this->store->target($id);
if (empty($target['enabled'])) throw new Problem('Подключение отключено.');
if (empty($target['verified'])) throw new Problem('Сначала проверьте подключение.');
return $target;
}
/** Проверяет неоднозначность всех alias, включая пару chob/bchoice и значения null. */
public static function assertAliases(\stdClass $config): void
{
if (!property_exists($config,'utm')) return;
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
foreach (self::ALIASES as $canonical=>$aliases) {
$found=0;foreach ($aliases as $alias) if (property_exists($config->utm,$alias)) $found++;
if ($found>1) throw new Problem("Конфликт UTM-ключей {$canonical}. Явно устраните неоднозначность.");
}
}
private 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;
foreach (self::ALIASES[$canonical]??[$canonical] as $alias) {
if (isset($config->utm) && property_exists($config->utm,$alias)) {$group=$config->utm->$alias;$present=true;break;}
}
// Отсутствующая группа источника никогда не удаляет группу получателя.
if (!$present) continue;
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights??null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$canonical} источника.");
$number=static function(mixed $value) use($canonical): mixed {
if (!is_string($value) && !is_int($value)) throw new Problem('Вариант UTM источника должен быть номером.');
$digit=array_search((string)$value,self::MAPS[$canonical]??[],true);
return $digit===false?$value:(string)$digit;
};
$weights=new \stdClass();
foreach (get_object_vars($group->weights) as $variant=>$weight) {
$variant=(string)$number($variant);
if (property_exists($weights,$variant)) throw new Problem('Конфликт semantic и numeric вариантов UTM источника.');
$weights->$variant=$weight;
}
$default=$number($group->default);
$projection->utm->$canonical=(object)['default'=>$default,'weights'=>$weights];
// Меняем листья, чтобы сохранить неизвестные свойства группы получателя.
$changes[]=['path'=>['utm',$canonical,'default'],'value'=>$default];
$changes[]=['path'=>['utm',$canonical,'weights'],'value'=>$weights];
}
// Наличие физических наборов и каталог проверяются отдельно на каждом получателе.
Config::validate($projection,static fn(int $number): bool=>true);
return $changes;
}
}
+4 -3
View File
@@ -5,7 +5,7 @@ final class Jobs
{
public function __construct(private Store $store) {}
public function create(string $type,array $payload): array {
if(!in_array($type,['apply','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.');
if(!in_array($type,['apply','copy','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.');
$targets=$payload['targets']??(isset($payload['target'])?[$payload['target']]:[]);if(!$targets||count($targets)>100)throw new Problem('Выберите от 1 до 100 подключений.');
$results=[];$seen=[];foreach($targets as $t){$id=is_array($t)?($t['id']??''):$t;if(!is_string($id)||!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id))throw new Problem('Некорректная цель задания.');if(isset($seen[$id]))continue;$seen[$id]=true;$results[]=['target'=>$id,'status'=>'queued','message'=>'Ожидает выполнения.'];}
if(isset($payload['changes']))foreach($payload['changes'] as &$change){if(array_key_exists('value',$change)){$change['value_json']=json_encode($change['value'],JSON_THROW_ON_ERROR);unset($change['value']);}}unset($change);
@@ -31,14 +31,15 @@ final class Jobs
$target=$this->store->target($result['target']);if(empty($target['enabled']))throw new Problem('Подключение отключено.');
if(empty($target['verified']))throw new Problem('Сначала проверьте подключение.');
$transport=new Transport($this->store,$target);$p=$job['payload'];$type=$job['type'];
if(in_array($type,['apply','restore'],true)){
if(in_array($type,['apply','copy','restore'],true)){
$current=$transport->readConfig();$expected=$p['version']??'';
if($type==='apply')foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??'';
if(in_array($type,['apply','copy'],true))foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??'';
// После обрыва/рестарта сперва сверяем ожидаемый хеш уже подготовленного контента.
$desired=$job['results'][$index]['desired_hash']??null;
if($desired && hash_equals($desired,$current['version'])) {$job['results'][$index]=['target'=>$target['id'],'status'=>'success','message'=>'Сохранённое состояние подтверждено после повторного чтения.','version'=>$current['version']];$save();continue;}
if(!is_string($expected)||!hash_equals($current['version'],$expected))throw new Conflict();
if(isset($p['changes']))foreach($p['changes'] as &$change){if(isset($change['value_json']))$change['value']=json_decode($change['value_json'],false,64,JSON_THROW_ON_ERROR);}unset($change);
if($type==='copy')Copy::assertAliases($current['config']);
$next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]);
$checked=[];$warnings=Config::validate($next,function($number)use($target,&$checked){if(array_key_exists($number,$checked))return $checked[$number];try{(new Sets($this->store))->check($target,$number);return $checked[$number]=true;}catch(\Throwable){return $checked[$number]=false;}},Catalog::read($this->store,$next));
$content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save();