Files
boursecu_server/app/Console/Commands/SettingsRestoreFromBackupCommand.php
T
mohmdsdgandCursor b21b136711 fix(settings): honor last-price switch and overlay only SuperAdmin access flags
Stored DB prices were treated as live quotes, and SuperAdmin overlay overwrote tenant keys like trading hours. Keep overlay to the 14 access flags and hide fallback prices when the panel says so.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 03:01:07 +03:30

373 lines
14 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Setting;
use App\Services\SuperAdminProgramSettingsOverlay;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* بازگرداندن مقادیر تنظیمات can_edit=0 از جدول بکاپ (پیش‌فرض settings_copy1)
* به جدول settings — بعد از این‌که مالکیت این کلیدها از سوپرادمین به همین نصب برگشت.
*
* قواعد سخت:
* 1) value غیرخالی لوکال هرگز عوض نمی‌شود (مگر --force-overwrite).
* 2) پیش‌فرض‌های seed/deploy (مثل limangold.ir) هرگز به‌عنوان مقدار واقعی نوشته نمی‌شوند.
* 3) --fill-from-superadmin دیگر کلیدهای غیر دسترسی را از سوپرادمین نمی‌خواند
* (سوپرادمین فقط ۱۴ دسترسی را مالک است).
*
* کلیدهای گروه «دسترسی مشتری به قابلیت‌ها» و runtime رد می‌شوند.
*/
class SettingsRestoreFromBackupCommand extends Command
{
protected $signature = 'settings:restore-from-backup
{--table=settings_copy1 : جدول بکاپ}
{--dry-run : فقط گزارش، بدون نوشتن}
{--fill-from-superadmin : برای کلید خالی/غایب اول از سوپرادمین پر کن، بعد بکاپ}
{--force-overwrite : خطرناک — value غیرخالی لوکال را هم بازنویسی کن}';
protected $description = 'بازگرداندن مقادیر تنظیمات پیشرفته از جدول بکاپ به settings (بدون دست زدن به value موجود)';
/**
* پیش‌فرض‌های INSERT در deploy_settings_const — اگر فقط همین‌ها در بکاپ/SA باشند،
* نوشته نمی‌شوند (خالی می‌ماند تا مقدار واقعی پروژه از بین نرود).
*
* @var array<string, list<string>>
*/
private const KEY_SEED_DEFAULTS = [
'montzar_base_url' => [
'https://limangold.ir',
'http://limangold.ir',
],
'payping_fin_url' => [
'https://limangold.ir/kernel/admin/fin',
],
'payping_start_url' => [
'https://payping.limangold.ir/payment/start',
],
'payping_verify_url' => [
'https://payping.limangold.ir/payment/verify',
],
];
/** دامنه/مقدارهایی که در هر کلیدی seed محسوب می‌شوند */
private const GLOBAL_SEED_MARKERS = [
'https://limangold.ir',
'http://limangold.ir',
'https://payping.limangold.ir',
'http://payping.limangold.ir',
];
public function handle(): int
{
$table = trim((string) $this->option('table')) ?: 'settings_copy1';
$dryRun = (bool) $this->option('dry-run');
$fillFromSuperAdmin = (bool) $this->option('fill-from-superadmin');
$forceOverwrite = (bool) $this->option('force-overwrite');
if (! Schema::hasTable($table)) {
$this->error("جدول بکاپ «{$table}» وجود ندارد.");
return self::FAILURE;
}
if ($forceOverwrite) {
$this->warn('--force-overwrite روشن است: value غیرخالی لوکال هم ممکن است عوض شود.');
}
$backupRows = $this->readBackup($table);
$superAdminValues = [];
if ($fillFromSuperAdmin) {
$this->warn('--fill-from-superadmin نادیده گرفته شد: سوپرادمین فقط دسترسی‌ها را دارد، نه بقیهٔ تنظیمات.');
}
if ($backupRows === [] && $superAdminValues === []) {
$this->warn('هیچ کلید قابل بازگردانی پیدا نشد.');
return self::SUCCESS;
}
$keys = array_values(array_unique(array_merge(
array_keys($backupRows),
array_keys($superAdminValues)
)));
sort($keys);
$localRows = Setting::query()
->whereIn('key', $keys)
->get()
->keyBy('key');
$hasType = Setting::hasTypeColumn();
$report = [];
$counts = [
'inserted' => 0,
'updated' => 0,
'skipped' => 0,
'kept' => 0,
'seed_ignored' => 0,
];
foreach ($keys as $key) {
$backup = $backupRows[$key] ?? null;
$local = $localRows->get($key);
[$value, $source, $ignoredSeed] = $this->resolveFillValue(
$key,
$backup['value'] ?? null,
$superAdminValues[$key] ?? null,
$fillFromSuperAdmin
);
if ($ignoredSeed) {
$counts['seed_ignored']++;
}
if (! $local) {
// ردیف غایب: فقط اگر مقدار واقعی داریم value می‌گذاریم؛ وگرنه خالی
$counts['inserted']++;
$report[] = [$key, '—', $this->mask($key, $value), 'INSERT '.($source ?: 'empty')];
if (! $dryRun) {
$row = new Setting();
$row->key = $key;
$row->value = $value ?? '';
$row->title_fa = $backup['title_fa'] ?? $key;
$row->description = $backup['description'] ?? '';
$row->is_active = $backup['is_active'] ?? 1;
$row->can_edit = 0;
if ($hasType) {
$row->type = $backup['type'] ?? '';
}
$row->save();
}
continue;
}
$localValue = trim((string) ($local->value ?? ''));
$localIsSeed = $localValue !== '' && $this->isSeedDefault($key, $localValue);
$saReal = isset($superAdminValues[$key]) && ! $this->isSeedDefault($key, $superAdminValues[$key])
? $superAdminValues[$key]
: null;
// value واقعی موجود: دست نخوره
// استثنا: لوکال فقط seed است و SA مقدار واقعی دارد → اصلاح اشتباه قبلی
$allowReplaceSeed = $fillFromSuperAdmin && $localIsSeed && $saReal !== null;
if ($localValue !== '' && ! $forceOverwrite && ! $allowReplaceSeed) {
$counts['kept']++;
continue;
}
if ($allowReplaceSeed) {
$value = $saReal;
$source = 'superadmin';
}
$writeValue = $value !== null && $value !== ''
&& ! $this->isSeedDefault($key, $value)
&& (
$localValue === ''
|| $allowReplaceSeed
|| ($forceOverwrite && $localValue !== (string) $value)
);
// فقط meta — و فقط وقتی value را عوض نمی‌کنیم و لوکال seed/خالی نیست
// برای ردیف با value واقعی، meta هم دست نمی‌زنیم (جلوگیری از side-effect)
$metaChanged = false;
if ($backup && ($localValue === '' || $allowReplaceSeed || $forceOverwrite)) {
$metaChanged = $this->metaDiffers($local, $backup, $hasType);
}
if (! $writeValue && ! $metaChanged) {
$counts['skipped']++;
continue;
}
$counts['updated']++;
$report[] = [
$key,
$this->mask($key, $localValue),
$writeValue ? $this->mask($key, $value) : '(بدون تغییر)',
$writeValue ? 'UPDATE '.$source : 'META',
];
if ($dryRun) {
continue;
}
if ($writeValue) {
$local->value = $value;
}
if ($backup && $metaChanged) {
$local->title_fa = $backup['title_fa'] ?: ($local->title_fa ?: $key);
if (($backup['description'] ?? '') !== '') {
$local->description = $backup['description'];
}
if ($hasType && ($backup['type'] ?? '') !== '') {
$local->type = $backup['type'];
}
}
$local->save();
}
if ($report !== []) {
$this->table(['key', 'مقدار لوکال', 'مقدار جدید', 'اقدام'], $report);
}
$this->info(sprintf(
'%sinsert=%d update=%d kept=%d skip=%d seed_ignored=%d (backup=%s, superadmin=%d)',
$dryRun ? '[dry-run] ' : '',
$counts['inserted'],
$counts['updated'],
$counts['kept'],
$counts['skipped'],
$counts['seed_ignored'],
$table,
count($superAdminValues)
));
$this->comment('kept = value واقعی لوکال دست نخورده | seed_ignored = پیش‌فرض limangold/... از منبع پر کردن حذف شد');
if ($dryRun) {
$this->comment('هیچ چیزی نوشته نشد. برای اجرا همین دستور را بدون --dry-run بزن.');
}
return self::SUCCESS;
}
/**
* @return array{0: ?string, 1: string, 2: bool} [value, source, ignoredSeed]
*/
private function resolveFillValue(
string $key,
?string $backupRaw,
?string $superAdminRaw,
bool $preferSuperAdmin
): array {
$backupWasSeed = $backupRaw !== null && trim($backupRaw) !== '' && $this->isSeedDefault($key, trim($backupRaw));
$saWasSeed = $superAdminRaw !== null && trim($superAdminRaw) !== '' && $this->isSeedDefault($key, trim($superAdminRaw));
$ignoredSeed = $backupWasSeed || $saWasSeed;
$backupValue = $this->nullIfSeed($key, $backupRaw);
$superAdminValue = $this->nullIfSeed($key, $superAdminRaw);
if ($preferSuperAdmin && $superAdminValue !== null && $superAdminValue !== '') {
return [$superAdminValue, 'superadmin', $ignoredSeed];
}
if ($backupValue !== null && $backupValue !== '') {
return [$backupValue, 'backup', $ignoredSeed];
}
return [null, '', $ignoredSeed];
}
private function nullIfSeed(string $key, ?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
if ($value === '') {
return null;
}
if ($this->isSeedDefault($key, $value)) {
return null;
}
return $value;
}
private function isSeedDefault(string $key, string $value): bool
{
$normalized = rtrim(strtolower(trim($value)), '/');
if ($normalized === '') {
return false;
}
foreach (self::KEY_SEED_DEFAULTS[$key] ?? [] as $default) {
if ($normalized === rtrim(strtolower($default), '/')) {
return true;
}
}
foreach (self::GLOBAL_SEED_MARKERS as $marker) {
$m = rtrim(strtolower($marker), '/');
if ($normalized === $m || str_starts_with($normalized, $m.'/')) {
return true;
}
}
return false;
}
/**
* @return array<string, array<string, mixed>>
*/
private function readBackup(string $table): array
{
$hasType = Schema::hasColumn($table, 'type');
$columns = ['key', 'value', 'title_fa', 'description', 'is_active', 'can_edit'];
if ($hasType) {
$columns[] = 'type';
}
$rows = [];
foreach (DB::table($table)->select($columns)->orderBy('key')->get() as $row) {
$key = trim((string) ($row->key ?? ''));
if ($key === '' || ! $this->isRestorable($key)) {
continue;
}
if ((int) ($row->can_edit ?? 0) !== 0) {
continue;
}
$rows[$key] = [
'value' => $row->value === null ? '' : (string) $row->value,
'title_fa' => (string) ($row->title_fa ?? ''),
'description' => (string) ($row->description ?? ''),
'is_active' => (int) ($row->is_active ?? 1),
'type' => $hasType ? (string) ($row->type ?? '') : '',
];
}
return $rows;
}
private function isRestorable(string $key): bool
{
if (SuperAdminProgramSettingsOverlay::isSuperAdminOwned($key)) {
return false;
}
return ! SuperAdminProgramSettingsOverlay::isRuntime($key);
}
/** @param array<string, mixed> $backup */
private function metaDiffers(Setting $local, array $backup, bool $hasType): bool
{
if (($backup['title_fa'] ?? '') !== '' && (string) ($local->title_fa ?? '') !== $backup['title_fa']) {
return true;
}
if (($backup['description'] ?? '') !== '' && (string) ($local->description ?? '') !== $backup['description']) {
return true;
}
return $hasType && ($backup['type'] ?? '') !== '' && (string) ($local->type ?? '') !== $backup['type'];
}
private function mask(string $key, ?string $value): string
{
$value = (string) $value;
if ($value === '') {
return '(خالی)';
}
if (Setting::isSecretOrServerOnlyKey($key)) {
return '***('.mb_strlen($value).' نویسه)';
}
return mb_strlen($value) > 40 ? mb_substr($value, 0, 37).'…' : $value;
}
}