Wujies 无界平台
平台功能(自研)打包与上架

上架与发布

这一页只讲上架端(/appconsole)从「手上有一个 zip 包」到「租户能在应用市场里装到它」之间的每一步: 接口、版本行字段、状态流转,以及每一步会因为什么被拒绝。

前置的、已经在别处写清的三件事不在这里重复:

主题去哪看
包怎么打、包内目录规范打包应用、打包与上架
签名、密钥、安全模式(off / warn / enforce)密钥与签名、包安全模式
全部端点索引(含安装侧、运行时、目录服务)HTTP 接口索引

下文用 app-market/… 指代后端源码前缀 wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/。

两个前提
  1. 形态必须是官方版:app-market.edition 为 official(默认)。用户版(client)下 /app/console、/app/version、/app/package 的全部方法,以及 /app/category 的写方法会被 MarketEditionGuardFilter 拦成 403;唯一例外是 GET /app/console/owned-menus(菜单管理页要用)。 前缀与例外写在 app-market/edition/MarketEdition.java 的 PUBLISH_PREFIXES / PUBLISH_ALLOWLIST。 形态机制见 目录中心化与版本形态。
  2. 四个权限点:app:console:list 看、app:console:add 传包与新建、app:console:edit 改 / 发布 / 弃用 / 部署 / 上下架、app:console:remove 删。

链路总览:五个动作,只有最后一个动磁盘

#动作接口权限改了什么
①上传包POST /app/package/parseapp:console:add包存入对象存储;返回清单、地址与 MD5/SHA256。不落位
②保存版本POST /app/versionapp:console:add插一行 app_version,status 默认 0
③发布版本POST /app/version/{versionId}/publishapp:console:edit该行 status=1 + release_time;回写 app_info.latest_version
④上架应用POST /app/console/{appId}/status?status=1app:console:editapp_info.status=1;要求 latest_version 非空
⑤部署POST /app/version/{versionId}/deployapp:console:edit下载 → 校验和 → 安全检查 → 解包落位 → 热加载。只有这一步写宿主磁盘
走到 ④ 也还装不上

①②③④ 都只是登记与状态变更,磁盘上什么都没有。产物要等 ⑤,或者等租户在自己那台点「安装」 (安装/升级走服务内部的 AppPackageDeployService.deploy(versionId, false),只落位,加载推迟到迁移之后)。

少了 ⑤ 的表现:市场里看得见、点得开,但接口报「应用未安装或未启用」,菜单点开是「远程视图不可用」。

控制台里的顺序与之对应:应用上架 → 应用管理 → 新增(登记 appCode、名称等)→ 编辑 → 版本管理(上传包、保存版本、发布、部署)→ 回应用列表上架。

POST /app/console 新增应用时有四个默认值(都在 app-market/controller/AppConsoleController.java 的 add 方法里):不传 status 落 0 草稿、不传 appType 落 module、不传 installMode 落 optional、 不传 audience 落 tenant。另有两条由服务端强制:latestVersion 置 null(只由发布流程写)、 installCount 置 0;PUT /app/console 同样忽略请求体里的 latestVersion。


① 上传包:POST /app/package/parse

项值
请求multipart/form-data(consumes 已声明,别的类型不匹配)
表单字段file(zip,必填)、appId(可选,Long)
权限app:console:add
实现app-market/controller/AppPackageController.java 的 parse

appId 传了会先按 ID 查 app_info(不存在报「应用不存在: N」),再用该应用的 app_code 校验包内清单的 code,不一致报「清单中的 code(X) 与所属应用(Y) 不一致」。

限制全部是类里的常量,越界文案如下:

限制值越界时
MAX_PACKAGE_BYTES200MB应用包不能超过 200MB
MAX_MANIFEST_BYTES1MB应用清单超过 1024KB,疑似异常文件
文件名后缀.zip(忽略大小写)应用包必须是 zip 格式,当前文件: <文件名>
清单位置包根 app.json;退一步接受一层嵌套的 */app.json应用包内未找到 app.json,请确认包结构(清单需位于压缩包根目录)

extractManifest 会跳过 __MACOSX/ 与目录项;根目录那份优先,一层嵌套那份只作兜底。 不要依赖这个兜底:产物白名单是按 zip 根目录判定的,多套一层会被判成非法顶层目录 (见 打包应用 的 Compress-Archive 一节)。

返回值 AppPackageParseVo(app-market/domain/vo/AppPackageParseVo.java):

字段含义
manifestJson清单。注意它是解析后重新序列化的结果(JsonUtils.toJsonString(manifest)),不是包内原文的字节
code / name / version / engine清单里的应用编码、名称、版本号、平台版本约束
menuCount菜单数量,递归含子级
packageUrl对象存储地址,原样填进版本行
packageSize / packageMd5 / packageSha256整包字节数与两个摘要,部署时逐项校验
securityNotePackageSecurityInspector 的一句话结论(签名是否有效、白名单有无问题)
安全检查发生在包已落对象存储之后

parse 的顺序是:解包读清单 → 清单校验 → sysOssService.upload(file) → packageSecurityInspector.inspectOrThrow(...)。 所以在 enforce 模式下被拒的那次上传,对象存储里已经多了一份对象,平台没有对应的清理分支 —— 要清只能自己按 packageUrl(或 OSS 后台)处理。warn 模式下这里只提示、不阻断。


② 保存版本:POST /app/version(版本行字段)

请求体就是 AppVersion 实体(表 app_version,源码 app-market/domain/AppVersion.java)。 每列的来源分三种:请求体、清单派生、服务端强制。分不清就会写出「传了却不生效」或 「不传就 500」的代码。

属性 / 列来源说明
versionId / version_id服务端强制新增时置 null;修改时必须传,否则报「版本ID不能为空」
appId / app_id请求体(新增必填)查不到报「应用不存在: N」;修改时被覆盖为库里那一行的 app_id
version / version清单派生新增时先用请求值做格式校验,随后若与清单不同就用清单值覆盖并再做一次唯一性校验;修改时一律取清单值
engineRange / engine_range清单派生取清单的 engine,请求体传什么都不看
manifestJson / manifest_json请求体,必填走 AppManifestValidator.parseAndValidate(...)(代码里判空,@Validated 不负责它);发布与部署都从这一列读清单
packageUrl / package_size / package_md5 / package_sha256请求体从 ① 的返回里回填。部署时按 MD5/SHA256 逐项校验;两列都为空则跳过校验并只打 WARN
signature / signature——没有任何代码读写这一列。签名是包根的 signature.txt,随包走(实体注释里的「第三方应用必填」是旧说法)
changelog请求体更新日志;市场详情页按纯文本渲染
releaseTime / release_time服务端(发布时)版本列表按它倒序
status见下节新增时请求体没给才落 0
delFlag / del_flag服务端新增置 0;删除是把这一列改成 1

新增时的三条校验(顺序即报错顺序):

  1. AppManifestValidator.assertVersionFormat —— 版本号格式(正则见下一节);
  2. 所属应用存在;
  3. 同应用下版本号唯一,只统计 del_flag='0' 的行 —— 冲突报「该应用下已存在版本: X」。
清单里的 version 是权威

请求体里的 version 与清单不一致时,服务端用清单里的值覆盖它并重新查一次唯一性。

注意 version 本身仍不能为空:assertVersionFormat 排在派生逻辑之前, 空值会直接报「版本号需为语义化版本…」。前端把 ① 的返回值填进表单就不会遇到这件事。

版本列表与详情:

接口权限说明
GET /app/version/list?appId=Napp:console:listappId 必填;只返回 del_flag='0';排序为 release_time 倒序、再 version_id 倒序
GET /app/version/{versionId}app:console:list按主键返回
这两条返回的是裸实体

含 packageUrl、MD5/SHA256、manifestJson。租户侧只能走 GET /app/market/{appCode}/versions, 那条返回脱敏后的 AppVersionPublicVo 且只含已发布版本(草稿与已弃用都不暴露)—— 见 HTTP 接口索引。

修改版本(PUT /app/version)里有两条硬规则:

  • 提交的 manifestJson 与库里那份不同、且该行当前 status=1,直接拒绝: 「该版本已发布,不允许再修改应用清单;如需变更请发布新版本」。理由是有租户可能已经装了它, 改清单会让「租户装到的东西」与「目录里描述的」不一致;
  • appId / version / engineRange 一律从库里与清单派生,不信任前端传值 —— 否则脏数据会直接写库并触发 Data truncation 这类 500。
PUT /app/version 是把整个实体交给 updateById

MyBatis-Plus 默认只写非空字段,所以请求体里出现的 status、delFlag 等列都会被写库。 发布、弃用、下架请走各自的专用端点,别用这个接口改状态。


版本号:打包脚本与后端两套正则

这是「打包能过、上架被拒」的根因。两边规则不一样,而且脚本更宽松。

环节正则实际要求
打包脚本 -Version(wujies/wujies-plugins/package.ps1)^[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]+)?$0–3 段数字,可选 - 或 + 后缀
后端清单校验(app-market/service/AppManifestValidator.java 的 assertVersionFormat)^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$必须正好三段数字,可选 - 后缀(不支持 +)

后端拒绝时的原文案:

版本号需为语义化版本,如 1.0.0 或 1.0.0-beta.1,当前值: (你传的值)

后端一共跑四处,任何一处都会拦下来:上传包(校验清单)、新增版本、修改版本、发布版本。

打包脚本放行、后端拒掉的组合
版本号打包脚本后端
1.0.0通过通过
1.0.0-beta.1通过通过
1.0通过拒绝(缺第三段)
1通过拒绝(缺两段)
1.0.0.1通过拒绝(多了一段)
1.0.0+build.5通过拒绝(不支持 +)

两条补充:

  • 那个正则只在传了 -Version 时生效;不传 -Version 时脚本只检查 app.json 的 version 非空 (package.ps1 里是 if (-not $appCode -or -not $appVersion) { throw ... }), 即手写的 1.0 也能打出包来。
  • 结论只有一句:统一用 x.y.z,两边都不会错。

顺带一个只有读代码才知道的细节:AppVersionController 里自己还留着一份同样的版本正则常量 (VERSION_PATTERN),但校验已经全部委托给 AppManifestValidator,那个常量当前没有任何引用。


版本状态流转

值名称含义
0待发布草稿。不对租户可见,不能作为安装/升级目标
1已发布对租户可见,可作为安装与升级目标
2已弃用不再推荐安装;已安装的租户不受影响
实体注释里的 3 是历史残留

app-market/domain/AppVersion.java 的 status 注释写的是「0待发布 1已发布 2已弃用 3已下架」, 但 AppVersionController 只定义并使用 0 / 1 / 2,没有任何代码路径写入 3。

动作前置结果
新增应用存在、版本号格式合法、同应用下版本号唯一(未删除的)status = 0(请求体没给时)
发布版本存在、manifest_json 非空、清单能过校验status = 1,release_time 置当前;回写 app_info.latest_version
弃用版本存在status = 2。若弃用的正是 latest_version,回退到仍处于已发布状态中 release_time 最新的一条;一条都没有则写成空字符串(不是 null)
删除版本存在,且没有租户在用它软删除(del_flag='1')。若删的是 latest_version,回退规则同「弃用」
部署版本存在、package_url 非空不改版本状态,也不要求 status=1 —— 草稿版本同样可部署

发布与弃用都不会修改清单、也不会碰磁盘;release_time 只在发布时写, 所以弃用不会刷新排序(列表仍是发布时间的倒序)。

版本占用统计是跨租户的(TenantHelper.ignore),判据是 app_install 里 del_flag='0' 且状态不是 uninstalled 的记录:

仍有 N 个租户在使用该版本,无法删除


应用上架状态

值名称
0草稿(未上架)
1已上架
2已下架

POST /app/console/{appId}/status?status=N 的判定顺序(AppConsoleController.updateStatus):

顺序条件结果
1status 不是 0 / 1 / 2拒绝:不支持的状态: <值>
2应用不存在拒绝:应用不存在: <appId>
3status=1 且 app_info.latest_version 为空拒绝:应用尚未发布任何版本,无法上架
4install_mode = 'system'拒绝:系统应用不允许上下架:<appCode>
5以上都过写状态,并整片失效菜单缓存

顺序本身有含义:系统应用在上架时若还没有已发布版本,先看到的是第 3 条那句, 而不是「系统应用不允许上下架」。

  • 下架(status=2):已安装的租户不受影响 —— 市场列表按 status='1' 过滤 (app-market/catalog/LocalAppCatalogService.java 的 searchOnShelf),所以只是不再出现、不能再被新装;
  • 置为草稿(status=0):接口接受这个值;
  • 每次变更都 AppMenuCache.evictAll() —— 「已上架数量」决定菜单过滤是否启用,所有租户的过滤结果都可能随之改变。

⑤ 部署:报告里的每个字段都值得看

POST /app/version/{versionId}/deploy 返回 AppPackageDeployService.deploy(versionId) 的报告 (单参版本 = 落位 + 立即热加载;安装/升级走双参 deploy(versionId, false)):

字段含义
appCode / version本次部署的应用与版本
pluginDir / frontendDir后端 jar 与前端产物的绝对落位路径
releasedCount / releasedBytes / released释放的文件数、字节数与逐条清单(backend: / frontend: 前缀)
migrationCount / migrationDir迁移脚本数量与落位目录。部署不执行迁移脚本,只是把包里的脚本放好
security安全检查结论(PackageSecurityInspector.summary())
hotLoaded / hotLoadNote是否热加载成功;失败原因在 hotLoadNote 里,且产物已经落位,重启后仍会生效
deferredLoad双参版本传 false 时才有:运行时开着就为 true,此时 restartRequired=false
restartRequired需要重启 JVM 才生效时为 true;热加载成功会被改成 false
staleJars在线删不掉的旧 jar(Windows 上被运行中的 JVM 占用时正常)。非空就不能直接重启
activeJar本次写入的 jar 文件名,同时记进 <pluginDir>/active-plugins.txt
warning只在 staleJars 非空时出现,给出清理办法
staleJars 非空不影响本次生效,但不能一直放着

2026-10-02 起,运行期与启动瞬间都按 active-plugins.txt 选版本:PluginRuntimeManager 只加载清单里 那个 jar,启动器的 PluginClassPathLoader.listJars 同样做过滤 —— 不在清单里的 jar 不挂载, 启动日志会打印「跳过 N 个不在 active-plugins.txt 里的 jar(刻意不挂载)」。

所以「残留旧 jar → 重启时两份同样的 Mapper XML 撞 Result Maps collection already contains value、 服务根本起不来」(改造前实测过)已经不成立了。仍然要清理的原因有两个:

  1. 读不到 active-plugins.txt(文件缺失或为空)时不过滤,退回改造前的宽容行为 —— 那一刻两个版本的 jar 会同时被加载,老坑就回来了;
  2. 旧产物会一直留在 pluginDir 里,没人认领。

清理脚本是 script/prune/prune-plugins.ps1(Linux 用同目录的 .sh), 它按 active-plugins.txt 删掉非生效版本的 jar;script/prune/start-host.ps1 启动前会自动跑一次。

⚠️ 部署报告里那句 warning(以及 AppPackageDeployService 的类注释)提示的路径是 scripts/prune-plugins.ps1,仓库里没有这个路径 —— 以 script/prune/ 下的实际文件为准: 那段文案是目录调整与 active-plugins.txt 过滤机制上线之前写的。

部署不改安装记录的版本

控制台「部署」只做「落位 + 热加载」,不动 app_install.version_id;而落位新版本时会清掉旧版本的 jar。 所以「部署 1.0.1 → 停用 → 启用」会报「插件 jar 不存在: …-1.0.0.jar」(安装记录还指向 1.0.0)。

要动安装记录就走升级:POST /app/install/{installId}/upgrade?versionId=…, 或者发布更高版本让租户在「我的应用」里升级 —— 后者是给客户交付的正路。

AppVersionController.deploy 的方法注释仍写着「后端 jar 需重启 JVM(loader.path 是启动期决定的)」, 那是热加载上线之前的说法;现在以报告里的 hotLoaded / restartRequired 为准。


上架端的保护

目录是平台资产:删掉一行会让别人的部署对不上(安装记录指向不存在的应用、升级查不到版本、 市场里凭空少一个应用),而且没有撤销入口。所以删除有三道闸,全在服务端,不依赖前端按钮置灰。

闸触发条件报错
系统应用install_mode = 'system'(平台自带功能)系统应用不允许删除:<appCode>
删除白名单app_code 不在 DELETABLE_APP_CODES 里(当前只有 demo-app)应用「<名称>」不允许删除:只有官方示例应用(demo-app)可以删除,内置模块与已上架应用都不允许删除
仍有租户在用app_install 里还有状态非 uninstalled 的记录(跨租户统计)仍有 N 个租户在使用该应用,无法删除

白名单常量在 app-market/controller/AppConsoleController.java 的 DELETABLE_APP_CODES。 要放开某个应用:加进这个集合,并同步前端 plus-ui/src/views/appstore/console/app/index.vue 里的 DELETABLE_APP_CODE —— 前端只负责把按钮置灰,不是安全边界。

删除本身是软删除(app_info.del_flag='1'),删完同样 AppMenuCache.evictAll()。

GET /app/console/owned-menus 是这一组里唯一的例外项:权限用 system:menu:list (这份数据是菜单管理页的附属信息,能看菜单列表的人就该能拿到),返回 {menuId, appCode, appName} 并按 menuId 升序。它也是用户版下唯一放行的上架端接口。


市场可见性:四条同时成立

一个应用要出现在某个登录者的市场列表里(GET /app/market/list → LocalAppCatalogService.searchOnShelf),必须同时满足:

条件字段
已上架status = '1'
未删除del_flag = '0'
不是系统应用install_mode != 'system'(系统应用不由租户安装)
命中当前身份FIND_IN_SET(<身份>, audience);超级管理员整条不拼,即可见全部

身份由服务端从登录态解析(AppMarketController.resolveAudience): 超级管理员 → 不限制;UserType.APP_USER → member;其余 B 端用户 → tenant。 audience 的可取值是 platform / tenant / member(app-market/constant/AppMarketConstants.java), 逗号分隔多值。

三件容易混的事
  1. 空串参数不是「没传」:跨进程拼 ?audience= 会让对端拿到空字符串,而判断是 audience != null → 去匹配 FIND_IN_SET('', audience) → 一条都查不出来。可选参数要整条丢掉 (见 目录中心化与版本形态)。
  2. 应用上架 ≠ 版本发布:上架强制要求 latest_version 非空,所以「已上架」蕴含「至少有一个已发布版本」; 但反过来,删掉/弃用最新版本会把 latest_version 回退甚至置空,已上架状态却不会自动撤销。
  3. 租户侧的版本列表只有已发布版本(releasedVersions 按 status='1' 过滤), 草稿与已弃用都不暴露。

照着敲一遍

HOST=http://localhost:8086          # server.port,见 wujies-admin/src/main/resources/application.yml
TOKEN=<你的 token>

# ① 上传(multipart;appId 可省,传了会校验清单 code 与应用一致)
curl -X POST "$HOST/app/package/parse" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@..\.plugin-dist\gallery\1.0.5\gallery-1.0.5.zip" -F "appId=<appId>"

# ② 保存版本:把 ① 返回的 packageUrl / packageSize / packageMd5 / packageSha256
#    与 manifestJson 填进 version.json,再带上 appId
curl -X POST "$HOST/app/version" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary @version.json          # UTF-8 且不带 BOM

# ③ 发布
curl -X POST "$HOST/app/version/<versionId>/publish" \
  -H "Authorization: Bearer $TOKEN"

# ④ 上架应用
curl -X POST "$HOST/app/console/<appId>/status?status=1" \
  -H "Authorization: Bearer $TOKEN"

# ⑤ 部署(落位 + 热加载;看响应里的 hotLoaded / staleJars)
curl -X POST "$HOST/app/version/<versionId>/deploy" \
  -H "Authorization: Bearer $TOKEN"
别把 clientId 塞进 clientid 头

application.yml 里的 sa-token.token-name: Authorization,clientid 表示 clientid 是 token 的另一个头名, 不是「客户端标识」——往它里面放 clientId 只会让这一路 token 解析失败。用 Authorization: Bearer <token> 就够了。

用 PowerShell 发 JSON 时先验一次中文

Invoke-RestMethod 在部分版本上按非 UTF-8 发送请求体,中文会变成 ? 并且真的写坏数据。 两种稳妥做法:用 curl.exe --data-binary @file(文件存成 UTF-8,不带 BOM 最保险 —— 打包脚本写包内 app.json 用的就是无 BOM 的 UTF8Encoding($false)), 或显式声明 charset=utf-8。不确定就先发一条带中文的请求、再回读确认。

另外,跑这类脚本前先删掉上一次的响应文件,或者断言响应里的 code / version 与本次请求一致 —— 实例中途挂掉时 curl 写不出新文件,上一次的 resp.json 还在原地,脚本会读到别的应用的返回 并高高兴兴地打印出来。


相关阅读

目录