Skip to content

目录中心化与版本形态

这两个特性解决的是同一个问题的两面:一套平台代码,怎么服务多种部署拓扑。

一、目录中心化(app-market.catalog.mode

问题

目录数据(app_info / app_version / app_category)是平台级的, 但原本和安装实例混在同一套表里。多部署一套系统,上架一次就要同步 N 遍。

为什么能干净地做掉

因为这几张表已经在 security.tenant.excludes 里(平台级数据,不按租户隔离), 所以集中它们不需要带租户参数

反过来说,app_install / app_install_task / app_schema_history 必须留在各自部署本地。

两种实现

组件作用
IAppCatalogService安装侧唯一允许依赖的目录入口(9 个业务语义方法:取应用 / 取版本 + 市场搜索 / 已发布版本)
LocalAppCatalogService直连本库;mode 缺省或 local 时装配
AppCatalogApiController服务间接口 /app/catalog/**(在 security.excludes 里,用共享令牌兜底)
CatalogApiContract路径 / 请求头常量,两端共用(避免改一处忘一处)
RemoteAppCatalogServiceHTTP 实现;mode: remote 时装配

两者用 @ConditionalOnProperty 二选一,刻意不用 @ConditionalOnMissingBean (后者依赖 bean 定义顺序)。

yaml
app-market:
  catalog:
    mode: remote                        # 默认 local
    base-url: https://market.example.com
    token: <两端一致的共享令牌>
    connect-timeout-ms: 3000
    read-timeout-ms: 15000

收口的范围

「不许直接读目录表」针对的是安装侧。

判断标准是一句话:这段代码会跑在别人的部署上吗?

  • 会 → 必须走目录接口;
  • 不会(上架端 / 控制台就是目录的所有者)→ 直接用 Mapper 是正常的。

为什么安装的主路径也必须收口

落位服务曾经直接 selectByIdapp_version / app_info —— 那是安装的主路径。 远程模式下本地没有目录数据,会直接报"版本不存在"。

「应用市场」的列表/详情/版本也一并收口了 —— 它们同样是安装侧要用的页面, 不收口的话远程模式下能装但逛不了市场。所以目录契约现在有 9 个方法。

远程传参的两个静默坑

  1. URI 会被二次编码。 自己 encodeQueryParam 之后交给 RestClient.uri(String), 那个字符串会走模板展开并被再编码一次 —— 对端解出来是百分号字面量, 于是中文关键词搜索永远返回空(英文照常,所以极易漏测)。

    正确做法:UriComponentsBuilder 拼好、encode(),传 URI 对象

  2. 空串参数不是"没传"。?audience= 时服务端拿到的是空字符串而不是 null, 而"可见范围"的判断是 audience != null → 会去匹配 FIND_IN_SET('', audience)一条都查不出来

    跨进程传参时,可选参数要整条丢掉,不要拼空值。

刻意不做缓存

目录远程读取不做缓存。理由是频率很低(只在安装/升级时几次), 而加缓存会遇到"刚上架却装到旧版本"这种难以解释的问题。

安全边界要说清楚

目录服务的共享令牌是明文放在请求头里的(X-Catalog-Token), 它只防"不知道地址的扫描器与误连",不防抓包

接口只暴露已发布应用的目录元数据(应用名、版本、包地址),不含任何业务数据。 真正的防护是:别把服务端的后端端口暴露到公网

服务端留空令牌 = 不校验调用方。内网自用可以,公网必须配。

二、版本形态(app-market.edition

同一套代码扮演两个角色,刻意不做 fork

official(默认)client
目录在自己库里(catalog.mode: local从官方 API 读(必须 remote
上架端有(应用 / 版本 / 分类 / 安装包上传)没有
/app/catalog/**对外提供不提供
应用市场页读本地目录读远端目录
安装 / 升级 / 任务中心 / 菜单保护 / 签名校验都有都有

用户版不是阉割版,它只是不上架。

为什么不做 fork

本项目有 2 个仓库、一堆 SQL 脚本、以及一堆构建期魔法(远程应用必须复刻宿主的 自动导入与组件解析器)。fork 之后每次改公共逻辑都要改两遍,而且必然漂移。

一个 jar、一个前端、一个开关,便宜得多。

三个刻意的设计

1. 用过滤器按路径前缀挡上架端,不是给控制器逐个加注解

挡住的前缀:/app/console/app/version/app/package, 以及 /app/category操作(分类的保留,市场页要用它做筛选)。

例外只有 /app/console/owned-menus(菜单管理页要用它打归属标签)。

这样做的好处:以后新增上架端接口会自动被挡住,不会因为"忘了加开关"漏出去。

2. 返回 403 + 明确文案,不是 404、更不是静默失效

用户版的上架端写的是本地库,而安装读的是远端目录 —— 放开的话"上架成功"其实对安装毫无影响,这是最坏的一种失败(看着成功、实际无效)。 404 则会让人去查路由和网络。

3. 启动期校验形态与目录配置自洽

client + catalog.mode=local 是"能起来但什么都装不了"的组合 (市场列表读本地空目录、安装报"应用版本不存在"),根因在报错里看不出来 —— 所以直接启动失败并写明该改哪一行。

用户版的前端半边

用户版的 sys_menu仍然有那几行上架菜单(平台初始化 SQL 建的,存量部署改不动), 所以不能靠"初始化 SQL 不插那几行"解决。现在是服务端隐藏 + 前端兜住

做法
菜单parent_id=0 且 path=appconsole 找根,连后代一起隐藏;另用权限标识兜底老结构
形态自述GET /app/edition{edition, consoleVisible}
前端只问一次服务端;生成路由时再摘一遍菜单;/appconsole 路由挂 beforeEnter(用户版跳回首页)

为什么必须有 GET /app/edition

因为静态路由服务端管不到/appconsole/app/create/appconsole/app/edit/:appId 是写死在前端的路由,手打 URL 就能打开一个"每个按钮都 403"的表单。

别把构建期变量当唯一真相

写死 VITE_APP_MARKET_EDITION=client第二个真相。 忘了设 / 设错的表现是"菜单没了、URL 还能进"或"用户版还挂着上架端菜单" —— 都是看着正常、出错很晚。

它现在只是兜底(取不到服务端形态时用),env 文件里默认注释掉。

三、三种部署拓扑

拓扑配置适用
单机自用官方版,目录与安装实例同库,什么都不用配自己用、单客户
官方版 + 多部署一台当"市场/目录服务"(mode=local),其它 mode=remote 指向它;安装实例各自一份多客户,统一上架
用户版客户那台 edition=client + catalog.mode=remote,无上架端交付给客户,不让客户上架

拓扑可以叠加:一台官方版同时给多个用户版供目录。

还差什么

把「目录服务」单独打成一个精简产物(只含上架端 + 目录接口)属于部署方式, 代码层面已经就绪 —— 现在同一个 jar 用 mode=local 就能当目录服务, 用 mode=remote 就是安装方。

是否要额外裁剪一个 catalog-only 启动模块,看实际部署拓扑再定。

相关阅读

基于 MIT 协议开源 · 文档与官网由源码生成