feat: load program-settings from SuperAdmin with 3-host failover

Use /api/public/program-settings only and retry montzar/montech/montest
three rounds before showing the bootstrap error.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 16:10:35 +03:30
co-authored by Cursor
parent 5d3ca82236
commit a9d0cba909
7 changed files with 250 additions and 89 deletions
+41
View File
@@ -0,0 +1,41 @@
import {
fetchSuperAdminJson,
resolveTenantDomain,
SA_PROGRAM_SETTINGS_PATH,
} from '@/config/superadmin'
export type SuperAdminSettingRow = {
id?: number
key?: string
value?: string | number
title_fa?: string
description?: string
is_active?: boolean | number | string
type?: string
can_edit?: number | boolean | string
}
function parseSettingsPayload(data: unknown): SuperAdminSettingRow[] {
if (Array.isArray(data)) return data as SuperAdminSettingRow[]
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>
if (Array.isArray(obj.settings)) return obj.settings as SuperAdminSettingRow[]
if (Array.isArray(obj.items)) return obj.items as SuperAdminSettingRow[]
if (Array.isArray(obj.data)) return obj.data as SuperAdminSettingRow[]
}
return []
}
/** تنظیمات پیشرفته نسخه از سوپرادمین — فقط GET /api/public/program-settings?domain= */
export async function fetchSuperAdminProgramSettings(): Promise<SuperAdminSettingRow[]> {
const domain = encodeURIComponent(resolveTenantDomain())
try {
const data = await fetchSuperAdminJson(
(base) => `${base}${SA_PROGRAM_SETTINGS_PATH}?domain=${domain}`,
)
return parseSettingsPayload(data)
} catch (error) {
console.warn('SuperAdmin program-settings failed after retries:', error)
return []
}
}
+2 -2
View File
@@ -48,13 +48,13 @@ function showBootstrapError(error: unknown) {
<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>
<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در راه‌اندازی برنامه</h2>
<p style="margin: 0 0 20px; color: var(--boot-muted, rgba(15,23,42,0.62)); line-height: 2; font-size: 14px;">
لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.
برقراری ارتباط با سرور امکان‌پذیر نیست. لطفاً اتصال اینترنت خود را بررسی کرده و صفحه را تازه‌سازی نمایید.
</p>
<button
onclick="location.reload()"
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
>
تلاش مجدد
تازه‌سازی صفحه
</button>
</div>
</div>
+38 -25
View File
@@ -1,44 +1,49 @@
import type { AppConfig } from '@/config/types'
import { normalizeAppConfig } from '@/config/app-config'
import { readCachedAppConfig, writeCachedAppConfig } from '@/config/config-cache'
import {
allowSuperAdminFallback,
getSuperAdminBase,
getSuperAdminBases,
isSuperAdminUrl,
resolveTenantDomain,
SA_RETRY_ROUNDS,
} from '@/config/superadmin'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
export const CONFIG_RETRY_ROUNDS = 5
const SUPERADMIN_BASES = [
'https://adm.montzar.ir',
'https://adm.montech.ir',
'https://adm.montest.ir',
] as const
export const CONFIG_RETRY_ROUNDS = SA_RETRY_ROUNDS
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
function resolveTenantDomain() {
const host = String(window.location.hostname || '').trim()
if (!host || host === 'localhost' || host === '127.0.0.1') {
return 'k.montzar.ir'
}
return host
}
export function getConfigUrls() {
const protocol = getProtocol()
const domain = encodeURIComponent(resolveTenantDomain())
const urls = getSuperAdminBases().map((base) => `${base}/get_user?domain=${domain}`)
return [
...SUPERADMIN_BASES.map((base) => `${base}/get_user?domain=${domain}`),
`${protocol}//tablo.talasevin.ir/get_user`,
`${protocol}//abshode-service.montgit.ir/server/get_user`,
`${protocol}//212.23.201.173:8381/get_user`,
`${protocol}//91.212.174.137:4040/server/get_user`,
]
if (allowSuperAdminFallback()) {
urls.push(
`${protocol}//tablo.talasevin.ir/get_user`,
`${protocol}//abshode-service.montgit.ir/server/get_user`,
`${protocol}//212.23.201.173:8381/get_user`,
`${protocol}//91.212.174.137:4040/server/get_user`,
)
}
return urls
}
export function sortConfigUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
if (!lastSuccessUrl || !urls.includes(lastSuccessUrl)) {
if (
!lastSuccessUrl ||
!urls.includes(lastSuccessUrl) ||
!isSuperAdminUrl(lastSuccessUrl)
) {
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
}
return [...urls]
}
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
@@ -91,7 +96,11 @@ export async function fetchFreshAppConfig(rounds = CONFIG_RETRY_ROUNDS): Promise
const timeout = index === urls.length - 1 ? 7000 : 4000
const config = await fetchConfig(url, timeout)
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
if (isSuperAdminUrl(url)) {
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
} else {
clearStaleConfigSuccessUrl(url)
}
writeCachedAppConfig(config)
return config
@@ -102,7 +111,11 @@ export async function fetchFreshAppConfig(rounds = CONFIG_RETRY_ROUNDS): Promise
}
}
throw new Error('Cannot load app config from any server')
throw new Error(
getSuperAdminBase()
? `Cannot load from SuperAdmin (${getSuperAdminBase()}).`
: 'Cannot load app config from any server',
)
}
export async function loadAppConfig(): Promise<AppConfig> {
+45 -41
View File
@@ -1,3 +1,11 @@
import {
allowSuperAdminFallback,
getSuperAdminBases,
isSuperAdminUrl,
resolveTenantDomain,
SA_RETRY_ROUNDS,
} from '@/config/superadmin'
type VersionResponse =
| string
| number
@@ -9,10 +17,9 @@ type VersionResponse =
}
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
export const VERSION_RETRY_ROUNDS = 5
export const VERSION_RETRY_ROUNDS = SA_RETRY_ROUNDS
let versionFetchPromise: Promise<string | null> | null = null
let cachedVersionResult: string | null | undefined
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
@@ -20,31 +27,34 @@ function getProtocol() {
function getVersionUrls() {
const protocol = getProtocol()
const host = String(window.location.hostname || '').trim()
const domain = encodeURIComponent(
!host || host === 'localhost' || host === '127.0.0.1' ? 'k.montzar.ir' : host,
)
const domain = encodeURIComponent(resolveTenantDomain())
const urls = getSuperAdminBases().map((base) => `${base}/get_version?domain=${domain}`)
return [
`https://adm.montzar.ir/get_version?domain=${domain}`,
`https://adm.montech.ir/get_version?domain=${domain}`,
`https://adm.montest.ir/get_version?domain=${domain}`,
'https://tablo.talasevin.ir/get_version',
'https://abshode-service.montgit.ir/server/get_version',
`${protocol}//212.23.201.173:8381/get_version`,
`${protocol}//91.212.174.137:4040/server/get_version`,
]
if (allowSuperAdminFallback()) {
urls.push(
'https://tablo.talasevin.ir/get_version',
'https://abshode-service.montgit.ir/server/get_version',
`${protocol}//212.23.201.173:8381/get_version`,
`${protocol}//91.212.174.137:4040/server/get_version`,
)
}
return urls
}
function sortVersionUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
if (!lastSuccessUrl) {
return urls
if (
!lastSuccessUrl ||
!urls.includes(lastSuccessUrl) ||
!isSuperAdminUrl(lastSuccessUrl)
) {
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
}
return [...urls]
}
const others = urls.filter((url) => url !== lastSuccessUrl)
return [lastSuccessUrl, ...others]
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
}
function clearStaleVersionSuccessUrl(url: string) {
@@ -95,10 +105,10 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
Accept: 'application/json',
},
cache: 'no-store',
signal: controller.signal
signal: controller.signal,
})
if (!response.ok) {
@@ -106,18 +116,22 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
}
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error('Version response is not JSON')
}
const data = contentType.includes('application/json')
? ((await response.json()) as VersionResponse)
: ((await response.text()) as VersionResponse)
const data = (await response.json()) as VersionResponse
const version = normalizeVersionResponse(data)
if (!version) {
throw new Error('Version response is empty')
}
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
if (isSuperAdminUrl(url)) {
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
} else {
clearStaleVersionSuccessUrl(url)
}
return version
} finally {
@@ -149,7 +163,6 @@ async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<str
}
export function resetVersionCheckCache() {
cachedVersionResult = undefined
versionFetchPromise = null
}
@@ -164,22 +177,13 @@ export async function loadRemoteAppVersion(options?: {
resetVersionCheckCache()
}
if (cachedVersionResult !== undefined) {
return cachedVersionResult
}
if (versionFetchPromise) {
return versionFetchPromise
}
versionFetchPromise = fetchVersionWithRetry(rounds)
.then((version) => {
cachedVersionResult = version
return version
})
.finally(() => {
versionFetchPromise = null
})
versionFetchPromise = fetchVersionWithRetry(rounds).finally(() => {
versionFetchPromise = null
})
return versionFetchPromise
}
+83
View File
@@ -0,0 +1,83 @@
/** سوپرادمین — مستر، بعد دو اسلیو */
export const SUPERADMIN_BASES = [
'https://adm.montzar.ir',
'https://adm.montech.ir',
'https://adm.montest.ir',
] as const
/** هر API: سه دامنه × سه دور */
export const SA_RETRY_ROUNDS = 3
/** تنظیمات پیشرفته نسخه — همان دادهٔ /get_settings بدون نیاز به nginx جدا */
export const SA_PROGRAM_SETTINGS_PATH = '/api/public/program-settings'
function normalizeBase(url: string) {
return String(url || '').trim().replace(/\/$/, '')
}
export function getSuperAdminBases(): string[] {
const fromEnv = normalizeBase(String(import.meta.env.VITE_SUPERADMIN_URL || ''))
const bases = [...SUPERADMIN_BASES]
if (fromEnv) {
return [fromEnv, ...bases.filter((base) => base !== fromEnv)]
}
return bases
}
export function getSuperAdminBase(): string {
return getSuperAdminBases()[0] || ''
}
export function resolveTenantDomain(): string {
const override = String(import.meta.env.VITE_TENANT_DOMAIN || '').trim()
if (override) return override
const host = String(window.location.hostname || '').trim()
if (!host || host === 'localhost' || host === '127.0.0.1') {
return 'k.montzar.ir'
}
return host
}
export function isSuperAdminUrl(url: string): boolean {
return getSuperAdminBases().some((base) => url.startsWith(base))
}
export function allowSuperAdminFallback(): boolean {
return Boolean(import.meta.env.DEV)
}
export async function fetchSuperAdminJson<T>(
buildUrl: (base: string) => string,
timeout = 5000,
): Promise<T> {
let lastError: unknown
const bases = getSuperAdminBases()
for (let round = 1; round <= SA_RETRY_ROUNDS; round++) {
for (const base of bases) {
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), timeout)
try {
const res = await fetch(buildUrl(base), {
method: 'GET',
cache: 'no-store',
signal: controller.signal,
headers: { Accept: 'application/json' },
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
throw new Error('SuperAdmin response is not JSON')
}
return (await res.json()) as T
} catch (error) {
lastError = error
} finally {
window.clearTimeout(timer)
}
}
}
throw lastError instanceof Error ? lastError : new Error('SuperAdmin unreachable')
}
+10 -3
View File
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { getSettings } from '@/api/configuration'
import { fetchSuperAdminProgramSettings } from '@/api/superadminSettings'
import { resolveMeltedGoldLabel, resolveMetalTypeLabel } from '@/utils/metalLabels'
import { resolveTradeWeightUnit } from '@/utils/weightUnit'
@@ -65,9 +66,15 @@ export const useSettingsStore = defineStore('settings', () => {
fetchPromise = (async () => {
try {
const data = await getSettings()
if (Array.isArray(data)) {
mergeSettings(data)
const [local, remote] = await Promise.all([
getSettings().catch(() => []),
fetchSuperAdminProgramSettings(),
])
if (Array.isArray(local)) {
mergeSettings(local)
}
if (remote.length) {
mergeSettings(remote as AppSetting[])
}
} catch (error) {
console.log(error)