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>
This commit is contained in:
2026-08-25 03:01:07 +03:30
co-authored by Cursor
parent 8984899042
commit b21b136711
9 changed files with 327 additions and 97 deletions
+86 -22
View File
@@ -25,6 +25,12 @@ class ConfigService
'trading_hours_by_day',
];
/**
* فقط این منابع «آخرین قیمت ذخیره‌شده» هستند؛ باقی price_source (montzar، zaarbox،
* main_product، tala_ir و ...) مظنهٔ زنده حساب می‌شود.
*/
public const STORED_PRICE_SOURCES = ['database_last'];
/**
* حداکثر انحراف مجاز مظنه نسبت به آخرین قیمت معتبر DB.
* پرش اشتباه provider (مثل ۱۶۸M ماندهٔ montzar روی مظنهٔ ~۲۲۰M) را رد می‌کند.
@@ -64,14 +70,15 @@ class ConfigService
}
/**
* مقدار setting؛ اول سوپرادمین، بعد DB لوکال، بعد price_providers.php.
* برای کلیدهای can_edit=0 همیشه از این مسیر (یا ConfigService) بخوانید — نه Setting::where مستقیم.
* مقدار setting: فقط دسترسی‌های سوپرادمین از overlay؛ بقیه از DB لوکال / فایل.
*/
public function get(string $key, $default = null)
{
$fromSa = $this->valueFromSuperAdmin($key, $default);
if ($fromSa !== '__sa_miss__') {
return $fromSa;
if (SuperAdminProgramSettingsOverlay::isSuperAdminOwned($key)) {
$fromSa = $this->valueFromSuperAdmin($key, $default);
if ($fromSa !== '__sa_miss__') {
return $fromSa;
}
}
try {
@@ -784,6 +791,46 @@ class ConfigService
|| (int) ($payload['price'] ?? 0) > 0;
}
/**
* مظنهٔ زنده داریم یا قیمت از آخرین ردیف prices آمده؟
* بدون این تفکیک، fallback دیتابیس مثل مظنهٔ زنده دیده می‌شود و
* «نمایش آخرین قیمت ذخیره‌شده» خاموش هم قیمت نشان می‌دهد.
*
* @param array<string, mixed> $payload
*/
public function payloadHasLiveQuote(array $payload): bool
{
if (! $this->payloadHasDisplayablePrice($payload)) {
return false;
}
$source = strtolower(trim((string) ($payload['price_source'] ?? '')));
if ($source !== '') {
return ! in_array($source, self::STORED_PRICE_SOURCES, true);
}
// بدون برچسب منبع، فقط عددِ مرجع زنده قابل اتکاست
return (int) ($payload['montzar'] ?? 0) > 0
|| (int) ($payload['zaarbox'] ?? 0) > 0
|| (int) ($payload['main_product'] ?? 0) > 0;
}
/**
* قیمت‌ها را صفر می‌کند تا هیچ کلاینتی مقدار مانده را نشان ندهد.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function clearPayloadDisplayPrices(array $payload): array
{
$payload['sell'] = 0;
$payload['buy'] = 0;
$payload['price_last'] = 0;
$payload['price'] = 0;
return $payload;
}
/**
* آخرین قیمت جدول prices را روی payload می‌گذارد — وقتی مظنه زنده نیست و show_last_price روشن است.
*
@@ -807,7 +854,8 @@ class ConfigService
$payload['buy'] = $trade['buy'];
$payload['price_last'] = $trade['priceLast'];
$payload['price'] = (int) $priceValue;
$payload['price_source'] = $payload['price_source'] ?? 'database_last';
// قیمت از DB آمد — برچسب منبع قبلی باید پاک شود وگرنه دفعهٔ بعد «زنده» حساب می‌شود
$payload['price_source'] = 'database_last';
return $payload;
}
@@ -823,29 +871,39 @@ class ConfigService
*/
public function applyHomePriceDisplayState(array $payload, ?Carbon $now = null): array
{
$outside = $this->isOutsideTradingHours($now);
$showWhenClosed = $this->shouldShowHomePriceWhenClosed();
$showLast = $this->shouldShowLastSavedPrice();
if ($outside) {
if ($this->isOutsideTradingHours($now)) {
$payload['closed'] = true;
if ($showWhenClosed) {
$payload = $this->fillPayloadFromLastStoredPrice($payload);
$payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote';
if (! $this->shouldShowHomePriceWhenClosed()) {
$payload = $this->clearPayloadDisplayPrices($payload);
$payload['status'] = 'closed';
return $payload;
}
$payload['status'] = 'closed';
$payload = $this->fillPayloadFromLastStoredPrice($payload);
$payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote';
return $payload;
}
$payload['closed'] = false;
if ($showLast) {
$payload = $this->fillPayloadFromLastStoredPrice($payload);
if ($this->payloadHasLiveQuote($payload)) {
$payload['status'] = 'open';
return $payload;
}
$payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote';
if ($this->shouldShowLastSavedPrice()) {
$payload = $this->fillPayloadFromLastStoredPrice($payload);
$payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote';
return $payload;
}
$payload = $this->clearPayloadDisplayPrices($payload);
$payload['status'] = 'no_quote';
return $payload;
}
@@ -911,16 +969,22 @@ class ConfigService
$patch = $this->clientTradingHoursSettings();
$cache = app(LivePriceCacheService::class);
$payload = $cache->get() ?? $cache->buildFromDatabase();
// کش ممکن است قیمت صفرشدهٔ حالت قبل باشد؛ فقط وقتی مظنهٔ زنده دارد قابل استفاده است
$cached = $cache->get();
$payload = is_array($cached) && $this->payloadHasLiveQuote($cached)
? $cached
: $cache->buildFromDatabase();
$payload = $this->applyHomePriceDisplayState($payload);
$payload = app(ZaarboxPriceService::class)->attachGoldOunceToPayload($payload);
$payload['settings_patch'] = $patch;
// settings_patch وارد کش نمی‌شود؛ رویداد settings مقدار تازه را می‌برد
unset($payload['settings_patch']);
$cache->replaceBody($payload);
$stream = app(PriceStreamService::class);
if ($patch !== []) {
$stream->publish('settings', $patch);
app(PriceStreamService::class)->publish('settings', $patch);
}
app(PriceBroadcastService::class)->broadcastAfterPriceUpdate($payload, true);
+12 -6
View File
@@ -115,6 +115,7 @@ class LivePriceCacheService
'zaarbox' => 0,
'closed' => false,
'status' => 'no_quote',
'price_source' => '',
'time' => now(),
];
} else {
@@ -151,20 +152,25 @@ class LivePriceCacheService
'zaarbox' => $zaarboxSource,
'closed' => false,
'status' => 'open',
'price_source' => $priceSource,
'time' => now(),
];
}
$payload['up'] = $config->resolvedDailyBaseExtremes()['up'] > 0
? [
'sell' => max(0, (int) ceil($config->resolvedDailyBaseExtremes()['down'] - $config->displaySellPriceDiffRial())),
'buy' => (int) ceil($config->resolvedDailyBaseExtremes()['up'] + $config->displayBuyPriceDiffRial()),
]
: ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0];
$payload['last_historical'] = $display->resolveHistoricalBasePrice();
$payload = $config->applyGlobalTradingHoursToPayload($payload);
$extremes = $config->resolvedDailyBaseExtremes();
$payload['up'] = ($payload['status'] ?? 'open') !== 'open'
? ['sell' => 0, 'buy' => 0]
: ($extremes['up'] > 0
? [
'sell' => max(0, (int) ceil($extremes['down'] - $config->displaySellPriceDiffRial())),
'buy' => (int) ceil($extremes['up'] + $config->displayBuyPriceDiffRial()),
]
: ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0]);
return app(ZaarboxPriceService::class)->attachGoldOunceToPayload($payload);
}
}
@@ -6,13 +6,13 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* خواندن تنظیمات پیشرفته از سوپرادمین (منبع حقیقت can_edit=0).
* خواندن ۱۴ دسترسی سوپرادمین (منبع حقیقت can_edit=0 برای قابلیت‌ها).
*
* Public اول (دامنه‌محور، بدون وابستگی به HMAC): GET {origin}/api/public/program-settings?domain=
* Full (با HMAC/Bearer): GET {origin}/api/remote/program-settings
*
* Public برای همین ۱۴ کلید مالکیت سوپرادمین کافی است؛ اگر remote اول باشد و replica عقب بماند،
* سوییچ‌هایی مثل enable_automation روی false کهنه می‌مانند.
* Overlay فقط کلیدهای SUPERADMIN_OWNED_KEYS را نگه می‌دارد؛ پاسخ remote ممکن است
* کلیدهای دیگری هم برگرداند ولی آن‌ها روی tenant اعمال نمی‌شوند.
*/
class SuperAdminProgramSettingsClient
{
@@ -11,21 +11,16 @@ use Illuminate\Support\Facades\Cache;
class SuperAdminProgramSettingsOverlay
{
/**
* آخرین پاسخ سالم سوپرادمین.
*
* کلیدهای can_edit=0 مالکشان سوپرادمین است و کپی‌شان در جدول settings لوکال
* ممکن است ماندهٔ deploy باشد. بدون این کش، یک timeout لحظه‌ای باعث می‌شود
* ConfigService به آن کپی سقوط کند — مثلاً price_provider از zaarbox به montzar
* می‌پرید و مظنهٔ قدیمی montzar_live_price به‌جای قیمت زنده نمایش داده می‌شد.
* آخرین پاسخ سالم سوپرادمین — فقط کلیدهای دسترسی (SUPERADMIN_OWNED_KEYS).
* پاسخ remote ممکن است کلیدهای دیگری هم داشته باشد؛ آن‌ها ذخیره و اعمال نمی‌شوند.
*/
private const LAST_GOOD_CACHE_KEY = 'superadmin:program_settings:last_good';
private const LAST_GOOD_CACHE_MINUTES = 1440;
/**
* کلیدهایی که فقط سوپرادمین ویرایش می‌کند (پنل خانگی 403).
* این لیست فیلتر overlay نیست — برای load از blacklist زیر استفاده می‌شود
* تا مثلاً price_provider از سوپرادمین/کش last-good حذف نشود.
* فقط این ۱۴ دسترسی از سوپرادمین خوانده می‌شود.
* هر کلید دیگری (قیمت، ساعت کاری، برندینگ، …) مال پنل خانگی است.
*/
public const SUPERADMIN_OWNED_KEYS = [
'customer_direct_sms',
@@ -185,32 +180,47 @@ class SuperAdminProgramSettingsOverlay
return null;
}
$map = self::ownedItemsMap($items);
self::$memo = $map;
return $map;
}
/**
* فقط کلیدهای دسترسی سوپرادمین — بقیهٔ پاسخ remote دور ریخته می‌شود.
*
* @param array<int, array<string, mixed>> $items
* @return array<string, array<string, mixed>>
*/
public static function ownedItemsMap(array $items): array
{
$map = [];
foreach ($items as $row) {
if (! is_array($row)) {
continue;
}
$key = trim((string) ($row['key'] ?? ''));
// blacklist: فقط کلیدهای لوکال/runtime را دور بریز — نه whitelist قابلیت‌ها
// (whitelist قبلی price_provider را حذف می‌کرد و با قطعی SA به montzar لوکال می‌پرید)
if ($key === '' || self::isLocalOwned($key) || self::isRuntime($key)) {
if ($key === '' || ! self::isSuperAdminOwned($key)) {
continue;
}
$map[$key] = $row;
}
self::$memo = $map;
return $map;
}
/** @param array<int, array<string, mixed>> $items */
private function rememberLastGood(array $items): void
{
$owned = array_values(self::ownedItemsMap($items));
if ($owned === []) {
return;
}
try {
Cache::put(
self::LAST_GOOD_CACHE_KEY,
$items,
$owned,
now()->addMinutes(self::LAST_GOOD_CACHE_MINUTES)
);
} catch (\Throwable $e) {
@@ -255,7 +265,7 @@ class SuperAdminProgramSettingsOverlay
return null;
}
$key = trim($key);
if ($key === '' || ! array_key_exists($key, $map)) {
if ($key === '' || ! self::isSuperAdminOwned($key) || ! array_key_exists($key, $map)) {
return null;
}
@@ -267,9 +277,14 @@ class SuperAdminProgramSettingsOverlay
return in_array($key, self::RUNTIME_KEYS, true);
}
/**
* کلیدهای صفحهٔ «ساعت کاری» هم مالکشان پنل خانگی است — اگر از overlay خوانده شوند
* مقدار ذخیره‌شده در پنل هیچ‌وقت روی قیمت کاربر اعمال نمی‌شود.
*/
public static function isLocalOwned(string $key): bool
{
return in_array($key, self::LOCAL_OWNED_KEYS, true);
return in_array($key, self::LOCAL_OWNED_KEYS, true)
|| in_array($key, ConfigService::TRADING_HOURS_CLIENT_KEYS, true);
}
public static function isSuperAdminOwned(string $key): bool