关于项目审核、权限与操作日志:intf 如何对接 bpmn 与公共封装

54 阅读8分钟
今天对公司的分层隔离以及注解和调用进行探究,涉及多方面,想到哪写到哪,(**文章已做脱敏处理**)从 intf 出发梳理 `ixxx-bpmn` 的 Feign 封装、权限码前缀,以及 `ixxx-common` 用 RocketMQ 记操作日志 / 行级变更、bpmn 用另一路 MQ 回传审核结果的用法。

一、背景: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.hasAuthoritySpring Security + ixxx-common 的 org Bean
操作日志@AuditLogixxx-common 切面 + RocketMQ

本文按「intf 怎么接到 bpmn」→「jar 从哪来」→「两层鉴权与权限码前缀」→「RocketMQ 怎么记日志、审核结果怎么走 MQ」→「登录响应里为什么没有权限码」这条线梳理。


二、bpmn 是审核引擎服务

ixxx-bpmn 管理端确实有流程定义、待办、已办等类似增删改查的页面。intf 真正用的不是这些页面接口,而是 Activiti 流程引擎 被包成的远程能力:

intf 调用bpmn 实际做的事
startProcess按流程 KEY 启动一条流程实例
审核人在 BPMN 待办页 doneTask完成当前节点,记录通过 / 驳回(Y / N)
cancelProcess删除运行中的流程实例
回调 ProcessResultDTO流程结束,把结果推回业务系统

bpmn 不理解「出口计划」是什么,只认:

  • procDefKey:流程定义 KEY
  • businessKey:业务单号(如 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-commonDTO 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 一般分三层:

  1. 本地仓库:~/.m2/repository(本机 mvn install 的结果)
  2. 公司 Nexus 私服:CI 或同事 mvn deploy 推内部包(1.0.0-SNAPSHOT、com.ixxx 都在这里)
  3. 公共源: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')")
注解哪个依赖做什么
@PreAuthorizeSpring Security(随 oauth2 starter 进来)方法执行前鉴权
@org.hasAuthority(...)ixxx-common 里 @Component("org") 的 OrgSecurityExpressionRoot在当前用户 authorities 里找 拼过租户前缀 的码
@AuditLogixxx-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-webhasAuthority('AUTH:USER')(Spring 原生,不加 @org)AUTH:USER

auth 自己不能写成 @org.hasAuthority('AUTH:USER'):那会去找 税号:AUTH:USER,平台菜单对不上。跨组织授权时,AuthGrantServiceImpl.isGrant 也是手写 grantOrgCode + ":" + permission,规则和 @org 一致。

intf 上配 INTF:EXPORT_PLAN 这一页,要同时满足:

  1. 菜单 APP_TYPE = 1,permissionCode 正好是 INTF:EXPORT_PLAN
  2. 用户当前组织的角色勾了这个菜单 / 按钮
  3. 进程环境变量 ORG = 该组织 ORG_CODE
  4. 会话 authorities 里有 {ORG}:INTF:EXPORT_PLAN
  5. 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 / AuditLogHandlerlog-requestLogRequestDTO这次点了哪个接口
同上log-responseLogResponseDTO返回了什么 / 是否抛错
ResponseBodyAdviceImpl / ServiceExceptionHandlerlog-dbList<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 POST ProcessResultDTO(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):必须先有 org
  • getRouter:给前端画菜单(同一套角色权限)

抓 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 与 bpmnintf 只引 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 / @AuditLogSpring Security + ixxx-common,不是 bpmn-api
ixxx.authorityURL 层:会话必须持有 裸组织码
@org.hasAuthority方法层:查 {ORG}: + 注解字符串;读环境变量 ORG
菜单 permissionCode不带税号(如 INTF:EXPORT_PLAN);会话里的 orgPermissionCode 才带前缀
auth vs intf 注解平台用原生 hasAuthority('AUTH:...');业务用 @org.hasAuthority
@AuditLog默认不记 SELECT;内部调用不记操作日志
审计 MQApache RocketMQ;asyncSend 三条 topic:request / response / db;用 requestId 串联
RocketMQHolder只给审计用;没 Template 则空跑
log-dbSQL 拦截器攒变更;仅 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。审计消息的落库消费者不在这些仓库里。