故障排查
这一页解决**「现在坏了,先看哪儿」**:按现象给判据与处理。每条都来自实测记录(wujies-platform/AGENTS.md 的实测段落、工作区 docs/开发问题与解决方案.md、以及平台源码本身),不是推测。
写法上刻意写到可核对的粒度:类名、配置键、日志原文、文件路径都给出来。标了「见某文件」的,就是让你直接去读那处代码 —— 宁可让你自己核对,也不写一句查不到的话。
设计意图(为什么会这样)不在这里,各节末尾与页脚给出对应页面。
- 第一站永远是「我的应用 → 应用任务」。 安装/升级/卸载的步骤与日志是边跑边落库的,失败原因(含服务端异常文本)就在那条任务记录里,比翻服务端日志快。见 任务中心与日志。
- 验证跨进程调用时,不要一边用
curl手工打对端。 否则对端访问日志里那几条GET /app/catalog/...是你自己打的,你会把「我打成功了」当成「调用方链路通了」—— 这个坑真实踩过。同理 HTTP 200 不等于成功:远程目录出现过「返回 200 但 body 是错误信封」。 - 先看「非对称」。 同一流程里 A 成 B 败,B 的差异点就是线索:
jar删不掉的死循环就是靠「同一分钟里 im 的 jar 删得掉、mall 的删不掉」定位的。 - 每次只改一件事,并让失败可见(日志/计数/断言),改完如实说「改完了,请验」,不要说「修好了」。
启动与加载
| 现象 | 判据与原因 | 处理 |
|---|---|---|
启动直接失败,最后一句是 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.jarscript/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 bean | Spring 父子关系单向、兄弟上下文互不可见 | PluginRuntimeManager 把插件的 Mapper 接口注册进宿主 bean 工厂(先摘同名定义),并补 definition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, mapperType) —— 这个属性是跨上下文按类型注入的必要条件,注销时对称摘掉 |
注销插件时抛 cannot be cast to ResultMap | Configuration.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 found | Jackson 解析类名时既不读 TypeFactory、也不读 TCCL,用它自身定义的加载器兜底 —— 平台侧三次尝试(换 TypeFactory 的类加载器、换 Redisson codec、用宿主的 getObjectMapper())实测全部无效 | 改到插件侧:不缓存对象,缓存 JSON 字符串,读的时候用显式 TypeReference 反序列化。member / exam / gallery / mall / social 的 10 处分页缓存都是这么改的 |
热加载插件后 MapStruct 报 cannot find converter,或 X cannot be cast to X | mapstruct-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/modal | shim 里用 endsWith('/src/...') 判路径:Windows 上 alias 解析出的 id 是 C:\...\exam-app\src/plugins/modal,src 前面是反斜杠,字面量比对永远不命中 | 用正则 /[/\\]plugins[/\\]modal$/ 这种写法 |
自己复刻一份 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) | 可选参数要整条丢掉,不要拼空值 |
静默失效会让「上架成功」看起来是真的成功了;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。日志文件位置见 运维手册。