应用清单 app.json
app.json 是应用在平台上的唯一权威描述:编码、版本、产物路径、菜单树、依赖。
上架、安装、升级、卸载全部读它。写错了不会在编译期报错,只会在安装时失败或
在运行期静默失效(菜单点不开、按钮不出现、C 端 404)。
这一页解决三件事:
- 每个字段真实的类型、必填性与取值约束(对着
AppManifest.java与校验器写,不凭印象); menus[]怎么设计,尤其是type: "F"按钮 —— 漏了会留下永久性的坏数据;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 类型 | 必填 | 含义与约束 |
|---|---|---|---|
code | string | 是 | 应用编码。正则 ^[a-z][a-z0-9-]{1,62}$(2–63 位)。同时是表名前缀、权限前缀、远程视图前缀 |
name | string | 是 | 应用名称。DB 列 app_info.app_name 为 varchar(64) |
version | string | 是 | 语义化版本,正则 ^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$ |
type | string | 否 | module / plugin / deploy,默认 module。抽出来的业务模块写 plugin |
engine | string | 否 | 平台版本约束,见下 |
author | object | 否 | 只有 name、url 两个子字段 |
license | string | 否 | 如 MIT / Apache-2.0。DB 列 varchar(32) |
summary | string | 否 | 一句话简介,市场卡片用。DB 列 varchar(200) |
categories | string[] | 否 | 分类名,必须与 app_category 里已存在的分类对得上 |
tags | string[] | 否 | 搜索标签。DB 里存成逗号分隔的 varchar(200) |
menus | MenuDef[] | 否 | 菜单树,安装时幂等写入 sys_menu |
dependencies | Dependency[] | 否 | 依赖的其它应用。没装/没启用时安装与升级会被拦住并明确告诉你缺谁,见 dependencies[] |
migrations | Migration[] | 否 | 预留字段,当前不参与执行(见下) |
config | object | 否 | 预留字段,当前没有任何代码读它(见下) |
assets | object | 否 | 前后端产物路径 |
宿主 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.sqlAppRuntimeController 拼出的入口是
/remote-apps/<appCode>/ + Paths.get(assets.frontend).getFileName() + ?v=<版本号>。
但落位用的是「最后一个 / 之前」的前缀(dirPrefix)。所以必须写成
frontend/index.js 这种带目录的形态;裸写 index.js 会让前缀为空,
整棵前端产物都不会被释放,装完菜单点开就是「远程视图不可用」。
两条必须对上的约定:
- 物理落位目录名必须等于
appCode—— 插件前端工程的defineRemoteApp({ appCode })(frontend/build/vite.ts)、package.ps1的-FrontendDist默认值、后端落位目录、上面拼出的 URL,四处是同一个约定; - 入口文件名必须真的存在于产物里。声明了
assets.frontend却没有frontend/index.js的包,账面上"装成功了",但这个应用的所有菜单点开都是「远程视图不可用」。
两种合法形态
assets.backend 与 assets.frontend 都不写在 Schema 的 required 里,可以只写一边:
| 形态 | 清单 | 例子 | 注意 |
|---|---|---|---|
| 全栈 | 两个都写 | mall / member / pay / hello | 迁移顺序:落位 → 跑迁移 → 加载 jar |
| 纯前端 | 只写 assets.frontend,包里没有 jar | finance | 页面读的是别的插件已有接口 |
| 纯后端 | 只写 assets.backend | gallery 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。
menus[]
字段与 sys_menu 一一对应。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
key | string | 是 | 应用内唯一键,幂等 upsert 的身份。同一应用同一 key 永远复用同一个 menu_id |
name | string | 是 | 菜单名称,DB 列上限 50 |
type | M / C / F | 是 | M 目录 / C 页面菜单 / F 按钮 |
path | string | 否 | 路由地址。F 按钮通常写 "" |
component | string | type=C 时必填 | 远程应用页面写 remote:{appCode}/{viewKey} |
perms | string | type=F 时必填 | 权限标识,约定 {appCode}:{biz}:{action} |
icon | string | 否 | 图标;F 按钮惯例写 "#" |
order | integer | 否 | 显示顺序,默认 0 |
children | MenuDef[] | 否 | 子菜单,最大深度 5 层 |
只有这五条是校验器强制的(AppMenuPlanner.collect):
key 合法、name 非空、type 属于 M/C/F、type=C 必有 component、type=F 必有 perms。
其余字段(path / icon / order / perms 用在 M/C 上)校验器不管。
AppMenuPlanner 的三条设计规则
- 按 key 幂等 —— 重复上架同一版本不会产生重复菜单;
- 跨版本复用
menu_id—— 新版本里同名 key 沿用旧menu_id, 否则租户已有的菜单授权会在升级后全部失效; - 深度优先、父先于子 —— 保证落库时父菜单 ID 一定已确定。
旧版本存在、新版本已移除的菜单会被算进 obsolete,
调用方应当将其停用而不是删除。
菜单只走清单 + AppMenuPlanner。手写 SQL 建的菜单不在 app_version_menu 归属映射里,
管理员能在菜单管理页随手删掉它,且"彻底删除"应用时也回收不了。
详见 菜单与权限。
F 按钮必须写进清单
type: "F" 的按钮不是可选项。漏了会同时踩两个坑:
- 全新部署装完没有按钮 —— 前端
v-hasPermi过不了,租户用户看不到任何操作按钮; - "彻底删除"时也删不掉它们 —— 归属映射表里没有它们。
这条是补出来的:图床的 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:adjustPoints | adjust-points |
mall:goods:generateSn | generate-sn |
mall:order:refundApply | refund-apply |
member:balanceLog:query | balance-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)> | 168 | im-friend-request-...、mall-order-index-query、quote-price-list |
<appCode>-<父级key>-<kebab(action)> | 100 | im-group-join-request-group-join-request-list |
| 其它(历史/平台初始化 SQL 迁移而来) | 139 | exam-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 必须由同一份数据生成。手写两份必然漂移, 而漂移的后果是升级时建出一套重复菜单。
生成步骤与两个已验证的坑:
-
从库里读那批菜单行(
menu_id/parent_id/type/name/path/component/perms/icon), 一次生成app.json+menu_id = key映射,再由映射生成 seed SQL; -
菜单要"从根整棵取",不要按
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'。) -
中间目录的 key 用整条路径转 kebab(
social-follow-management); 只取末段会撞车(两个都叫management)。
key 是 menu_id 的身份标识,一旦上架就不能改 —— 改了等于新菜单,
老租户的授权全失效、旧菜单变 obsolete。所以生成脚本定下的前缀方案要一次定对。
dependencies[]
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | string | 是 | 依赖的应用编码,规则同顶层 code |
version | string | 否 | 版本约束,如 >=1.0.0。当前只做展示,不参与判定(见下) |
optional | boolean | 否 | 默认 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,当前值: x | version 格式不对 | 同上 |
应用清单 engine 过长(N 字符,上限 64):x | engine 太长 | 同上 |
应用清单 engine 含非法字符(只允许数字、点、空格与 <>= ):x | 写了 ^ ~ | | 同上 |
应用清单 engine 表达式非法: ... | 如 >=1.0.0 < | 同上 |
第 N 个菜单 key 非法(需为小写字母开头的 2-63 位小写字母/数字/连字符): x | key 用了大写 / 驼峰 / 下划线 / 太短 | AppMenuPlanner |
菜单 key 重复: x | 同一清单里 key 撞了 | 同上 |
菜单 x 缺少 name / 菜单 x 缺少 type | 必填项缺 | 同上 |
菜单 x 的 type 非法(只能是 M/C/F): y | type 写了别的值 | 同上 |
菜单 x 是页面菜单(C),必须声明 component | C 类型没写 component | 同上 |
按钮 x 必须声明 perms | F 类型没写 perms | 同上 |
菜单树深度超过上限 5,请检查清单嵌套 | 嵌套太深 | 同上 |
第 N 个菜单声明为空 | menus 数组里有 null | 同上 |
AppManifestValidator 会把菜单类报错再包一层:
应用清单菜单声明非法: <上面那条>。
.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)。
相关阅读
- 菜单与权限 —— 菜单落库、归属映射与保护
- 后端工程 —— 自包含工程结构与
provided依赖 - 前端远程应用 ——
remote:{appCode}/{viewKey}怎么解析 - 数据库迁移 ——
migrations/目录驱动与幂等要求 - 应用范例 hello —— 五组演示的源码对照