Wujies 无界平台
运维

故障排查

本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。

新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。

这一页解决**「现在坏了,先看哪儿」**:按现象给判据与处理。每条都来自实测记录(wujies-platform/AGENTS.md 的实测段落、工作区 docs/开发问题与解决方案.md、以及平台源码本身),不是推测。

写法上刻意写到可核对的粒度:类名、配置键、日志原文、文件路径都给出来。标了「见某文件」的,就是让你直接去读那处代码 —— 宁可让你自己核对,也不写一句查不到的话。

设计意图(为什么会这样)不在这里,各节末尾与页脚给出对应页面。

排查纪律(先读,能省一半时间)
  1. 第一站永远是「我的应用 → 应用任务」。 安装/升级/卸载的步骤与日志是边跑边落库的,失败原因(含服务端异常文本)就在那条任务记录里,比翻服务端日志快。见 任务中心与日志。
  2. 验证跨进程调用时,不要一边用 curl 手工打对端。 否则对端访问日志里那几条 GET /app/catalog/... 是你自己打的,你会把「我打成功了」当成「调用方链路通了」—— 这个坑真实踩过。同理 HTTP 200 不等于成功:远程目录出现过「返回 200 但 body 是错误信封」。
  3. 先看「非对称」。 同一流程里 A 成 B 败,B 的差异点就是线索:jar 删不掉的死循环就是靠「同一分钟里 im 的 jar 删得掉、mall 的删不掉」定位的。
  4. 每次只改一件事,并让失败可见(日志/计数/断言),改完如实说「改完了,请验」,不要说「修好了」。

启动与加载

现象判据与原因处理
启动直接失败,最后一句是 Result Maps collection already contains value同一个 appCode 名下两个版本的 jar 同时进了 MyBatis,各自带一份同样的 mapper XML。两个加载时机(启动期的 PluginClassPathLoader.listJars、运行期的 PluginRuntimeManager)都按 active-plugins.txt 选版本,但清单缺失或为空时两者都不过滤,退化成「目录下所有 jar 全挂」先确认 <插件目录>/active-plugins.txt 存在且非空(格式 appCode=jar 文件名)。再清残留 jar(见下面那条 warning)后重启
启动日志有 跳过 N 个不在 active-plugins.txt 里的 jar(**刻意不挂载**),或运行期 WARN 插件 X 有 N 个 jar **在场但未生效**(不在 active-plugins.txt 里)旧版本残留。Windows 上被 JVM 占用的 jar 删不掉是常态,所以这个状态是刻意允许的不用处理。但若某个应用本该生效却没生效,说明清单里那一行丢了:重装一次该应用,或按 appCode=jar 文件名 手工补回清单
插件完全没加载,只打一条醒目告警只在 yml 里改了 app-market.plugin-runtime.enabled。启动加载器在 Spring 之前执行,读的是系统属性(PluginRuntimeManager.ENABLED_PROPERTY),application.yml 里根本没有这个键 → 启动器让路、运行期又没开只在要退回旧行为时用 JVM 参数 -Dapp-market.plugin-runtime.enabled=false;正常启用不要在 yml 里写这个键。见 配置项索引 的「不能只写在 yml 里的配置」
插件控制器启动即报 expected single matching bean but found 2宿主上下文里注册了与插件实现同类型的 bean(哪怕是转发代理):Spring 按类型注入会把父子上下文的候选一起算宿主侧不要注册插件实现类型。宿主取插件服务只有两条路:PluginServiceProvider(wujies-common-core),或 MemberServiceLocator / PayServiceLocator / ImServiceLocator(wujies-common-plugin-sdk)。见 插件运行时
正在运行的服务突然抛 NoClassDefFoundError有人在正在跑的 JVM 上执行了 mvn install:compiler 插件先清空输出目录再全编,而运行中的 JVM 按需懒解析类不要在别人正在跑的 JVM 上 install;要并行就改工作副本或先停服务。见 必须遵守的不变量 第 11 条
日志出现 ZipException: invalid LOC header (bad signature),或重装后行为诡异边读边写:被启动期加载器打开着的 jar 被同名新 jar 覆盖了(Windows 上覆盖允许、删除被拒)停宿主 → 删残留 jar → 重装。「停掉宿主 → 删 jar → 重装」这条土办法每次有效,正因为它绕开了覆盖
PowerShell 里报 ClassNotFoundException: /path=plugins-dist-D 参数没加引号,PowerShell 把 .path 当路径拆开了加引号,见下面的代码块
现有命令 powershell ... -File script\prune\prune-plugins.ps1 报一串中文乱码 + Unexpected token脚本 script/prune/prune-plugins.ps1 当前没有 UTF-8 BOM,Windows PowerShell 5.1 按 ANSI 解码中文源码 → 解析失败(已实测复现)给该文件补回前三字节 EF BB BF 再跑,或按清单手工删残留 jar。script/prune/start-host.ps1 有 BOM、能解析,但它内部同样是用 powershell.exe 调这个脚本,所以它那一步"清理旧 jar"当前会失败并只打一条警告(不阻断启动)
# 手工启动:-D 参数必须加引号
java "-Dloader.path=plugins-dist" "-Dspring.profiles.active=prod" -jar wujies-admin\target\wujies-admin.jar
清残留 jar 的命令要照这里敲

script/prune/prune-plugins.ps1 还有一个目录推断错位:-PluginDir 留空时它按「脚本的上级目录 + plugins-dist」推断,算出的是 wujies-platform/script/plugins-dist,而不是平台根下的 plugins-dist(见该文件第 31-34 行)。所以必须显式传 -PluginDir:

# 在 wujies-platform 目录下执行;脚本有 BOM 之后这两条才跑得起来
powershell -NoProfile -ExecutionPolicy Bypass -File script\prune\prune-plugins.ps1 -PluginDir plugins-dist -DryRun
powershell -NoProfile -ExecutionPolicy Bypass -File script\prune\prune-plugins.ps1 -PluginDir plugins-dist

它只删「同一个 appCode 名下、但不是当前生效版本」的 jar,手工放进去的 jar 不碰。start-host.ps1 内部传的是绝对路径,不受推断错位影响(但受上面那条 BOM 影响)。


改了没生效:构建与验证

现象判据与原因处理
改了后端代码,行为没变只跑了 mvn compile —— 它不会更新 wujies-admin.jar,你启动的还是旧代码mvn -pl wujies-admin -am -B install -DskipTests 后重启。确认 jar 是新的:模块类在 fat jar 里是嵌套的(BOOT-INF/lib/wujies-<模块>-<版本>.jar,本机实测 29 个),在顶层找 cn/wujies/...class 永远找不到;嵌套条目的时间戳也不可靠 —— 要把嵌套 jar 抽出来看里面那个 class
改了注解/常量后 BUILD SUCCESS,但行为仍是旧的maven-compiler-plugin 按时间戳判断是否重编:源码与上次的 class 落在同一秒内会被判「已是最新」,装进本地仓库的还是旧注解验证前先 mvn -pl <模块> clean install;下结论前用 javap -v 看 jar 里那个 class
新增的测试「跑了」但没执行根 pom 的 surefire 配了 <groups>${profiles.active}</groups>,没打 @Tag 的测试根本不会被执行照抄现有测试的 @Tag("dev") @Tag("local") @Tag("prod"),并在新增后核对 Tests run 总数(总数不变 + BUILD SUCCESS 就是没跑)
明明改了、验了,结论却是错的宿主进程的启动时间必须晚于 jar 的构建时间;本轮多次因为没重启而误判「修复无效」重启后再验;拿不准就比对 jar 的 LastWriteTime 与进程启动时间
篡改/负面用例「通过」了用例里替换的字符串没匹配上,包一个字节都没变,于是「签名有效」—— 结论对、测试错做负面用例时先断言「内容确实变了」(比字节数/长度)
上架/部署脚本打印的版本号不是这次的curl.exe -s -o resp.json 写的响应文件是上一次留下的(这次 curl 失败了,旧文件还在),脚本读到了别的应用的结果跑之前先删临时响应文件,或断言响应里的 code/版本与本次请求一致
跨服务的调用「看起来成功」调用方有兜底 catch,而客户端拼 URL 漏了路径前缀(打到 /count-on-shelf 而不是 /app/catalog/count-on-shelf);再加上手工 curl 过对端,日志里那几条是自己打的验证期间不要手工打对端,去对端访问日志里找调用方打来的请求;把 HTTP 状态与响应体一起判
只验证到编译通过时,就明说「只验证了编译」

这个项目里反复出现「构建期查不出、只在运行期炸」的问题:未导入的标识符、未注册的组件、错误的路径。不要在没有实证时写「已验证」。见 不变量 的「先按症状查」。


类加载与跨插件依赖

现象判据与原因处理
宿主启动失败:No qualifying bean of type 'cn.wujies.member.fallback.MemberServiceLocator'(MallServiceLocator 等同理)跨插件用的定位器不在「父加载器可见」的位置。它必须住在平台段,否则插件子上下文与宿主都拿不到定位器现在在 wujies-common-plugin-sdk:MemberServiceLocator / PayServiceLocator / ImServiceLocator。新增定位器要同时补 wujies-common-plugin-sdk/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(当前里面正好三行),只加 @AutoConfiguration 不够
No qualifying bean of type 'cn.wujies.member.mapper.MemberMapper' —— 但这个 bean 明明存在;或 ClassCastException: X cannot be cast to X同一个 FQCN 在 JVM 里出现了两个 Class 对象(host 的 bean 用的是 A 加载器定义的 Class,注入方用的是 B 加载器定义的 Class),Spring 按类型匹配就找不到PluginClassLoader.findClass 在「从自己的 URL 定义」之前按包名把类委托给拥有它的那个加载器(拥有者是活查询,后加载的插件也能被找到)。回归测试 PluginClassLoaderIdentityTest(核心断言是 assertSame,不是「能解析」)。判别口诀:ClassNotFoundException 与 No qualifying bean 长得像,但后者要立刻怀疑类身份
插件加载失败,报缺 @JobExecutor(com.aizuda.snailjob.client.job.core.annotation.JobExecutor)这类注解/类型注解与基类由谁提供没想清楚:定时任务随应用走(cn.wujies.<app>.task),宿主只提供 wujies-common-job(SnailJob 客户端)例子见 wujies-plugins/wujies-hello/src/main/java/cn/wujies/hello/task/(HelloDemoScheduledTask 本地 @Scheduled、HelloDemoSnailJobTask 走 @JobExecutor)。扫描侧有 isLinkable 守卫:解析不到的类跳过而不是让整个插件装不上,并在日志里列出「缺 cn.wujies.x.*」。读法见 跟着 hello 读源码
组件扫描报 Failed to read candidate component class: .../wujies-mall-plugin-1.0.1.jar!/...MallCoupon.class扫描读到了旧版本 jar;历史上是 siblingJarsOf 把同目录下自己的其它版本也交给了组件扫描PluginClassLoader.siblingJarsOf 现在只挂 active-plugins.txt 里登记的那个版本(回归 PluginClassLoaderSiblingTest 断言 getURLs().length == 1)。处理:清残留 + 确认清单
日志列出「缺 cn.wujies.x.*」,引用兄弟插件的类解析不到插件加载器只挂自己的 jar(刻意如此:挂兄弟 jar 会让每个加载器都缓存住别人的 JarFile 句柄,卸载时关不掉 → 文件删不掉)。跨插件解析纯靠宿主委托装上被依赖的那个应用。依赖没装就不该能跑,这比「悄悄加载一份副本」正确
@EventListener 收不到事件,且不报任何错事件类型匹配认的是事件对象的 Class:事件类跟着发布方插件的 jar 走,订阅方就是第二个 Class → 永远不匹配跨插件事件类必须由宿主加载器加载,现在放在 wujies-common-core 的 cn.wujies.common.core.domain.event(PaySuccessEvent / PayRefundSuccessEvent / UserRegisteredEvent / ProcessEvent 等)。回归 PluginEventClassLoaderTest(5 项)。宿主→插件方向由 PluginEventRelay 中继,细节见 插件运行时

插件 Mapper 与 SQL

现象判据与原因处理
接口 500:org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): cn.wujies.mall.product.mapper.MallCategoryMapper.selectList关键特征是 Mapper bean 注入得到、只是内置方法没有。历史上根因是「卸载侧按差集记账漏删 → 重新解析 XML 撞重复键 → 解析静默中断」。现在由 PluginMapperRegistrar.sweepPluginNamespace(按 cn.wujies.<app>. 前缀扫除,不依赖任何记账)+ forceReparse(解析前显式摘掉 isResourceLoaded 标记)修掉先看加载日志那一行:插件 mall 的 Mapper 已注册:接口 26 个,XML 9 个,语句 292 条。语句 0 条就是没注册进去(热加载时实测复现过 292 → 0)。回归测试 PluginMapperReloadTest(刻意不卸载、再注册一次,断言语句仍在)
兄弟插件注入不到插件的 Mapper beanSpring 父子关系单向、兄弟上下文互不可见PluginRuntimeManager 把插件的 Mapper 接口注册进宿主 bean 工厂(先摘同名定义),并补 definition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, mapperType) —— 这个属性是跨上下文按类型注入的必要条件,注销时对称摘掉
注销插件时抛 cannot be cast to ResultMapConfiguration.getResultMaps() 返回的集合里混着非 ResultMap 的元素(MyBatis StrictMap 的占位/短名条目)已改为 instanceof 判类型 + removeAll,不再强转
插件里的 XML 到底该放哪、怎么被加载XML 随插件 jar 走,位置是插件模块自己的 src/main/resources/mapper/<app>/*.xml(例:wujies-plugins/wujies-mall/src/main/resources/mapper/mall/MallBrandMapper.xml),由 PluginMapperRegistrar 在加载时动态注册进宿主 SqlSessionFactory不要指望宿主的 mapperLocations 扫到它:classpath*:mapper/**/*Mapper.xml 用的是宿主类加载器,会把宿主自己的映射文件再捞一遍并撞 Result Maps collection already contains value。见 不变量 第 2 条

缓存与序列化(热加载之后)

现象判据与原因处理
从 Redis 读插件 VO 时 Jackson 报 no such class foundJackson 解析类名时既不读 TypeFactory、也不读 TCCL,用它自身定义的加载器兜底 —— 平台侧三次尝试(换 TypeFactory 的类加载器、换 Redisson codec、用宿主的 getObjectMapper())实测全部无效改到插件侧:不缓存对象,缓存 JSON 字符串,读的时候用显式 TypeReference 反序列化。member / exam / gallery / mall / social 的 10 处分页缓存都是这么改的
热加载插件后 MapStruct 报 cannot find converter,或 X cannot be cast to Xmapstruct-plus 内部 AbstractCachedConverterFactory 缓存里存的是上一代加载器定义的转换器,新加载器一换缓存就陈旧PluginConverterRegistrar 在注册转换器之前清掉该缓存,并在注册后回读校验。带静态缓存的框架 + 可卸载的类加载器,必须显式清缓存

安装与升级

现象判据与原因处理
安装报「jar 不存在,后端产物还没有落位」市场里该版本没上传安装包或没部署(package_url 为空 / 没点部署)。真实文案见 PluginAppInstaller.buildNotLoadedMessage去上架端「应用管理 → 版本管理」上传并点部署
安装报「jar 已经存在于 <路径>,但不在运行时 classpath 上 —— 通常是启动方式不对」-Dloader.path 只被打包后的 PropertiesLauncher 识别,从 IDE 直接跑主类时会被静默忽略用 java "-Dloader.path=plugins-dist" -jar wujies-admin.jar 启动(工作目录为平台根),或把该 jar 加进 IDE 运行配置的 classpath
安装报「应用版本不存在」目录集中模式(app-market.catalog.mode=remote)下目录服务不可达、或两端数据不一致看任务日志的具体报错;再去目录服务的访问日志里找 GET /app/catalog/version。验证纪律见本页开头第 2 条
安装报「应用包未签名(缺少 signature.txt)」app-market.security.mode=enforce 下的预期行为(当前仓库 yml 就是 enforce)打包时带 -SigningKey;历史版本走「就地重签」。见 密钥与签名
安装报「签名校验失败」/「签名与包内容不匹配」有签名但校验不过:包被改过,或不是服务端 app-market.security.public-key 对应的私钥签的重新打包并签名;换钥之后的老包全部要重签
安装报「应用包带签名,但服务端未配置 public-key」没配公钥就切了 enforce —— 此时所有包都会被拒,包括你自己签的配 app-market.security.public-key,重建宿主并重启(公钥是启动期读入的)。模式与取值来源见 安全校验模式
上传时就被拒,报「应用包安全检查未通过(app-market.security.mode=enforce):…」上传(AppPackageController.parse)与下载后(AppPackageDeployService.deploy)两处都校验,上传这处只是尽早告知发布方修包重传。注意部署未签名包同样会被拒,且不会落位任何文件(校验在落位之前)
1Panel 部署上「未签名的包居然装上了」1Panel 包的安装表单 SECURITY_MODE 默认值是 warn(deploy/1panel/apps/wujies-platform-server/5.6.3/data.yml),与仓库 application.yml 里的 enforce 不是同一个值要 enforce 就在安装表单改成 enforce,并确认公钥已配。切之前先跑签名就绪度自检 GET /app/console/signature-readiness
安装卡在「执行版本迁移脚本」某个 .sql 报错。MySQL 的 DDL 是隐式提交的,失败没法整体回滚,任务会停在失败态看任务日志里那条脚本的错误文本;app_schema_history 里 success='0' 那行也在。不要改同一个文件名,新增一个脚本
升级报「迁移脚本 X 的内容已被修改(摘要与首次执行时不一致)」已执行过的脚本内容被改过(AppMigrationService 按 (app_code, script) 比对摘要,不一致直接拒绝)新增一个幂等脚本承载变更。若是「把 01 里的种子数据拆出去」这类结构性改动,存量部署先跑 script/sql/update/app_market_app_seed_split_repair.sql(幂等)再升级 —— 迁移脚本只在 install/upgrade 里跑。见 数据库迁移
升级报 Duplicate entry '000000-exam-1.0.1' for key 'app_schema_history.uk_schema_history'app_schema_history 的唯一键不是 (app_code, script)。老 DDL 写的是 (tenant_id, app_code, version),一个版本里只要第二个迁移脚本就撞键执行 script/sql/update/app_market_schema_history_fix.sql(幂等:先去重、再删旧键、再建新键)。不只是升级:全新部署安装多脚本应用同样会炸。回归 AppMigrationLedgerDdlTest
升级成功了但表没变 / 页面还是旧版迁移脚本没打进包(package.ps1 只扫 -MigrationPath 顶层的 .sql,子目录里的会告警跳过),或升级没重新落位检查包内有没有 migrations/*.sql。升级按 fromVersion != toVersion 判断,一定会重新从市场拉包落位,不会沿用旧产物
包里已经删掉/改名的旧迁移脚本又被执行了一次落位早期只释放新内容、不删旧文件,于是磁盘上的幽灵脚本会在下次安装时再执行现已修:落位前先 deleteQuietly(migrationRoot) 清空该应用的迁移目录。手工判据:往 <插件目录>/migrations/<app>/ 放一个不在包里的 99-ghost.sql → 重装 → 它必须消失
存量部署上点「安装」报「非法的应用状态迁移」,或升级到同一版本报「当前已是该版本」迁移脚本回填的安装实例状态是 enabled,而状态机里 enabled → installing 非法两条路:上架端点「版本管理 → 部署」,或发布一个更高版本再升级。给客户交付时直接发 1.0.1,让「升级」成为自然路径。见 应用生命周期
装完应用,页面全报「表不存在」建表脚本没随包走(客户那台只导入了平台初始化 SQL),或「彻底删除」后重装时迁移记录没清把建表脚本补进 wujies-plugins/wujies-<app>/migrations/;「彻底删除」会删 app_schema_history,这正是重装能重建表的前提
应用装好了,但页面标签是空的、筛选下拉没数据该应用的字典(sys_dict_type / sys_dict_data)或站点配置(sys_config)没随包包里应有 0N-create-<app>-dictionaries.sql / 0N-create-<app>-website-config.sql;老包只能补发版本。见 种子数据
手工执行了字典 SQL,接口还是旧标签字典与参数缓存在 Redis(CacheNames.SYS_DICT / SYS_DICT_TYPE / SYS_CONFIG)迁移脚本执行后平台会自动失效缓存;手工执行时要自己调 /system/dict/type/refreshCache(或界面「刷新缓存」)
部署/安装日志里说成功,但前端 /remote-apps/<code>/index.js 是 404清单的 assets.frontend 写成了裸文件名(index.js)。AppPackageDeployService.dirPrefix 对不含 / 的路径返回空串,释放分支要求前缀非空 → 前端整棵树都不释放(后端 jar 照常落位,所以任务仍然成功)改成 frontend/index.js。判据:部署报告的 released 数组里没有 frontend: 开头的条目、releasedCount 只等于后端产物数。历史样本:demo-app 0.1.1(见 wujies-platform/AGENTS.md §8 待办 3)

菜单

菜单类问题有一半时间花在「这是两类完全不同的故障」上。先按这张表分流。

现象判据与原因处理
控制台报 [permission] 菜单「XXX」的 component 解析不出来,已跳过该路由:…(同一棵树走三遍只报一次,且只报当前用户看得到的)—— 情况① 这条菜单在 app_version_menu 里没有任何归属平台初始化 SQL 留下的孤儿菜单:菜单同步只处理清单声明过的键,永远修不到它手工清(样例 script/sql/update/fix_dead_mall_finance_menus.sql)。判断依据:归属映射里查不到它
同上 —— 情况② 有归属,但指向的页面不存在该应用清单里的 component 目标写错了,或本机产物是旧的(菜单同步只在安装/升级时跑,回填式的安装实例从没跑过)让该应用升级一次(升级顺带跑菜单同步),或执行 script/sql/update/fix_app_menu_component_remote.sql(幂等)。判断依据:归属映射里查得到它
分不清是①还是②——跑 npm run audit:menus -- <菜单导出 SQL 的结果>(plus-ui/scripts/audit-menus.mjs,脚本头部有导出 SQL),一次看全(含当前用户看不到的菜单)
控制台一片 [Vue Router warn]: Record with path "/im/workbench" is either missing a "component(s)" or "children" property菜单在、页面不在:库里那条菜单的 component 还是抽离前的宿主路径(im/workbench/index),而 .vue 已经搬进插件同上:执行修复 SQL,或让该应用升级一次。plus-ui/src/store/modules/permission.ts 现在会把这类路由跳过并打一条指明菜单与 component 的 error
菜单点得开,但页面「裸奔」:没有顶栏、没有侧边栏应用清单里声明了 routes。path 与宿主菜单生成的路径相同,loader.ts 会把清单里的路由注册成顶级路由(没有 Layout),抢先匹配清单里不要声明 routes,只留 menus
点菜单进去 403菜单的 perms 与接口上的 @SaCheckPermission 不一致对齐两处:菜单管理里的权限标识 vs 代码注解。漏写按钮权限的表现就是「菜单看得见但点进去 403」
表格里的「新增 / 折叠 / 删除」按钮变成纯文字模板里的 K* 组件没进该前端工程的 HOST_SDK_COMPONENTS 白名单 → Vue 当它是未知自定义元素:不报错、不白屏,只剩插槽文字补白名单(先确认它在宿主 plus-ui/src/plugins/appmarket/runtime.ts 的 SHARED_COMPONENTS 里)后重新构建,再跑 npm run check:remote
装完一个菜单都没有 / 显示「已启用」但一个菜单都没有库里的归属映射指向已删除的菜单 ID(残留死映射被菜单同步按 appId 跨版本复用了):updateById 影响 0 行、映射照样写回服务端日志里搜 菜单归属指向已不存在的菜单行。菜单同步现在会自愈(丢掉死映射并按新菜单重建);源头修复见下面「卸载」一节那条清映射的三条线索
「重新同步菜单」跑完菜单变成两套(数量暴涨)归属映射表为空时 AppMenuPlanner 会把清单里每个菜单都当新建,插出第二套菜单树(实测 709 → 1140)用 script/sql/update/cleanup_duplicate_app_menus.sql(幂等)清理。兜底认领按结构(AppMenuSyncServiceImpl.claimExistingMenus:从 (parent_id=0, 清单根 path) 起步逐层用 path/perms 匹配、一行只认领一次),不看 remark。端点是 POST /app/install/{installId}/sync-menus(权限 app:install:edit)
应用菜单受保护,改不了也删不掉是设计如此

path / component / perms / menuType / parentId 的修改与删除都会被服务端拒绝(不只是页面禁用按钮):拦截点在 SysMenuServiceImpl 的 updateMenu / deleteMenuById,用 ObjectProvider 注入 MenuOwnershipProvider,缺席即「没有受保护菜单」。改名称/图标/排序/显隐是放行的;想隐藏请用「停用」。

⚠️ 实测时要注意:框架自带的 存在子菜单,不允许删除 与 菜单已分配,不允许删除 发生在进入 service 之前,所以要先摘掉角色分配才能触达归属检查 —— 归属检查是最后一道。原理与复核 SQL 见 菜单归属与保护。


前端远程应用

先记住这条:远程应用是独立构建的 ESM,它继承宿主源码但不继承宿主的构建配置。宿主里靠构建期魔法生效的东西(自动导入、组件解析器、指令),插件必须逐项复刻,否则静默失效。

现象判据与原因处理
远程应用能打开,但完全没有样式工程 src/index.ts 里 manifest 的 styles 路径与产物文件名不一致(应当是 <code>-app.css)改成与产物文件名一致,重新构建,再跑 npm run check:remote(plus-ui/scripts/check-remote-apps.mjs,构建期就拦)
装载失败:[app-market] 按需装载失败: social ReferenceError: process is not defined产物里残留 process.env。echarts(内置 zrender)与 vue-types 会在模块顶层读它,浏览器没有 process给该工程的 vite.config.ts 补 define: { 'process.env.NODE_ENV': JSON.stringify('production') },重新构建,再跑 check:remote。loader.ts 只报「按需装载失败」,从那一行看不出是谁
装载失败:ReferenceError: useStorage is not defined(随后菜单点开是「远程视图不可用」)产物里混着宿主源码(@/store/**、@/utils/** 按别名原样打进产物),而该插件工程的 AutoImport 少了 @vueuse/core —— 宿主 src/utils/auth.ts 的 useStorage 靠自动导入注入,在产物里成了裸全局把该工程的 imports 与宿主 plus-ui/vite/plugins/auto-import.ts 逐项对齐,重新构建,再跑 npm run check:globals
SyntaxError: The requested module '@dromara/request' does not provide an export named 'globalHeaders'运行期垫片少一个具名导出。产物把 vue / element-plus / @dromara/* external 掉了,靠 import map 指到 app-market-dist/app-runtime/*.js,少一个名字浏览器在链接期整块报错、页面完全打不开@dromara/* 是虚拟说明符、只能静态声明,加具名导出要同时改两处:plus-ui/scripts/gen-runtime-shims.mjs 的 staticNames 与 plus-ui/src/plugins/appmarket/runtime.ts 里对应 slot。改完 npm run gen:runtime + npm run check:runtime(从已构建产物读 bare import 与垫片对账,有缺口非零退出)
菜单点开是「远程视图不可用: exam/settings(应用 exam 未安装或入口地址未配置)」宿主不知道该去哪儿拿那份 ESM(loader.ts 抛的)看 GET /app/runtime/remote-apps 有没有这个应用:没装/没启用 → 正常没有;有 entry 但请求 404 → 静态目录没部署到 /remote-apps/<code>/。浏览器控制台里那句 [app-market] 远程应用入口(后端): + Network 里的 entry 请求就是证据
打开 /remote-apps/<code>/ 是 404该应用没有前端产物;纯后端应用(清单没写 assets.frontend)本来就不会出现在 remote-apps 清单里部署/安装对应应用;确认 app-market.deploy.frontend-dir/<code>/ 里有 index.js。⚠️ 前后端分离部署时这个目录必须就是 nginx 托管的 /remote-apps/ 物理路径,否则报告里 frontendDir 与 nginx 指的不是同一处,见 部署配置
文件明明在卷里,/remote-apps/<code>/index.js 仍 404,控制台 TypeError: Failed to fetch dynamically imported module: …/remote-apps/<code>/index.js?v=<版本>nginx 的 location /remote-apps/ 写成了普通前缀 ⇒ 被末尾那条静态资源正则段 location ~* \.(js|css|…)$ 抢先命中,按 root /usr/share/nginx/html 找文件,alias /var/www/remote-apps/ 完全不参与。两端都"看着正常":后端卷里有 hello/index.js、nginx 容器也挂着同一个宿主目录 —— 所以只看卷会误判成落位失败。2026-10-03 线上故障就是这个给前缀 location 加 ^~(location ^~ /remote-apps/),重建前端镜像、重建容器。一条命令判别是哪一段接的:curl -sI http://<域名>/remote-apps/<code>/index.js | grep -i cache-control —— no-cache = 走了 /remote-apps/(对),max-age=604800 或 404 = 被正则段接走(错)。详见 前端部署与 nginx 坑 3
升级后页面还是老样子浏览器缓存了旧的远程产物入口本身带 ?v=<版本号> 破缓存(AppRuntimeController 拼的);同时确认前端确实调了 GET /app/runtime/remote-apps,而不是走了构建期静态清单 VITE_APP_MARKET_REMOTE_APPS(plus-ui/src/plugins/appmarket/bootstrap.ts:静态清单优先)
页面完全不打开,只有链接期报错运行期垫片与产物 import 的名字对不上跑 npm run check:runtime 对账
应用已停用/卸载,前端 console 里还有它接口的 404宿主页面在应用不可用时仍在发请求用 isAppEnabled('<code>') 先守卫(plus-ui/src/plugins/appmarket/appAvailability.ts,数据源 GET /app/runtime/enabled-apps,一次会话只问一次、按 token 失效、取不到清单一律当「可用」)。宿主里的示例见 plus-ui/src/views/dashboard/composables/useMemberCheckin.ts
某个应用的接口返回 code: 4610,提示「应用「X」已停用 / 未安装或已卸载 / 已启用但后端产物未加载」不是 bug,是对症提示:路由已被摘除,宿主用路由墓碑认出这条 404 本该由它提供(AppRouteHintProvider.CODE_APP_UNAVAILABLE)去「我的应用」重新安装/启用;若提示「已启用但后端产物未加载」→ 本机缺插件 jar,走上架端「部署」补。前端侧是 HttpStatus.APP_UNAVAILABLE(plus-ui/src/enums/RespEnum.ts)+ plus-ui/src/utils/request.ts 里同一应用的消息只提示一次
构建后 Could not load .../src/plugins/modalshim 里用 endsWith('/src/...') 判路径:Windows 上 alias 解析出的 id 是 C:\...\exam-app\src/plugins/modal,src 前面是反斜杠,字面量比对永远不命中用正则 /[/\\]plugins[/\\]modal$/ 这种写法
别在插件里写「薄垫片」绕 SDK

自己复刻一份 src/utils/request.ts 并 import { getToken } from '@/utils/auth',正是把宿主源码拉进产物的那条路径。@/utils/request 直接指向 @dromara/request 就行(它已具名导出 globalHeaders / download / isRelogin),并且 alias 要用正则精确匹配 —— 字符串 alias 是前缀匹配,@/utils/request 会把 @/utils/requestCache 一起吃掉。


目录与版本形态

现象判据与原因处理
用户版里点「应用上架」→ 403设计如此。用户版没有上架端:它的上架端写的是本地库,而安装读的是远端目录,放开会导致「上架成功但安装毫无影响」这种最坏的失败(看着成功、实际无效)。回 404 则会让人去查路由和网络不用处理。闸门是 MarketEditionGuardFilter:按前缀挡 /app/console、/app/version、/app/package 与 /app/category 的写操作;例外只有 /app/console/owned-menus(菜单管理页打归属标签用)与 /app/category/list(市场页筛选用)。菜单侧由 AppConsoleMenuResolver 整棵隐藏,前端 /appconsole 另有 beforeEnter 守卫(plus-ui/src/plugins/appmarket/edition.ts)
用户版启动就失败,报「app-market.edition=client(用户版)必须配合 app-market.catalog.mode=remote」形态与目录配置不自洽(client + catalog.mode=local)—— 那个组合是「能起来但什么都装不了」,根因从别的报错里看不出来改成 catalog.mode: remote 并配 base-url。启动期自检 MarketEditionStartupCheck 是刻意直接拦住的
用户版市场列表 500,报「目录接口令牌校验失败」两端 app-market.catalog.token 不一致(抛点在 AppCatalogApiController)把服务端与调用方的 app-market.catalog.token 配成同一个值。服务端留空表示不校验调用方 —— 内网自用可以,公网必须配。1Panel 服务端包的 CATALOG_TOKEN 默认是 catalog 且 random: true,客户端必须手抄那一个值
用户版市场页是空的 / 装不上官方目录服务不可达,或令牌不一致先确认网络与 base-url(只写到主机,路径前缀代码里拼),再对令牌;验证时按本页开头的纪律,不要手工 curl 对端。超时参数是 app-market.catalog.connect-timeout-ms(3000)/ read-timeout-ms(15000),宁可快速失败
用户版 GET /app/category/list、GET /app/console/owned-menus 返回 200这是刻意的例外,不是漏挡见上面 403 那条的例外清单;分类的写仍是 403
用户版 /app/console/signature-readiness 返回 403设计如此:它读的是本地目录,用户版下没有意义去官方版上调这个接口
中文关键词搜索永远返回空,英文正常远程调用的 URI 被二次编码:自己 encode 后再交给按模板展开的 uri(String),对端解出来是 %E5%9B%BE... 这种百分号字面量已按 UriComponentsBuilder + encode() + 传 URI 对象修掉。自建类似的远程调用时不要手写 encode
传了 audience 参数却一条都查不出来空串参数不是「没传」:拼 ?audience= 时服务端拿到的是空字符串而不是 null,而可见范围判断是 audience != null → 去匹配 FIND_IN_SET('', audience)可选参数要整条丢掉,不要拼空值
为什么用户版报 403 而不是静默失效

静默失效会让「上架成功」看起来是真的成功了;404 会把人引向路由与网络排查。403 + 明确文案(写明是 app-market.edition=client 导致的)是唯一不会误导人的做法。详细推导与核对命令见 目录中心化与版本形态。


卸载

现象判据与原因处理
「彻底删除」之后表没被删删表清单完全来自迁移脚本里解析出的 CREATE TABLE 表名(AppMigrationService.declaredTables:先剥注释、只认 CREATE TABLE,ALTER/INSERT/DROP 都不算「拥有」某张表),平台不维护「应用 → 表」映射任务日志里会写明「该应用没有声明任何数据表」。把建表语句补进 migrations/ 并发布新版本(旧版本已无法补救)
菜单/表没被删,日志说「检测到还有 N 个租户装着该应用……已跳过删除」设计如此:sys_menu、数据表、本机产物都是一个部署内全局共享的。只要还有别的租户装着同一应用(app_install 里状态非 uninstalled 的其它记录),全局性删除一律跳过,只清本租户的授权与安装记录不用处理。真要清理得先把其它租户的安装记录也卸掉
「彻底删除」之后按钮还在,父菜单没了按钮(menu_type='F')没写进 app.json 的 menus,归属映射里没有它 —— 它们历史上来自平台初始化 SQL现在的版本会按 parent_id 把后代一起删(AppMenuTreeExpander.withDescendants,实测踩到过 23 个 gallery 按钮)。给已有部署补按钮时要配一条 seed SQL(把新 key 映射到既有按钮 ID),否则升级会建出一套重复按钮
应用显示已启用但一个菜单都没有残留的旧版本归属映射指向已删除的菜单 ID,跨版本解析时被复用了:updateById 影响 0 行、映射照样写回菜单同步现在会自愈(同步前丢掉死映射并按新菜单重建,AppMenuSyncServiceImpl 会 WARN)。清映射要按三条线索并行:menu_id ∈ 本次删掉的菜单 ∪ menu_key ∈ 清单声明的键 ∪ version_id ∈ 该应用已知版本 —— 少一条就留下死映射
重装后页面全报「表不存在」迁移记录没清(正常流程会清),或建表脚本没进包「彻底删除」会删 app_schema_history,这正是重装能重新建表的前提(实测验证过);脚本没进包则只能补包
「彻底删除」后重装,页面标签是旧值字典与站点配置在旧版本里留成了孤儿,而重装时脚本用 WHERE NOT EXISTS / INSERT IGNORE 会沿用上次留下的旧值现版本会按 dict_type LIKE '<app>_%' 与 config_category = <app> 一并删除并失效缓存;确认你用的是包含该修复的版本
卸载时报「以下产物未能删除,可停服务后手工清理」Windows 上被运行中的 JVM 占用的 jar 无法删除(删除是尽力而为,AppPurgeService 会如实报出来)停服务后清残留 jar(命令与两个坑见本页「启动与加载」的 warning)
卸载后历史任务显示「(应用已删除)」「彻底删除」会把安装记录一起删掉,而任务历史(app_install_task)刻意保留当审计线索,于是查不到应用名设计取舍,不是 bug。前端 appMissing 字段就是为此
卸载/停用后调某应用的接口返回 code: 4610见「前端远程应用」一节:路由墓碑给出的对症提示重新安装/启用;提示「已启用但后端产物未加载」就走「部署」补
对象存储里的文件两种卸载模式都不删

平台不知道那些图片属于谁,也不该替用户删文件。要清自己清。备份与密钥纪律见 备份与安全。


1Panel / Docker

现象判据与原因处理
安装日志 拉取镜像 失败 … error from registry: denied,紧接着 镜像已存在,使用存量镜像正常。1Panel 装应用时无条件先 docker pull 一遍 compose 里的每个镜像;wujies-platform/xxx 会被解析成 docker.io/wujies-platform/xxx,而 Docker Hub 上不是本项目的命名空间不用处理,本地有镜像就会继续装。想省掉等待:安装抽屉 → 高级 → 取消勾选「拉取镜像」。多机部署/上架官方商店时必须推到自己的仓库并同步改 compose 的 image:
拉取失败之后没有「镜像已存在」,安装失败本机真的没有那个镜像在宿主机 docker build 出对应 tag,或推到仓库后改 compose 的 image:
中间件下拉是空的 / 只显示「去安装」面板里还没装对应的官方应用应用商店里先装 MySQL、Redis(RabbitMQ 可选),再回到安装抽屉
打开安装抽屉就报 服务错误: record not found(请求 /api/v2/apps/services/undefined)data.yml 里 type: apps 的字段漏了外层 default(如 default: mysql):1Panel 前端拿 p.default 当服务类型 key 去查服务列表补上 default。本仓库由 deploy/tools/gen-appstore-data.mjs 生成、deploy/tools/check-appstore-data.mjs 会拦住这类错误;改完在面板「本地应用」点同步再重试
[entry] !! 没有数据库地址既没选服务也没填外部地址按安装表单补上;改完 docker compose up -d --force-recreate backend
[entry] !! 等待 MySQL 超时地址/端口/账号密码不对,或该库不允许本机访问;也可能是装完后改了库名/账号 —— 面板只改容器的 .env,不会去改 MySQL 里的库名与用户在宿主机先 mysql -h <主机> -u <用户> -p 连一次;检查该服务的「允许访问」设置。库名/账号是安装时按 PANEL_DB_NAME / PANEL_DB_USER 的默认值(都是 wujies)加 6 位随机字符生成的,装完就别改
[entry] !! 初始化不完整(缺 N 张关键表)某条初始化 SQL 报错(导入日志里有 ERROR),或库被删过。入口脚本会全检 19 张关键表(sys_user … app_schema_history,清单见 deploy/docker/backend/entrypoint.sh),缺一张就不写标记并让容器失败看导入日志定位;修好后清空该库里的表 + 重启重新导入。别只 DROP DATABASE —— 应用账号没有全局 CREATE 权限,库建不回来
后端起来了但接口 500 Table xxx doesn't exist平台表没建全,却被判为「已初始化」(判据是 sys_config.platform.init.version 这行标记)同上:清表 + 重启。只改数据时更省事:删掉 platform.init.version 那行 + 重启,脚本本身幂等
改了初始化 SQL,重装/重启后没有变化① 已装实例用的是它自己那份 sql/files/(bind mount,宿主机在 /opt/1panel/apps/<应用>/<实例名>/sql/files/),不是仓库资源目录那份;② 有 platform.init.version 标记时入口脚本直接跳过导入更新实例目录里的 SQL → 清表 → 重启。SQL 是挂载进去的,改 SQL 不用重建镜像
nginx 起不来变量没渲染(模板位置不对)模板必须 COPY 到 /etc/nginx/templates/*.template(走 envsubst),不是 conf.d/ —— 否则 nginx 把 ${BACKEND_HOST} 当成自己的变量,启动即报 unknown variable。见 deploy/docker/frontend/Dockerfile
RabbitMQ 401 ACCESS_REFUSED面板不会把 RabbitMQ 的账号密码带进安装表单,用的是默认 rabbitmq / rabbitmq去「应用商店 → 已安装 → RabbitMQ → 参数」抄账号密码,填进(或改)本应用的「RabbitMQ 用户 / 密码」。应用不依赖它启动,只影响消息中心/站内信
监控中心打开弹登录框 / 401它是 HTTP Basic 认证用安装时填的账号密码(deploy/docker/monitor/entrypoint.sh 的默认值 wujies / 123456);主应用那边的账号密码要一致,否则注册不上
打开 http://<主机>:9090/ 或 :8800/ 是 404(Whitelabel / Tomcat)这两个服务都有 context-path,根路径没有映射用 http://<主机>:9090/admin(监控中心,登录页 /admin/login)、http://<主机>:8800/snail-job/(调度中心控制台)
数据库 → MySQL 里找不到本应用的库库名是「前缀 + 6 位随机字符」,前缀是 PANEL_DB_NAME 的默认值 wujies(deploy/1panel/apps/wujies-platform-server/5.6.3/data.yml)按前缀 wujies_ 过滤。卸载应用不会删库(刻意的:卸载不该顺手删数据),要清就手工删库删用户
「Redis 服务密码」空着又必填面板里那个 Redis 没设密码,所以没有值可带入先给 Redis 设个密码(数据库 → Redis → 改密码),再回到安装抽屉
定时任务一条都不跑主应用表单里「调度中心地址」留空了(= 不启用)填固定别名 snailjob-server;日志里有 [entry] 定时任务调度中心:snailjob-server:17888(命名空间 prod) 才说明开了
有调度中心,但任务列表还是空的任务定义要在 SnailJob 控制台导入走控制台「任务管理 → 导入」。⚠️ sj_job 不要直接 INSERT:导出的任务 JSON 里没有 namespace_id 与 next_trigger_at(后者要按 cron 算),算错会让任务立刻狂跑
调度中心里执行器数一直是 0客户端连的是旧容器(重建/换容器后 gRPC 缓存了旧 IP)重启主应用容器让它重新注册;sj_job_executor 出现 13 行才算通
多套部署的任务互相乱跑共用了同一个调度中心 + 同一个命名空间主应用表单里把「调度中心命名空间」改成各自不同的名字,并在调度中心里建出来
排障入口
cd /opt/1panel/apps/local/wujies-platform-server
docker compose ps
docker compose logs --tail=200 backend | grep -E '\[entry\]|ERROR'

[entry] 前缀的行来自容器入口脚本 deploy/docker/backend/entrypoint.sh:中间件地址解析、初始化结论、调度/监控注册都在这里。落位目录是 /app/plugins-dist(后端 jar + active-plugins.txt)与 /app/remote-apps(前端 ESM),对应宿主机 ./data/plugins-dist、./data/remote-apps。日志文件位置见 运维手册。


相关阅读

目录