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

@@ -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,
}