mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 05:19:14 +00:00
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
224 lines
8.8 KiB
JavaScript
224 lines
8.8 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { EventEmitter } from 'node:events'
|
|
import { readFileSync } from 'node:fs'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { loadConfigFromFile, loadEnv } from 'vite'
|
|
import { normalizePluginBase, validatePluginSession } from '../../src/utils/pluginBridge.js'
|
|
import { normalizeTransportPath } from '../../src/utils/transportPath.js'
|
|
|
|
const frontendRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|
const originalEnvironment = { ...process.env }
|
|
const originalDirectory = process.cwd()
|
|
|
|
/**
|
|
* 加载指定环境的实际配置,并恢复 Vite 读取配置时涉及的进程环境。
|
|
*
|
|
* @param {string} mode Vite 环境名称
|
|
* @param {string|undefined} pluginBase 显式前缀;未指定时使用该环境默认值
|
|
* @returns {Promise<Object>} 实际代理配置与客户端使用的环境值
|
|
*/
|
|
async function loadConfiguration(mode, pluginBase) {
|
|
const environment = { ...process.env }
|
|
const directory = process.cwd()
|
|
try {
|
|
process.chdir(frontendRoot)
|
|
delete process.env.VITE_APP_PLUGIN_BASE
|
|
delete process.env.VITE_APP_BASE_API
|
|
if (pluginBase !== undefined) process.env.VITE_APP_PLUGIN_BASE = pluginBase
|
|
const env = loadEnv(mode, frontendRoot)
|
|
const loaded = await loadConfigFromFile(
|
|
{ command: 'serve', mode },
|
|
resolve(frontendRoot, 'vite.config.js'),
|
|
frontendRoot,
|
|
'silent'
|
|
)
|
|
assert.ok(loaded, '实际 Vite 配置必须成功加载')
|
|
return {
|
|
proxy: loaded.config.server.proxy,
|
|
pluginBase: env.VITE_APP_PLUGIN_BASE || '',
|
|
baseApi: env.VITE_APP_BASE_API || '',
|
|
}
|
|
} finally {
|
|
process.chdir(directory)
|
|
for (const key of Object.keys(process.env)) {
|
|
if (!Object.hasOwn(environment, key)) delete process.env[key]
|
|
}
|
|
Object.assign(process.env, environment)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 验证真实代理保留 SSE 上下游异常与退出清理。
|
|
*
|
|
* @param {Object} appProxy 插件应用的实际 Vite 代理配置
|
|
* @returns {void}
|
|
*/
|
|
function checkStreamCleanup(appProxy) {
|
|
const proxy = new EventEmitter()
|
|
appProxy.configure(proxy)
|
|
const pair = (contentType = 'text/event-stream; charset=utf-8') => {
|
|
const upstream = new EventEmitter()
|
|
const response = new EventEmitter()
|
|
upstream.headers = { 'content-type': contentType }
|
|
upstream.complete = false
|
|
upstream.destroy = () => {
|
|
upstream.destroyed = true
|
|
}
|
|
response.destroy = () => {
|
|
response.destroyed = true
|
|
}
|
|
proxy.emit('proxyRes', upstream, {}, response)
|
|
return { upstream, response }
|
|
}
|
|
for (const event of ['aborted', 'error']) {
|
|
const { upstream, response } = pair()
|
|
upstream.emit(event)
|
|
assert.equal(response.destroyed, true, `SSE 上游 ${event} 必须结束下游`)
|
|
}
|
|
const disconnected = pair()
|
|
disconnected.response.emit('close')
|
|
assert.equal(disconnected.upstream.destroyed, true)
|
|
const complete = pair()
|
|
complete.upstream.complete = true
|
|
complete.response.emit('close')
|
|
assert.equal(complete.upstream.destroyed, undefined)
|
|
const json = pair('application/json')
|
|
assert.equal(json.upstream.listenerCount('aborted'), 0)
|
|
assert.equal(json.upstream.listenerCount('error'), 0)
|
|
assert.equal(json.response.listenerCount('close'), 0)
|
|
}
|
|
|
|
/**
|
|
* 验证浏览器会话、专用代理与加密 AAD 使用同一个部署前缀。
|
|
*
|
|
* @param {Object} configuration 实际加载的环境配置
|
|
* @returns {void}
|
|
*/
|
|
function checkPluginPaths({ proxy, pluginBase, baseApi }) {
|
|
const prefix = normalizePluginBase(pluginBase)
|
|
const appProxy = proxy[`${prefix}/apps/`]
|
|
const sessionProxy = proxy[`${prefix}/plugin/runtime/`]
|
|
assert.ok(appProxy, '插件应用必须走独立代理')
|
|
assert.ok(sessionProxy, '插件会话必须走独立代理')
|
|
assert.equal(appProxy.changeOrigin, false)
|
|
assert.equal(sessionProxy.changeOrigin, false)
|
|
assert.equal(appProxy.ws, true)
|
|
for (const [entry, path] of [
|
|
[appProxy, '/apps/bundle_demo/ui/index.html?asset=main.js'],
|
|
[appProxy, '/apps/task_demo/api/events?tag=first&tag=second&next=%2Fdev-api%2F'],
|
|
[sessionProxy, '/plugin/runtime/bundle_demo/session?mode=preview'],
|
|
]) {
|
|
assert.equal(typeof entry.rewrite, 'function')
|
|
assert.equal(entry.rewrite(`${prefix}${path}`), path, '只剥离外部前缀并完整保留查询串')
|
|
assert.equal(
|
|
normalizeTransportPath(`${prefix}${path}`, baseApi, pluginBase),
|
|
path.split('?')[0],
|
|
'AAD 使用后端接收到的无前缀路径'
|
|
)
|
|
}
|
|
for (const pluginId of ['bundle_demo', 'task_demo']) {
|
|
const session = {
|
|
pluginId,
|
|
bridgeVersion: 1,
|
|
uiBase: `${prefix}/apps/${pluginId}/ui/`,
|
|
apiBase: `${prefix}/apps/${pluginId}/api/`,
|
|
csrfToken: 'test-plugin-csrf-token',
|
|
expiresIn: 300,
|
|
}
|
|
assert.deepEqual(validatePluginSession(session, pluginId, pluginBase, 100), {
|
|
...session,
|
|
expiresAt: 300100,
|
|
})
|
|
}
|
|
checkStreamCleanup(appProxy)
|
|
}
|
|
|
|
/**
|
|
* 只提取后端非敏感路径配置,避免断言和日志包含其他环境值。
|
|
*
|
|
* @param {string} filename 已有的后端环境文件名
|
|
* @returns {string} 后端应用部署前缀
|
|
*/
|
|
function readBackendRootPath(filename) {
|
|
const match = readFileSync(
|
|
resolve(frontendRoot, '../ruoyi-fastapi-backend', filename),
|
|
'utf8'
|
|
).match(/^APP_ROOT_PATH\s*=\s*['"]?([^'"\s#]*)['"]?\s*(?:#.*)?$/m)
|
|
assert.ok(match, `${filename} 必须显式配置 APP_ROOT_PATH`)
|
|
return match[1]
|
|
}
|
|
|
|
/**
|
|
* 检查实际 Docker nginx 插件代理的路径替换与同源流式配置。
|
|
*
|
|
* @param {string} variant Docker 数据库配置名称
|
|
* @param {string} upstream 对应的后端服务名
|
|
* @param {string} prefix 客户端与后端共有的部署前缀
|
|
* @returns {void}
|
|
*/
|
|
function checkDockerNginx(variant, upstream, prefix) {
|
|
const source = readFileSync(resolve(frontendRoot, `bin/nginx.${variant}.conf`), 'utf8')
|
|
const locations = [...source.matchAll(/^\s*location\s+\^~\s+(\S+)\s*\{([^{}]*)\}/gm)]
|
|
const blocks = {}
|
|
for (const route of ['/apps/', '/plugin/runtime/']) {
|
|
const matches = locations.filter((match) => match[1] === `${prefix}${route}`)
|
|
assert.equal(matches.length, 1, `${variant} 必须有唯一的 ${prefix}${route} 专用代理`)
|
|
const block = matches[0][2]
|
|
const proxyPass = block.match(/^\s*proxy_pass\s+(\S+)\s*;/m)
|
|
assert.equal(proxyPass?.[1], `http://${upstream}:9099${route}`)
|
|
assert.match(block, /^\s*proxy_set_header\s+Host\s+\$http_host\s*;/m)
|
|
assert.match(block, /^\s*proxy_set_header\s+X-Forwarded-Proto\s+\$scheme\s*;/m)
|
|
assert.ok(!locations.some((match) => match[1] === route), '不得遗留无部署前缀的专用代理')
|
|
blocks[route] = block
|
|
}
|
|
assert.match(blocks['/apps/'], /^\s*proxy_http_version\s+1\.1\s*;/m)
|
|
assert.match(blocks['/apps/'], /^\s*proxy_set_header\s+Upgrade\s+\$http_upgrade\s*;/m)
|
|
assert.match(
|
|
blocks['/apps/'],
|
|
/^\s*proxy_set_header\s+Connection\s+\$plugin_connection_upgrade\s*;/m
|
|
)
|
|
assert.match(blocks['/apps/'], /^\s*proxy_buffering\s+off\s*;/m)
|
|
assert.match(blocks['/apps/'], /^\s*proxy_read_timeout\s+300s\s*;/m)
|
|
assert.match(
|
|
blocks['/plugin/runtime/'],
|
|
/^\s*add_header\s+Cache-Control\s+"no-store"\s+always\s*;/m
|
|
)
|
|
const upgradeMap = source.match(
|
|
/^\s*map\s+\$http_upgrade\s+\$plugin_connection_upgrade\s*\{([^{}]*)\}/m
|
|
)
|
|
assert.ok(upgradeMap, 'WebSocket 的 Connection 请求头必须按 Upgrade 状态切换')
|
|
assert.match(upgradeMap[1], /^\s*default\s+upgrade\s*;/m)
|
|
assert.match(upgradeMap[1], /^\s*''\s+close\s*;/m)
|
|
}
|
|
|
|
for (const [mode, expectedBase, backendFiles] of [
|
|
['development', '/dev-api', ['.env.dev']],
|
|
['production', '/prod-api', ['.env.prod']],
|
|
['docker', '/docker-api', ['.env.dockermy', '.env.dockerpg']],
|
|
['staging', '/stage-api', []],
|
|
]) {
|
|
const configuration = await loadConfiguration(mode)
|
|
assert.equal(configuration.pluginBase, expectedBase)
|
|
assert.equal(configuration.pluginBase, configuration.baseApi, `${mode} 的两种 API 前缀必须一致`)
|
|
for (const filename of backendFiles) {
|
|
assert.equal(configuration.pluginBase, readBackendRootPath(filename))
|
|
}
|
|
checkPluginPaths(configuration)
|
|
if (mode === 'docker') {
|
|
checkDockerNginx('dockermy', 'ruoyi-backend-my', configuration.pluginBase)
|
|
checkDockerNginx('dockerpg', 'ruoyi-backend-pg', configuration.pluginBase)
|
|
}
|
|
}
|
|
|
|
for (const base of ['', '/', '/gateway', '/gateway/nested-v1/']) {
|
|
const configuration = await loadConfiguration('development', base)
|
|
assert.equal(configuration.pluginBase, base, '显式前缀必须覆盖开发环境默认值')
|
|
checkPluginPaths(configuration)
|
|
}
|
|
for (const base of ['https://foreign.example', '//gateway', '/gateway/../admin', '/gateway?x=1']) {
|
|
await assert.rejects(loadConfiguration('development', base), /插件部署路径无效/)
|
|
}
|
|
assert.deepEqual({ ...process.env }, originalEnvironment)
|
|
assert.equal(process.cwd(), originalDirectory)
|