一、背景:intf 里贴的注解,实现并不在 intf
在 ixxx-intf-91111111111 的 Controller 上,经常能看到三类「公共能力」:
- 审核:注入
ProcInstApi,调用startProcess/cancelProcess - 权限:
@PreAuthorize("@org.hasAuthority('INTF:EXPORT_PLAN')") - 操作日志:
@AuditLog(module = ..., type = ..., description = ...)
读 intf 源码会发现:只有引用和调用,没有引擎、没有切面实现。
| 能力 | intf 里看到的 | 真正实现所在 |
|---|---|---|
| 审核流转 | ProcInstApi | 独立服务 ixxx-bpmn(Activiti) |
| 权限校验 | @PreAuthorize + @org.hasAuthority | Spring Security + ixxx-common 的 org Bean |
| 操作日志 | @AuditLog | ixxx-common 切面 + RocketMQ |
本文按「intf 怎么接到 bpmn」→「jar 从哪来」→「两层鉴权与权限码前缀」→「RocketMQ 怎么记日志、审核结果怎么走 MQ」→「登录响应里为什么没有权限码」这条线梳理。
二、bpmn 是审核引擎服务
ixxx-bpmn 管理端确实有流程定义、待办、已办等类似增删改查的页面。intf 真正用的不是这些页面接口,而是 Activiti 流程引擎 被包成的远程能力:
| intf 调用 | bpmn 实际做的事 |
|---|---|
startProcess | 按流程 KEY 启动一条流程实例 |
审核人在 BPMN 待办页 doneTask | 完成当前节点,记录通过 / 驳回(Y / N) |
cancelProcess | 删除运行中的流程实例 |
回调 ProcessResultDTO | 流程结束,把结果推回业务系统 |
bpmn 不理解「出口计划」是什么,只认:
procDefKey:流程定义 KEYbusinessKey:业务单号(如innerNo)callbackApiUrl:审完回调哪个 HTTP 地址
ORG 隔离方式和 basic 相同:按租户部署整套实例,不是在一张共享表上按登录人 org_code 做行级过滤。
- 服务名:
ixxx-bpmn-service-${ORG} - 库:
ixxx_bpmn_${ORG} - MQ:
ixxx-bpmn-${ORG}
intf 配置:
api:
ixxx-bpmn-service: ixxx-bpmn-service-${ORG}
StartProcessDTO.orgCode 主要用于消息通知,不是 SQL 里的租户条件。
三、Maven 坐标:intf 引入的不是整个 bpmn
3.1 依赖到底是哪一块
ixxx-intf-service/pom.xml:
<dependency>
<groupId>com.ixxx</groupId>
<artifactId>ixxx-bpmn-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
这三行合称 坐标(groupId + artifactId + version),像商品的「品牌 + 品名 + 型号」。Maven 按坐标去仓库找 jar,不认你磁盘上文件夹叫不叫 ixxx-bpmn。
bpmn 工程拆成多个模块,打包结果不同:
| 模块 | 打成什么 | intf 会不会引入 |
|---|---|---|
ixxx-bpmn-api | 普通小 jar(Feign 接口) | 会 |
ixxx-bpmn-common | DTO jar | 会(被 api 传递依赖) |
ixxx-bpmn-service | 可运行的审核服务 | 不会 |
intf 编译期只能看到 ProcInstApi、StartProcessDTO 这类类型,看不到 Activiti 和 ProcInstServiceImpl。审核引擎在另一个进程里跑。
类比:ixxx-bpmn-api 是快递单模板;ixxx-bpmn-service 是仓库。intf 只能填单下单,货还在仓库处理。
3.2 再新建一个 bpmn 项目会怎样
Maven 只认坐标,不认工程目录名。
- 换坐标(例如
ixxx-bpmn2-api):intf 不会自动用它,除非改 pom。 - 坐标完全一样再
mvn install:同坐标在仓库里只有一份,后安装的会盖掉先安装的,intf 下次编译就会用新 jar。
3.3 仓库不是 Maven 官方中央库
com.ixxx:ixxx-bpmn-api:1.0.0-SNAPSHOT 不会发到 Maven Central。
本机 Maven 一般分三层:
- 本地仓库:
~/.m2/repository(本机mvn install的结果) - 公司 Nexus 私服:CI 或同事
mvn deploy推内部包(1.0.0-SNAPSHOT、com.ixxx都在这里) - 公共源:
spring-boot等开源库走 Central(环境里常镜像到阿里云)
别人电脑能编译 intf,是因为大家的 settings.xml 指向同一套私服,不是因为官网有 com.ixxx。
本地 clone bpmn 不是必须的;要的是已经 install/deploy 过的 api jar。clone 源码是为了看实现。
四、运行时:Feign 调的是 service,不是页面 Controller
4.1 编译期 vs 运行期
编译期(Maven)
ixxx-intf-service
└── 依赖 ixxx-bpmn-api(接口)
└── 依赖 ixxx-bpmn-common(DTO)
intf 和 ixxx-bpmn-service 没有 Maven 依赖。
运行期(HTTP + Nacos)
intf:procInstApi.startProcess(dto)
→ OpenFeign 生成 HTTP 客户端
→ Nacos 找到 ixxx-bpmn-service-${ORG}
→ POST /procInst/startProcess
→ bpmn 的 ProcInstApiImpl
→ ProcInstServiceImpl(Activiti)
只引了 jar、bpmn 服务没开:编译能过,一调 startProcess 会连不上。
ProcInstApi 上写死了调谁、调哪条路径:
@FeignClient(value = "${api.ixxx-bpmn-service}/procInst")
public interface ProcInstApi {
@RequestMapping(value = "/startProcess", method = RequestMethod.POST)
String startProcess(StartProcessDTO startProcessDTO);
}
服务端 ProcInstApiImpl 是路径对得上的 @RestController,并不 implements ProcInstApi。约定是:Feign 接口 path = Controller path。
4.2 Feign API 和 Web Controller 的区别
bpmn 有两层 HTTP,最终都进同一套 Service:
Feign(ProcInstApi / ProcInstApiImpl) | Web(ProcInstController) | |
|---|---|---|
| 给谁用 | intf 等服务 | 浏览器 / BPMN 前端 |
| 跑在哪 | ixxx-bpmn-service-${ORG} | ixxx-bpmn-web |
| 入参 / 返回 | DTO;直接返回对象 | VO;包 ResponseResult |
| 权限 / 日志 | 一般无页面权限 | @PreAuthorize、@AuditLog |
页面链路是:浏览器 → bpmn-web Controller → 再 Feign 调 api → service。
intf 跳过 web,直接打到 service。
startProcess / cancelProcess 只出现在 Feign API 上,不会做成前台按钮。待办办理、转办才走 web Controller。
4.3 审核闭环(以出口计划为例)
intf 提交审核
→ 本地 bpmnCheckStatus = PENDING
→ ProcInstApi.startProcess
callbackApiUrl = http://intf服务/exportPlan/updateBpmnStatus
bpmn 启动流程 → 审批人 doneTask(Y/N)→ 流程结束
→ 有 callbackApiUrl:动态 Feign POST ProcessResultDTO(intf 出口计划走这条)
→ 没有:bpmn 自己 asyncSend 审核结果(与审计日志不是同一条 topic,见第六节)
intf 回调接口(加 @Inner,内部调用)
→ click=Y → ADOPT
→ click=N → REJECT
@Inner 不是「这个接口没有权限码」。bpmn Feign 会带内部请求头(Intf-Type = inner)。common 的 InnerVoter 见到内部请求,方法级 @PreAuthorize 直接放行;AuditLogAspect 同时 不记 这次操作。审核回调不需要登录用户,也不会写成一次前台审计。
多次提交时,intf 会调 definitionKeyAndBusinessKeyCount:已经审过就必须填 editCause。撤销走 cancelProcess,bpmn 用标记跳过完成回调,intf 再把状态改回初始。
五、权限与操作日志:ixxx-common 验,ixxx-auth 产
这两个注解 不是 ixxx-bpmn-api 提供的。intf / bpmn 父 POM 都依赖:
<groupId>com.ixxx</groupId>
<artifactId>ixxx-common</artifactId>
<version>1.2.0-SNAPSHOT</version>
同样发在公司私服。本地已 clone 后可以对上源码:
| 仓库 | 角色 |
|---|---|
| ixxx-auth | 菜单、角色、组织、授权。登录后算出「这个人在这个 org 有哪些码」 |
| ixxx-common | 资源侧:名为 org 的 Bean、@Inner / @Permit、@AuditLog 切面 |
| ixxx-sso-web(未 clone) | 把权限列表写入会话里的 UserDetailsImpl.authorities,处理 sso.needSelectOrg |
intf / bpmn 不实现切面,只声明注解。
5.1 两个注解来源不同
@AuditLog(module = INTFConstants.MODULE, type = AuditLogType.SELECT, description = "查询文档留存")
@PreAuthorize("@org.hasAuthority('INTF:EXPORT_PLAN')")
| 注解 | 哪个依赖 | 做什么 |
|---|---|---|
@PreAuthorize | Spring Security(随 oauth2 starter 进来) | 方法执行前鉴权 |
@org.hasAuthority(...) | ixxx-common 里 @Component("org") 的 OrgSecurityExpressionRoot | 在当前用户 authorities 里找 拼过租户前缀 的码 |
@AuditLog | ixxx-common 的 AuditLogAspect | 拦截方法,往 RocketMQ 发操作日志 |
5.2 两层鉴权:yml 的 authority 和方法上的 @org 不是一回事
intf 的 yml:
ixxx:
authority: ${ORG}
auditLog:
topic:
log-request: ixxx-log-request-${ORG}
log-db: ixxx-log-db-${ORG}
log-response: ixxx-log-response-${ORG}
${ORG} 启动时替换成税号等租户号。两段配置进的是 不同组件:
URL 层 —— OuterSecurityConfig 读 ixxx.authority,变成 Spring 原生 表达式:
anyRequest().access("hasAnyAuthority('91111111111')")
比的是会话里有没有 裸组织码。没有这个码,整站接口 403,还没走到方法注解。裸组织码很大概率是 SSO 登录时额外塞进 authorities 的。
方法层 —— @org.hasAuthority('INTF:EXPORT_PLAN') 走 OrgSecurityExpressionRoot。它读的是环境变量 ORG(@Value("${ORG:-}")),不是 yml 的 ixxx.authority。两边通常都是税号,但不是同一个配置项。
实际查找:
${ORG} + ":" + 注解字符串
= 91111111111:INTF:EXPORT_PLAN
auditLog.topic 仍按租户分队列,请求 / 响应日志不混。
5.3 菜单里配裸码,会话里存带前缀的码
auth 菜单表存的是 不带税号 的 permissionCode,按类型拼接:
- 模块 =
menuCode→INTF - 页面 = 模块码 +
:+ 页面码 →INTF:EXPORT_PLAN - 按钮 = 上级码 +
:+ 按钮码 →INTF:EXPORT_PLAN:INSERT
登录要带进会话的是另一列 orgPermissionCode。SQL 按应用类型决定要不要加组织前缀:
IF(APP_TYPE = '0',
PERMISSION_CODE,
CONCAT(ORG_CODE, ':', PERMISSION_CODE))
APP_TYPE = 0:平台菜单(AUTH 自己)。码就是AUTH:USER,不加税号。APP_TYPE = 1:组织业务菜单(INTF / BPMN)。码是91111111111:INTF:EXPORT_PLAN。
AuthPermissionServiceImpl.getUserPermission(orgId, userId):查该组织下的角色 → 从 Redis(没有就回源菜单)收集 orgPermissionCode → 再并上别的组织授权过来的码 → 返回 LoginUserPermissionDTO.permissionList。
SSO 选完组织后应 Feign 调 AuthPermissionApi.getUserPermission,把这些字符串变成 GrantedAuthority。赋值发生在 SSO,auth 仓库里看不到 setAuthorities。
请求到达 intf 时,@org.hasAuthority 只做精确字符串匹配(Set.contains),不会再查菜单树,也没有编译期检查。写成 INTF:EXPORT_PLANN 也能编译,只是几乎没人能过。菜单能进、接口 403,多半是码没对上。
| 服务 | 注解写法 | 会话里实际存在的码 |
|---|---|---|
| intf / bpmn | @org.hasAuthority('INTF:EXPORT_PLAN') | {ORG}:INTF:EXPORT_PLAN |
| auth-web | hasAuthority('AUTH:USER')(Spring 原生,不加 @org) | AUTH:USER |
auth 自己不能写成 @org.hasAuthority('AUTH:USER'):那会去找 税号:AUTH:USER,平台菜单对不上。跨组织授权时,AuthGrantServiceImpl.isGrant 也是手写 grantOrgCode + ":" + permission,规则和 @org 一致。
intf 上配 INTF:EXPORT_PLAN 这一页,要同时满足:
- 菜单
APP_TYPE = 1,permissionCode正好是INTF:EXPORT_PLAN - 用户当前组织的角色勾了这个菜单 / 按钮
- 进程环境变量
ORG= 该组织ORG_CODE - 会话
authorities里有{ORG}:INTF:EXPORT_PLAN - URL 层还有裸
{ORG},否则过不了ixxx.authority
缺 1~4 → 方法 403;缺 5 → 连方法注解都到不了。
5.4 @AuditLog 默认不记查询
切面是 AuditLogAspect。要点:
- 默认只记
INSERT/UPDATE/DELETE(可用ixxx.auditLog.type改) SELECT/IMPORT/EXPORT默认不发 MQ,除非配进 type,或类型里带NEED_LOG/LOGIN/LOGOUT- 内部请求(
@Inner+ 内部请求头)不记操作日志 - 操作人从
SecurityContext的UserDetailsImpl取 - 没配 RocketMQ 时切面空跑,接口照常执行
查询类接口上挂 @AuditLog(..., type = SELECT) 不等于一定有审计记录。@AuditLog 也不读权限码:文案来自 description,topic 来自 yml。常见顺序:先验权,通过后再记这次调用。发送细节见下一节。
六、RocketMQ:审计日志和审核结果不是同一路
中间件是 Apache RocketMQ(rocketmq-spring-boot-starter + RocketMQTemplate),不是 Kafka / RabbitMQ。NameServer 等连接在 Nacos 的 rocketmq.yml,不在 common 里写死。intf / auth / basic / bpmn 启动类只设了 client 日志文件大小,真正发消息的是下面两套代码。
6.1 common 不建 Producer,只包一层 Template
@Component
@ConditionalOnClass(RocketMQTemplate.class)
public class RocketMQHolder {
@Autowired(required = false)
private RocketMQTemplate rocketMQTemplate;
}
classpath 没有 RocketMQTemplate,这个 Bean 不创建;有类但没配好 MQ,template 可能是 null。所有发送点都先判断 Holder / Template 非空,没有 MQ 时接口照跑,只是不发日志。starter 在 common 的 pom 里是 optional,业务服务自己引入才会真正发出去。
审计发送全部是 asyncSend + SendCallback:不阻塞 HTTP;失败只打 error,不会让接口失败。没有 syncSend。common 没有 Consumer,落库在独立日志服务。
6.2 三条审计 topic,一个 requestId 串起来
intf yml:
ixxx.auditLog.topic:
log-request: ixxx-log-request-${ORG}
log-db: ixxx-log-db-${ORG}
log-response: ixxx-log-response-${ORG}
| 谁发 | Topic | 消息体 | 含义 |
|---|---|---|---|
AuditLogAspect / AuditLogHandler | log-request | LogRequestDTO | 这次点了哪个接口 |
| 同上 | log-response | LogResponseDTO | 返回了什么 / 是否抛错 |
ResponseBodyAdviceImpl / ServiceExceptionHandler | log-db | List<LogDbDTO>(每批最多 50 条) | 这次 SQL 改了哪些列 |
Holder 注释写「主要是日志使用」:它 只服务这三张审计队列。bpmn 审核结果、semw 导入任务都直接注入 RocketMQTemplate,不走 Holder。
6.3 @AuditLog:一次调用拆成请求 + 响应两条 MQ
切面在 AuditLogAspect。
@Around:只组包,不发送。 内部请求直接 proceed();type 未命中默认可记集合则不建 DTO。通过则 new LogRequestDTO() 放进 ThreadLocal:雪花 requestId、URL、query、操作人、模块 / 描述。改密码会滤掉 oldPassword / newPassword。然后才 proceed()。
@Before:发 log-request。 方法入参(去掉 ServletRequest/Response)打成 JSON 字节放进 body。ThreadLocal 里没有 DTO 则不发。
方法执行完:
- 正常:
@AfterReturning组LogResponseDTO(同一requestId),结果 JSON 进result,发log-response - 抛错:
@AfterThrowing同样发log-response,flag为错误,result是堆栈
没有注解、业务要手写日志时,走 AuditLogHandler.logRequest / logResponse,发送代码一样。
请求日志在业务跑之前就发出去,后面崩了也有入口记录。异步、无到达顺序保证:消费端必须按 requestId 对上,不能假设 response 晚于 request。
6.4 log-db:SQL 先攒在线程里,HTTP 200 再刷
和 @AuditLog 是另一条线:MyBatis SQLAuditLogInterceptor 拦 Executor.update(INSERT / UPDATE / DELETE)。
要同时满足:LogDbHelper.isLogDb() 为 true(默认 true);当前有 requestId(没有 @AuditLog 就不会记行级变更);本请求累计条数 < 上限(默认 100)。
requestId 来源按应用类型分:
- web(intf-web):
AuditLogAspect的 ThreadLocal - service(Feign 打到的 intf-service):入口
ixxxFeignRequestFilter从请求头log-request-id写入LogDbContextHolder
web 调下游 Feign 时,ixxxFeignRequestInterceptor 带上当前 requestId,并打内部头 Intf-Type=inner。于是:下游 不记 @AuditLog 操作日志,但 SQL 变更仍能挂在同一个 requestId 上。
刷盘只在 HTTP 200(ResponseBodyAdviceImpl.saveDbLog;纯 service 在 ServiceExceptionHandler 里同一套逻辑),每 50 条一批 asyncSend。业务抛 ServiceException 把状态码改成 597 等时,已执行的 SQL 变更不会发 log-db;切面里的 request / response 可能已经在队列里。
一次改单的完整审计:
浏览器 POST /exportPlan/update
→ @PreAuthorize 通过
→ @Around 生成 requestId
→ @Before asyncSend ixxx-log-request-{ORG}
→ Service UPDATE
SQL 拦截器 ThreadLocal 攒 LogDbDTO
→ 若再 Feign 调 basic
头 log-request-id + Intf-Type=inner
→ @AfterReturning asyncSend ixxx-log-response-{ORG}
→ HTTP 200
asyncSend ixxx-log-db-{ORG}
日志服务按 requestId 把「点了什么 / 返回什么 / 改了哪些列」拼成一条审计。
6.5 bpmn 自己的 MQ:启动流程、回传结果
这路 不经过 RocketMQHolder,ProcInstServiceImpl 直接注入 RocketMQTemplate。
流程结束时:
- 有
callbackApiUrl→ 动态 Feign POSTProcessResultDTO(intf 提交审核填了回调地址,走这条) - 没有 →
asyncSend(resultTopic + "-" + applicationName + ":" + procDefKey, processResultDTO)
另有 Consumer:BpmnListener 监听 ixxx.bpmn.topic.ixxx-bpmn,收到 StartProcessDTO 后调 startProcess。这是「用消息启动流程」的入口,和审计三条 topic 无关。
6.6 当前这套 intf 工程本身不写业务 MQ
| 工程 | 和 MQ 的关系 |
|---|---|
ixxx-common | 只发审计三 topic;无 Consumer |
ixxx-intf-91111111111 / auth / basic | 引 starter + 拉 rocketmq.yml,让 审计切面能发出去;Java 里没有自己的 asyncSend / @RocketMQMessageListener |
ixxx-bpmn | 审核结果发送 + BpmnListener 收启动消息 |
ixxx-intf-semw | 另一套业务:导入任务 syncSendOrderly + 一堆 Listener,和操作日志不是一回事 |
topic 配成空字符串时,asyncSend 仍可能被调用,但发不出去,业务无感。
七、SSO 登录:权限码不在第一次 /auth 里
权限按组织隔离,没选 org 就不能下发权限码。sso.needSelectOrg 不在 auth 里,仍在 SSO。auth 只提供原料:
LoginApi:按 username / openid 查出用户(还没有权限列表)AuthPermissionApi.getUserPermission(orgId, userId):必须先有 orggetRouter:给前端画菜单(同一套角色权限)
抓 ixxx-sso-web/auth 时常见两步。
第一步:账号对了,还没选组织
{
"flag": "0",
"data": {
"message": "sso.needSelectOrg",
"userId": "123456",
"orgId": null
}
}
这里只有 userId。没 org 就调不了 getUserPermission,intf 上的 @org.hasAuthority 也无从匹配。
第二步:已经选定组织
{
"flag": "0",
"data": {
"userId": "123456",
"orgId": "111111"
}
}
这只确认「谁、进哪个组织」。带前缀的权限码随后进入会话 authorities(以及菜单 / 路由接口)。前端 /auth 响应里通常仍看不到完整码列表。
后端 @org.hasAuthority 读的是 服务端会话里的权限,不是前端再传一遍码。前端菜单能画出来,靠的是选 org 之后的 getRouter;接口注解是同一套码再验一次(intf 侧还会再拼一次 {ORG}:)。
八、小结
| 概念 | 说明 |
|---|---|
| intf 与 bpmn | intf 只引 ixxx-bpmn-api,运行时 Feign 打到 ixxx-bpmn-service-${ORG} |
| Maven 坐标 | groupId + artifactId + version,与文件夹名无关 |
| 仓库 | 内部包在公司 Nexus + 本地 .m2,不是 Maven Central |
| Feign vs Controller | 同一套 Service;Feign 给服务,Controller 给页面 |
| 审核闭环 | startProcess → 待办办理 → HTTP/MQ 回调改 bpmnCheckStatus |
@Inner | 内部请求头让 InnerVoter 跳过方法权限,切面不记日志 |
@PreAuthorize / @AuditLog | Spring Security + ixxx-common,不是 bpmn-api |
ixxx.authority | URL 层:会话必须持有 裸组织码 |
@org.hasAuthority | 方法层:查 {ORG}: + 注解字符串;读环境变量 ORG |
菜单 permissionCode | 不带税号(如 INTF:EXPORT_PLAN);会话里的 orgPermissionCode 才带前缀 |
| auth vs intf 注解 | 平台用原生 hasAuthority('AUTH:...');业务用 @org.hasAuthority |
@AuditLog | 默认不记 SELECT;内部调用不记操作日志 |
| 审计 MQ | Apache RocketMQ;asyncSend 三条 topic:request / response / db;用 requestId 串联 |
RocketMQHolder | 只给审计用;没 Template 则空跑 |
log-db | SQL 拦截器攒变更;仅 HTTP 200 才刷;无 @AuditLog 则无 requestId、不记列变更 |
| bpmn MQ | 无回调地址时发审核结果;BpmnListener 收启动消息;不走 Holder |
| 当前 intf | 不写业务 MQ,starter 是为了让 common 切面能发 |
SSO /auth | 先 needSelectOrg,再带 orgId;getUserPermission 发生在选 org 之后 |
common 的切面、org Bean、审计三条 MQ,以及 auth 的菜单码与 getUserPermission 已能对上。SSO 如何把 permissionList 写成 GrantedAuthority、以及 needSelectOrg 的精确分支,还要看 ixxx-sso-web。审计消息的落库消费者不在这些仓库里。