update at 2026-02-10 14:10:20

This commit is contained in:
douboer
2026-02-10 14:10:20 +08:00
parent b6742cb13a
commit b43155dd0f
9 changed files with 579 additions and 58 deletions

View File

@@ -21,9 +21,10 @@ miniprogram/
│ └── server.js # 远端地址/端口/API 路径统一配置
├── utils/
│ ├── core/ # 纯算法模块
│ └── mp/ # 小程序 API 适配层
│ └── mp/ # 小程序 API 适配层(含 route-manager
├── assets/fonts.json # 字体清单(由脚本生成)
├── assets/default.json # 首次加载默认配置(内容/颜色/字号/默认字体)
├── assets/route-config.json # 手动切换 A/B 服务器配置
├── app.js / app.json / app.wxss
└── project.config.json
```
@@ -47,9 +48,30 @@ miniprogram/
- `apiPrefix`: API 前缀(默认 `/api`
- `fontsManifestPath`: 字体清单路径(默认 `/miniprogram/assets/fonts.json`
- `defaultConfigPath`: 默认配置路径(默认 `/miniprogram/assets/default.json`
- `routeConfigPath`: 路由配置路径(默认 `/miniprogram/assets/route-config.json`
`app.js` 和 API 调用会自动使用该配置生成完整 URL。
## 手动切换 A/B 服务器(无需发版)
远端 `route-config.json`A、B 都部署)示例:
```json
{
"active": "A",
"cooldownMinutes": 10,
"servers": {
"A": { "baseUrl": "https://fonts.biboer.cn" },
"B": { "baseUrl": "https://mac.biboer.cn" }
}
}
```
- 冷启动时先读取当前服务器的 `route-config.json`
- 若发现 `active` 指向另一台服务器,会读取目标服务器配置做“双确认”。
- 仅当目标服务器也返回相同 `active`,并且满足 `cooldownMinutes`,才切换。
- 回前台会按 60 秒节流检查一次API/配置请求失败时会触发一次兜底检查。
## 导出说明
- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。

View File

@@ -1,4 +1,5 @@
const { buildRuntimeConfig } = require('./config/server')
const { bootstrapRoute, checkRouteOnShow } = require('./utils/mp/route-manager')
const runtimeConfig = buildRuntimeConfig()
@@ -8,5 +9,20 @@ App({
apiTimeoutMs: 30000,
fonts: null,
defaultConfig: null,
routeReadyPromise: null,
},
onLaunch() {
this.globalData.routeReadyPromise = bootstrapRoute(this)
.catch((error) => {
console.warn('[app] 路由初始化失败,使用当前配置继续运行:', error)
return null
})
},
onShow() {
checkRouteOnShow(this).catch((error) => {
console.warn('[app] 回前台路由检查失败:', error)
})
},
})

View File

@@ -0,0 +1,12 @@
{
"active": "A",
"cooldownMinutes": 10,
"servers": {
"A": {
"baseUrl": "https://fonts.biboer.cn"
},
"B": {
"baseUrl": "https://mac.biboer.cn"
}
}
}

View File

@@ -9,6 +9,7 @@ const SERVER_CONFIG = {
apiPrefix: '/api',
fontsManifestPath: '/miniprogram/assets/fonts.json',
defaultConfigPath: '/miniprogram/assets/default.json',
routeConfigPath: '/miniprogram/assets/route-config.json',
}
function buildOrigin() {
@@ -33,16 +34,29 @@ function normalizePath(path, fallback) {
return value.startsWith('/') ? value : `/${value}`
}
function buildRuntimeConfig() {
const origin = buildOrigin()
function normalizeBaseUrl(baseUrl) {
const value = String(baseUrl || '').trim()
if (!value) {
return buildOrigin()
}
const withProtocol = /^https?:\/\//i.test(value) ? value : `https://${value}`
return withProtocol.replace(/\/+$/, '')
}
function buildRuntimeConfig(options = {}) {
const origin = normalizeBaseUrl(options.baseUrl)
const apiPrefix = normalizePath(SERVER_CONFIG.apiPrefix, '/api').replace(/\/$/, '')
const fontsManifestPath = normalizePath(SERVER_CONFIG.fontsManifestPath, '/miniprogram/assets/fonts.json')
const defaultConfigPath = normalizePath(SERVER_CONFIG.defaultConfigPath, '/miniprogram/assets/default.json')
const routeConfigPath = normalizePath(SERVER_CONFIG.routeConfigPath, '/miniprogram/assets/route-config.json')
return {
activeServerKey: String(options.activeServerKey || '').trim(),
fontsBaseUrl: origin,
fontsManifestUrl: `${origin}${fontsManifestPath}`,
defaultConfigUrl: `${origin}${defaultConfigPath}`,
routeConfigUrl: `${origin}${routeConfigPath}`,
svgRenderApiUrl: `${origin}${apiPrefix}/render-svg`,
pngRenderApiUrl: `${origin}${apiPrefix}/render-png`,
}

View File

@@ -1,4 +1,5 @@
const { request, downloadFile, readFile } = require('./wx-promisify')
const { ensureRouteReady, checkRouteOnFailure } = require('./route-manager')
const localFonts = require('../../assets/fonts')
const localDefaultConfig = require('../../assets/default')
@@ -111,6 +112,7 @@ function buildDefaultConfigUrl(manifestUrl, baseUrl) {
async function loadFontsManifest(options = {}) {
const app = getApp()
await ensureRouteReady(app)
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
@@ -138,7 +140,11 @@ async function loadFontsManifest(options = {}) {
return fonts
} catch (error) {
console.warn('远程字体清单加载失败,回退到本地清单:', error)
const fallbackFonts = normalizeManifest(localFonts, baseUrl)
await checkRouteOnFailure(app).catch((routeError) => {
console.warn('字体清单失败后的路由检查失败:', routeError)
})
const effectiveBaseUrl = app.globalData.fontsBaseUrl || baseUrl
const fallbackFonts = normalizeManifest(localFonts, effectiveBaseUrl)
app.globalData.fonts = fallbackFonts
return fallbackFonts
}
@@ -146,6 +152,7 @@ async function loadFontsManifest(options = {}) {
async function loadDefaultConfig(options = {}) {
const app = getApp()
await ensureRouteReady(app)
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
const defaultConfigUrl = options.defaultConfigUrl ||
@@ -172,6 +179,9 @@ async function loadDefaultConfig(options = {}) {
return remoteConfig
} catch (error) {
console.warn('远程 default.json 加载失败,回退到本地默认配置:', error)
await checkRouteOnFailure(app).catch((routeError) => {
console.warn('default 配置失败后的路由检查失败:', routeError)
})
const fallbackConfig = normalizeDefaultConfig(localDefaultConfig)
app.globalData.defaultConfig = fallbackConfig
return fallbackConfig

View File

@@ -1,4 +1,5 @@
const { request } = require('./wx-promisify')
const { ensureRouteReady, checkRouteOnFailure } = require('./route-manager')
function buildApiUrl() {
const app = getApp()
@@ -47,41 +48,50 @@ function decodeArrayBuffer(buffer) {
async function renderSvgByApi(payload) {
const app = getApp()
await ensureRouteReady(app)
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
? Number(app.globalData.apiTimeoutMs)
: 30000
const response = await request({
url: buildApiUrl(),
method: 'POST',
timeout,
header: {
'content-type': 'application/json',
},
data: {
fontId: payload.fontId,
text: payload.text,
fontSize: payload.fontSize,
fillColor: payload.fillColor,
letterSpacing: payload.letterSpacing,
maxCharsPerLine: payload.maxCharsPerLine,
},
})
try {
const response = await request({
url: buildApiUrl(),
method: 'POST',
timeout,
header: {
'content-type': 'application/json',
},
data: {
fontId: payload.fontId,
text: payload.text,
fontSize: payload.fontSize,
fillColor: payload.fillColor,
letterSpacing: payload.letterSpacing,
maxCharsPerLine: payload.maxCharsPerLine,
},
})
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`渲染服务请求失败,状态码: ${response && response.statusCode}`)
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`渲染服务请求失败,状态码: ${response && response.statusCode}`)
}
const body = response.data || {}
if (!body.ok) {
throw new Error(body.error || '渲染服务返回错误')
}
return normalizeResult(body.data)
} catch (error) {
await checkRouteOnFailure(app).catch((routeError) => {
console.warn('SVG 渲染失败后的路由检查失败:', routeError)
})
throw error
}
const body = response.data || {}
if (!body.ok) {
throw new Error(body.error || '渲染服务返回错误')
}
return normalizeResult(body.data)
}
async function renderPngByApi(payload) {
const app = getApp()
await ensureRouteReady(app)
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
? Number(app.globalData.apiTimeoutMs)
: 30000
@@ -93,39 +103,46 @@ async function renderPngByApi(payload) {
: `${baseApiUrl.replace(/\/$/, '')}/render-png`
)
const response = await request({
url: apiUrl,
method: 'POST',
timeout,
responseType: 'arraybuffer',
header: {
'content-type': 'application/json',
accept: 'image/png',
},
data: {
fontId: payload.fontId,
text: payload.text,
fontSize: payload.fontSize,
fillColor: payload.fillColor,
letterSpacing: payload.letterSpacing,
maxCharsPerLine: payload.maxCharsPerLine,
},
})
try {
const response = await request({
url: apiUrl,
method: 'POST',
timeout,
responseType: 'arraybuffer',
header: {
'content-type': 'application/json',
accept: 'image/png',
},
data: {
fontId: payload.fontId,
text: payload.text,
fontSize: payload.fontSize,
fillColor: payload.fillColor,
letterSpacing: payload.letterSpacing,
maxCharsPerLine: payload.maxCharsPerLine,
},
})
if (!response || response.statusCode !== 200) {
let message = `PNG 渲染服务请求失败,状态码: ${response && response.statusCode}`
const maybeText = decodeArrayBuffer(response && response.data)
if (maybeText && maybeText.includes('error')) {
message = maybeText
if (!response || response.statusCode !== 200) {
let message = `PNG 渲染服务请求失败,状态码: ${response && response.statusCode}`
const maybeText = decodeArrayBuffer(response && response.data)
if (maybeText && maybeText.includes('error')) {
message = maybeText
}
throw new Error(message)
}
throw new Error(message)
}
if (!(response.data instanceof ArrayBuffer)) {
throw new Error('PNG 渲染服务返回格式无效')
}
if (!(response.data instanceof ArrayBuffer)) {
throw new Error('PNG 渲染服务返回格式无效')
}
return response.data
return response.data
} catch (error) {
await checkRouteOnFailure(app).catch((routeError) => {
console.warn('PNG 渲染失败后的路由检查失败:', routeError)
})
throw error
}
}
module.exports = {

View File

@@ -0,0 +1,399 @@
const { buildRuntimeConfig } = require('../../config/server')
const { request } = require('./wx-promisify')
const { loadRouteState, saveRouteState } = require('./storage')
const ROUTE_CHECK_INTERVAL_MS = 60 * 1000
const ROUTE_REQUEST_TIMEOUT_MS = 8000
const FAILURE_CHECK_INTERVAL_MS = 15 * 1000
let inFlightCheckPromise = null
let lastFailureRouteCheckAt = 0
function normalizeServerKey(value) {
const key = String(value || '').trim().toUpperCase()
if (!key) {
return ''
}
return key
}
function normalizeBaseUrl(value) {
const text = String(value || '').trim()
if (!text) {
return ''
}
const withProtocol = /^https?:\/\//i.test(text) ? text : `https://${text}`
return withProtocol.replace(/\/+$/, '')
}
function findServerKeyByBaseUrl(servers, baseUrl) {
const target = normalizeBaseUrl(baseUrl)
if (!target || !servers || typeof servers !== 'object') {
return ''
}
const keys = Object.keys(servers)
for (const key of keys) {
const item = servers[key]
const candidate = normalizeBaseUrl(item && item.baseUrl)
if (candidate && candidate === target) {
return key
}
}
return ''
}
function normalizeRouteConfig(payload) {
if (!payload || typeof payload !== 'object') {
throw new Error('route-config.json 格式错误')
}
const rawServers = payload.servers && typeof payload.servers === 'object' ? payload.servers : {}
const servers = {}
Object.keys(rawServers).forEach((rawKey) => {
const key = normalizeServerKey(rawKey)
const baseUrl = normalizeBaseUrl(rawServers[rawKey] && rawServers[rawKey].baseUrl)
if (!key || !baseUrl) {
return
}
servers[key] = { baseUrl }
})
const keys = Object.keys(servers)
if (!keys.length) {
throw new Error('route-config.json 缺少 servers.baseUrl')
}
let active = normalizeServerKey(payload.active)
if (!active || !servers[active]) {
active = keys[0]
}
const parsedCooldown = Number(payload.cooldownMinutes)
const cooldownMinutes = Number.isFinite(parsedCooldown)
? Math.max(0, Math.round(parsedCooldown))
: 10
return {
active,
cooldownMinutes,
servers,
}
}
function canSwitchByCooldown(lastSwitchAt, cooldownMinutes, now) {
if (!Number.isFinite(cooldownMinutes) || cooldownMinutes <= 0) {
return true
}
const switchAt = Number(lastSwitchAt) || 0
if (!switchAt) {
return true
}
return now - switchAt >= cooldownMinutes * 60 * 1000
}
function getAppInstance(explicitApp) {
if (explicitApp && explicitApp.globalData) {
return explicitApp
}
try {
return getApp()
} catch (error) {
return null
}
}
function applyRuntimeConfig(app, baseUrl, activeServerKey, options = {}) {
if (!app || !app.globalData) {
return null
}
const runtimeConfig = buildRuntimeConfig({
baseUrl,
activeServerKey,
})
const previousBaseUrl = app.globalData.fontsBaseUrl || ''
const switched = previousBaseUrl && previousBaseUrl !== runtimeConfig.fontsBaseUrl
Object.assign(app.globalData, runtimeConfig)
if (switched && options.resetDataCache !== false) {
app.globalData.fonts = null
app.globalData.defaultConfig = null
}
return runtimeConfig
}
async function fetchRouteConfig(baseUrl) {
const runtimeConfig = buildRuntimeConfig({ baseUrl })
const response = await request({
url: runtimeConfig.routeConfigUrl,
method: 'GET',
timeout: ROUTE_REQUEST_TIMEOUT_MS,
})
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`读取 route-config 失败,状态码: ${response && response.statusCode}`)
}
return normalizeRouteConfig(response.data)
}
function resolveCurrentServerKey(routeConfig, state, currentBaseUrl) {
if (!routeConfig || !routeConfig.servers) {
return ''
}
const stateKey = normalizeServerKey(state && state.activeServerKey)
if (stateKey && routeConfig.servers[stateKey]) {
return stateKey
}
const fromBase = findServerKeyByBaseUrl(routeConfig.servers, currentBaseUrl)
if (fromBase) {
return fromBase
}
return normalizeServerKey(routeConfig.active)
}
async function runRouteCheck(explicitApp, options = {}) {
const app = getAppInstance(explicitApp)
if (!app || !app.globalData) {
return {
switched: false,
reason: 'app_not_ready',
}
}
const now = Date.now()
const force = Boolean(options.force)
const state = loadRouteState()
const lastRouteCheckAt = Number(state.lastRouteCheckAt) || 0
if (!force && now - lastRouteCheckAt < ROUTE_CHECK_INTERVAL_MS) {
return {
switched: false,
reason: 'interval_skip',
}
}
const currentBaseUrl = normalizeBaseUrl(app.globalData.fontsBaseUrl)
|| buildRuntimeConfig().fontsBaseUrl
let currentRouteConfig
try {
currentRouteConfig = await fetchRouteConfig(currentBaseUrl)
} catch (error) {
console.warn(`[route-manager] 读取当前路由配置失败(${options.reason || 'unknown'}):`, error)
saveRouteState({
lastRouteCheckAt: now,
})
return {
switched: false,
reason: 'fetch_current_failed',
error,
}
}
const currentServerKey = resolveCurrentServerKey(currentRouteConfig, state, currentBaseUrl)
const desiredServerKey = normalizeServerKey(currentRouteConfig.active)
const desiredServer = currentRouteConfig.servers[desiredServerKey]
if (!currentServerKey || !desiredServerKey || !desiredServer) {
saveRouteState({
routeConfigCache: currentRouteConfig,
lastRouteCheckAt: now,
})
return {
switched: false,
reason: 'invalid_server_key',
}
}
if (desiredServerKey === currentServerKey) {
const stableBaseUrl = currentRouteConfig.servers[currentServerKey].baseUrl
applyRuntimeConfig(app, stableBaseUrl, currentServerKey, { resetDataCache: false })
saveRouteState({
activeServerKey: currentServerKey,
routeConfigCache: currentRouteConfig,
lastRouteCheckAt: now,
})
return {
switched: false,
reason: 'already_active',
activeServerKey: currentServerKey,
}
}
let targetRouteConfig
try {
targetRouteConfig = await fetchRouteConfig(desiredServer.baseUrl)
} catch (error) {
console.warn(`[route-manager] 双确认读取目标配置失败(${currentServerKey}->${desiredServerKey}):`, error)
saveRouteState({
activeServerKey: currentServerKey,
routeConfigCache: currentRouteConfig,
lastRouteCheckAt: now,
})
return {
switched: false,
reason: 'fetch_target_failed',
error,
}
}
if (normalizeServerKey(targetRouteConfig.active) !== desiredServerKey) {
console.warn(
`[route-manager] 双确认未通过: 当前=${currentServerKey}, 期望=${desiredServerKey}, 目标返回=${targetRouteConfig.active}`,
)
saveRouteState({
activeServerKey: currentServerKey,
routeConfigCache: currentRouteConfig,
lastRouteCheckAt: now,
})
return {
switched: false,
reason: 'double_confirm_rejected',
}
}
if (!canSwitchByCooldown(state.lastSwitchAt, currentRouteConfig.cooldownMinutes, now)) {
console.info(
`[route-manager] cooldown 未到,保留当前服务器 ${currentServerKey} (cooldownMinutes=${currentRouteConfig.cooldownMinutes})`,
)
saveRouteState({
activeServerKey: currentServerKey,
routeConfigCache: currentRouteConfig,
lastRouteCheckAt: now,
})
return {
switched: false,
reason: 'cooldown_blocked',
}
}
applyRuntimeConfig(app, desiredServer.baseUrl, desiredServerKey)
saveRouteState({
activeServerKey: desiredServerKey,
routeConfigCache: targetRouteConfig,
lastSwitchAt: now,
lastRouteCheckAt: now,
})
console.info(`[route-manager] 已切换路由 ${currentServerKey} -> ${desiredServerKey}`)
return {
switched: true,
reason: 'switched',
from: currentServerKey,
to: desiredServerKey,
}
}
function checkRoute(explicitApp, options = {}) {
if (inFlightCheckPromise) {
return inFlightCheckPromise
}
inFlightCheckPromise = runRouteCheck(explicitApp, options)
.catch((error) => {
console.warn('[route-manager] 路由检查异常:', error)
return {
switched: false,
reason: 'unexpected_error',
error,
}
})
.finally(() => {
inFlightCheckPromise = null
})
return inFlightCheckPromise
}
function hydrateRuntimeFromState(explicitApp) {
const app = getAppInstance(explicitApp)
if (!app || !app.globalData) {
return null
}
const state = loadRouteState()
const fallbackRuntime = buildRuntimeConfig()
const cachedRouteConfig = state && state.routeConfigCache && typeof state.routeConfigCache === 'object'
? state.routeConfigCache
: null
const activeServerKey = normalizeServerKey(state && state.activeServerKey)
let baseUrl = fallbackRuntime.fontsBaseUrl
if (cachedRouteConfig && cachedRouteConfig.servers && activeServerKey && cachedRouteConfig.servers[activeServerKey]) {
const cachedBaseUrl = normalizeBaseUrl(cachedRouteConfig.servers[activeServerKey].baseUrl)
if (cachedBaseUrl) {
baseUrl = cachedBaseUrl
}
}
return applyRuntimeConfig(app, baseUrl, activeServerKey, { resetDataCache: false })
}
function bootstrapRoute(explicitApp) {
const app = getAppInstance(explicitApp)
if (!app || !app.globalData) {
return Promise.resolve(null)
}
hydrateRuntimeFromState(app)
return checkRoute(app, {
force: true,
reason: 'launch',
})
}
function ensureRouteReady(explicitApp) {
const app = getAppInstance(explicitApp)
if (!app || !app.globalData) {
return Promise.resolve()
}
const promise = app.globalData.routeReadyPromise
if (promise && typeof promise.then === 'function') {
return promise.catch(() => null)
}
return Promise.resolve()
}
function checkRouteOnShow(explicitApp) {
return checkRoute(explicitApp, {
force: false,
reason: 'foreground',
})
}
function checkRouteOnFailure(explicitApp) {
const now = Date.now()
if (now - lastFailureRouteCheckAt < FAILURE_CHECK_INTERVAL_MS) {
return Promise.resolve({
switched: false,
reason: 'failure_interval_skip',
})
}
lastFailureRouteCheckAt = now
return checkRoute(explicitApp, {
force: true,
reason: 'failure',
})
}
module.exports = {
bootstrapRoute,
ensureRouteReady,
checkRouteOnShow,
checkRouteOnFailure,
checkRoute,
}

View File

@@ -1,6 +1,7 @@
const STORAGE_KEYS = {
APP_STATE: 'font2svg:app-state',
FAVORITES: 'font2svg:favorites',
ROUTE_STATE: 'font2svg:route-state',
}
function getStorage(key, fallbackValue) {
@@ -49,6 +50,21 @@ function saveFavorites(favorites) {
return unique
}
function loadRouteState() {
return getStorage(STORAGE_KEYS.ROUTE_STATE, {})
}
function saveRouteState(partialState) {
const current = loadRouteState()
const next = {
...current,
...partialState,
updatedAt: Date.now(),
}
setStorage(STORAGE_KEYS.ROUTE_STATE, next)
return next
}
module.exports = {
STORAGE_KEYS,
getStorage,
@@ -57,4 +73,6 @@ module.exports = {
saveAppState,
loadFavorites,
saveFavorites,
loadRouteState,
saveRouteState,
}

View File

@@ -14,6 +14,7 @@ LOCAL_WEB_FONTS_JSON="frontend/public/fonts.json"
LOCAL_WEB_DEFAULT_JSON="frontend/public/default.json"
LOCAL_MP_FONTS_JSON="miniprogram/assets/fonts.json"
LOCAL_MP_DEFAULT_JSON="miniprogram/assets/default.json"
LOCAL_MP_ROUTE_CONFIG_JSON="miniprogram/assets/route-config.json"
# 颜色输出
RED='\033[0;31m'
@@ -91,6 +92,11 @@ upload_miniprogram_config() {
else
log_warn "未找到小程序 default.json已跳过: $LOCAL_MP_DEFAULT_JSON"
fi
if [ -f "$LOCAL_MP_ROUTE_CONFIG_JSON" ]; then
scp "$LOCAL_MP_ROUTE_CONFIG_JSON" "$SERVER:$REMOTE_MP_ASSETS_DIR/route-config.json"
else
log_warn "未找到小程序 route-config.json已跳过: $LOCAL_MP_ROUTE_CONFIG_JSON"
fi
log_info "小程序配置上传完成"
}
@@ -105,6 +111,7 @@ verify_deployment() {
# 检查小程序 fonts.json
MP_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/miniprogram/assets/fonts.json")
MP_ROUTE_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/miniprogram/assets/route-config.json")
if [ "$MP_HTTP_CODE" = "200" ]; then
log_info "小程序 fonts.json 可访问 ✓"
@@ -114,6 +121,12 @@ verify_deployment() {
exit 1
fi
if [ "$MP_ROUTE_HTTP_CODE" = "200" ]; then
log_info "小程序 route-config.json 可访问 ✓"
else
log_warn "小程序 route-config.json 不可访问 (HTTP $MP_ROUTE_HTTP_CODE)"
fi
WEB_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/fonts.json")
if [ "$WEB_HTTP_CODE" = "200" ]; then
log_info "Web fonts.json 可访问 ✓"