本地开发与联调方案
这一页解决用户最直接的那个抱怨:
总不能我写点东西,就重新打包 → 上传 → 安装嘛。
先给结论:日常改代码不需要走完整上架流程。平台已经具备运行期热加载能力, 问题只在于"怎么把新产物递进去"。这一页给出四条可执行的路径,每条写清适合什么场景 / 步骤 / 命令 / 局限,以及一条明确的不可行说明。
| 我改了什么 | 走哪条路 | 一次回路多长 |
|---|---|---|
| 插件前端 Vue | 方案 A(本地宿主)或 方案 B 的 B-1 | 秒级(重建 ESM → 刷新浏览器) |
| 插件后端 Java,宿主开了 dev-mode | 方案 B-2(POST /app/dev/reload)—— 推荐 | 秒级:重建 jar → 一条命令 |
| 插件后端 Java,宿主没有 dev-mode | 方案 B-2 的旧做法(上传包 → 对同一 versionId 重复 deploy),或用 方案 D 压成一条命令 | 分钟级,不重启宿主 |
| 插件后端 Java,在本地开发、不连线上数据 | 方案 A(沙箱 / 本地宿主) | 分钟级 |
| 只想改前端、宿主在远端 | 方案 C —— 不可行,别试 | — |
宿主自己的代码(plus-ui / wujies-platform) | 不在本页范围,见本地联调 §三、§四 | Vite 热更新 / 重建 jar |
本地联调 已经讲清了三条链路(沙箱 / 插件前端 / 宿主前端)、
前置条件、端口、plugins-dist 与 active-plugins.txt 的关系、以及"怎么确认插件真的加载了"。
本页只补它没有的那部分:对标 Halo / NiuCloud 的做法,给出"远程宿主 + 本地代码"的
四条路径与一条命令的自动化脚本。两页互补,不重复。
一、先看别人怎么做:Halo 与 NiuCloud
用户点名要参考这两家。下面每一条都是查到的,附来源;查不到的地方明说「未查到」。
1.1 Halo:有真正的 development 运行模式,插件从目录加载
Halo 的插件体系建在 PF4J 上,并且明确区分运行模式。源码里的配置项:
// run/halo/app/plugin/PluginProperties.java
/** Runtime Mode:development/deployment. */
private RuntimeMode runtimeMode = RuntimeMode.DEPLOYMENT;
/** ... In the development mode, you can specify the plugin path as the project directory. */
private List<Path> fixedPluginPath = new ArrayList<>();
/** Extended Plugin Class Directory. */
private List<String> classesDirectories = new ArrayList<>();
/** Extended Plugin Jar Directory. */
private List<String> libDirectories = new ArrayList<>(List.of("build/libs"));| 问题 | Halo 的答案 | 来源 |
|---|---|---|
| 有没有 dev 模式 | 有。属性前缀 halo.plugin,runtime-mode 取 development / deployment(PF4J 的 RuntimeMode) | PluginProperties.java |
| 插件从哪加载 | dev 模式从目录,正式模式从 jar。加载器是二选一的:DevPluginLoader(isApplicable = Files.isDirectory(pluginPath))优先,否则 JarPluginLoader | DevPluginLoader.java |
| 目录模式怎么生效 | classpath 从插件的 build/classes + build/libs 拼;fixedPluginPath 让插件根目录直接指向工程目录,而不是拷贝到 plugins/ | PluginProperties.java · PluginAutoConfiguration.java(createPluginRepository 里 DefaultDevelopmentPluginRepository 的 setFixedPaths) |
| 改了 Java 要不要重启 | 不要。改完执行 ./gradlew reloadPlugin 应用改动;不重启 Halo 进程 | DevTools 文档 |
| 能不能自动重载 | 能。./gradlew watch 监视 src/main/ 并自动重新加载(watchDomains 可加目录,默认排除 build/**、src/main/resources/ui/** 等构建产物) | DevTools 文档 |
| 怎么起一套开发环境 | ./gradlew haloServer —— 用 Docker 起一个 Halo,把工作目录挂到插件工程的 workplace/(重启不丢数据),并以开发模式把本插件加载进去;控制台 http://localhost:8090/console,默认 admin/admin | DevTools 文档 |
| 后端怎么打断点 | halo { debug = true },再用 IDE attach;suspend = true 可让它在启动时挂起等调试器 | DevTools 文档 |
| 前端(Console 插件)有没有 HMR | 没有查到"HMR / vite dev server"的机制。查到的是:UI 源码在工程的 ui/ 目录,构建产物进 jar 内的 resources/ui;watch 通过 watchDomains 监听源码变化并触发重载,属"重新构建 + 重载"而不是 Vite HMR | 插件项目结构 · DevTools 文档 |
reloadPlugin 打的是什么 | 文档说它"基于 halo.externalUrl 等配置调用 Halo API 重新加载插件",但页面里没有写出端点路径 | DevTools 文档 |
Halo 把"dev 模式"做成了平台的一等公民:一个属性决定"从目录加载还是从 jar 加载",
再配一个"重新加载这个插件"的触发口(CLI 任务 → HTTP API)。
./gradlew reloadPlugin 之所以能"不重启就生效",靠的就是平台侧的运行期插件重载能力——
而不是靠什么魔法。这一点我们这个平台已经有了(见 §二)。
2026-10-06 起我们把这半截也补上了(只抄思路、不抄实现):
开关 app-market.plugin-runtime.dev-mode + 端点 POST /app/dev/reload,
对应 Halo 的"runtime-mode + reloadPlugin"这两件。没抄的是它"从 build/classes 加载"那半截 ——
我们的插件依赖全是 provided,没有 lib/ 可挂,硬做会做成假功能(见第五节 第 ② 条)。
1.2 NiuCloud:源码目录就地开发,前端独立构建,没有自动热加载
NiuCloud(PHP SaaS 框架)的插件叫 addon,住在站点根的一个目录里:
niucloud/
├── addon/
│ └── hello_world/
│ ├── admin/ 管理端前端源码(api / assets / lang / layout / views)
│ ├── app/ 后端业务代码(adminapi / api / dict / job / listener / model / service / validate)
│ ├── compile/ 编译目录(admin / aliapp / wap / weapp / web)
│ ├── uni-app/ 手机端前端源码
│ ├── web/ web 端前端源码
│ ├── sql/ install.sql / unInstall.sql(安装/卸载时自动执行)
│ ├── package/ 依赖包(admin-package.json / composer.json 等)
│ ├── Addon.php 生命周期:install / uninstall / upgrade
│ └── info.json 插件配置(title / key / version / type / support_version)来源:插件目录结构。
| 问题 | NiuCloud 的答案 | 来源 |
|---|---|---|
| 有没有插件/应用市场 | 有。插件在平台端「开发 → 插件列表」里创建与打包 | 打包插件 |
| 分发形态 | 目录(addon/<key>/)+ 一个通过平台 UI 触发的「打包插件」动作;打包会把前端构建产物整理进插件的 admin / uni-app / web 目录,再归到 dist/ | 同上 |
| 本地怎么调 | 直接改源码目录。后端 PHP 就在 addon/<key>/app/ 下,改完即生效(PHP 无编译期);文档里明确写了插件 uniapp 源码「导入到开发工具中,即可进行二次开发」 | 插件 uniapp 开发 |
| 前端怎么联调 | 独立的 Vite 工程 + .env.development 指后端:VITE_APP_BASE_URL='http://127.0.0.1:8080/adminapi/',然后 npm install + npm run dev | 管理端新手入门 |
| 前端有没有 HMR | 有,因为就是普通 Vite dev server(npm run dev)。另外 uni-app 那侧「可直接在 HBuilder 中运行到浏览器查看实时效果,不需要一直在框架中调试」 | 同上 · 插件 uniapp 开发 |
| 后端改代码要不要重启 | 不要(PHP)。文档里没有"重启服务"这一步 | 同上 |
| 插件前端怎么单独构建 | npm run build:addon {插件key}(也可 npm run build 构建全部),产物进 niucloud/addon/插件/dist/ | 打包插件 |
| 有没有"目录加载 / 开发者模式"开关 | 未查到。文档里没有 dev mode、也没有"从源码目录安装"这类机制 | — |
| 有没有 CLI 脚手架 / 生成器 | 未查到命令行的。查到的是平台 UI 里的「新建插件」与「代码生成器」 | 插件目录结构 · 左侧导航「代码生成器」 |
| 打包是不是每次都要做 | 前端要:文档明确"在打包插件前需对插件进行编译"。但它同时说 2.0 起做了分离构建,「框架可以加载插件编译好的文件。实现插件动态组装后无需进行云编译即可正常使用」——省的是云编译,不是前端构建 | 打包插件 |
| 一个要记住的坑 | 文档原话:「不可以随意卸载插件,否则会导致开发的相关内容全部丢失」 | 插件 uniapp 开发 |
NiuCloud 并没有"不打包就能改后端"的机制 —— 它之所以不用打包,是因为
PHP 的源码目录本身就是运行目录(addon/<key>/app/ 就是被加载的代码),
加上它把源码目录放在了站点内。换句话说:"不用打包"是语言特性换来的,不是平台特性。
前端那侧它反而每次都要重新构建(npm run build:addon {key}),
因为产物是预构建的 ESM/静态文件 —— 这和我们平台的处境一模一样。
我们不可能靠"抄 NiuCloud"省掉打包:我们是 JVM,插件是 jar。
省掉打包的办法只有 Halo 那条路(dev 模式 + 从目录加载),或者我们自己的热加载。
二、对照我们平台:哪些已经具备,哪些缺
先把事实核实清楚(都来自本仓库代码,不是推测)。
| 能力 | 现状 | 依据 |
|---|---|---|
| 插件 jar 从哪个目录加载 | app-market.deploy.plugin-dir,当前值 plugins-dist | wujies-platform/wujies-admin/src/main/resources/application.yml:225 |
| 插件前端产物从哪个目录加载 | app-market.deploy.frontend-dir,当前值 ../wujies-admin/app-market-dist/remote-apps | 同上,:228 |
| 宿主支持运行期热加载 | 支持。PluginRuntimeManager.load(appCode, jar) / unload(appCode) 是公开方法,装/卸/升级/停用全走它 | wujies-app-market/.../plugin/PluginRuntimeManager.java:219(load)、:304(unload)、:366(isLoaded) |
| 有个 HTTP 端点能触发它吗 | 两个:① POST /app/version/{versionId}/deploy(权限 app:console:edit)——走完整落位(解包 + 校验签名 + 释放 jar 与前端产物)再热加载;② 开发模式的 POST /app/dev/reload(权限 app:dev:reload)——直接按本地路径加载,默认不存在 | AppVersionController.java:362-365 → AppPackageDeployService.deploy(versionId) · PluginDevController.java(@ConditionalOnProperty) |
| 那个端点的前置条件 | ① 必须先有一个"目录里的版本行 + 一个已落在对象存储上的包":deploy 开头就是 requireVersion → 没有 packageUrl 直接抛「该版本没有安装包,无法部署」;② 开发端点没有这些前置,只要路径存在并且是 jar | AppPackageDeployService.java:170-173、:182(download) |
| 有没有"直接热加载本地某个 jar"的端点 | 有,但默认关闭:POST /app/dev/reload,由 app-market.plugin-runtime.dev-mode=true 装配出来(关闭时整个 controller 不注册 ⇒ 404)。见下面方案 B-2 | PluginDevController.java · PluginDevReloadService.java · AGENTS.md §3.11 |
| 热加载能不能重复对同一个版本做 | 能。deploy(versionId) 里没有任何"版本状态/是否已部署"的判断;文档也写明「重新发布同一个版本号也会产生新的 version_id」,说明重复部署是预期用法 | AppPackageDeployService.java:142-144 · AppVersionController.syncMenus 的注释 |
| 部署响应怎么读 | hotLoaded(是否真的热加载成功)、hotLoadNote(一句话摘要)、restartRequired、staleJars | AppPackageDeployService.java:331-364 |
| 插件前端工程的产物能指到宿主目录吗 | 能。WUJIES_FRONTEND_DIST 环境变量,工厂会把 <appCode> 追加在后面 | wujies-plugins/frontend/build/vite.ts:209-212 |
宿主 dev server 从哪提供 /remote-apps/** | 只从 <宿主根>/app-market-dist/,sourceDir 写死不可配置 | plus-ui/vite/plugins/appMarketStatic.ts:27、:30 |
| 宿主能不能吃一个绝对 URL 作为远程应用入口 | 代码上能:入口是 import(/* @vite-ignore */ entry),entry 是任意字符串,没有协议/同源校验 | plus-ui/src/plugins/appmarket/loader.ts:130-145 |
import() 接受绝对 URL,不等于"指向本地 vite dev server 就能用"。这条路不可行,
理由见下面方案 C。不要试着走它。
方案 A:全本地跑宿主
场景:一个人独立开发,有自己的 MySQL / Redis,想完全掌控重启与日志。
步骤:
# ① 平台构件进本地仓库(插件以已发布构件形式依赖平台;平台升级后要重跑)
cd wujies-platform
& $mvn -B -pl wujies-admin -am install -DskipTests
# ② 起宿主 —— ⚠️ 工作目录必须是 wujies-platform/
# deploy 下两个路径都是相对路径,在别处起会让产物落到错误位置
java "-Dloader.path=plugins-dist" "-Dspring.profiles.active=dev" -jar "wujies-admin\target\wujies-admin.jar"
# ③ 另开一个终端:宿主前端 dev server
cd ..\plus-ui
npm run dev # 80 端口,/dev-api 代理到 127.0.0.1:8086
# ④ 再开一个:插件前端,把产物直接落到宿主 dev server 认的目录
cd ..\wujies\wujies-plugins
$env:WUJIES_UI_DIR = 'C:\Users\TX\Desktop\ruoyi\plus-ui'
$env:WUJIES_FRONTEND_DIST = 'C:\Users\TX\Desktop\ruoyi\plus-ui\app-market-dist\remote-apps'
pnpm --filter @wujies-plugins/hello-app run build| 说明 | |
|---|---|
| 适合 | 独立开发;需要打断点;需要改宿主代码;不依赖线上数据 |
| 快了在哪 | 改前端 → 重跑第 ④ 步 → 刷新浏览器(宿主 dev server 直接读磁盘,响应带 Cache-Control: no-cache)。改后端 → 只重建插件 jar,见下 |
| 后端那段回路 | 源码级热加载没有。两个选择:① 用沙箱(H2 内存库,不需要 MySQL / Redis / RabbitMQ):powershell -File sandbox\run.ps1 -Plugin wujies-hello,端口 18080 —— 适合先验语法与装载;② 重建 jar 后重启宿主,或把 jar 覆盖进 plugins-dist 再重启。⚠️ 覆盖同名文件最省事(active-plugins.txt 不用改);换文件名就必须同步改清单 |
| 局限 | ① 后端改一行要重启(几十秒);② 沙箱不是生产宿主(不做安装/签名/菜单同步,H2 schema 是开发用最小集);③ 两套插件加载机制不能同时开,要退回旧机制必须用 JVM 参数 -Dapp-market.plugin-runtime.enabled=false(只在 yml 里改会让插件完全不加载) |
详细前置、端口表与"怎么确认插件真的加载了"见本地联调。
方案 B:远程宿主 + 本地产物
场景:想用线上(或测试环境)那套真实数据与配置联调,但不想为了改一行代码就重启那个宿主。
两条快路径,别混:改前端和改后端在这个方案里走的是完全不同的路,原因也很直白 —— 前端产物是静态文件(宿主 dev server 直接读磁盘,覆盖即生效),后端产物是jar(需要一个热加载入口)。
| 改动 | 走哪条 | 有没有前置 | 为什么 |
|---|---|---|---|
| 插件前端 Vue/TS | B-1:重建 ESM → scp 覆盖目录 | 没有(不需要任何开关、不需要签名、不需要端点) | 静态资源,宿主按文件读,改完刷新浏览器就是新的 |
| 插件后端 Java | B-2:dev-mode + POST /app/dev/reload | 宿主开 dev-mode 并重启一次 | jar 要经过类加载器,必须有人调宿主的热加载能力 |
后端那半截的核心事实:宿主本来就能运行期热加载(PluginRuntimeManager.load(appCode, jar):
卸载旧插件 → 用新 jar 建类加载器与子上下文 → 注册接口)。原先缺的只是"把本地那份产物递进去"的入口:
旧入口 POST /app/version/{versionId}/deploy 要求先有一个包在对象存储上 + 目录里有版本行;
开发模式补上的 POST /app/dev/reload 只要求路径存在 —— 这就是 B-2 省掉的那一大段。
B-1 前端产物:最省事,直接覆盖目录
前端产物不是 jar,不涉及类加载器,落位即生效:
cd wujies\wujies-plugins
# 插件的 vite.config.ts 用 defineRemoteApp(),产物是纯静态 ESM
$env:WUJIES_UI_DIR = 'C:\Users\TX\Desktop\ruoyi\plus-ui'
pnpm --filter @wujies-plugins/hello-app run build
# 把 dist 推到远程宿主的 remote-apps 目录(路径 = 宿主的 app-market.deploy.frontend-dir/<appCode>)
scp -r .\frontend\hello-app\dist\* user@host:/path/to/wujies-admin/app-market-dist/remote-apps/hello/刷新浏览器即可(入口 URL 带 ?v=<版本号>,同版本号下必要时硬刷新)。
因为前端产物由宿主 dev server / 静态资源目录直接读磁盘,没有类加载器、没有
active-plugins.txt、没有签名校验。它也不需要热加载端点 —— 注意 B-2 那个
POST /app/dev/reload 只管后端 jar,前端产物连它的作用范围都不在
(dev-mode 开着也一样是"scp 覆盖目录即生效")。
B-2 后端 jar:dev-mode + POST /app/dev/reload(推荐,秒级)
它把什么省掉了:对象存储、版本行、发布、签名 —— 一条命令直接从本地路径热加载。
宿主本来就能运行期热加载(PluginRuntimeManager.load(appCode, jar)),原先唯一缺的是
"把本地那份产物递进去"的入口;开发模式就是那个入口。
前置条件(一次性,要重启宿主一次)
在宿主(wujies-platform/wujies-admin/src/main/resources/application.yml)里打开开关:
app-market:
plugin-runtime:
# 默认 false;打开后才有 POST /app/dev/reload。⚠️ 仅本地/内网联调
dev-mode: true
# 可选:只允许加载这个目录之下的 jar(默认空 = 不限制任何绝对路径)
dev-base-dir: ""⚠️ 这个开关是 @ConditionalOnProperty 判定的,必须重启宿主才能生效
(改完这一次之后,后面每次改插件代码都不用再重启了)。
打开后启动日志第一屏会出现(照仓库既有风格的中文告警)—— 看到它就说明这台机器不是生产:
⚠️ 插件开发模式已开启(app-market.plugin-runtime.dev-mode=true)—— 仅用于本地联调,绝不要在生产打开
· 开发端点:POST /app/dev/reload(body: appCode + path),需登录 + 权限 app:dev:reload
· 它会**绕过**包白名单 / MD5 / SHA256 / **签名**校验(不经过 AppPackageDeployService),并且**不写** active-plugins.txt
· ⚠️ 未限制可加载路径(app-market.plugin-runtime.dev-base-dir 为空)—— 任何绝对路径上的 jar 都能被加载回路:改一行 Java → 重建 jar → 一条命令
# ① 重建插件 jar —— 只 build,**不打包 zip、不签名**(dev-mode 不验签)
# 在插件仓库根(wujies-plugins/):模块名是目录名,产物在 <模块>/target/
& $mvn -B -pl wujies-hello -am package -DskipTests
# → wujies-hello\target\wujies-hello-plugin-<maven 版本>.jar
# ② 一条命令热加载(宿主不重启)
$base = 'http://<host>:8086' # ← 你的宿主地址
$token = '<你的登录 token>' # ← 浏览器里 Authorization 头的值去掉 "Bearer " 前缀之后的那串
$tenant = '000000'
curl.exe -s -X POST "$base/app/dev/reload" `
-H "Authorization: Bearer $token" -H "tenant-id: $tenant" `
-H "Content-Type: application/json" `
-d '{"appCode":"hello","path":"<插件仓库绝对路径>/wujies-hello/target/wujies-hello-plugin-1.0.0.jar"}'path 也可以指向只含一个 jar 的目录(比如直接指 <...>/wujies-hello/target),
这时服务端会自动选中那唯一的 jar 并把实际选中的绝对路径写进报告与日志;
目录里有 0 个或 ≥2 个 jar 时它会明确报错并列出候选,不会去猜。
响应怎么读(一份可核对的报告)
{ "code": 200, "data": {
"appCode": "hello",
"path": "<解析后的绝对路径>",
"jarFileName": "wujies-hello-plugin-1.0.0.jar",
"devMode": true,
"signatureVerified": false, // ← 如实告诉你:这条路径没验签
"activePluginsFileTouched": false, // ← 没写 active-plugins.txt
"unloadedPrevious": true, // ← 本次是先卸了上一代再装的
"componentCount": 150,
"endpointCount": 224,
"mapperCount": 26,
"classLoader": "PluginClassLoader[hello <- wujies-hello-plugin-1.0.0.jar]",
"elapsedMs": 148
} }字段与 PluginRuntimeManager.load() 那条加载日志同源(都取自同一个运行时快照),
所以"报告说组件 150 个"与"日志说组件 150 个"不可能对不上。
安全前提(这一节必须读完再用)
- 默认关闭。
dev-mode不配 / 配false时,整个 controller 不注册 ——POST /app/dev/reload就是 404(不是 403/500),与"这版程序没这个功能"完全一致; - 它绕过包校验与签名校验。这条路径不经
AppPackageDeployService,所以 产物白名单 / MD5 / SHA256 /app-market.security.mode=enforce一处都不走。 这是刻意的(本地联调不该为每个临时 jar 签名),但它意味着 "能调这个端点的人 = 能在宿主 JVM 里跑任意代码"; - 鉴权是两层:登录(宿主路由统一由
SaInterceptor兜底,匿名 401)+ 权限app:dev:reload。超级管理员天然持有*:*:*,所以开发者零配置;别的账号必须被显式授权 (只查登录的话,任何登录用户都能 RCE); - 只在本地 / 内网用。共享联调机上建议配
dev-base-dir把可加载路径收进插件构建目录; - 重启后需要重新 reload 一次(见下一条"它不做什么")。
它不做什么(这几条都是刻意的)
| 不做 | 后果 / 原因 |
|---|---|
不写 active-plugins.txt | 那份清单是部署链路维护的("每个应用当前生效哪个 jar"),开发模式绝不参与。代价:重启后需要重新 reload 一次 —— 除非那个 jar 恰好落在 plugin-dist 里,那时启动期会按"手工放置的 jar"把它加载起来 |
不建 app_install / app_version 数据行 | 所以它不需要先把应用装过一次;但也因此菜单、前端产物、迁移脚本都不管 —— 菜单仍来自安装/升级链路,前端产物见 B-1 |
不做 Halo 那种 classes + libs 目录形态 | 我们的插件依赖全是 provided(AGENTS §2.1),构建产物里根本没有可挂载的 lib/ 目录,单独挂 classes/ 一调接口就 NoClassDefFoundError。所以只支持 jar(或"只含一个 jar 的目录") |
| 加载失败时不保留旧版本 | 既有热加载语义就是"先卸后装"(PluginRuntimeManager.load 内部如此),失败后该应用处于"未加载"。路径写错则不会走到卸载那一步(校验在前),跑着的插件不受影响 |
局限(如实说)
- jar 还是要"到那台机器上":dev-mode 省掉的是对象存储 / 版本行 / 发布 / 签名,
不是网络传输。宿主在远端时,你仍需把
target/*.jar放过去一次 (scp即可,比"打包 zip + 上传 + 建版本"短得多,但不是零传输); - 要动宿主的配置并重启一次(
dev-mode是启动期装配的); - 不要在生产打开。误开 = 任意 jar 加载 = 任意代码执行(虽然仍需登录 + 权限);
- 适合"连远程数据联调";如果你能随便重启宿主,方案 A 的沙箱更快。
B-2(旧做法,保留):没有 dev-mode 时,上传包 → 对同一 versionId 重复部署
若你的宿主版本还没有 dev-mode(或你不愿意为它改配置、重启),就走这条。
核心事实:热加载的唯一 HTTP 入口是 POST /app/version/{versionId}/deploy,
它要求先有一个包在对象存储上。所以这条路的最小闭环是"上传包 → 部署同一个版本",
中间不发布新版本、也不重启宿主。
$base = 'http://<host>:8086'
$token = '<你的登录 token>'
$tenant = '000000'
$appId = <hello 的 appId>
# ① 打包(含前端构建、组装、签名 —— enforce 模式必须签)
# 产物默认落在**仓库外**的 ..\.plugin-dist\<code>\<version>\,见「打包应用」一页
& .\package.ps1 -Plugin wujies-hello -SigningKey keys\private.pem
# (正式发版用 .\release.ps1 -Plugin wujies-hello:它还会打 git tag)
# ② 上传包(服务端解包读清单 → 落对象存储 → 回填 packageUrl/size/md5/sha256)
$parsed = curl.exe -s -X POST "$base/app/package/parse" `
-H "Authorization: Bearer $token" -H "tenant-id: $tenant" `
-F "file=@..\.plugin-dist\hello\1.0.1\hello-1.0.1.zip" -F "appId=$appId"
# ③ 部署(落位 + 热加载,不重启)
curl.exe -s -X POST "$base/app/version/<versionId>/deploy" `
-H "Authorization: Bearer $token" -H "tenant-id: $tenant"看响应里的 hotLoaded:true 就是成功了(hotLoadNote 是「插件 X 已加载:组件 N 个」这类摘要);
false + restartRequired: true 说明这次没热加载成功,产物已经落位、重启会生效。
| 说明 | |
|---|---|
| 适合 | 宿主版本较老 / 不想改宿主配置 / 需要走一遍真实签名链路 |
| 步骤要点 | 只做三件事:打包 → 上传包 → 对同一个 versionId 重复部署。不需要新建版本、不需要重新发布、不需要重启宿主 |
| 快了在哪 | 相对"发一个新版本等市场升级":省掉版本管理、菜单同步、迁移脚本、升级状态机 |
| 局限 | ① 仍然要打包并上传(这是还慢着的那一环,用方案 D 自动化);② 第一次要先把应用装上(有安装记录),否则没有 appId / versionId 可用;③ app-market.security.mode=enforce 下包必须签名,否则上传就被拒;④ Windows 上旧 jar 可能被 JVM 占用删不掉 → 响应里的 staleJars 会列出来,别留下两个同名 appCode 的 jar |
方案 C:为什么不能指向本地 vite dev server
用户问过这条路:远程宿主 + 本地前端 HMR,只改前端时最快。 我按"先核实是否可行、不可行就明说"的要求去读了代码。结论:不可行,别试。
代码上"看起来能"的部分
入口确实没有任何校验:
// plus-ui/src/plugins/appmarket/loader.ts:130-145
export function loadRemoteApp(entry: string): Promise<RemoteAppRecord> {
const task = (async () => {
const runtime = requireRuntime();
// 变量说明符 + @vite-ignore:让 Vite/Rollup 保留为运行时 import,不参与打包
const module = (await import(/* @vite-ignore */ entry)) as RemoteAppModule;
return registerRemoteAppModule(module, entry, runtime);
})();
}entry 是任意字符串,import() 也接受绝对 URL。入口解析器同样宽松:
setRemoteAppEntryResolver 只要求返回一个字符串,静态清单
(VITE_APP_MARKET_REMOTE_APPS)里写 hello=http://localhost:5173/index.js 语法上是合法的
(plus-ui/src/plugins/appmarket/bootstrap.ts:34-50)。
实际跑不通的三个原因
① 插件前端工程根本没有 dev server。 defineRemoteApp() 返回的是一份
库模式构建配置,没有 server 段:
// wujies-plugins/frontend/build/vite.ts:449-463
build: {
outDir: opts.outDir ?? resolveOutDir(root, appCode),
lib: { entry: path.resolve(root, 'src/index.ts'), formats: ['es'], fileName: () => 'index.js' },
rollupOptions: { external: [...SHARED_EXTERNALS, ...(opts.extraExternals ?? [])] }
}它 external 掉了 vue / vue-router / pinia / element-plus / @dromara/*,
指望宿主 index.html 里的 import map 在运行期把它们映射到 /app-runtime/*.js 垫片
(plus-ui/index.html:20-30)。所以"起个 dev server 就能 HMR"这件事在这个工程里不存在 ——
它只能 vite build。
② 跨源加载模块要 CORS,宿主 dev server 不提供。 即便你在别的机器上手工起了个静态服务,
import('http://localhost:5173/index.js') 是跨源模块请求,浏览器按 CORS 规则拦;
而 appMarketStatic.ts 只处理同源的 /remote-apps/**(isOwned 按路径前缀判断,
plus-ui/vite/plugins/appMarketStatic.ts:47-50),插件工程那边的静态服务也不会自带
Access-Control-Allow-Origin。
③ 最根本的一条:宿主的静态服务不从这里读。 sourceDir 写死:
// plus-ui/vite/plugins/appMarketStatic.ts:26-30
const root = process.cwd();
const sourceDir = path.resolve(root, 'app-market-dist');
const urlPrefixes = ['/app-runtime/', '/remote-apps/'];它只从 <宿主根>/app-market-dist/ 读磁盘文件,没有任何代理/转发能力。
所以"给宿主一个 http://localhost:5173/... 的入口"这件事,宿主侧没有任何配套支持。
那个"最接近可行"的替代方案
不要 dev server,改成构建监听。vite build --watch 在产物目录里持续重建,
而该目录正是宿主 dev server 直接读磁盘的那一个(响应带 Cache-Control: no-cache),
所以改完文件 → 重建完成 → 刷新浏览器即可:
cd wujies\wujies-plugins
$env:WUJIES_UI_DIR = 'C:\Users\TX\Desktop\ruoyi\plus-ui'
$env:WUJIES_FRONTEND_DIST = 'C:\Users\TX\Desktop\ruoyi\plus-ui\app-market-dist\remote-apps'
pnpm --filter @wujies-plugins/hello-app exec vite build --watch这是方案 A 的一个变体,不是 HMR:省的是"每次手动敲构建命令",省不掉"刷新浏览器"。
⚠️ 上表那条 D. 与这条的适用场景不同 —— 这条只对本地宿主有效,
因为落点必须是宿主 dev server 读的那个目录。
见文末缺什么。简单说:要么给远程应用加一个 "dev 入口 URL"(配合 CORS 与 import map 转发),要么把 dev server 的模块图接进宿主的加载器。 这两件都不是插件作者能自己解决的。
方案 D:一条命令的"打包 + 上传 + 热加载"脚本
场景:日常改后端,要连远程宿主那套数据。把方案 B 自动化掉。
它依赖的真实端点(全部已核实存在):
| 端点 | 方法 | 权限 | 作用 |
|---|---|---|---|
/app/console/list | GET | app:console:list | 按 code 找到 appId |
/app/version/list?appId= | GET | app:console:list | 找可复用的 versionId |
/app/package/parse | POST(multipart) | app:console:add | 上传包 → 落对象存储 → 回填 packageUrl / size / md5 / sha256 |
/app/version | POST(JSON) | app:console:add | 新建版本行(只在没有可复用版本时) |
/app/version/{id}/publish | POST | app:console:edit | 发布(回写 app_info.latest_version) |
/app/version/{id}/deploy | POST | app:console:edit | 落位 + 热加载,不重启宿主 |
来源:AppConsoleController.java、AppVersionController.java、AppPackageController.java。
脚本骨架(PowerShell)
把 <...> 换成你自己的值;先在一台测试部署上跑通。本页作者没有连接任何服务器,
脚本未经端到端实跑 —— 端点的存在性与字段名来自源码,但"你的部署能不能过"
还取决于签名模式、网络、权限。
# dev-loop.ps1 —— 打包 + 上传 + 部署同一版本(热加载,不重启宿主)
# 用法: .\dev-loop.ps1 -Plugin wujies-hello -AppCode hello
param(
[Parameter(Mandatory)][string]$Plugin, # 插件仓库里的模块名,如 wujies-hello
[Parameter(Mandatory)][string]$AppCode, # = app.json 的 code,如 hello
[string]$Base = 'http://127.0.0.1:8086',
[string]$Token = $env:WUJIES_TOKEN,
[string]$Tenant = '000000',
[string]$SigningKey = 'keys\private.pem' # enforce 模式下必填,否则上传被拒
)
$ErrorActionPreference = 'Stop'
$headers = @{ Authorization = "Bearer $Token"; 'tenant-id' = $Tenant }
function Invoke-Api($Method, $Path, $Body) {
# ⚠️ 别把变量取名 $args —— 它在 PowerShell 里是保留的自动变量
$req = @{ Method = $Method; Uri = "$Base$Path"; Headers = $headers }
if ($Body) { $req.Body = ($Body | ConvertTo-Json -Depth 12); $req.ContentType = 'application/json' }
(Invoke-RestMethod @req).data
}
# ---------- ① 打包(含前端构建、组装、签名) ----------
& .\package.ps1 -Plugin $Plugin -SigningKey $SigningKey
# 产物落在**仓库外**:..\.plugin-dist\<code>\<version>\<code>-<version>.zip
# (按 app.json 的 code / version 拼出来,比按文件名猜稳)
$ver = (Get-Content "$Plugin\app.json" -Raw -Encoding UTF8 | ConvertFrom-Json).version
$zip = Get-Item "..\.plugin-dist\$AppCode\$ver\$AppCode-$ver.zip"
Write-Host "包: $($zip.FullName)"
# ---------- ② 找 appId ----------
$app = Invoke-Api GET '/app/console/list' | Where-Object { $_.appCode -eq $AppCode } | Select-Object -First 1
if (-not $app) { throw "目录里没有应用 $AppCode —— 先在控制台登记它" }
# ---------- ③ 上传包,拿 packageUrl / 校验和 ----------
$parsed = (curl.exe -s -X POST "$Base/app/package/parse" `
-H "Authorization: Bearer $Token" -H "tenant-id: $Tenant" `
-F "file=@$($zip.FullName)" -F "appId=$($app.appId)") | ConvertFrom-Json
if ($parsed.code -ne 200) { throw "上传失败: $($parsed.msg)" }
$pkg = $parsed.data
Write-Host "已上传: routeIndexSource=$($pkg.routeIndexSource) routePatternCount=$($pkg.routePatternCount)"
# ---------- ④ 复用版本行;没有就新建一条 ----------
$ver = Invoke-Api GET "/app/version/list?appId=$($app.appId)" | Select-Object -First 1
if ($ver) {
$versionId = $ver.versionId
# ⚠️ 已发布的版本不允许改清单;这里只把 packageUrl 指到新包(字段名以 deploy 的报告为准)
Invoke-Api POST "/app/version/$versionId/repack" @{ packageUrl = $pkg.packageUrl } | Out-Null
} else {
# manifestJson 取自包内 app.json(parse 已经把清单解出来了)
$versionId = Invoke-Api POST '/app/version' @{
appId = $app.appId
manifestJson = $pkg.manifestJson
}
}
if (-not $ver) { Invoke-Api POST "/app/version/$versionId/publish" | Out-Null }
# ---------- ⑤ 部署 = 落位 + 热加载(不重启) ----------
$report = Invoke-Api POST "/app/version/$versionId/deploy"
if ($report.hotLoaded) {
Write-Host "✓ 热加载成功: $($report.hotLoadNote)" -ForegroundColor Green
} else {
Write-Warning "未热加载 (restartRequired=$($report.restartRequired));产物已落位,重启会生效"
if ($report.staleJars) { Write-Warning "旧 jar 删不掉: $($report.staleJars -join ', ')" }
}骨架里标了 ⚠️ 的那一步,说明一下
repack 这个名字是我按"把已发布版本的 package_url 指到新上传的包"这件事拟的:
AppVersionController 现有端点里,edit(PUT /app/version)会拒绝已发布版本改清单,
但没有一个专门"只换包、不改清单"的端点。所以你实际落地时有两条选择:
- 每次 bump 版本号(
1.0.1→1.0.2),走"新建版本 → 发布 → 部署"—— 完全用现有端点,这条一定能跑,代价是要管版本号; - 或者按
AGENTS.md§3.5 那条「就地重签」的思路,直接用返回的package_url/size/md5/sha256更新那一行app_version(连package_url一起换)。 这条路要能直接改库。
这也曾是"平台缺一样东西"的一个具体落点(下节第 ① 条):当时没有"用本地构建产物直接热加载"的端点, 所以任何脚本都得绕经"包 + 版本行"。2026-10-06 起这条已经补上了 —— 见 B-2 的 dev-mode。
五、我们发现平台缺哪三样东西
如实列出来。第 ① 条已于 2026-10-06 实现(dev-mode + POST /app/dev/reload,见 B-2);
其余两条仍然缺,各自都是独立的平台改动。
| # | 缺什么 | 现在只能怎么绕 | 加上之后能省掉什么 |
|---|---|---|---|
app-market.plugin-runtime.dev-mode(默认 false)+ POST /app/dev/reload(登录 + 权限 app:dev:reload;关闭时整套 controller 不注册 ⇒ 404)。绕过签名校验是刻意的,所以只在本地/内网用、启动日志有醒目告警。⚠️ 实现里没有采用原文设想的"只允许 plugin-dir 根下的文件",而是默认不限制 + 可选的 dev-base-dir 收紧(取舍见 PluginDevReloadService.resolveJar 的注释)。 | 现在:重建 jar → 一条命令,秒级,不碰目录数据。旧做法(打包 → 上传 → 部署)保留为宿主没开 dev-mode 时的兜底。 | — | |
| ② | 没有 runtime-mode=development 这样的"从目录加载"开关(Halo 有 halo.plugin.runtime-mode + fixedPluginPath + DevPluginLoader)。我们只有 app-market.plugin-runtime.enabled 这个"用哪套加载机制"的开关,dev-mode 也只吃 jar / 只含一个 jar 的目录。 | 改一行 Java 必须重新出 jar(mvn package),没有"指向 target/classes"这条路。 | 若能按 Halo 那样从目录加载(target/classes + 依赖目录),mvn compile 之后重载即可,连打包都省掉。实现代价不小:PluginClassLoader 现在只吃 jar,而且我们的依赖全是 provided,没有 lib/ 可挂 —— 要做就得先解决"依赖从哪来"。 |
| ③ | 远程应用没有 dev 入口的支持(CORS + import map 转发),所以"远程宿主 + 本地前端 HMR"做不到。 | 只能"本地构建 → 推到宿主的 app-market-dist/",或者用 vite build --watch 手动刷新(方案 C 末尾那条)。 | 能给真 HMR。但要注意:这不是"加个头就行" —— 还得解决 import map 幂等、@dromara/* 垫片的 dev 版本、以及 dev 入口在生产环境必须硬关掉。优先级排在 ② 之后。 |
另有两条已知的既有缺口,与本页主题相关但不是新发现(记录在
AGENTS.md 的遗留清单里):
script/prune/start-host.ps1与prune-plugins.ps1会把工程根算错,现在别直接跑 (详见本地联调 §一的 warning);- 验证"应用停用后接口该说什么"这类改动时,不要再叠
-Dloader.path—— 那会把插件类同时注册到宿主 mapping 上,卸载摘不掉,表现为"停用了接口还能用"。
六、一句话选择指南
一个人独立开发,能随便重启宿主 → 方案 A
改前端,宿主在哪都行 → 方案 A 第④步(本地)或方案 B-1(远程 scp,秒级)
改后端,宿主开了 dev-mode → 方案 B-2:重建 jar + POST /app/dev/reload(秒级)← 首选
改后端,宿主没开 dev-mode → 方案 B-2 旧做法,或用方案 D 压成一条命令
想给远程宿主配 HMR → 方案 C:不可行,用 `vite build --watch` 代替
宿主本身(plus-ui / wujies-platform) → 不在本页,见 /plugin-dev/backend/local-dev相关阅读
- 本地联调 —— 先读这一页:三条链路、前置条件、端口、
active-plugins.txt、怎么确认插件真的加载了 - 插件能力速查(怎么调) —— 改完代码要写什么
- 前端远程应用 ——
WUJIES_FRONTEND_DIST、组件白名单、守门脚本 - 后端工程 —— 构建、打包、沙箱
- 插件运行时 ——
PluginRuntimeManager与类加载器的实现(dev-mode就是它的一个开发专用入口) - 应用市场模块 —— 安装 / 升级 / 部署的生命周期
- 打包应用 / 包签名 ——
package.ps1与enforce模式(dev-mode绕过这一页讲的全部校验) - 故障排查 · 应用任务中心