feat(admin): единые процентные веса без старого формата (#28)
This commit is contained in:
1 parent
a22519e754
commit
47405b27d8
21 files changed
+326
-46
No files matched your search
@@ -56,6 +56,7 @@ final class Config
|
||||
}
|
||||
public static function validate(\stdClass $config, callable $setExists,?array $catalog=null): array {
|
||||
self::assertWritable($config);
|
||||
PercentWeights::validate($config);
|
||||
if (isset($config->host)) {
|
||||
$host = $config->host;
|
||||
if (!is_string($host) || !preg_match('~^(?:https://)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$~iD',$host) || str_contains($host,'..')) throw new Problem('Адрес сайта должен быть HTTPS-хостом без пути.');
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
|
||||
/** Явная миграция #28: заменяет исключительно числовые токены известных весов. */
|
||||
final class PercentMigration
|
||||
{
|
||||
private const KEYS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus'];
|
||||
|
||||
public static function patch(string $raw): string
|
||||
{
|
||||
$config=Config::decode($raw);
|
||||
$offset=0;
|
||||
$tree=self::node($raw,$offset);
|
||||
$utm=self::property($tree,'utm');
|
||||
if($utm===null||$utm['type']!=='object')throw new Problem('Миграция: отсутствует объект utm.');
|
||||
foreach([['reg','registration'],['banch','chob','bchoice']] as $aliases){
|
||||
$present=array_filter($aliases,static fn($key)=>self::property($utm,$key)!==null);
|
||||
if(count($present)>1)throw new Problem('Миграция: конфликт UTM-псевдонимов.');
|
||||
}
|
||||
// Запрещаем потерю положительных значений при json_decode (underflow).
|
||||
foreach($utm['properties'] as $key=>$setting){
|
||||
if(!in_array((string)$key,self::KEYS,true))continue;
|
||||
$weights=self::property($setting,'weights');
|
||||
if($weights===null||$weights['type']!=='object')throw new Problem('Миграция: отсутствует объект весов '.$key.'.');
|
||||
foreach($weights['properties'] as $variant=>$node){
|
||||
$token=substr($raw,$node['start'],$node['end']-$node['start']);
|
||||
if($node['type']!=='number')throw new Problem('Миграция: вес должен быть числом.');
|
||||
$number=json_decode($token,false,64,JSON_THROW_ON_ERROR);
|
||||
if(!is_finite((float)$number)||((float)$number===0.0&&preg_match('/[1-9]/',explode('e',strtolower($token))[0])))throw new Problem('Миграция: вес вне безопасного числового диапазона.');
|
||||
}
|
||||
}
|
||||
$normalized=PercentWeights::normalizeRelative($config);
|
||||
PercentWeights::validate($normalized);
|
||||
$replacements=[];
|
||||
foreach($utm['properties'] as $key=>$setting){
|
||||
if(!in_array((string)$key,self::KEYS,true))continue;
|
||||
$weights=self::property($setting,'weights');
|
||||
foreach($weights['properties'] as $variant=>$node){
|
||||
$value=$normalized->utm->$key->weights->$variant;
|
||||
$old=$config->utm->$key->weights->$variant;
|
||||
if((float)$old===(float)$value)continue;
|
||||
$text=json_encode($value,JSON_THROW_ON_ERROR);
|
||||
$replacements[]=[$node['start'],$node['end'],$text];
|
||||
}
|
||||
}
|
||||
usort($replacements,static fn($a,$b)=>$b[0]<=>$a[0]);
|
||||
foreach($replacements as [$start,$end,$text])$raw=substr_replace($raw,$text,$start,$end-$start);
|
||||
PercentWeights::validate(Config::decode($raw));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** Грамматика предварительно проверена Config::decode; диапазоны аналогичны ensure-utm-config.php. */
|
||||
private static function node(string $raw,int &$offset): array
|
||||
{
|
||||
$length=strlen($raw);
|
||||
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
|
||||
$start=$offset;$char=$raw[$offset]??'';
|
||||
if($char==='"'){
|
||||
$offset++;
|
||||
while($offset<$length){if($raw[$offset]==='\\'){$offset+=2;continue;}if($raw[$offset++]==='"')break;}
|
||||
return ['start'=>$start,'end'=>$offset,'type'=>'string','value'=>json_decode(substr($raw,$start,$offset-$start),true,64,JSON_THROW_ON_ERROR)];
|
||||
}
|
||||
if($char==='{'||$char==='['){
|
||||
$object=$char==='{';$close=$object?'}':']';$offset++;$properties=[];
|
||||
while(true){
|
||||
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
|
||||
if(($raw[$offset]??'')===$close){$offset++;break;}
|
||||
if($object){
|
||||
$key=self::node($raw,$offset)['value'];
|
||||
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
|
||||
$offset++;
|
||||
if(array_key_exists($key,$properties))throw new Problem('Миграция: повторный JSON-ключ; запись запрещена.');
|
||||
$properties[$key]=self::node($raw,$offset);
|
||||
}else{self::node($raw,$offset);}
|
||||
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
|
||||
if(($raw[$offset]??'')===','){$offset++;continue;}
|
||||
$offset++;break;
|
||||
}
|
||||
return ['start'=>$start,'end'=>$offset,'type'=>$object?'object':'array','properties'=>$properties];
|
||||
}
|
||||
while($offset<$length&&!str_contains(",]} \t\r\n",$raw[$offset]))$offset++;
|
||||
return ['start'=>$start,'end'=>$offset,'type'=>preg_match('/^-?[0-9]/',substr($raw,$start,$offset-$start))?'number':'scalar'];
|
||||
}
|
||||
private static function property(array $node,string $key): ?array {return $node['properties'][$key]??null;}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
|
||||
/** Контракт хранимых процентов. Миграция вызывается только явно, отдельно от чтения и записи. */
|
||||
final class PercentWeights
|
||||
{
|
||||
public const GROUPS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus'];
|
||||
|
||||
private static function groups(\stdClass $config): array
|
||||
{
|
||||
if (!property_exists($config,'utm')) return [];
|
||||
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
|
||||
$groups=[];
|
||||
foreach (get_object_vars($config->utm) as $key=>$group) {
|
||||
if (!in_array($key,self::GROUPS,true)) continue;
|
||||
if (!$group instanceof \stdClass || !property_exists($group,'weights') || !$group->weights instanceof \stdClass) throw new Problem("Веса UTM {$key} должны быть объектом процентов.");
|
||||
$groups[$key]=$group;
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
private static function number(mixed $value,string $key): float
|
||||
{
|
||||
if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value<0) throw new Problem("Вес UTM {$key} должен быть конечным неотрицательным числом процентов.");
|
||||
return (float)$value;
|
||||
}
|
||||
|
||||
/** Допуск покрывает только погрешность двоичного float при переводе в сотые процента. */
|
||||
private static function basisPoints(float $value,string $key): int
|
||||
{
|
||||
if ($value!==round($value,2)) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой.");
|
||||
$scaled=$value*100;$rounded=round($scaled);
|
||||
if (($value>0 && $rounded===0.0) || abs($scaled-$rounded)>PHP_FLOAT_EPSILON*max(1.0,abs($scaled))*8) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой; точные доли должны представляться с шагом 0,01%.");
|
||||
return (int)$rounded;
|
||||
}
|
||||
|
||||
public static function validate(\stdClass $config): void
|
||||
{
|
||||
foreach (self::groups($config) as $key=>$group) {
|
||||
$sum=0;
|
||||
foreach (get_object_vars($group->weights) as $weight) {
|
||||
$value=self::number($weight,$key);
|
||||
if ($value>100) throw new Problem("Вес UTM {$key} должен быть от 0 до 100%.");
|
||||
$sum+=self::basisPoints($value,$key);
|
||||
if ($sum>10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%.");
|
||||
}
|
||||
if ($sum!==0 && $sum!==10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%. Старые относительные веса требуют явной миграции.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Только для явной миграции: округляем каждую долю, остаток не перераспределяем. */
|
||||
public static function normalizeRelative(\stdClass $config): \stdClass
|
||||
{
|
||||
// Клонирование не через JSON: сохраняем типы чисел и проверяем INF/NAN сами.
|
||||
$clone=static function(mixed $value) use (&$clone): mixed {
|
||||
if ($value instanceof \stdClass) {$copy=new \stdClass();foreach (get_object_vars($value) as $key=>$item) $copy->$key=$clone($item);return $copy;}
|
||||
if (is_array($value)) {foreach ($value as $key=>$item) $value[$key]=$clone($item);}
|
||||
return $value;
|
||||
};
|
||||
$copy=$clone($config);
|
||||
foreach (self::groups($copy) as $key=>$group) {
|
||||
$values=[];$max=0.0;
|
||||
foreach (get_object_vars($group->weights) as $variant=>$weight) {$values[$variant]=self::number($weight,$key);$max=max($max,$values[$variant]);}
|
||||
if ($max===0.0) continue;
|
||||
$sum=0.0;foreach ($values as $weight) $sum+=$weight/$max;
|
||||
foreach ($values as $variant=>$weight) {
|
||||
$percent=($weight/$max)/$sum*100;
|
||||
if ($weight>0 && $percent===0.0) throw new Problem("Доля UTM {$key} слишком мала для точного представления с шагом 0,01%.");
|
||||
$points=(int)round($percent*100);
|
||||
$group->weights->{(string)$variant}=$points%100===0?intdiv($points,100):$points/100;
|
||||
}
|
||||
}
|
||||
self::validate($copy);
|
||||
return $copy;
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,16 @@ final class Transport
|
||||
if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.');
|
||||
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY'))$secret=PublicKeyLoader::loadPrivateKey($secret);if(!$sftp->login($t['user'],$secret))throw new Problem('Не удалось войти в SFTP.');return $sftp;
|
||||
}
|
||||
public function test(): array {return $this->call(['op'=>'test']);}
|
||||
public function test(): array {
|
||||
$result=$this->call(['op'=>'test']);
|
||||
$read=$this->readConfig();
|
||||
if (!hash_equals($result['version'],$read['version'])) throw new Conflict();
|
||||
PercentWeights::validate($read['config']);
|
||||
return $result;
|
||||
}
|
||||
public function readConfig(): array {$r=$this->call(['op'=>'read']);return ['config'=>Config::decode($r['content']),'version'=>$r['version'],'content'=>$r['content']];}
|
||||
public function writeConfig(string $version,string $content): array {
|
||||
Config::assertWritable(Config::decode($content));
|
||||
$config=Config::decode($content);Config::assertWritable($config);PercentWeights::validate($config);
|
||||
$before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict();
|
||||
$this->store->backup($this->target['id'],$before['content']);
|
||||
return $this->call(['op'=>'write','version'=>$version,'content'=>$content]);
|
||||
|
||||
Reference in new issue
Block a user