目录中心化与版本形态
这两个特性解决的是同一个问题的两面:一套平台代码,怎么服务多种部署拓扑。
一、目录中心化(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 | 路径 / 请求头常量,两端共用(避免改一处忘一处) |
RemoteAppCatalogService | HTTP 实现;mode: remote 时装配 |
两者用 @ConditionalOnProperty 二选一,刻意不用 @ConditionalOnMissingBean (后者依赖 bean 定义顺序)。
app-market:
catalog:
mode: remote # 默认 local
base-url: https://market.example.com
token: <两端一致的共享令牌>
connect-timeout-ms: 3000
read-timeout-ms: 15000收口的范围
「不许直接读目录表」针对的是安装侧。
判断标准是一句话:这段代码会跑在别人的部署上吗?
- 会 → 必须走目录接口;
- 不会(上架端 / 控制台就是目录的所有者)→ 直接用 Mapper 是正常的。
为什么安装的主路径也必须收口
落位服务曾经直接 selectById 读 app_version / app_info —— 那是安装的主路径。 远程模式下本地没有目录数据,会直接报"版本不存在"。
「应用市场」的列表/详情/版本也一并收口了 —— 它们同样是安装侧要用的页面, 不收口的话远程模式下能装但逛不了市场。所以目录契约现在有 9 个方法。
远程传参的两个静默坑
URI 会被二次编码。 自己
encodeQueryParam之后交给RestClient.uri(String), 那个字符串会走模板展开并被再编码一次 —— 对端解出来是百分号字面量, 于是中文关键词搜索永远返回空(英文照常,所以极易漏测)。正确做法:
UriComponentsBuilder拼好、encode(),传 URI 对象。空串参数不是"没传"。 拼
?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 启动模块,看实际部署拓扑再定。