Wujies 无界平台
机制

目录中心化与版本形态

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

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

这一页讲 wujies-app-market 里两个部署拓扑开关的语义、互相约束与失败表现:

开关谁读它它决定什么
app-market.catalog.modeLocalAppCatalogService / RemoteAppCatalogService(@ConditionalOnProperty 二选一)目录(应用 / 版本 / 分类)从本机库读,还是从另一台部署读
app-market.editionMarketEdition这套部署是官方版(含上架端)还是用户版(不含上架端)

两者互相牵制: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。

app_version_menu 也在全局表里

它是"版本 → 菜单"的映射,属于目录的一部分(部署内共享)。租户维度的过滤不靠多租户插件,而是 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)按编码取,不校验上架状态,找不到返回 nullAppMarketController.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/LocalAppCatalogServiceapp-market.catalog.mode=local,matchIfMissing = true(不配就是它)直连本库的 AppInfoMapper / AppVersionMapper
catalog/RemoteAppCatalogServiceapp-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 的类注释里:

#坑不处理的后果处理
1HTTP 200 也可能是失败:平台约定"业务错误也返回 200,用响应体的 code 表达"{"code":500,"msg":"应用版本不存在"} 被当成一个 AppVersion 反序列化 → 字段全 null 且不报错,一路带到业务代码才以 NPE 炸出来先取 JsonNode,先判错误信封再转换
2对端把 Long 写成字符串(框架为防前端 JS 精度丢失)客户端默认 ObjectMapper 转不回 Long,留成 null 也不报错 → AppInfo.appId 为 null客户端专用副本上给 Long 注册"数字和字符串都收"的反序列化器
3URI 会被二次编码:自己 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.tsensureMarketEdition() 只问一次服务端、缓存结果;生成路由时再摘一遍菜单
前端守卫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)。

三、两个开关的组合

editioncatalog.mode结果该不该用
officiallocal单机自用:目录与安装实例同库✅ 默认值,什么都不用配
officialremote能启动,但上架端写的是本地库,而安装读远端目录 → 上架"成功"却对谁都不生效❌ 别用;安装方请用用户版,见 /deploy/multi-deployment 的 warning
clientremote用户版:无上架端,目录读官方✅
clientlocal启动失败(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。

相关阅读

目录