老炮踩坑录 · 💎 老炮视野系列
基于真实企业项目实读:19 个 inner 类、3 个 job 触发口(
@XxlJob方法),逐类核对包名、URL 前缀、触发方式关键词:inner 接口 · XXL-Job · URL 前缀 · 暴露面 · 租户上下文 · @GetMapping
🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:内部接口与定时任务触发口的暴露面复盘
引言
上一篇写了这个供应链中台的分层,application 和 domain 怎么切。这篇往回收一点,只看 controller 目录里的两个子包:inner/ 和 job/。
项目开发文档里写了这两个包是干什么的,两条约定,两行字。我把三个业务模块的 78 个 controller 翻了一遍,逐类对了包名、URL 前缀和触发方式。对完发现,目录约定和实际暴露面是两套东西,而且对不上。
统计口径是主代码,src/test 不算。
如果只记三件事:
① inner/ 和 job/ 子包管的是开发者找代码,管不了暴露面。项目约定用
unit-deploy.prefix-inner这个 URL 前缀区分内外——但网关是否真按它隔离,仓库里看不到,文末「三点没确认」有交代;② 采购模块根目录下的 Inner 类,前缀倒是用对了;主数据 job/ 包里的类,反而挂在没有任何内部前缀的路径下,还留着两个必抛异常的测试接口;
③ 仓配 4 个任务方法同时标了
@XxlJob和@GetMapping,调度中心能调,HTTP GET 也能调,全租户的事件重试和历史数据清理都在里面。
文档写了两条约定,三个模块执行出三种样子
项目开发文档里的原文,两条:
web/controller/inner/:内部服务间 API 端点,不对外暴露web/controller/job/:XXL-Job 触发端点
实扫出来的目录是这样的。
inner/ 子包一共 19 个类:主数据 12 个,仓配 5 个,采购 2 个。采购另外还有 1 个 PurchaseInnerController,直接放在 controller 根目录,没进 inner/。
job/ 子包只有主数据在用,1 个类。仓配的 JobController 放在 inner/ 里,采购的 PurchaseJobController 放在根目录。三个模块,三个位置。
inner/、job/ 加上两个放错在根目录的类,一共 22 个,占 78 个 controller 的近三成。文档写的是 A,代码里跑的是 B。看目录找接口,基本会找错。
决定暴露面的是 URL 前缀,包名对不上
先说这个项目的实际机制。接口内外不由包名决定,由 URL 前缀配置项决定:unit-deploy.prefix-inner 是内部前缀,部署和网关按约定用它做隔离;unit-deploy.prefix-main 是业务前缀。包名 inner/ 只是给开发者看的提示。
提示和配置对齐也就罢了。实际五种写法:
// 主数据 inner/ 下的 SupplierInnerController:内部前缀,路径里再带一层 inner
@RequestMapping("${unit-deploy.prefix-inner:}/masterdata/inner/supplier")
// 采购根目录下的 PurchaseInnerController:不在 inner/ 包,前缀倒是用对了
@RequestMapping("${unit-deploy.prefix-inner:}/purchase/api")
// 仓配 inner/ 下的 JobController:main 前缀后面拼一段 "-inner"
@RequestMapping("${unit-deploy.prefix-main:}-inner/job/api")
// 采购根目录下的 PurchaseJobController:main 前缀,路径里带 /job/
@RequestMapping("${unit-deploy.prefix-main:}/job/purchaseJob")
// 主数据 job/ 下的 MasterDataJobController:什么前缀都没有
@RequestMapping("/masterDataJob")
同一个"内部接口"语义,五种路径表达。
采购那个类,位置不守约定,前缀守了。主数据那个 job 类,位置守了约定,前缀没了。按目录判断内外,这两个判断都会错:前者你会当成对外接口,后者你会当成内部接口。
仓配那种 prefix-main 拼 -inner 的写法,还多一个问题:隔离依赖的是字符串拼接的结果,配置项本身还是 main。网关如果按配置项做路由规则,这条路径归在哪一类,得看网关怎么配的。配置不在仓库里,我说不准,放在后面「三点没确认」。
对照档案服务那套做法。它的 controller 按访问来源分包:external、externalno(外部免鉴权)、internal、self、store,谁要鉴权、谁免鉴权、谁只给内网,目录结构直接对应策略。中台这套,目录只是提示,策略在配置里,配置本身还没写统一。两种管理思路,落地差别就在这。
一个方法两个触发口,job 类里还留着测试接口
仓配的 JobController,301 行。里面 4 个任务方法,全是双注解:
@XxlJob("retryTask")
@GetMapping("/retryTask")
@Operation(summary = "单据事件重试", description = "单据事件重试任务")
public Boolean retryTask() {
SpringUtil.getAllTenantIdFromConfig().forEach(tenantId -> {
TenantContext.clear();
TenantContext.set(tenantId);
// 查最近一天、重试次数少于 3 次的异常事件,逐个重新执行
...
});
return true;
}
@XxlJob 注册到调度中心,@GetMapping 注册到 HTTP。同一个入口,两个触发方。调度中心能调;只要这个路径对外可达,发一个 GET 也能触发。
清理历史数据那个方法,同样是 GET:
@XxlJob("delHistoryLog")
@GetMapping("/delHistoryLog")
public Boolean delHistoryLog(@RequestParam("beforeDate") String beforeDate) {
注解上 beforeDate 是必填,不传直接 400;要落到「默认清三个月」这个行为,签名得写成 required = false 并带默认值。方法体是全租户循环,挨个 TenantContext.set,然后 lambdaUpdate().remove() 删除——实体上有没有挂 @TableLogic 我没核对,物理删还是逻辑删以实体为准。往这个 URL 发一个 GET,这批清理就跑一遍。类上没有类级或方法级鉴权注解;Filter、网关都不在类上留痕,拦不拦我没有证据。
主数据那边更随意。job/ 包里那个 201 行的类,末尾挂着两个测试方法:
@Operation(summary = "测试方法")
@GetMapping("/testDetail")
public Result<SupplierDTO> testDetail() {
BizExceptions.throwWithErrorCode(ErrorCodeEnum.SUPPLIER_ERROR, new String[]{"0001"});
return Results.ofSuccess();
}
方法体第一行就抛异常,return 永远走不到。另一个 testPurchase 一模一样,换了个错误码。两个方法挂在 /masterDataJob 下面,这个路径前面说过,没有任何内部前缀。
这种测试代码留在生产环境,本身就是问题;网关到底拦没拦,我没有证据。
同一个类里,风格也不统一。前三个任务方法只有 @XxlJob,没有 HTTP 映射,但方法签名还带着 @RequestParam:
@XxlJob("execContractJob")
public void execContractJob(@RequestParam("tenantIds") String tenantIds,
@RequestParam("contractNo") String contractNo) {
没有 HTTP 映射的方法上写 @RequestParam,这个注解是失效的:Spring 不会处理它,XXL-Job 也不按名字注入。反编译 2.3.1 的 MethodJobHandler 可以确认,它只给带参方法传一个等长的 null 数组,参数值仍由方法体里的 XxlJobHelper.getJobParam() 兜底。
所以这类注解不影响调度执行,但它会让人误以为这里有 HTTP 入口——采购那个 job 类是真正的双入口(@XxlJob + @PostMapping),两种写法混在一起,光看签名分不出来。
job 类里的手工租户上下文
定时任务没有登录用户,租户上下文得自己塞。仓配的做法,是每个方法开头手写四行:
TenantContext.clear();
TenantContext.set(tenantId);
TraceIds.clear();
TraceIds.setOrRandom(null);
301 行的类里,这段重复 4 次。全租户循环也手写:SpringUtil.getAllTenantIdFromConfig().forEach(...)。
主数据没用这个工具方法,自己从框架的分片配置里解析:
private List<String> getAllTenantIdFromConfig() {
Collection<List<String>> tenantList = Optional.ofNullable(context.getBean(ShardingProperties.class))
.map(ShardingProperties::getDataSourceRouter)
.map(ShardingProperties.DataSourceRouter::getConfig)
.map(Map::values)
.orElseThrow(() -> new RuntimeException("未查询到多租户配置信息"));
return tenantList.stream().flatMap(Collection::stream).collect(Collectors.toList());
}
采购的 job 类自己写了同样一段解析,方法体我核过,和主数据一字不差。仓配走工具方法,主数据自己写解析,同一个项目里两种获取方式并存。工具方法就在公共包里,主数据这段属于是没看见,或者看见了没改。
set 漏一次,任务跑在错误租户上;clear 漏一次,带上一个租户的残留。四行代码,全靠人记。一个基类或者一个切面就能收走的事,没人做。
还有分层。仓配这个 JobController 直接注入了 repository 和 domain service,门店过滤、单据分组、Redis 缓存,全写在 controller 方法里。上一篇说应用层偶尔越层,inner 和 job 这层是直接把 application 整个跳过去。分层约定管不到这两个子包。
接口不止一套。主数据的内部接口分了新老两代:SupplierInnerController 旁边放着 SupplierInnerOldController,DTO 也分成 SupplierDTO 和 SupplierOldDTO;仓配根目录有 BillEventController,inner/ 下有 BillEventInnerController,采购 inner/ 下也有一个 BillEventInnerController。同一类接口或事件有两个入口,想拼出完整的路由表,得把根目录和 inner/ 翻两遍。
对照表
| 维度 | 主数据模块 | 采购模块 | 仓配模块 |
|---|---|---|---|
| inner 类数量与位置 | inner/ 12 个 | inner/ 2 个 + 根目录 1 个 | inner/ 5 个 |
| job 触发口位置 | job/ 1 个 | 根目录 1 个 | inner/ 里 1 个 |
| inner 接口路径特征 | prefix-inner + 路径再带 /inner/ | prefix-inner,路径无 inner | 事件口在 inner/,导出类不带 Inner 命名 |
| job 接口 URL 前缀 | 无前缀,/masterDataJob | prefix-main + /job/ | prefix-main 拼 -inner |
| 任务触发方式 | 3 个 @XxlJob + 2 个仅 @GetMapping的测试方法 | 4 个 @XxlJob,全部另带 @PostMapping | 4 个方法 @XxlJob + @GetMapping 双注解 |
| 租户列表获取 | 自己解析分片配置 | 自己解析同一个分片配置 | 公共工具方法 |
| 额外发现 | 2 个必抛异常的测试接口、Old 后缀接口 | Inner 类位置不守约定但前缀用对 | 301 行,直注 repository/domain |
三点没确认:
一是
prefix-inner的网关隔离配置不在仓库里,内部前缀实际怎么生效、仓配那种 main 拼-inner的路径归在哪类路由,我没有证据;二是 inner 接口是否完全无鉴权,我只看到类上没有类级或方法级鉴权注解,Filter 和网关侧有没有拦截没抓到;
三是
SupplierInnerOldController在整个仓库里没有调用方,仓外是否还在调,现状未知。
自查打卡
inner/ 目录类数:
find . -path '*/web/controller/inner/*' -name '*.java' | wc -l
job 触发口的实际位置,不限于 job/ 子包:
grep -rl '@XxlJob' --include='*Controller.java' .
URL 前缀写法分布:
grep -rh '@RequestMapping' --include='*Controller.java' . | sort | uniq -c | sort -rn
双注解方法,@XxlJob 下一行就是 @GetMapping:
grep -rn -A1 '@XxlJob' --include='*.java' . | grep '@GetMapping'
老炮点评
三件事。
第一,暴露面清单从 URL 前缀和网关配置生成,别从目录读。grep @RequestMapping 全量导一遍,按前缀分组,对外、对内、没标记三类直接分出来。目录约定可以作为辅助,不能作为依据。
第二,定时任务的租户上下文和 TraceId,收进基类或者切面。现在这种每个方法手写四行的做法,漏一处就是一次串租户。
第三,测试接口别留在生产代码里。要留就加 @Profile 限定环境,要么删。挂在无内部前缀路径下的测试方法,等于给暴露面清单添了两条没人认领的记录。
问你一句
你们项目的内部接口清单,是从目录里数出来的,还是从网关配置里拉出来的?两个来源对过没有?
评论区聊聊。
如果这篇对你有帮助,欢迎: 👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言 我是老炮,18 年 Java 老兵,仍在一线。🔥关注「Java老炮踩坑录」,真实企业项目复盘,不错过每一篇真实案例,少踩坑。
下期预告
《controller 五分包的暴露面管理:external/externalno/internal/self/store》
中台这边,目录只是提示,策略在配置里。档案服务那边反过来,谁要鉴权、谁免鉴权、谁只给内网,目录结构直接对应安全策略。同一套暴露面问题,另一种解法,下期对照着看。