feat(server): overlay admin get_settings from SuperAdmin program-settings

Fetch public program-settings with 3-origin retry and no Laravel cache so
local DB settings stay in sync with the sold version.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 16:10:37 +03:30
co-authored by Cursor
parent 7d405cb83b
commit da9f98b958
5 changed files with 477 additions and 11 deletions
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\PriceChannelBroadcastService;
use App\Services\SuperAdminProgramSettingsOverlay;
use Illuminate\Http\Request;
class SettingsController extends Controller
@@ -19,8 +20,6 @@ class SettingsController extends Controller
*/
private const PANEL_MANAGED_KEYS = [
'tracking_systems',
'tracking_systems_enabled',
'banner_enabled',
];
public function index(Request $request)
@@ -30,6 +29,11 @@ class SettingsController extends Controller
->orderBy('key');
if ($request->boolean('system_only')) {
$overlay = app(SuperAdminProgramSettingsOverlay::class)->items();
if ($overlay) {
return response()->json($this->authorizeSettingsPayload($this->overlayToRows($overlay)));
}
$rows = $query
->where(function ($q) {
$q->where('can_edit', 0)->orWhere('can_edit', false);
@@ -48,26 +52,30 @@ class SettingsController extends Controller
return response()->json($this->authorizeSettingsPayload($query->where('can_edit', 1)->get()));
}
return response()->json($this->authorizeSettingsPayload($query->get()));
return response()->json($this->authorizeSettingsPayload(
$this->mergeOverlayIntoLocal($query->get())
));
}
public function edit(Request $request)
{
$isDeployAdmin = $this->isDeployAdminUser();
$isSystemDeploy = $request->boolean('system_deploy');
$setting = Setting::where('key', $request->key)->first();
$isNew = !$setting;
if ($isNew) {
if ($isSystemDeploy && !$isDeployAdmin) {
return response()->json(['message' => 'Unauthorized'], 403);
if ($request->boolean('system_deploy')) {
return response()->json([
'message' => 'تنظیمات پیشرفته فقط از سوپرادمین اضافه می‌شوند',
], 403);
}
$setting = new Setting();
$setting->key = $request->key;
$setting->can_edit = $isSystemDeploy ? 0 : 1;
} elseif (!$setting->can_edit && !$isDeployAdmin && !$this->isPanelManagedKey((string) $request->key)) {
return response()->json(['message' => 'Forbidden'], 403);
$setting->can_edit = 1;
} elseif (!$setting->can_edit && !$this->isPanelManagedKey((string) $request->key)) {
return response()->json([
'message' => 'این تنظیم از سوپرادمین کنترل می‌شود و در پنل خانگی قابل ویرایش نیست',
], 403);
}
$previousValue = $setting->exists ? (string) ($setting->value ?? '') : null;
@@ -94,7 +102,7 @@ class SettingsController extends Controller
})->afterResponse();
}
if ($isNew && $isSystemDeploy) {
if ($isNew) {
$projectName = Setting::where('key', 'name_fa')->value('value') ?: 'پروژه';
$settingTitle = trim($request->title_fa ?? '') ?: $request->key;
$this->Send(
@@ -135,6 +143,71 @@ class SettingsController extends Controller
return in_array($key, self::PANEL_MANAGED_KEYS, true);
}
private function mergeOverlayIntoLocal($rows)
{
$overlay = app(SuperAdminProgramSettingsOverlay::class)->items();
if (! $overlay) {
return $rows;
}
$byKey = [];
foreach ($rows as $row) {
$byKey[(string) $row->key] = $row;
}
foreach ($overlay as $item) {
$key = trim((string) ($item['key'] ?? ''));
if ($key === '' || SuperAdminProgramSettingsOverlay::isLocalOwned($key)) {
continue;
}
if (isset($byKey[$key])) {
$byKey[$key]->value = $item['value'] ?? $byKey[$key]->value;
if (array_key_exists('title_fa', $item) && $item['title_fa'] !== null && $item['title_fa'] !== '') {
$byKey[$key]->title_fa = $item['title_fa'];
}
if (array_key_exists('description', $item) && $item['description'] !== null) {
$byKey[$key]->description = $item['description'];
}
if (array_key_exists('type', $item) && $item['type'] !== null && $item['type'] !== '') {
$byKey[$key]->type = $item['type'];
}
if (array_key_exists('is_active', $item)) {
$byKey[$key]->is_active = $item['is_active'];
}
} else {
$byKey[$key] = $this->overlayRowToSetting($item);
}
}
return collect(array_values($byKey));
}
/**
* @param array<int, array<string, mixed>> $overlay
*/
private function overlayToRows(array $overlay)
{
return collect($overlay)
->filter(fn ($item) => is_array($item) && trim((string) ($item['key'] ?? '')) !== '')
->map(fn ($item) => $this->overlayRowToSetting($item))
->values();
}
private function overlayRowToSetting(array $item): Setting
{
$setting = new Setting();
$setting->id = isset($item['id']) && is_numeric($item['id']) ? (int) $item['id'] : null;
$setting->key = (string) ($item['key'] ?? '');
$setting->value = $item['value'] ?? '';
$setting->title_fa = $item['title_fa'] ?? '';
$setting->description = $item['description'] ?? '';
$setting->type = $item['type'] ?? '';
$setting->is_active = $item['is_active'] ?? 1;
$setting->can_edit = 0;
return $setting;
}
/**
* اپ کاربر (بدون auth ادمین) هرگز secret/credential نمی‌بیند.
* پنل ادمین با auth:user همه کلیدها را می‌بیند.
+39
View File
@@ -24,6 +24,11 @@ class ConfigService
*/
public function get(string $key, $default = null)
{
$fromSa = $this->valueFromSuperAdmin($key, $default);
if ($fromSa !== '__sa_miss__') {
return $fromSa;
}
try {
$row = Setting::where('key', $key)->first();
@@ -39,6 +44,40 @@ class ConfigService
return $fromFile !== null ? $fromFile : $default;
}
/**
* مقدار سوپرادمین اگر کلید در overlay باشد؛ وگرنه سنتنل miss.
*
* @return mixed
*/
private function valueFromSuperAdmin(string $key, $default)
{
try {
$row = app(SuperAdminProgramSettingsOverlay::class)->row($key);
} catch (\Throwable $e) {
return '__sa_miss__';
}
if ($row === null) {
return '__sa_miss__';
}
$active = $row['is_active'] ?? 1;
if ($active === 0 || $active === false || $active === '0') {
$fromFile = config("price_providers.{$key}");
return $fromFile !== null ? $fromFile : $default;
}
$value = $row['value'] ?? null;
if ($value === null || $value === '') {
$fromFile = config("price_providers.{$key}");
return $fromFile !== null ? $fromFile : $default;
}
return $value;
}
public function getString(string $key, string $default = ''): string
{
$value = $this->get($key, $default);
@@ -0,0 +1,245 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* خواندن تنظیمات پیشرفته از سوپرادمین (منبع حقیقت can_edit=0).
*
* Full (با HMAC/Bearer): GET {origin}/api/remote/program-settings
* Public: GET {origin}/api/public/program-settings?domain=
*/
class SuperAdminProgramSettingsClient
{
public const REMOTE_PATH = '/api/remote/program-settings';
public const PUBLIC_PATH = '/api/public/program-settings';
public const RETRY_ROUNDS = 3;
public function fetchItems(): ?array
{
if ($this->canAuth()) {
$items = $this->fetchRemoteFull();
if ($items !== null) {
return $items;
}
}
return $this->fetchPublic();
}
private function canAuth(): bool
{
return $this->sharedSecret() !== '' || $this->bearerToken() !== '';
}
/**
* @return array<int, array<string, mixed>>|null
*/
private function fetchRemoteFull(): ?array
{
foreach ($this->attemptOrigins() as $origin) {
try {
$response = Http::acceptJson()
->withHeaders(array_merge($this->authHeaders('GET', self::REMOTE_PATH), [
'Cache-Control' => 'no-store',
]))
->timeout(6)
->get($origin.self::REMOTE_PATH);
if (! $response->successful() || ! $this->isJsonResponse($response)) {
Log::warning('SuperAdmin program-settings remote failed', [
'origin' => $origin,
'status' => $response->status(),
]);
continue;
}
$items = $this->extractItems($response->json());
if ($items !== null) {
return $items;
}
} catch (\Throwable $e) {
Log::warning('SuperAdmin program-settings remote exception', [
'origin' => $origin,
'error' => $e->getMessage(),
]);
}
}
return null;
}
/**
* @return array<int, array<string, mixed>>|null
*/
private function fetchPublic(): ?array
{
$domain = $this->domain();
if ($domain === '') {
return null;
}
foreach ($this->attemptOrigins() as $origin) {
try {
$response = Http::acceptJson()
->withHeaders(['Cache-Control' => 'no-store'])
->timeout(6)
->get($origin.self::PUBLIC_PATH, ['domain' => $domain]);
if (! $response->successful() || ! $this->isJsonResponse($response)) {
continue;
}
$items = $this->extractItems($response->json());
if ($items !== null) {
return $items;
}
} catch (\Throwable $e) {
Log::warning('SuperAdmin public program-settings exception', [
'origin' => $origin,
'error' => $e->getMessage(),
]);
}
}
return null;
}
/**
* سه دامنه × سه دور (مستر، بعد اسلیوها).
*
* @return iterable<int, string>
*/
private function attemptOrigins(): iterable
{
$origins = $this->origins();
for ($round = 1; $round <= self::RETRY_ROUNDS; $round++) {
foreach ($origins as $origin) {
yield $origin;
}
}
}
/**
* @return list<string>
*/
private function origins(): array
{
$defaults = [
'https://adm.montzar.ir',
'https://adm.montech.ir',
'https://adm.montest.ir',
];
$extra = [];
$explicit = trim((string) config('services.superadmin.origin', ''));
if ($explicit !== '') {
$extra[] = rtrim($explicit, '/');
}
$sms = trim((string) config('services.superadmin.sms_base_url', ''));
if ($sms !== '') {
$parsed = parse_url($sms);
if (! empty($parsed['scheme']) && ! empty($parsed['host'])) {
$port = isset($parsed['port']) ? ':'.$parsed['port'] : '';
$extra[] = $parsed['scheme'].'://'.$parsed['host'].$port;
}
}
return array_values(array_unique(array_merge($extra, $defaults)));
}
private function isJsonResponse($response): bool
{
$contentType = strtolower((string) $response->header('Content-Type'));
return str_contains($contentType, 'json');
}
/**
* @param mixed $data
* @return array<int, array<string, mixed>>|null
*/
private function extractItems($data): ?array
{
if (! is_array($data)) {
return null;
}
if (array_is_list($data)) {
return $data;
}
foreach (['settings', 'items', 'data'] as $key) {
if (isset($data[$key]) && is_array($data[$key])) {
return $data[$key];
}
}
return null;
}
/**
* @return array<string, string>
*/
private function authHeaders(string $method, string $canonicalPath): array
{
$secret = $this->sharedSecret();
if ($secret !== '') {
$domain = $this->domain();
$timestamp = (string) time();
$payload = $timestamp.'.'.strtoupper($method).'.'.$canonicalPath.'.'.$domain;
$signature = hash_hmac('sha256', $payload, $secret);
return [
'X-Abshode-Domain' => $domain,
'X-Abshode-Timestamp' => $timestamp,
'X-Abshode-Signature' => $signature,
];
}
$token = $this->bearerToken();
if ($token !== '') {
return [
'Authorization' => 'Bearer '.$token,
];
}
return [];
}
private function sharedSecret(): string
{
return trim((string) config('services.superadmin.sms_shared_secret', ''));
}
private function bearerToken(): string
{
return trim((string) config('services.superadmin.remote_token', ''));
}
private function domain(): string
{
$override = trim((string) config('services.superadmin.sms_domain', ''));
if ($override !== '') {
return $this->normalizeHost($override);
}
$fromApp = parse_url((string) config('app.url'), PHP_URL_HOST);
return $this->normalizeHost(is_string($fromApp) ? $fromApp : '');
}
private function normalizeHost(string $value): string
{
$host = strtolower(trim($value));
$host = preg_replace('#^https?://#', '', $host) ?? $host;
$host = explode('/', $host)[0] ?? $host;
$host = explode(':', $host)[0] ?? $host;
return $host;
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Services;
/**
* تنظیمات پیشرفته سوپرادمین برای ConfigService.
* کش پایدار ندارد؛ فقط یک بار در همان درخواست PHP نگه داشته می‌شود.
*/
class SuperAdminProgramSettingsOverlay
{
/** کلیدهایی که هنوز صفحهٔ اختصاصی ادمین خانگی مالک‌شان است */
public const LOCAL_OWNED_KEYS = [
'tracking_systems',
];
/** لاگ/قیمت لحظه‌ای همکار — مال دیتابیس نسخه است نه سوپرادمین */
public const RUNTIME_KEYS = [
'montzar_last_product_log',
'montzar_order_response',
'montzar_live_price',
'montzar_mazaneh',
'montzar_diff',
'montzar_truly_buy_mazaneh',
'montzar_truly_sell_mazaneh',
'montzar_token',
'montzar_closed',
'up_price',
'down_price',
'price_extreme_date',
'do_send_liman',
];
/** @var array<string, array<string, mixed>>|null */
private static ?array $memo = null;
private static bool $loaded = false;
/**
* @return array<string, array<string, mixed>>|null null = سوپرادمین در دسترس نبود
*/
public function map(): ?array
{
if (self::$loaded) {
return self::$memo;
}
self::$loaded = true;
$items = app(SuperAdminProgramSettingsClient::class)->fetchItems();
if ($items === null) {
self::$memo = null;
return null;
}
$map = [];
foreach ($items as $row) {
if (! is_array($row)) {
continue;
}
$key = trim((string) ($row['key'] ?? ''));
if ($key === '' || in_array($key, self::LOCAL_OWNED_KEYS, true) || in_array($key, self::RUNTIME_KEYS, true)) {
continue;
}
$map[$key] = $row;
}
self::$memo = $map;
return $map;
}
/**
* @return array<int, array<string, mixed>>
*/
public function items(): array
{
$map = $this->map();
if ($map === null || $map === []) {
return [];
}
return array_values($map);
}
/**
* @return array<string, mixed>|null
*/
public function row(string $key): ?array
{
$map = $this->map();
if ($map === null) {
return null;
}
$key = trim($key);
if ($key === '' || ! array_key_exists($key, $map)) {
return null;
}
return $map[$key];
}
public static function isLocalOwned(string $key): bool
{
return in_array($key, self::LOCAL_OWNED_KEYS, true);
}
}
+2
View File
@@ -50,6 +50,8 @@ return [
'sms_shared_secret' => env('SUPERADMIN_SMS_SHARED_SECRET'),
'sms_domain' => env('SUPERADMIN_SMS_DOMAIN'),
'remote_token' => env('SUPERADMIN_REMOTE_TOKEN'),
/** Origin سوپرادمین برای /api/public/program-settings و /api/remote/program-settings */
'origin' => env('SUPERADMIN_ORIGIN'),
],
];