目录中心化与版本形态
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
这一页讲 wujies-app-market 里两个部署拓扑开关的语义、互相约束与失败表现:
| 开关 | 谁读它 | 它决定什么 |
|---|---|---|
app-market.catalog.mode | LocalAppCatalogService / RemoteAppCatalogService(@ConditionalOnProperty 二选一) | 目录(应用 / 版本 / 分类)从本机库读,还是从另一台部署读 |
app-market.edition | MarketEdition | 这套部署是官方版(含上架端)还是用户版(不含上架端) |
两者互相牵制:edition=client 必须配 catalog.mode=remote,否则启动直接失败。组合错了有两种坏结果 —— 起不来,或者更糟的看着成功、实际无效(在用户版的上架端"上架成功",而安装读的是远端目录)。
要装这样的多套部署(1Panel 包、端口、令牌分发、链路验证)见 /deploy/multi-deployment;每一项配置的默认值与环境变量名见 /deploy/configuration 与 /reference/config;本页讲的是代码里为什么这么写、改哪一行会坏什么。
一、目录中心化(app-market.catalog.mode)
1.1 前提:三类数据必须先分家
三种数据混在同一套表里,才会出现"多部署一套系统,上架一次要同步 N 遍"。它们的边界是表上有没有 tenant_id,判据落在两处:DDL 在 wujies-platform/script/sql/update/app_market.sql,全局表白名单在 wujies-platform/wujies-admin/src/main/resources/application.yml 的 security.tenant.excludes。
| 类别 | 表 | 应处位置 | 依据 |
|---|---|---|---|
| 目录 | app_info、app_version、app_category、app_dependency、app_plan、app_version_menu | 中心一份(多部署时可远程读) | 表上没有 tenant_id,已列入 security.tenant.excludes |
| 安装实例 | app_install、app_install_task | 每套部署各一份 | 表上有 tenant_id,不在 excludes 里,由多租户插件隔离 |
| 迁移账本 | app_schema_history | 每套部署各一份 | 同上;唯一键是 (app_code, script),见 /advanced/lifecycle |
因为目录这几张表不带租户参数,目录接口 /app/catalog/** 的请求里不需要传租户 —— 这是整件事能干净做掉的原因,也是"目录能被搬到另一台机器"的全部前提。表间关系见 /design/data-model。
它是"版本 → 菜单"的映射,属于目录的一部分(部署内共享)。租户维度的过滤不靠多租户插件,而是 SQL 自己带条件:AppVersionMenuMapper.selectInvisibleMenuIds(tenantId),消费方是 AppMenuFilterImpl。菜单归属本身的规则见 /advanced/menu-ownership。
1.2 唯一入口:IAppCatalogService
安装侧的代码只允许通过它读目录,不要直接注入 AppInfoMapper / AppVersionMapper。接口是"按业务语义取用"而不是"把 Mapper 包一层",所以远程实现只用 URL + 参数就能做出来,不必把 LambdaQueryWrapper 这种本地 ORM 概念漏到远端。
契约共 9 个方法(wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/catalog/IAppCatalogService.java),每个都有真实消费方:
| 方法 | 语义 | 消费方(都在 appmarket/ 下) |
|---|---|---|
requireOnShelf(appCode) | 取已上架应用,未上架即抛异常 | service/impl/AppInstallServiceImpl(安装入口) |
requireApp(appId) | 按 ID 取应用 | AppInstallServiceImpl、service/AppPackageDeployService、controller/AppRuntimeController |
appsByIds(appIds) | 批量取应用(列表展示补齐,缺的不返回) | AppInstallServiceImpl |
requireVersion(versionId) | 取指定版本 | AppPackageDeployService、spi/impl/PluginAppInstaller、controller/AppInstallController、controller/AppRuntimeController、AppInstallServiceImpl |
requireLatestVersion(appId) | 取该应用最新已发布版本 | AppInstallServiceImpl |
countOnShelf() | 已上架数量(为 0 时菜单过滤可短路) | service/impl/AppMenuFilterImpl.enabled() |
searchOnShelf(keyword, categoryId, audience, limit) | 市场列表(按可见范围筛选) | controller/AppMarketController.list |
findApp(appCode) | 按编码取,不校验上架状态,找不到返回 null | AppMarketController.detail、service/AppRouteHintResolver |
releasedVersions(appId) | 已发布版本列表(草稿/弃用不暴露) | AppMarketController.versions、service/AppPurgeService |
AppPackageDeployService.deploy(...) 里的两处调用是"这条规矩的主路径":以前它直接 selectById 读 app_version / app_info,远程模式下本地没有目录数据,安装会在"下载并落位"这一步报"版本不存在"。
判断标准是一句话:这段代码会跑在别人的部署上吗?
- 会(安装 / 升级 / 市场页 / 卸载 / 运行时答复)→ 必须走目录接口;
- 不会(上架端与控制台就是目录的所有者)→ 直接用 Mapper 是正常的。实际这么做的有
controller/AppConsoleController、controller/AppVersionController、controller/AppPackageController、security/SignatureReadinessService(签名就绪度要按状态查全部版本,目录接口没有也不需要这种查询)。
AppInstallServiceImpl 里仍声明着 appInfoMapper / appVersionMapper 两个字段,但已经没有任何调用(目录读取全部走 appCatalogService,见该类 574 行附近的注释);AppPackageDeployService 顶部也留着这两个 Mapper 的 import。它们不影响行为,只是改造期的残留。
1.3 两种实现怎么二选一
remote 与 local 用 @ConditionalOnProperty 二选一,刻意不用 @ConditionalOnMissingBean —— 后者依赖 bean 定义的先后顺序,换个扫描顺序就可能两个都在或两个都不在,而条件属性是确定的。
| 实现 | 装配条件 | 行为 |
|---|---|---|
catalog/LocalAppCatalogService | app-market.catalog.mode=local,matchIfMissing = true(不配就是它) | 直连本库的 AppInfoMapper / AppVersionMapper |
catalog/RemoteAppCatalogService | app-market.catalog.mode=remote | 走 HTTP 打 base-url;构造时 base-url 为空 → 抛 IllegalStateException,启动即失败 |
app-market:
catalog:
mode: local # local(默认,直连本库) / remote(走 base-url)
base-url: '' # mode=remote 时必填,否则启动失败
token: '' # 服务间共享令牌,两端要一致;服务端留空 = 不校验调用方
connect-timeout-ms: 3000 # 只被 remote 实现读
read-timeout-ms: 15000 # 只被 remote 实现读除了 base-url 的构造期检查,还有一道形态自洽检查(edition/MarketEditionStartupCheck,@PostConstruct):edition=client 时 catalog.mode 不是 remote、或 base-url 为空,都直接启动失败并写出该改哪一行。理由见 2.2 第 3 条。
日志是核对"到底装配了哪个"最快的办法,RemoteAppCatalogService 构造时打:
目录访问走远程服务: https://<host>(超时 连接3000ms / 读取15000ms,令牌已配置)1.4 服务间接口 /app/catalog/**
| 事实 | 位置 |
|---|---|
前缀常量 BASE_PATH = "/app/catalog",令牌请求头 TOKEN_HEADER = "X-Catalog-Token" | catalog/CatalogApiContract(两端共用一份,避免改一处忘一处) |
| 提供方 | catalog/AppCatalogApiController,9 个 GET 端点,逐个对应上面 9 个方法 |
| 免登录 | wujies-admin/src/main/resources/application.yml 的 security.excludes 里有 - /app/catalog/** —— 调用方是另一个部署,没有本机登录会话 |
| 鉴权 | app-market.catalog.token;服务端留空 = 不校验调用方,只在启动时打一条 WARN。不一致时抛 目录接口令牌校验失败(不区分"没带"与"带错") |
| 响应体 | 不套 R 信封,直接返回业务对象;出错时由 GlobalExceptionHandler 照常吐 {code, msg},客户端取出 msg 原样再抛 |
9 个端点的路径、参数、权限逐条见 /reference/api;令牌的安全边界(明文请求头、只防误连与扫描器)见 /ops/backup-security。
AppCatalogApiController 上没有任何形态条件,两种 edition 都会响应 /app/catalog/**。用户版下这台装配的 IAppCatalogService 是远程实现,所以它等于把请求转发给 base-url,拿回来的还是官方目录的数据。
所以真正的边界只有两条:上架端的 403 闸门(见 2.2),以及别把后端端口暴露到公网。别把"客户端那台会 404/403"写进任何依赖里。
1.5 远程实现的四个坑
四个坑都真实踩过,处理方式写在 catalog/RemoteAppCatalogService 的类注释里:
| # | 坑 | 不处理的后果 | 处理 |
|---|---|---|---|
| 1 | HTTP 200 也可能是失败:平台约定"业务错误也返回 200,用响应体的 code 表达" | {"code":500,"msg":"应用版本不存在"} 被当成一个 AppVersion 反序列化 → 字段全 null 且不报错,一路带到业务代码才以 NPE 炸出来 | 先取 JsonNode,先判错误信封再转换 |
| 2 | 对端把 Long 写成字符串(框架为防前端 JS 精度丢失) | 客户端默认 ObjectMapper 转不回 Long,留成 null 也不报错 → AppInfo.appId 为 null | 客户端专用副本上给 Long 注册"数字和字符串都收"的反序列化器 |
| 3 | URI 会被二次编码:自己 encodeQueryParam 后再交给 RestClient.uri(String),那个字符串会走模板展开并被再编码一次 | 中文关键词搜索永远返回空(对端收到的是百分号字面量),英文照常 → 极易漏测 | UriComponentsBuilder 拼好、encode(),传 URI 对象(uri(URI) 不做模板展开) |
| 4 | 空串参数不是"没传" | 拼出 ?audience= 时服务端拿到的是空字符串,而可见范围判断是 audience != null → 匹配 FIND_IN_SET('', audience) → 一条都查不出来 | 值为 null 或空串的参数整条丢掉(uri(...) 里统一过滤) |
第 3 条的判据很好记:英文能搜到、中文搜不到,就是编码问题。
1.6 刻意不做缓存
目录读取的频率是"安装 / 升级 / 逛市场时几次",不是热路径;而目录的写入发生在另一个部署里,本地根本感知不到,加缓存只能靠 TTL 猜 —— 那会让"刚上架却装到旧版本"变得不可解释。宁可每次直查:慢一点,但结果可解释。
同理,"应用市场"的列表 / 详情 / 版本三个页面也一并收口到目录接口(AppMarketController 的 searchOnShelf / findApp / releasedVersions)。不收口的话远程模式下能装但逛不了市场:安装走远端目录、市场页却读本地那份过期或空的目录。
二、版本形态(app-market.edition)
2.1 两个角色差在哪
同一套代码扮演两个角色,刻意不做 fork:本项目有三个仓库(后端 wujies-platform、宿主前端 plus-ui、插件仓库 wujies-plugins,见 /design/repo-layout)、一批 SQL 脚本,以及一堆构建期约定(远程应用要复刻宿主的自动导入与组件解析器,见 /advanced/remote-app)。fork 之后每次改公共逻辑都要改两遍,而且必然漂移。
official(默认) | client | |
|---|---|---|
| 目录来源 | 自己库里(catalog.mode=local) | 官方目录服务(必须 catalog.mode=remote) |
| 上架端接口 | 有 | 没有,按路径前缀被闸门拦成 403 |
| 应用市场页 | 读本地目录 | 读远端目录(同一份代码,换的是实现) |
| 安装 / 升级 / 卸载 / 任务中心 / 菜单保护 / 签名校验 | 都有 | 都有 |
/app/catalog/** | 对外提供 | 也响应(详见 1.4 的 danger) |
用户版不是阉割版,它只是不上架。
2.2 三个刻意的设计
1)用过滤器按路径前缀挡上架端,不是给控制器逐个加注解。
edition/MarketEditionGuardFilter(OncePerRequestFilter)匹配 MarketEdition.PUBLISH_PREFIXES 这四个前缀:
| 常量 | 用户版下的行为 |
|---|---|
/app/console | 全部方法 403,例外只有 GET /app/console/owned-menus(PUBLISH_ALLOWLIST:菜单管理页要用它打归属标签)。GET /app/console/signature-readiness 不在例外里 —— 它读的是本地目录,用户版下没有意义 |
/app/version | 全部方法 403 |
/app/package | 全部方法 403 |
/app/category | 只挡写:GET 放行(市场页要拿分类做筛选) |
好处是以后新增上架端接口会自动被挡住,不会因为"忘了加开关"漏出去。
2)返回 403 + 明确文案,不是 404、更不是静默失效。
用户版的上架端写的是本地库,而安装读的是远端目录 —— 放开的话"上架成功"对安装毫无影响,这是最坏的一种失败(看着成功、实际无效);404 则会把人引向路由与网络排查。响应体与 GlobalExceptionHandler 同结构:
{
"code": 403,
"msg": "本版本不含「应用上架」功能(app-market.edition=client)。应用由官方统一上架,本机通过 app-market.catalog.base-url 从官方目录读取。",
"data": null
}拒的是过滤器,而登录检查走的是 MVC 拦截器(SaInterceptor;全项目唯一的 SaServletFilter 只覆盖 /actuator/**,见 wujies-common-security 的 SecurityConfig)。所以用户版下不带 token 打上架端接口,先拿到的是 403 而不是 401 —— 这正好可以用来区分"被形态闸门拦了"和"确实没登录"。
3)启动期校验形态与目录配置自洽。
edition/MarketEditionStartupCheck 在 @PostConstruct 里判定,不自洽就直接启动失败并写明该改哪一行:
| 组合 | 结果 |
|---|---|
edition=client + catalog.mode != remote | 启动失败(报错正文点明"必须配合 app-market.catalog.mode=remote") |
edition=client + catalog.base-url 为空 | 启动失败(报错正文点明必须配 base-url) |
拦这个的理由:client + local 是"能起来但什么都装不了"的组合(市场列表读本地空目录、安装报"应用版本不存在"),根因在任何一个报错里都看不出来。
启动日志里也能直接读到形态(官方版/用户版各一句),配合 1.3 里那条远程目录日志,就能确认"这台到底是什么形态、目录从哪来"。
2.3 用户版的前端半边
用户版的 sys_menu 里仍然有那几行上架菜单(平台初始化 SQL 建的,存量部署改不动),所以不能靠"初始化 SQL 不插那几行"解决。现在是服务端隐藏 + 前端兜住:
| 层 | 位置 | 做法 |
|---|---|---|
| 菜单(服务端) | app-market/service/AppConsoleMenuResolver,经 service/impl/AppMenuFilterImpl.invisibleMenuIds() 生效 | 按 parent_id=0 且 path=appconsole(MarketEdition.CONSOLE_MENU_ROOT_PATH)找根,认不到时用 app:console:list / app:category:list(CONSOLE_PERMS)兜底老结构;连后代一起隐藏(按钮也在这棵树里) |
| 形态自述 | controller/AppEditionController → GET /app/edition,返回 {edition, consoleVisible} | 不加权限注解(任何登录用户都可能需要它)。前端必须有它,因为静态路由服务端管不到 |
| 前端 | plus-ui/src/plugins/appmarket/edition.ts + plus-ui/src/api/appstore/edition/index.ts | ensureMarketEdition() 只问一次服务端、缓存结果;生成路由时再摘一遍菜单 |
| 前端守卫 | plus-ui/src/router/index.ts 的 /appconsole 路由 | beforeEnter 里 ensureMarketEdition(),用户版直接返回 /index;这样手打 /appconsole/app/create 也进不去 |
| 前端接入点 | plus-ui/src/store/modules/permission.ts(generateRoutes 前) | await ensureMarketEdition(),然后 filterAppConsoleRoutes 摘菜单 |
AppMenuFilterImpl.enabled() 在用户版直接 return true(不查"目录里有没有已上架应用"):要隐藏的上架端菜单是平台 SQL 建的,与目录里有没有应用无关;短路掉的话,刚部署好、还没上架任何应用的用户版会看到一整套点了就 403 的菜单。
写死 VITE_APP_MARKET_EDITION=client 是第二个真相:忘了设 / 设错的表现是"菜单没了、URL 还能进"或"用户版还挂着上架端菜单",都是看着正常、出错很晚。
它现在只是兜底:edition.ts 里取不到服务端形态时回落到它(默认按 official 处理),三个 env 文件里默认注释掉。宁可维持官方版行为(功能都在、点了才 403),也不要因一次网络抖动把官方版用户的上架端藏起来。
2.4 取值怎么被规范化
MarketEdition 用 StringUtils.trim + equalsIgnoreCase 判定,因此:
- 大小写不敏感(
Client、CLIENT都算用户版); - 任何非
client的值都按official处理,不报错(写错了不会在启动时报出来,只会"看起来还是官方版"); GET /app/edition返回的是name()规范化后的值(只可能是official/client)。
三、两个开关的组合
edition | catalog.mode | 结果 | 该不该用 |
|---|---|---|---|
official | local | 单机自用:目录与安装实例同库 | ✅ 默认值,什么都不用配 |
official | remote | 能启动,但上架端写的是本地库,而安装读远端目录 → 上架"成功"却对谁都不生效 | ❌ 别用;安装方请用用户版,见 /deploy/multi-deployment 的 warning |
client | remote | 用户版:无上架端,目录读官方 | ✅ |
client | local | 启动失败(MarketEditionStartupCheck) | ❌ |
一台官方版可以同时给多个用户版供目录;拓扑怎么选、包怎么装见 /deploy/multi-deployment。
四、怎么核对
# 1) 形态自述:任何已登录用户的 token 都能调;用户版应为 {"edition":"client","consoleVisible":false}
curl.exe -H "Authorization: Bearer <token>" http://localhost:8080/app/edition
# 2) 上架端闸门:用户版下应为 403(且响应体里的 msg 点明是 edition=client 导致的)
curl.exe -s -o NUL -w '%{http_code}\n' http://localhost:8080/app/console/list
# 3) 目录服务自测(令牌 = app-market.catalog.token;服务端留空时可不带头)
curl.exe -H "X-Catalog-Token: <token>" "http://<目录服务>:8080/app/catalog/count-on-shelf"
# 4) 中文搜索自检:英文能搜到、中文搜不到 = URI 二次编码那个坑(见 1.5)
curl.exe -H "X-Catalog-Token: <token>" --get --data-urlencode "keyword=图床" `
"http://<目录服务>:8080/app/catalog/market-search"启动日志里找这三类行,就能确认形态与目录实现:应用市场版本形态: official(目录 local,本机含上架端) / 应用市场版本形态: client(不含上架端,目录走远端 ...) / 目录访问走远程服务: ...。
不要一边用 curl 手工打对端。 否则对端访问日志里那几条 GET /app/catalog/... 是你自己打的,你会把"我打成功了"当成"调用方链路通了"的证据。正确做法是去目录服务那一侧的访问日志里找安装方打来的请求。上面的命令 1、2、3、4 都是打本机或做单点自测,别拿它们当"链路通了"的证据。
报错对照与排查路径见 /ops/troubleshooting,安装/升级任务里的失败原因见 /ops/task-center。