Wujies 无界平台
插件开发basics

应用清单 app.json

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

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

app.json 是应用在平台上的唯一权威描述:编码、版本、产物路径、菜单树、依赖。 上架、安装、升级、卸载全部读它。写错了不会在编译期报错,只会在安装时失败或 在运行期静默失效(菜单点不开、按钮不出现、C 端 404)。

这一页解决三件事:

  1. 每个字段真实的类型、必填性与取值约束(对着 AppManifest.java 与校验器写,不凭印象);
  2. menus[] 怎么设计,尤其是 type: "F" 按钮 —— 漏了会留下永久性的坏数据;
  3. key 的命名转换,以及"为什么不能只用 perms 推 key"。
两个权威来源
  • Java 映射:wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/domain/AppManifest.java
  • 校验入口:AppManifestValidator.parseAndValidate(manifestJson, expectedAppCode); 菜单部分复用安装时的 AppMenuPlanner.plan(...),所以"保存时通过、安装时失败"不可能发生。

最小可用清单

范例应用是 wujies-hello,它的清单有 12 个顶层字段(没有 dependencies / migrations):

{
  "code": "hello",
  "name": "Hello 示例",
  "version": "1.0.0",
  "type": "plugin",
  "engine": ">=5.6.2 <6.0.0",
  "author": { "name": "无界创镜" },
  "license": "MIT",
  "summary": "插件开发范例:一个表 + 五组演示,前端页面随包一起发。",
  "categories": ["开发工具"],
  "tags": ["示例", "插件开发", "PoC"],
  "assets": {
    "backend": "backend/wujies-hello-plugin-1.0.0.jar",
    "frontend": "frontend/index.js"
  },
  "menus": [
    {
      "key": "hello",
      "name": "Hello 示例",
      "type": "M",
      "path": "hello",
      "icon": "ri:emotion-happy-line",
      "order": 99,
      "children": [
        {
          "key": "hello-probe",
          "name": "运行时探针",
          "type": "C",
          "path": "probe",
          "component": "remote:hello/demo/home",
          "perms": "hello:probe:view",
          "icon": "ri:pulse-line",
          "order": 0,
          "children": [
            { "key": "hello-probe-view", "name": "查看探针", "type": "F", "path": "",
              "perms": "hello:probe:view", "icon": "#", "order": 1 }
          ]
        }
      ]
    }
  ]
}

源文件是 wujies/wujies-plugins/wujies-hello/app.json(下称"仓库路径", 按 设计文档索引 的约定,仓库路径不是站内链接,故用行内代码写)。 新插件直接照它复制,不要从别的业务插件里抄(那些带一堆历史包袱)。

顶层字段

字段Java 类型必填含义与约束
codestring是应用编码。正则 ^[a-z][a-z0-9-]{1,62}$(2–63 位)。同时是表名前缀、权限前缀、远程视图前缀
namestring是应用名称。DB 列 app_info.app_name 为 varchar(64)
versionstring是语义化版本,正则 ^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$
typestring否module / plugin / deploy,默认 module。抽出来的业务模块写 plugin
enginestring否平台版本约束,见下
authorobject否只有 name、url 两个子字段
licensestring否如 MIT / Apache-2.0。DB 列 varchar(32)
summarystring否一句话简介,市场卡片用。DB 列 varchar(200)
categoriesstring[]否分类名,必须与 app_category 里已存在的分类对得上
tagsstring[]否搜索标签。DB 里存成逗号分隔的 varchar(200)
menusMenuDef[]否菜单树,安装时幂等写入 sys_menu
dependenciesDependency[]否依赖的其它应用。没装/没启用时安装与升级会被拦住并明确告诉你缺谁,见 dependencies[]
migrationsMigration[]否预留字段,当前不参与执行(见下)
configobject否预留字段,当前没有任何代码读它(见下)
assetsobject否前后端产物路径
清单里写错字段名不会报错,会被静默丢弃

宿主 Jackson 配了 fail_on_unknown_properties: false (wujies-admin/src/main/resources/application.yml)。所以拼错的键名不会让解析失败, 只会表现为"这个功能没生效",且日志里什么都没有。

排查手段只有一条:对着 AppManifest.java 的字段名逐字比。改动字段名时, AppManifest.java、docs/app-market/app.schema.json(在工作区根松散 docs/ 下, 不在任何 git 仓库里)与已上架的历史包三处都要看。

engine 的两道闸

engine 有两层校验,顺序很重要 —— 先查长度与字符,再查表达式语法:

private static final Pattern ENGINE_PATTERN = Pattern.compile("^[0-9.\\s<>=]+$");
private static final int ENGINE_MAX_LENGTH = 64;   // 与 app_version.engine_range varchar(64) 对齐
报错触发条件
应用清单 engine 过长(N 字符,上限 64):x超过 64 字符
应用清单 engine 含非法字符(只允许数字、点、空格与 <>= ):x写了 ^ ~ | 等
应用清单 engine 表达式非法: ...语法错,如 >=1.0.0 <

长度上限的目的是把数据库层的失败提前到业务层:否则用户看到的是 Data too long for column 'engine_range' 这种 500。当前平台版本常量是 AppMarketConstants.PLATFORM_VERSION = "5.6.2",现有清单统一写 >=5.6.2 <6.0.0。

保存时只校验语法,不校验是否满足

上架端允许先存草稿,所以 parseAndValidate 不会因为"引擎版本不满足"而拒绝保存; 真正拒绝发生在安装时。这也意味着上架成功不等于客户端装得上。

两个"占位"字段

上架端把整份 JSON 原样存进 app_version.manifest_json,所以任何合法 JSON 字段都能存下来 —— 下表这两个字段就是这种状态:结构在,逻辑没写。

字段真实状态依据
migrations[]不执行。只有 version / up / down 三个子字段,down 是为回滚预留的真正执行的是目录驱动:打包时把 migrations/*.sql 放进包,安装时扫描目录按文件名排序执行。详见 数据库迁移
config没有任何代码读它。平台侧不存在 manifest.getConfig() 的调用,也没有"按清单渲染设置界面"的实现app_install.config_json 列存在(注释写着"按 manifest.config 渲染存储"),但同样是空的。现有 12 份 app.json 全部没有 config 字段

migrations[] 不执行是刻意的:目录驱动让"清单声明了脚本却忘了打包"这类错误 根本不会发生,手工补一个修数据脚本也只需把 .sql 扔进目录。

字典、站点配置、示例数据也不走 config,而是随包走的迁移脚本: 02-create-<app>-seed-data.sql / 0N-create-<app>-dictionaries.sql / 0N-create-<app>-website-config.sql(范例见仓库路径 wujies/wujies-plugins/wujies-hello/migrations/,共三份脚本)。

assets

AppManifest.Assets 只有三个字段。

字段打包时的真实含义落位结果
backend后端 jar 在包内的完整相对路径,如 backend/wujies-hello-plugin-1.0.0.jar取文件名,放到 <pluginDir> 根下(loader.path 不递归子目录)
frontend目录前缀,必须是带目录的路径,如 frontend/index.js(裸写 index.js 会让前缀为空,前端整棵树不落位)包内该前缀整棵释放到 app-market.deploy.frontend-dir/<appCode>/,去掉前缀
signature预留,无任何代码读它验签只看包根有没有独立的 signature.txt

真实包结构(wujies-plugins/dist-packages/mall-1.0.3.zip,9 个条目 —— 那是产物默认落点还在仓库内时打的历史包;现在的新包落在仓库外的 <插件仓库的上一级>\.plugin-dist\<code>\<版本>\,见打包应用):

app.json
signature.txt
backend/wujies-mall-plugin-1.0.3.jar
frontend/index.js
frontend/assets/mall-app.css
migrations/01-create-mall-tables.sql
migrations/02-create-mall-dictionaries.sql
migrations/03-create-mall-website-config.sql
migrations/04-create-mall-seed-data.sql
入口 URL 与落位前缀是两件事

AppRuntimeController 拼出的入口是 /remote-apps/<appCode>/ + Paths.get(assets.frontend).getFileName() + ?v=<版本号>。

但落位用的是「最后一个 / 之前」的前缀(dirPrefix)。所以必须写成 frontend/index.js 这种带目录的形态;裸写 index.js 会让前缀为空, 整棵前端产物都不会被释放,装完菜单点开就是「远程视图不可用」。

两条必须对上的约定:

  1. 物理落位目录名必须等于 appCode —— 插件前端工程的 defineRemoteApp({ appCode })(frontend/build/vite.ts)、 package.ps1 的 -FrontendDist 默认值、后端落位目录、上面拼出的 URL,四处是同一个约定;
  2. 入口文件名必须真的存在于产物里。声明了 assets.frontend 却没有 frontend/index.js 的包,账面上"装成功了",但这个应用的所有菜单点开都是「远程视图不可用」。

两种合法形态

assets.backend 与 assets.frontend 都不写在 Schema 的 required 里,可以只写一边:

形态清单例子注意
全栈两个都写mall / member / pay / hello迁移顺序:落位 → 跑迁移 → 加载 jar
纯前端只写 assets.frontend,包里没有 jarfinance页面读的是别的插件已有接口
纯后端只写 assets.backendgallery 1.0.5不要顺手写上 frontend,见下
一个真实的坏包:声明了前端,包里却没有

gallery-1.0.5.zip 的清单写着 "frontend": "frontend/index.js", 但包里只有 backend/ 与 migrations/,没有任何 frontend/ 目录 (同一仓库里 frontend/gallery-app/dist/index.js 是存在的,只是没打进包)。

后果是静默的:落位代码只要求"至少释放出一个 assets 声明的产物", 后端 jar 释放成功就通过了,frontend/ 目录被创建但是空的。 只声明你真正打进包里的产物;纯后端应用就别写 assets.frontend。

字段与 sys_menu 一一对应。

字段类型必填说明
keystring是应用内唯一键,幂等 upsert 的身份。同一应用同一 key 永远复用同一个 menu_id
namestring是菜单名称,DB 列上限 50
typeM / C / F是M 目录 / C 页面菜单 / F 按钮
pathstring否路由地址。F 按钮通常写 ""
componentstringtype=C 时必填远程应用页面写 remote:{appCode}/{viewKey}
permsstringtype=F 时必填权限标识,约定 {appCode}:{biz}:{action}
iconstring否图标;F 按钮惯例写 "#"
orderinteger否显示顺序,默认 0
childrenMenuDef[]否子菜单,最大深度 5 层

只有这五条是校验器强制的(AppMenuPlanner.collect): key 合法、name 非空、type 属于 M/C/F、type=C 必有 component、type=F 必有 perms。 其余字段(path / icon / order / perms 用在 M/C 上)校验器不管。

AppMenuPlanner 的三条设计规则

  1. 按 key 幂等 —— 重复上架同一版本不会产生重复菜单;
  2. 跨版本复用 menu_id —— 新版本里同名 key 沿用旧 menu_id, 否则租户已有的菜单授权会在升级后全部失效;
  3. 深度优先、父先于子 —— 保证落库时父菜单 ID 一定已确定。

旧版本存在、新版本已移除的菜单会被算进 obsolete, 调用方应当将其停用而不是删除。

不要手写 INSERT INTO sys_menu

菜单只走清单 + AppMenuPlanner。手写 SQL 建的菜单不在 app_version_menu 归属映射里, 管理员能在菜单管理页随手删掉它,且"彻底删除"应用时也回收不了。 详见 菜单与权限。

F 按钮必须写进清单

type: "F" 的按钮不是可选项。漏了会同时踩两个坑:

  1. 全新部署装完没有按钮 —— 前端 v-hasPermi 过不了,租户用户看不到任何操作按钮;
  2. "彻底删除"时也删不掉它们 —— 归属映射表里没有它们。

这条是补出来的:图床的 23 个按钮不在原清单里(历史上来自平台初始化 SQL), 后来才补进 wujies-gallery/app.json。对已有部署还要配一条 seed SQL 把新 key 映射到既有的按钮 ID(script/sql/update/fix_gallery_menu_tree.sql), 否则升级会建出一套重复按钮 —— 这与 app_market_gallery_migrate.sql 当初为 7 个菜单做的是同一件事。

key 的命名转换(本页重点)

唯一的硬约束

// AppMenuPlanner
private static final Pattern KEY_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,62}$");

key 必须是小写字母开头的 2–63 位小写字母 / 数字 / 连字符, 且同一份清单内不许重复。除此之外,key 怎么取名完全自由 —— 这一点很重要,因为下面会看到真实清单里并存着好几种形状。

驼峰转换

perms 里普遍有驼峰,直接拿 perms 当 key 会在上传时被拒:

第 N 个菜单 key 非法(需为小写字母开头的 2-63 位小写字母/数字/连字符): member-member-adjustPoints

转换规则只有一条:驼峰前插 -,再整体小写。

perms转换后
member:member:adjustPointsadjust-points
mall:goods:generateSngenerate-sn
mall:order:refundApplyrefund-apply
member:balanceLog:querybalance-log-query

为什么不能只从 perms 推 key

同一个 perms 可能在多行菜单上重复出现。IM 那轮实测:工作台自带一套按钮、 各管理页又各带一套,19 个 perms 重复 2~3 次。只按 perms 推 key 必然撞车, 而撞车会被校验器直接拒掉(菜单 key 重复: x)。

所以 F 行的 key 必须由父键限定。当前约定是:

F 行 key = <父级 key> + "-" + kebab(perms 的最后一段)

对 407 个真实 F 按钮做统计,三种形状并存:

形状数量真实例
<父级key>-<kebab(action)>168im-friend-request-...、mall-order-index-query、quote-price-list
<appCode>-<父级key>-<kebab(action)>100im-group-join-request-group-join-request-list
其它(历史/平台初始化 SQL 迁移而来)139exam-exam-category-query、member-member-balance-log-query
结论:没有"标准答案",只有"不能撞车"

追加 <appCode>- 前缀(第二种形状)的明确理由是避免冲突: IM 的 im-group-join-request 下既要表达 im:group:join 又要表达 im:group:joinRequest:list, 只取尾段会出现两个 ...-list。

你要做的只有两件: ① 满足正则;② 断言"同一份清单内 key 唯一"。 生成脚本跑完必须自查一遍:

SELECT perms, COUNT(*) FROM sys_menu
-- 把 :appCode 换成真实应用编码(如 social),也可以直接写 CONCAT('social', ':%')
WHERE perms LIKE CONCAT(:appCode, ':%') GROUP BY perms HAVING COUNT(*) > 1;

生成而不是手写

菜单多的模块(会员 68 行、商城 163+ 行)必须用脚本生成,理由不是省事:

app.json 与 seed SQL 的 key 必须由同一份数据生成。手写两份必然漂移, 而漂移的后果是升级时建出一套重复菜单。

生成步骤与两个已验证的坑:

  1. 从库里读那批菜单行(menu_id / parent_id / type / name / path / component / perms / icon), 一次生成 app.json + menu_id = key 映射,再由映射生成 seed SQL;

  2. 菜单要"从根整棵取",不要按 perms LIKE '<appCode>:%' + path 过滤。 社交那轮实测:根与页面之间还有两个中间目录(follow/management、action/management), 它们既没有以应用编码开头的 perms、path 里也不含 social, 过滤会把它们连同下面 11 个页面一起丢掉 —— 结果清单里只剩一个光杆根目录。 用递归 CTE 从根往下取整棵:

    WITH RECURSIVE tree AS (
      SELECT menu_id FROM sys_menu WHERE menu_type = 'M' AND path = 'social'
      UNION ALL
      SELECT m.menu_id FROM sys_menu m JOIN tree t ON m.parent_id = t.menu_id
    )
    SELECT ... FROM sys_menu
    WHERE menu_id IN (SELECT menu_id FROM tree)
    ORDER BY parent_id, order_num, menu_id

    ('social' 处换成真实应用编码;社交的根 menu_type='M'、path='social'。)

  3. 中间目录的 key 用整条路径转 kebab(social-follow-management); 只取末段会撞车(两个都叫 management)。

少一层嵌套 = 少一层 key 前缀,这是个不可逆的决定

key 是 menu_id 的身份标识,一旦上架就不能改 —— 改了等于新菜单, 老租户的授权全失效、旧菜单变 obsolete。所以生成脚本定下的前缀方案要一次定对。

dependencies[]

字段类型必填说明
codestring是依赖的应用编码,规则同顶层 code
versionstring否版本约束,如 >=1.0.0。当前只做展示,不参与判定(见下)
optionalboolean否默认 false。可选依赖缺失时只提示,不阻断安装

什么时候会用到它

依赖检查发生在安装与升级,位置在最前面(落位、迁移、加载之前):

时机表现
市场详情页「简介」标签页顶部列出每个依赖及状态:已安装(绿)/ 未安装(红)/ 已安装但已停用(红)/ 可选未安装(灰)
市场卡片 / 详情页底部必需依赖未满足时按钮变灰并显示原因(不会让你点了再失败)
安装 / 升级接口未满足 ⇒ 直接拒绝,不产生安装任务记录,报错里带应用中文名与缺的那些应用

那句报错长这样(mall 没装 member / pay / im 时):

应用「商城管理」依赖「会员管理」「支付管理」「即时通讯」,请先安装并启用它们,才能安装。

已装但被停用的依赖会另起一句说明处置办法(停用会卸载它的插件后端,依赖它的应用照样加载不了):

其中「会员管理」已安装但处于已停用状态:停用会卸载插件后端,依赖它的应用照样加载不了,
请先到「我的应用」里启用。

为什么必须声明,而不是"装的时候自然会知道"

mall 的代码直接注入兄弟应用的 Service(IMemberService 等)。插件运行时是这样的:

  • 每个插件一个子 Spring 上下文,父上下文是宿主 —— 所以插件能注入宿主,宿主看不见插件;
  • 兄弟插件之间能互相注入(Spring 按类型解析会一路问到祖先上下文),前提是对端已经加载, 且它的类能被 PluginClassLoader 委托到(按包名 cn.wujies.<code>.* 找拥有者加载器)。

于是"依赖没装"的表现不是一句"缺少依赖",而是:

Error creating bean with name 'mallMerchantController':
No qualifying bean of type 'cn.wujies.mall.merchant.service.IMallMerchantService'

引用缺失依赖的那些类会被守卫跳过并只 WARN,它们的 @Service 没有 bean, 控制器自然建不出来 —— 用户看到的是最后一环,根因要往回翻很多层。这就是"依赖必须能声明"的原因。 完整的机制与顺序约束见仓库根 wujies-platform/AGENTS.md 的 §2.5。

⚠️ 版本约束(version)当前不参与判定

判定只看在不在与启没启用,version 字段原样透出给前端展示。 原因:平台现有的版本比较器(EngineConstraint)语义是"平台版本 vs 语义化版本", 拿它比"已装应用的版本"会把 1.0.10 判成小于 1.0.9,那比不判更糟(会误拦)。

{
  "dependencies": [
    { "code": "member" },
    { "code": "pay", "version": ">=1.0.0" },
    { "code": "chat", "optional": true }
  ]
}

卸载时的反向提示

卸载 A 时,如果本租户还有已装应用依赖 A,任务日志里会多一条 WARN:

注意:「商城管理」(mall) 依赖本应用。卸载后它们的相关功能会跟着不可用
(接口会回「应用不可用」);如需继续使用,请先安装回本应用,或先一并卸载它们。

只提示、不拦截 —— 卸载是用户明确要做的事,而且默认卸载保留数据(可逆)。

依赖是安装期的,与"编译期依赖兄弟插件"是两件事

mall 的 app.json 声明了 member / pay / im 依赖;同时它的 pom.xml 把 wujies-member-plugin 等兄弟插件构件也声明为 <scope>provided</scope>。 前者管"装没装",后者管"能不能编译出 IMemberService"。 两者都要有,缺任何一个的表现都不同(一个是装不上,一个是编译不过)。跨插件调用的真实写法见仓库路径 wujies/wujies-plugins/wujies-hello/src/main/java/cn/wujies/hello/controller/HelloDemoCrossPluginController.java ("类可见 vs bean 可见")。

校验报错速查

这些都是会真实看到的中文报错,看到就知道改哪里。

报错原因出处
应用清单(app.json)不能为空传了空字符串AppManifestValidator
应用清单 JSON 解析失败: ...JSON 语法错,或文件带 BOM同上
应用清单缺少 code顶层 code 为空同上
应用清单缺少 version顶层 version 为空同上
清单中的 code(x) 与所属应用(y) 不一致上架时清单里的 code 与所选应用对不上同上
版本号需为语义化版本,如 1.0.0 或 1.0.0-beta.1,当前值: xversion 格式不对同上
应用清单 engine 过长(N 字符,上限 64):xengine 太长同上
应用清单 engine 含非法字符(只允许数字、点、空格与 <>= ):x写了 ^ ~ |同上
应用清单 engine 表达式非法: ...如 >=1.0.0 <同上
第 N 个菜单 key 非法(需为小写字母开头的 2-63 位小写字母/数字/连字符): xkey 用了大写 / 驼峰 / 下划线 / 太短AppMenuPlanner
菜单 key 重复: x同一清单里 key 撞了同上
菜单 x 缺少 name / 菜单 x 缺少 type必填项缺同上
菜单 x 的 type 非法(只能是 M/C/F): ytype 写了别的值同上
菜单 x 是页面菜单(C),必须声明 componentC 类型没写 component同上
按钮 x 必须声明 permsF 类型没写 perms同上
菜单树深度超过上限 5,请检查清单嵌套嵌套太深同上
第 N 个菜单声明为空menus 数组里有 null同上

AppManifestValidator 会把菜单类报错再包一层: 应用清单菜单声明非法: <上面那条>。

app.json 必须是无 BOM 的 UTF-8

.ps1 脚本写成 BOM 是必需的,app.json 正相反:开头的 BOM 会让 Jackson 直接报解析失败。用 PowerShell 生成时写:

[System.IO.File]::WriteAllText($p, $json, (New-Object System.Text.UTF8Encoding($false)))

package.ps1 在改写版版本号时用的就是这一行(Set-Content -Encoding UTF8 会加 BOM)。

相关阅读

目录