上架与发布
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
这一页只讲上架端(/appconsole)从「手上有一个 zip 包」到「租户能在应用市场里装到它」之间的每一步:
接口、版本行字段、状态流转,以及每一步会因为什么被拒绝。
前置的、已经在别处写清的三件事不在这里重复:
下文用 app-market/… 指代后端源码前缀
wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/。
- 形态必须是官方版:
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。 形态机制见 目录中心化与版本形态。 - 四个权限点:
app:console:list看、app:console:add传包与新建、app:console:edit改 / 发布 / 弃用 / 部署 / 上下架、app:console:remove删。
链路总览:五个动作,只有最后一个动磁盘
| # | 动作 | 接口 | 权限 | 改了什么 |
|---|---|---|---|---|
| ① | 上传包 | POST /app/package/parse | app:console:add | 包存入对象存储;返回清单、地址与 MD5/SHA256。不落位 |
| ② | 保存版本 | POST /app/version | app:console:add | 插一行 app_version,status 默认 0 |
| ③ | 发布版本 | POST /app/version/{versionId}/publish | app:console:edit | 该行 status=1 + release_time;回写 app_info.latest_version |
| ④ | 上架应用 | POST /app/console/{appId}/status?status=1 | app:console:edit | app_info.status=1;要求 latest_version 非空 |
| ⑤ | 部署 | POST /app/version/{versionId}/deploy | app: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_BYTES | 200MB | 应用包不能超过 200MB |
MAX_MANIFEST_BYTES | 1MB | 应用清单超过 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 | 整包字节数与两个摘要,部署时逐项校验 |
securityNote | PackageSecurityInspector 的一句话结论(签名是否有效、白名单有无问题) |
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 |
新增时的三条校验(顺序即报错顺序):
AppManifestValidator.assertVersionFormat—— 版本号格式(正则见下一节);- 所属应用存在;
- 同应用下版本号唯一,只统计
del_flag='0'的行 —— 冲突报「该应用下已存在版本: X」。
请求体里的 version 与清单不一致时,服务端用清单里的值覆盖它并重新查一次唯一性。
注意 version 本身仍不能为空:assertVersionFormat 排在派生逻辑之前,
空值会直接报「版本号需为语义化版本…」。前端把 ① 的返回值填进表单就不会遇到这件事。
版本列表与详情:
| 接口 | 权限 | 说明 |
|---|---|---|
GET /app/version/list?appId=N | app:console:list | appId 必填;只返回 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。
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 | 已弃用 | 不再推荐安装;已安装的租户不受影响 |
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):
| 顺序 | 条件 | 结果 |
|---|---|---|
| 1 | status 不是 0 / 1 / 2 | 拒绝:不支持的状态: <值> |
| 2 | 应用不存在 | 拒绝:应用不存在: <appId> |
| 3 | status=1 且 app_info.latest_version 为空 | 拒绝:应用尚未发布任何版本,无法上架 |
| 4 | install_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 非空时出现,给出清理办法 |
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、
服务根本起不来」(改造前实测过)已经不成立了。仍然要清理的原因有两个:
- 读不到
active-plugins.txt(文件缺失或为空)时不过滤,退回改造前的宽容行为 —— 那一刻两个版本的 jar 会同时被加载,老坑就回来了; - 旧产物会一直留在
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),
逗号分隔多值。
- 空串参数不是「没传」:跨进程拼
?audience=会让对端拿到空字符串,而判断是audience != null→ 去匹配FIND_IN_SET('', audience)→ 一条都查不出来。可选参数要整条丢掉 (见 目录中心化与版本形态)。 - 应用上架 ≠ 版本发布:上架强制要求
latest_version非空,所以「已上架」蕴含「至少有一个已发布版本」; 但反过来,删掉/弃用最新版本会把latest_version回退甚至置空,已上架状态却不会自动撤销。 - 租户侧的版本列表只有已发布版本(
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"application.yml 里的 sa-token.token-name: Authorization,clientid 表示 clientid 是 token 的另一个头名,
不是「客户端标识」——往它里面放 clientId 只会让这一路 token 解析失败。用 Authorization: Bearer <token> 就够了。
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 还在原地,脚本会读到别的应用的返回
并高高兴兴地打印出来。