Wujies 无界平台
平台功能(自研)插件开发

本地开发与联调方案

这一页解决用户最直接的那个抱怨:

总不能我写点东西,就重新打包 → 上传 → 安装嘛。

先给结论:日常改代码不需要走完整上架流程。平台已经具备运行期热加载能力, 问题只在于"怎么把新产物递进去"。这一页给出四条可执行的路径,每条写清适合什么场景 / 步骤 / 命令 / 局限,以及一条明确的不可行说明。

我改了什么走哪条路一次回路多长
插件前端 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 的做法,给出"远程宿主 + 本地代码"的 四条路径与一条命令的自动化脚本。两页互补,不重复。

2026-10-20 更新:这些操作已经有后台页面

新建骨架、热加载、打开发包都搬进了 「应用上架 → 插件开发」,不需要再手敲 curl。 第一次做插件请直接看快速开始,页面细节见可视化开发台。 本页保留为原理与兜底方案:为什么热加载只能管后端、远程宿主时前端产物怎么过去、 没有 dev-mode 的旧宿主怎么办。

2026-10-06 更新:平台已经补上了 Halo 那条路(dev-mode)

本页原文的结论是"平台缺三样东西",其中第 ① 条(没有"热加载本地 jar"的入口)已经实现: 开关 app-market.plugin-runtime.dev-mode(默认 false)+ 端点 POST /app/dev/reload。 于是"改一行后端代码"的回路从"打包 → 上传 → 建版本 → 安装"压成了 "重建 jar → 一条命令",见下面方案 B-2。 ⚠️ 它是开发专用、默认关闭、且绕过签名校验 —— 什么时候能用、代价是什么,B-2 里逐条写明。 第 ② ③ 条(从 target/classes 直接加载、远程应用 HMR)仍然缺,见第五节。


一、先看别人怎么做: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))优先,否则 JarPluginLoaderDevPluginLoader.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/adminDevTools 文档
后端怎么打断点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 这里真正值得抄的一件事

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 这里得到的真正结论

NiuCloud 并没有"不打包就能改后端"的机制 —— 它之所以不用打包,是因为 PHP 的源码目录本身就是运行目录(addon/<key>/app/ 就是被加载的代码), 加上它把源码目录放在了站点内。换句话说:"不用打包"是语言特性换来的,不是平台特性。

前端那侧它反而每次都要重新构建(npm run build:addon {key}), 因为产物是预构建的 ESM/静态文件 —— 这和我们平台的处境一模一样。 我们不可能靠"抄 NiuCloud"省掉打包:我们是 JVM,插件是 jar。 省掉打包的办法只有 Halo 那条路(dev 模式 + 从目录加载),或者我们自己的热加载。


二、对照我们平台:哪些已经具备,哪些缺

先把事实核实清楚(都来自本仓库代码,不是推测)。

能力现状依据
插件 jar 从哪个目录加载app-market.deploy.plugin-dir,当前值 plugins-distwujies-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 直接抛「该版本没有安装包,无法部署」;② 开发端点没有这些前置,只要路径存在并且是 jarAppPackageDeployService.java:170-173、:182(download)
有没有"直接热加载本地某个 jar"的端点有,但默认关闭:POST /app/dev/reload,由 app-market.plugin-runtime.dev-mode=true 装配出来(关闭时整个 controller 不注册 ⇒ 404)。见下面方案 B-2PluginDevController.java · PluginDevReloadService.java · AGENTS.md §3.11
热加载能不能重复对同一个版本做能。deploy(versionId) 里没有任何"版本状态/是否已部署"的判断;文档也写明「重新发布同一个版本号也会产生新的 version_id」,说明重复部署是预期用法AppPackageDeployService.java:142-144 · AppVersionController.syncMenus 的注释
部署响应怎么读hotLoaded(是否真的热加载成功)、hotLoadNote(一句话摘要)、restartRequired、staleJarsAppPackageDeployService.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/TSB-1:重建 ESM → scp 覆盖目录没有(不需要任何开关、不需要签名、不需要端点)静态资源,宿主按文件读,改完刷新浏览器就是新的
插件后端 JavaB-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=<版本号>,同版本号下必要时硬刷新)。

为什么这条路比 jar 省事

因为前端产物由宿主 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 内部如此),失败后该应用处于"未加载"。路径写错则不会走到卸载那一步(校验在前),跑着的插件不受影响

局限(如实说)

  1. jar 还是要"到那台机器上":dev-mode 省掉的是对象存储 / 版本行 / 发布 / 签名, 不是网络传输。宿主在远端时,你仍需把 target/*.jar 放过去一次 (scp 即可,比"打包 zip + 上传 + 建版本"短得多,但不是零传输);
  2. 要动宿主的配置并重启一次(dev-mode 是启动期装配的);
  3. 不要在生产打开。误开 = 任意 jar 加载 = 任意代码执行(虽然仍需登录 + 权限);
  4. 适合"连远程数据联调";如果你能随便重启宿主,方案 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 读的那个目录。

想要真 HMR 需要平台做什么

见文末缺什么。简单说:要么给远程应用加一个 "dev 入口 URL"(配合 CORS 与 import map 转发),要么把 dev server 的模块图接进宿主的加载器。 这两件都不是插件作者能自己解决的。


方案 D:一条命令的"打包 + 上传 + 热加载"脚本

场景:日常改后端,要连远程宿主那套数据。把方案 B 自动化掉。

它依赖的真实端点(全部已核实存在):

端点方法权限作用
/app/console/listGETapp:console:list按 code 找到 appId
/app/version/list?appId=GETapp:console:list找可复用的 versionId
/app/package/parsePOST(multipart)app:console:add上传包 → 落对象存储 → 回填 packageUrl / size / md5 / sha256
/app/versionPOST(JSON)app:console:add新建版本行(只在没有可复用版本时)
/app/version/{id}/publishPOSTapp:console:edit发布(回写 app_info.latest_version)
/app/version/{id}/deployPOSTapp: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)会拒绝已发布版本改清单, 但没有一个专门"只换包、不改清单"的端点。所以你实际落地时有两条选择:

  1. 每次 bump 版本号(1.0.1 → 1.0.2),走"新建版本 → 发布 → 部署"—— 完全用现有端点,这条一定能跑,代价是要管版本号;
  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); 其余两条仍然缺,各自都是独立的平台改动。

#缺什么现在只能怎么绕加上之后能省掉什么
① ✅没有一个"热加载本地 jar"的入口 已实现:开关 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

相关阅读

目录