/** * @description 本地登录代理脚本:启动后会调用后端登录接口并缓存 accessToken,供前端调试读取。 * * 使用方式: * - 通过 package.json 脚本启动(推荐): * - `pnpm dev` * - `pnpm dev:pet` * - `pnpm dev:qa` * - 或直接启动: * - `node ./scripts/local-auth-proxy.mjs --mode pet` * * 可配置项(优先级:process.env > .env/.env.): * - `BUSINESS_RULES_MODE`:环境模式(默认 `pet`) * - `LOCAL_AUTH_PROXY_PORT`:代理服务端口(默认 `9530`) * - `LOCAL_AUTH_PROFILE`:启动时指定登录 profile,未配置时读取本地配置中的 defaultProfile * - `LOCAL_AUTH_CONFIG_PATH`:本地账号配置文件路径,默认 `./dev-tools/local-auth/config.local.json` * - `VITE_API_BASE`:后端网关地址(必填) * * 代理接口: * - `GET /__local_auth/access-token` * - `GET /__local_auth/refresh-token` * - `GET /__local_auth/state` * - `GET /__local_auth/switch-profile?profile=` */ import { readFile } from 'node:fs/promises'; import http from 'node:http'; import { dirname, relative, resolve } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { LOCAL_AUTH_ACCESS_TOKEN_PATH, LOCAL_AUTH_PROXY_DEFAULT_PORT, LOCAL_AUTH_PROXY_PORT_SCAN_LIMIT, LOCAL_AUTH_PROXY_SERVICE_ID, LOCAL_AUTH_REFRESH_TOKEN_PATH, LOCAL_AUTH_STATE_PATH, LOCAL_AUTH_SWITCH_PROFILE_PATH, } from './local-auth-shared.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const pkgRoot = resolve(__dirname, '..'); const localAuthDir = resolve(pkgRoot, 'dev-tools/local-auth'); const defaultLocalAuthConfigPath = resolve(localAuthDir, 'config.local.json'); const localAuthConfigPath = process.env.LOCAL_AUTH_CONFIG_PATH ? resolve(pkgRoot, process.env.LOCAL_AUTH_CONFIG_PATH) : defaultLocalAuthConfigPath; const localAuthExampleConfigPath = resolve(localAuthDir, 'config.example.json'); const proxyPort = Number(process.env.LOCAL_AUTH_PROXY_PORT || LOCAL_AUTH_PROXY_DEFAULT_PORT); const cliArgs = process.argv.slice(2); const modeFlagIndex = cliArgs.findIndex(arg => arg === '--mode'); const modeArg = modeFlagIndex >= 0 ? String(cliArgs[modeFlagIndex + 1] || '').trim() : ''; const mode = modeArg || String(process.env.BUSINESS_RULES_MODE || 'pet').trim() || 'pet'; const instanceId = String(process.env.LOCAL_AUTH_INSTANCE_ID || '').trim(); const loginPath = '/biz-gateway/base/foundation/api/auth/login'; /** * @description 解析 .env 文件文本为键值对象。 * @param {string} source .env 文件原始文本。 * @returns {Record} 解析后的环境变量映射。 */ function parseEnvText(source) { return source .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')) .reduce((acc, line) => { const equalIndex = line.indexOf('='); if (equalIndex <= 0) { return acc; } const key = line.slice(0, equalIndex).trim(); const value = line.slice(equalIndex + 1).trim(); acc[key] = value; return acc; }, /** @type {Record} */ ({})); } /** * @description 读取基础环境和 mode 环境,合并生成最终环境变量。 * @returns {Promise>} 当前模式生效的环境变量。 */ async function loadBusinessRulesEnv() { const baseEnvPath = resolve(pkgRoot, '.env'); const modeEnvPath = resolve(pkgRoot, `.env.${mode}`); const [baseText, modeText] = await Promise.all([ readFile(baseEnvPath, 'utf8').catch(() => ''), readFile(modeEnvPath, 'utf8').catch(() => ''), ]); return { ...parseEnvText(baseText), ...parseEnvText(modeText), }; } /** * @description 读取本地账号配置。 * @returns {Promise<{ config: { defaultProfile: string, profiles: Record }, configPath: string } | null>} 账号配置;文件不存在时返回 null。 */ async function loadLocalAuthConfig() { let source = ''; try { source = await readFile(localAuthConfigPath, 'utf8'); } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { return null; } throw error; } let parsed; try { parsed = JSON.parse(source); } catch (error) { throw new Error(`local auth config parse failed: ${error instanceof Error ? error.message : String(error)}`); } const rawProfiles = parsed?.profiles; if (!rawProfiles || typeof rawProfiles !== 'object' || Array.isArray(rawProfiles)) { throw new Error(`local auth config invalid: "profiles" must be an object in ${localAuthConfigPath}`); } const profiles = Object.entries(rawProfiles).reduce((acc, [profileName, profile]) => { const userCode = String(profile?.userCode || '').trim(); const password = String(profile?.password || '').trim(); if (!userCode || !password) { throw new Error(`local auth config invalid: profile "${profileName}" must contain "userCode" and "password"`); } acc[profileName] = { userCode, password }; return acc; }, /** @type {Record} */ ({})); if (!Object.keys(profiles).length) { throw new Error(`local auth config invalid: no profiles found in ${localAuthConfigPath}`); } return { config: { defaultProfile: String(parsed?.defaultProfile || '').trim(), profiles, }, configPath: localAuthConfigPath, }; } /** * @description 解析启动时使用的 profile 名称。 * @param {{ defaultProfile: string, profiles: Record }} config 本地账号配置。 * @returns {string} 当前应使用的 profile。 */ function resolveInitialProfileName(config) { const preferredProfile = String(process.env.LOCAL_AUTH_PROFILE || '').trim(); if (preferredProfile) { if (!config.profiles[preferredProfile]) { throw new Error(`LOCAL_AUTH_PROFILE "${preferredProfile}" not found in current local auth config`); } return preferredProfile; } if (config.defaultProfile) { if (!config.profiles[config.defaultProfile]) { throw new Error(`defaultProfile "${config.defaultProfile}" not found in current local auth config`); } return config.defaultProfile; } return Object.keys(config.profiles)[0]; } /** * @description 启动登录流程并返回登录结果。 * @param {string} apiBase 远端网关基础域名。 * @param {{ userCode: string, password: string }} credential 登录凭据。 * @returns {Promise} 后端登录接口返回 JSON。 */ async function login(apiBase, credential) { const url = new URL(loginPath, apiBase).toString(); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'App-Id': 'app-cisdigital-base-foundation', }, body: JSON.stringify({ userCode: credential.userCode, password: credential.password, }), }); if (!response.ok) { throw new Error(`login failed: ${response.status} ${response.statusText}`); } return response.json(); } /** * @description 从不同响应结构中提取 accessToken。 * @param {any} payload 登录接口响应体。 * @returns {string} 提取到的 accessToken;无值时返回空字符串。 */ function extractAccessToken(payload) { return ( payload?.accessToken ?? payload?.data?.token ?? payload?.data?.accessToken ?? payload?.result?.accessToken ?? '' ); } const env = await loadBusinessRulesEnv(); const apiBase = env.VITE_API_BASE || ''; const authConfigResult = await loadLocalAuthConfig(); const authConfig = authConfigResult?.config || null; const activeLocalAuthConfigPath = authConfigResult?.configPath || localAuthConfigPath; const enabled = Boolean(authConfig); const availableProfiles = authConfig ? Object.keys(authConfig.profiles) : []; function toProjectRelativePath(filePath) { return relative(pkgRoot, filePath) || '.'; } if (enabled && !apiBase) { throw new Error(`VITE_API_BASE is empty for mode "${mode}"`); } let accessToken = ''; let loginError = ''; let currentProfile = authConfig ? resolveInitialProfileName(authConfig) : ''; let lastAttemptProfile = currentProfile; let listeningPort = proxyPort; /** * @description 使用指定 profile 重新登录并更新缓存 token。 * @param {string} profileName profile 名称。 * @returns {Promise} */ async function refreshAccessToken(profileName) { if (!authConfig) { return; } const credential = authConfig.profiles[profileName]; if (!credential) { throw new Error(`profile "${profileName}" not found`); } lastAttemptProfile = profileName; const nextLoginResponse = await login(apiBase, credential); const nextAccessToken = extractAccessToken(nextLoginResponse); if (!nextAccessToken) { throw new Error(`profile "${profileName}" login succeeded but accessToken is empty`); } accessToken = nextAccessToken; currentProfile = profileName; loginError = ''; console.log(`[local-auth-proxy] login success, profile: ${profileName}, accessToken exists: true`); } /** * @description 获取当前代理状态。 * @returns {{ * enabled: boolean * serviceId: string * instanceId: string * mode: string * port: number * apiBase: string * accessToken: string * accessTokenReady: boolean * activeProfile: string * lastAttemptProfile: string * availableProfiles: string[] * configPath: string * exampleConfigPath: string * error?: string * }} 当前状态。 */ function getProxyState() { return { enabled, serviceId: LOCAL_AUTH_PROXY_SERVICE_ID, instanceId, mode, port: listeningPort, apiBase, accessToken, accessTokenReady: Boolean(accessToken), activeProfile: currentProfile, lastAttemptProfile, availableProfiles, configPath: toProjectRelativePath(activeLocalAuthConfigPath), exampleConfigPath: toProjectRelativePath(localAuthExampleConfigPath), error: loginError || undefined, }; } /** * @description 写入 JSON 响应。 * @param {http.ServerResponse} res 响应对象。 * @param {number} statusCode 状态码。 * @param {unknown} payload 响应体。 */ function writeJson(res, statusCode, payload) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.writeHead(statusCode); res.end(JSON.stringify(payload)); } /** * @description 在给定端口上启动服务;若端口被占用则自动向后探测可用端口。 * @param {http.Server} targetServer http 服务实例。 * @param {number} startPort 起始端口。 * @returns {Promise} 实际监听端口。 */ async function listenOnAvailablePort(targetServer, startPort) { const maxAttempts = Math.max(1, LOCAL_AUTH_PROXY_PORT_SCAN_LIMIT); /** * @param {number} port 目标端口。 * @returns {Promise} 监听成功后的实际端口。 */ function listenOnce(port) { return new Promise((resolvePromise, rejectPromise) => { const cleanup = () => { targetServer.off('error', handleError); targetServer.off('listening', handleListening); }; function handleError(error) { cleanup(); rejectPromise(error); } function handleListening() { cleanup(); resolvePromise(port); } targetServer.once('error', handleError); targetServer.once('listening', handleListening); targetServer.listen(port, '0.0.0.0'); }); } for (let offset = 0; offset < maxAttempts; offset += 1) { const nextPort = startPort + offset; try { return await listenOnce(nextPort); } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') { continue; } throw error; } } throw new Error(`no available local auth port found from ${startPort} to ${startPort + maxAttempts - 1}`); } try { if (enabled) { await refreshAccessToken(currentProfile); } } catch (error) { loginError = error instanceof Error ? error.message : String(error); console.error('[local-auth-proxy] login failed:', loginError); } const server = http.createServer((req, res) => { void (async () => { if (req.method === 'OPTIONS') { writeJson(res, 204, {}); return; } const requestUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_ACCESS_TOKEN_PATH) { writeJson(res, loginError ? 500 : 200, getProxyState()); return; } if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_REFRESH_TOKEN_PATH) { if (!enabled) { writeJson(res, 200, getProxyState()); return; } const profileName = requestUrl.searchParams.get('profile')?.trim() || currentProfile; try { await refreshAccessToken(profileName); writeJson(res, 200, getProxyState()); } catch (error) { loginError = error instanceof Error ? error.message : String(error); console.error('[local-auth-proxy] refresh token failed:', loginError); writeJson(res, 500, getProxyState()); } return; } if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_STATE_PATH) { writeJson(res, 200, getProxyState()); return; } if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_SWITCH_PROFILE_PATH) { if (!enabled) { writeJson(res, 200, getProxyState()); return; } const profileName = requestUrl.searchParams.get('profile')?.trim(); if (!profileName) { writeJson(res, 400, { ...getProxyState(), error: 'query param "profile" is required', }); return; } try { await refreshAccessToken(profileName); writeJson(res, 200, getProxyState()); } catch (error) { loginError = error instanceof Error ? error.message : String(error); console.error('[local-auth-proxy] switch profile failed:', loginError); writeJson(res, 500, getProxyState()); } return; } writeJson(res, 404, { message: 'Not Found' }); })().catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); console.error('[local-auth-proxy] request failed:', errorMessage); writeJson(res, 500, { ...getProxyState(), error: errorMessage }); }); }); listeningPort = await listenOnAvailablePort(server, proxyPort); console.log(`[local-auth-proxy] listening on http://localhost:${listeningPort}`); if (listeningPort !== proxyPort) { console.log(`[local-auth-proxy] default port ${proxyPort} is busy, switched to ${listeningPort}`); } if (instanceId) { console.log(`[local-auth-proxy] instance id: ${instanceId}`); } if (!enabled) { console.log(`[local-auth-proxy] local auth disabled: config not found at ${toProjectRelativePath(localAuthConfigPath)}`); console.log(`[local-auth-proxy] copy template from ${toProjectRelativePath(localAuthExampleConfigPath)} when needed`); } else { console.log(`[local-auth-proxy] config path: ${toProjectRelativePath(activeLocalAuthConfigPath)}`); console.log(`[local-auth-proxy] active profile: ${currentProfile}`); console.log(`[local-auth-proxy] available profiles: ${availableProfiles.join(', ')}`); console.log(`[local-auth-proxy] accessToken exists: ${Boolean(accessToken)}`); }