update at 2026-02-10 14:10:20
This commit is contained in:
@@ -21,9 +21,10 @@ miniprogram/
|
|||||||
│ └── server.js # 远端地址/端口/API 路径统一配置
|
│ └── server.js # 远端地址/端口/API 路径统一配置
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── core/ # 纯算法模块
|
│ ├── core/ # 纯算法模块
|
||||||
│ └── mp/ # 小程序 API 适配层
|
│ └── mp/ # 小程序 API 适配层(含 route-manager)
|
||||||
├── assets/fonts.json # 字体清单(由脚本生成)
|
├── assets/fonts.json # 字体清单(由脚本生成)
|
||||||
├── assets/default.json # 首次加载默认配置(内容/颜色/字号/默认字体)
|
├── assets/default.json # 首次加载默认配置(内容/颜色/字号/默认字体)
|
||||||
|
├── assets/route-config.json # 手动切换 A/B 服务器配置
|
||||||
├── app.js / app.json / app.wxss
|
├── app.js / app.json / app.wxss
|
||||||
└── project.config.json
|
└── project.config.json
|
||||||
```
|
```
|
||||||
@@ -47,9 +48,30 @@ miniprogram/
|
|||||||
- `apiPrefix`: API 前缀(默认 `/api`)
|
- `apiPrefix`: API 前缀(默认 `/api`)
|
||||||
- `fontsManifestPath`: 字体清单路径(默认 `/miniprogram/assets/fonts.json`)
|
- `fontsManifestPath`: 字体清单路径(默认 `/miniprogram/assets/fonts.json`)
|
||||||
- `defaultConfigPath`: 默认配置路径(默认 `/miniprogram/assets/default.json`)
|
- `defaultConfigPath`: 默认配置路径(默认 `/miniprogram/assets/default.json`)
|
||||||
|
- `routeConfigPath`: 路由配置路径(默认 `/miniprogram/assets/route-config.json`)
|
||||||
|
|
||||||
`app.js` 和 API 调用会自动使用该配置生成完整 URL。
|
`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` 需由单次点击直接触发,建议逐个字体导出。
|
- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { buildRuntimeConfig } = require('./config/server')
|
const { buildRuntimeConfig } = require('./config/server')
|
||||||
|
const { bootstrapRoute, checkRouteOnShow } = require('./utils/mp/route-manager')
|
||||||
|
|
||||||
const runtimeConfig = buildRuntimeConfig()
|
const runtimeConfig = buildRuntimeConfig()
|
||||||
|
|
||||||
@@ -8,5 +9,20 @@ App({
|
|||||||
apiTimeoutMs: 30000,
|
apiTimeoutMs: 30000,
|
||||||
fonts: null,
|
fonts: null,
|
||||||
defaultConfig: 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)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
12
miniprogram/assets/route-config.json
Normal file
12
miniprogram/assets/route-config.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"active": "A",
|
||||||
|
"cooldownMinutes": 10,
|
||||||
|
"servers": {
|
||||||
|
"A": {
|
||||||
|
"baseUrl": "https://fonts.biboer.cn"
|
||||||
|
},
|
||||||
|
"B": {
|
||||||
|
"baseUrl": "https://mac.biboer.cn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const SERVER_CONFIG = {
|
|||||||
apiPrefix: '/api',
|
apiPrefix: '/api',
|
||||||
fontsManifestPath: '/miniprogram/assets/fonts.json',
|
fontsManifestPath: '/miniprogram/assets/fonts.json',
|
||||||
defaultConfigPath: '/miniprogram/assets/default.json',
|
defaultConfigPath: '/miniprogram/assets/default.json',
|
||||||
|
routeConfigPath: '/miniprogram/assets/route-config.json',
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOrigin() {
|
function buildOrigin() {
|
||||||
@@ -33,16 +34,29 @@ function normalizePath(path, fallback) {
|
|||||||
return value.startsWith('/') ? value : `/${value}`
|
return value.startsWith('/') ? value : `/${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRuntimeConfig() {
|
function normalizeBaseUrl(baseUrl) {
|
||||||
const origin = buildOrigin()
|
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 apiPrefix = normalizePath(SERVER_CONFIG.apiPrefix, '/api').replace(/\/$/, '')
|
||||||
const fontsManifestPath = normalizePath(SERVER_CONFIG.fontsManifestPath, '/miniprogram/assets/fonts.json')
|
const fontsManifestPath = normalizePath(SERVER_CONFIG.fontsManifestPath, '/miniprogram/assets/fonts.json')
|
||||||
const defaultConfigPath = normalizePath(SERVER_CONFIG.defaultConfigPath, '/miniprogram/assets/default.json')
|
const defaultConfigPath = normalizePath(SERVER_CONFIG.defaultConfigPath, '/miniprogram/assets/default.json')
|
||||||
|
const routeConfigPath = normalizePath(SERVER_CONFIG.routeConfigPath, '/miniprogram/assets/route-config.json')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activeServerKey: String(options.activeServerKey || '').trim(),
|
||||||
fontsBaseUrl: origin,
|
fontsBaseUrl: origin,
|
||||||
fontsManifestUrl: `${origin}${fontsManifestPath}`,
|
fontsManifestUrl: `${origin}${fontsManifestPath}`,
|
||||||
defaultConfigUrl: `${origin}${defaultConfigPath}`,
|
defaultConfigUrl: `${origin}${defaultConfigPath}`,
|
||||||
|
routeConfigUrl: `${origin}${routeConfigPath}`,
|
||||||
svgRenderApiUrl: `${origin}${apiPrefix}/render-svg`,
|
svgRenderApiUrl: `${origin}${apiPrefix}/render-svg`,
|
||||||
pngRenderApiUrl: `${origin}${apiPrefix}/render-png`,
|
pngRenderApiUrl: `${origin}${apiPrefix}/render-png`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { request, downloadFile, readFile } = require('./wx-promisify')
|
const { request, downloadFile, readFile } = require('./wx-promisify')
|
||||||
|
const { ensureRouteReady, checkRouteOnFailure } = require('./route-manager')
|
||||||
|
|
||||||
const localFonts = require('../../assets/fonts')
|
const localFonts = require('../../assets/fonts')
|
||||||
const localDefaultConfig = require('../../assets/default')
|
const localDefaultConfig = require('../../assets/default')
|
||||||
@@ -111,6 +112,7 @@ function buildDefaultConfigUrl(manifestUrl, baseUrl) {
|
|||||||
|
|
||||||
async function loadFontsManifest(options = {}) {
|
async function loadFontsManifest(options = {}) {
|
||||||
const app = getApp()
|
const app = getApp()
|
||||||
|
await ensureRouteReady(app)
|
||||||
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
|
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
|
||||||
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
|
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
|
||||||
|
|
||||||
@@ -138,7 +140,11 @@ async function loadFontsManifest(options = {}) {
|
|||||||
return fonts
|
return fonts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('远程字体清单加载失败,回退到本地清单:', 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
|
app.globalData.fonts = fallbackFonts
|
||||||
return fallbackFonts
|
return fallbackFonts
|
||||||
}
|
}
|
||||||
@@ -146,6 +152,7 @@ async function loadFontsManifest(options = {}) {
|
|||||||
|
|
||||||
async function loadDefaultConfig(options = {}) {
|
async function loadDefaultConfig(options = {}) {
|
||||||
const app = getApp()
|
const app = getApp()
|
||||||
|
await ensureRouteReady(app)
|
||||||
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
|
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
|
||||||
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
|
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
|
||||||
const defaultConfigUrl = options.defaultConfigUrl ||
|
const defaultConfigUrl = options.defaultConfigUrl ||
|
||||||
@@ -172,6 +179,9 @@ async function loadDefaultConfig(options = {}) {
|
|||||||
return remoteConfig
|
return remoteConfig
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('远程 default.json 加载失败,回退到本地默认配置:', error)
|
console.warn('远程 default.json 加载失败,回退到本地默认配置:', error)
|
||||||
|
await checkRouteOnFailure(app).catch((routeError) => {
|
||||||
|
console.warn('default 配置失败后的路由检查失败:', routeError)
|
||||||
|
})
|
||||||
const fallbackConfig = normalizeDefaultConfig(localDefaultConfig)
|
const fallbackConfig = normalizeDefaultConfig(localDefaultConfig)
|
||||||
app.globalData.defaultConfig = fallbackConfig
|
app.globalData.defaultConfig = fallbackConfig
|
||||||
return fallbackConfig
|
return fallbackConfig
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { request } = require('./wx-promisify')
|
const { request } = require('./wx-promisify')
|
||||||
|
const { ensureRouteReady, checkRouteOnFailure } = require('./route-manager')
|
||||||
|
|
||||||
function buildApiUrl() {
|
function buildApiUrl() {
|
||||||
const app = getApp()
|
const app = getApp()
|
||||||
@@ -47,10 +48,12 @@ function decodeArrayBuffer(buffer) {
|
|||||||
|
|
||||||
async function renderSvgByApi(payload) {
|
async function renderSvgByApi(payload) {
|
||||||
const app = getApp()
|
const app = getApp()
|
||||||
|
await ensureRouteReady(app)
|
||||||
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
||||||
? Number(app.globalData.apiTimeoutMs)
|
? Number(app.globalData.apiTimeoutMs)
|
||||||
: 30000
|
: 30000
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await request({
|
const response = await request({
|
||||||
url: buildApiUrl(),
|
url: buildApiUrl(),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -78,10 +81,17 @@ async function renderSvgByApi(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return normalizeResult(body.data)
|
return normalizeResult(body.data)
|
||||||
|
} catch (error) {
|
||||||
|
await checkRouteOnFailure(app).catch((routeError) => {
|
||||||
|
console.warn('SVG 渲染失败后的路由检查失败:', routeError)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPngByApi(payload) {
|
async function renderPngByApi(payload) {
|
||||||
const app = getApp()
|
const app = getApp()
|
||||||
|
await ensureRouteReady(app)
|
||||||
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
||||||
? Number(app.globalData.apiTimeoutMs)
|
? Number(app.globalData.apiTimeoutMs)
|
||||||
: 30000
|
: 30000
|
||||||
@@ -93,6 +103,7 @@ async function renderPngByApi(payload) {
|
|||||||
: `${baseApiUrl.replace(/\/$/, '')}/render-png`
|
: `${baseApiUrl.replace(/\/$/, '')}/render-png`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await request({
|
const response = await request({
|
||||||
url: apiUrl,
|
url: apiUrl,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -126,6 +137,12 @@ async function renderPngByApi(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response.data
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
await checkRouteOnFailure(app).catch((routeError) => {
|
||||||
|
console.warn('PNG 渲染失败后的路由检查失败:', routeError)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
399
miniprogram/utils/mp/route-manager.js
Normal file
399
miniprogram/utils/mp/route-manager.js
Normal 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,
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
APP_STATE: 'font2svg:app-state',
|
APP_STATE: 'font2svg:app-state',
|
||||||
FAVORITES: 'font2svg:favorites',
|
FAVORITES: 'font2svg:favorites',
|
||||||
|
ROUTE_STATE: 'font2svg:route-state',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStorage(key, fallbackValue) {
|
function getStorage(key, fallbackValue) {
|
||||||
@@ -49,6 +50,21 @@ function saveFavorites(favorites) {
|
|||||||
return unique
|
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 = {
|
module.exports = {
|
||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
getStorage,
|
getStorage,
|
||||||
@@ -57,4 +73,6 @@ module.exports = {
|
|||||||
saveAppState,
|
saveAppState,
|
||||||
loadFavorites,
|
loadFavorites,
|
||||||
saveFavorites,
|
saveFavorites,
|
||||||
|
loadRouteState,
|
||||||
|
saveRouteState,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ LOCAL_WEB_FONTS_JSON="frontend/public/fonts.json"
|
|||||||
LOCAL_WEB_DEFAULT_JSON="frontend/public/default.json"
|
LOCAL_WEB_DEFAULT_JSON="frontend/public/default.json"
|
||||||
LOCAL_MP_FONTS_JSON="miniprogram/assets/fonts.json"
|
LOCAL_MP_FONTS_JSON="miniprogram/assets/fonts.json"
|
||||||
LOCAL_MP_DEFAULT_JSON="miniprogram/assets/default.json"
|
LOCAL_MP_DEFAULT_JSON="miniprogram/assets/default.json"
|
||||||
|
LOCAL_MP_ROUTE_CONFIG_JSON="miniprogram/assets/route-config.json"
|
||||||
|
|
||||||
# 颜色输出
|
# 颜色输出
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -91,6 +92,11 @@ upload_miniprogram_config() {
|
|||||||
else
|
else
|
||||||
log_warn "未找到小程序 default.json,已跳过: $LOCAL_MP_DEFAULT_JSON"
|
log_warn "未找到小程序 default.json,已跳过: $LOCAL_MP_DEFAULT_JSON"
|
||||||
fi
|
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 "小程序配置上传完成"
|
log_info "小程序配置上传完成"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +111,7 @@ verify_deployment() {
|
|||||||
|
|
||||||
# 检查小程序 fonts.json
|
# 检查小程序 fonts.json
|
||||||
MP_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/miniprogram/assets/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
|
if [ "$MP_HTTP_CODE" = "200" ]; then
|
||||||
log_info "小程序 fonts.json 可访问 ✓"
|
log_info "小程序 fonts.json 可访问 ✓"
|
||||||
@@ -114,6 +121,12 @@ verify_deployment() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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")
|
WEB_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://fonts.biboer.cn/fonts.json")
|
||||||
if [ "$WEB_HTTP_CODE" = "200" ]; then
|
if [ "$WEB_HTTP_CODE" = "200" ]; then
|
||||||
log_info "Web fonts.json 可访问 ✓"
|
log_info "Web fonts.json 可访问 ✓"
|
||||||
|
|||||||
Reference in New Issue
Block a user