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:
@@ -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 همه کلیدها را میبیند.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user