这次工作最初是一份接口问题清单:找到对应的 iFlow,用已有请求触发,结合 Trace 和 S/4 程序逻辑,判断字段为什么没有返回、应该由哪一侧调整。

随着 Bruno 请求集合和历史接口材料补齐,FAQ 接口的工作逐渐明确:先对齐已有可用接口的调用方式,再把登录逻辑从业务 iFlow 中抽出,交给公共 iFlow 管理。这里记录这条已经完成验证的改造链路,以及过程中几个容易误判的地方。

文中的流程名称使用 FAQ_FLOW 和 TOKEN_FLOW 代称。租户地址、凭据别名、业务设备号和原始 Trace 均不公开。代码片段用于说明实现原则,不是可以直接导入任意租户的完整部署包。

从“字段没返回”开始,先建立证据边界

会话前半段涉及多个接口:设备档案、特征值和 FAQ。排查材料既有接口清单,也有数据库查询结果、表截图和业务顾问对记录出现时间的反馈。

这些材料回答的问题并不相同。

材料可以确认的事情不能单独推出的结论
Bruno 请求请求路径、方法、参数、认证方式当前部署的 iFlow 一定按相同方式调用
CPI Trace某一次运行中具体步骤的报文所有调用都经过同一分支
S/4 表记录查询时该环境中存在这些数据这些记录一定由某次接口调用创建
ABAP 源码被阅读版本的取数及处理逻辑另一 client 的数据和运行结果必然相同
最终 HTTP 响应调用方实际收到的内容中间每个步骤都正确

例如,返回中没有 characteristics ,需要继续区分:后端没查到数据、程序没有填充字段、映射没有传递,还是序列化改变了空值的表达方式。

又例如,顾问说“下午之后才出现第一条记录”,这能帮助缩小时间窗口,但不能直接证明记录由我的测试创建。还需要把请求内容、运行时间、消息关联标识和后端写入路径对起来。

因此,这篇文章不把前面所有问题归结为“S/4 的问题”或“CPI 的问题”。后面 FAQ 的修复有独立的请求、部署和运行证据,其结论只覆盖这条链路。

Bruno 提供了认证协议,不能从旧 iFlow 的名字猜

补充材料中的请求集合给出了当前可用的 FAQ 调用方式:先发 JSON 登录请求,取出响应里的 token ,再把它放进业务请求的 Bearer header。

登录请求的字段名有大小写要求:

{
  "Username": "<username-from-secure-store>",
  "Password": "<password-from-secure-store>"
}

这不是看到“获取 Token”几个字,就可以套用的 OAuth2 client-credentials grant。旧项目里常见的 clientId/clientSecret 、 access_token 或嵌套字段,都不能代替实际接口协议。

第一阶段已经让 FAQ 正确调用了这个接口,但登录仍发生在 FAQ 自己的 Groovy 脚本中,每次请求都重新获取 token。第二阶段的目标才是本次重点:把这段已经验证的登录逻辑移入公共流,同时保持业务响应。

准备复用的公共流来自另一个项目。下载当前工件后,发现了几个明确的遗留问题:

  • 登录 body 使用旧项目字段,响应解析读取旧的嵌套字段。
  • Data Store 的读取名称和写入名称不同。
  • 过期时间来自配置的固定分钟数,没有绑定实际签发时间。
  • 缓存命中分支没有像刷新分支一样设置返回 header。
  • 部分步骤把 token 当作 body 返回,不符合这次公共接口约定。

这些问题解释了为什么不能只替换 URL 就部署。可以复用它的 Data Store、Router、Local Integration Process 结构,但必须重新核对协议、存储和分支出口。

修改从当前工件和可回退版本开始

这次没有拿旧的本地 ZIP 接着改,而是先从 QAS 下载两个当前工件,再保存未修改版本。

流程修改前版本本次备份版本新运行版本
FAQ_FLOW1.0.101.0.111.0.12
TOKEN_FLOW1.0.1,尚未部署1.0.21.0.3

保存时还遇到了编辑锁。API 返回:

Cannot update the artifact as it is locked.

退出编辑后第一次重试仍失败,用户明确完成解锁后,保存才成功。随后重新下载并比较内容,确认备份没有夹带新的业务改动。

这个步骤同时保护两件事:回退时有准确的原始版本,修改时也不会覆盖浏览器中另一份尚未确认的编辑。

公共流管理 Token,FAQ 保留业务路径

最终调用关系如下。图中的步骤名称是概念说明,对应实际部署中的脚本、Data Store、Router 和 ProcessDirect 调用。

客户端
  │
  ▼
FAQ_FLOW:检查并保存 FAQ 路径
  │
  ├── ProcessDirect ──► TOKEN_FLOW
  │                       │
  │                       ├─ 读取加密缓存
  │                       ├─ 检查作用域、格式和有效期
  │                       │    ├─ 有效:复用
  │                       │    └─ 无效:登录 → 计算有效期 → 写入缓存
  │                       └─ 返回 access_token header,body 为空
  │
  ▼
恢复 FAQ 路径,设置 Authorization
  │
  ▼
调用 FAQ 接口
  │
  ▼
清理认证 header,原样返回业务响应

FAQ 在调用公共流前保存经过校验的路径。这样,即使公共流为了读取和写入缓存而使用了消息 body,也不会改变业务请求的地址。

公共流两个成功出口采用同一约定:返回 access_token header 和有效截止时间 header,body 为空。FAQ 收到后生成 Authorization: Bearer ... ,随即删除内部 token header;正常和异常出口都会清理认证信息。

这里的“原样返回”指不增加业务字段转换、排序或 JSON 包装。接口认证方式改变,不应顺便改变调用方依赖的响应结构。

有效期取实际签发值,再减十分钟

实际登录响应包含 tokenValidTo ,JWT 中包含 exp 。本次观察到的 token 有效期是七天,但代码没有把七天写成常量。

实现选用 JWT 的绝对 UTC 过期时间计算缓存截止时间:

// Use the issuer's absolute expiry, not a fixed duration from configuration.
long actualExpiresAtEpochMillis = expSeconds * 1000L
long expiresAtEpochMillis = actualExpiresAtEpochMillis - 600000L

// Refresh at the cutoff itself; do not wait until the actual token expiry.
boolean reusable = nowEpochMillis < expiresAtEpochMillis

保留十分钟余量,目的是让 token 在被取出之后,还能留出业务调用和网络传输的时间。到达截止点时必须刷新,所以比较条件是严格的小于。

加密 Data Store 中保存的记录包含以下字段:

字段用途
token实际使用的 token
actualExpiresAtEpochMillis签发方声明的实际过期时间
expiresAtEpochMillis减去十分钟后的缓存截止时间
credentialAlias标识缓存对应的凭据配置
authenticationUrl标识缓存对应的登录服务
schemaVersion区分缓存结构版本

读取缓存时不仅判断“还没过期”,还校验作用域和字段之间的一致性。缺失、格式损坏、旧格式、配置不匹配或已过期,都按缓存未命中处理。

新签发的 token 如果已经不足十分钟有效时间,会直接报错,而不是写入一条马上失效的缓存。命中缓存时也不会重新计算“从现在开始七天”,因此重复访问不会无限续期。

这里解码 JWT 只是为了读取从可信 HTTPS 登录响应取得的 exp ,没有实现 JWT 签名验证。它不能被当成验证外部调用方 token 的鉴权模块。

凭据和缓存各自有不同的保护边界

用户名和密码从 CPI Secure Store 读取。登录请求和完整登录响应保留为脚本局部变量,没有写入交换属性或日志附件;只有供加密 Data Store 写入的缓存记录进入消息 body。

Data Store 使用本地作用域、加密存储,且不保存消息 headers。读写使用同一个名称和 entry ID。

不过,“加密存储”不等于“运行中永远不可见”。管理员开启 Trace 后,临时 body 和出站 Bearer header 仍可能被记录。为确认缓存是否工作,没有必要导出这些内容。

这次增加了两个不含 token 的 MPL 自定义属性:

  • SAGTokenCache :记录 MISS 、 REFRESHED 或 HIT 。
  • SAGTokenExpiresAt :记录有效缓存截止时间。

这样可以在 INFO 级别确认分支行为,同时把凭据和完整 token 排除在排障报告之外。

保存成功之后,还要核对真正保存了什么

上传新包并保存版本后,回读发现一个细节:FAQ 中已经移除了两个认证参数的定义和 BPMN 引用,但 CPI 导出的 parameters.prop 仍保留原配置值。

公共 token 流的配置行顺序也被重新整理了。如果只比较 ZIP 哈希或文本行顺序,很容易把这些变化与业务修改混在一起。

本次核对采用了更窄的规则:

  1. 脚本和 BPMN 按实际内容逐字节比较。
  2. Java Properties 按键值比较,忽略已确认的注释和顺序变化。
  3. 对残留配置键逐一确认:值与备份相同,且新定义和 BPMN 都不再引用。
  4. 再通过配置 API 核对有效 endpoint、认证 URL 和凭据别名。

不能因为发现一项导出差异,就把所有差异都当作“平台自动处理”。也不能因为配置文件里还留着一个字段,就认为运行逻辑仍在使用它。

部署顺序是先公共流、确认 STARTED ,再部署 FAQ。公共流启动成功只是下一步测试的前提,还不能证明整个业务链路可用。

测试覆盖边界,也覆盖真正的运行链路

本地 Groovy 测试使用模拟 Message、Secure Store 和 HTTP 连接,检查登录协议与缓存判断,包括:

  • 首次未命中、有效缓存命中、过期后重新登录。
  • 截止时间前一毫秒、恰好截止、截止后一毫秒。
  • 损坏缓存、作用域不符、旧存储格式和不一致的有效期字段。
  • token 缺失、格式错误、剩余时间不足以及登录 HTTP 错误。
  • FAQ 路径恢复、body 清理、认证 header 清理及业务响应保留。

这些测试通过后,才使用真实 QAS 链路验证 Data Store 和 ProcessDirect。实际结果如下:

调用HTTP公共 Token 流行为FAQ 与公共流 MPL
第一次200MISS → REFRESHED均为 COMPLETED
第二次200HIT均为 COMPLETED
第三次200HIT均为 COMPLETED

三次观察到的缓存截止时间相同。两次命中没有重新登录,也没有把有效期向后延长。客户端响应中没有泄露认证或内部 token headers。

实际七天后的自然过期没有等待完成;过期刷新与精确边界由本地测试验证。这个限制需要写清楚,不能把模拟时间边界测试描述成“线上已连续观察一周”。

“返回结果一样”也需要拆开验证

验收中最值得保留的一个细节,是新旧响应比较第一次失败了。

新流程返回 HTTP 200,而且与当次直接调用后端取得的完整 JSON 一致。但是,与修改前保存的基线相比,严格的数组逐项比较失败。

继续检查后,差异只在 applianceCodes :共 10,863 个元素,数量和全部值相同,排列顺序不同。其他字段没有差异。

因此,验收结论必须分别表述:

  • 与当次直接调用后端的响应相比,完整解析后的 JSON 一致。
  • 与较早基线相比,业务值一致,但一个数组的顺序不同。
  • 仅在测试中对该数组排序后,完整结果一致。

生产 iFlow 没有因此增加排序步骤。直接后端响应也呈现了相同结果,说明这次观察到的顺序差异并非 CPI 添加的转换。

如果调用方依赖某个数组的顺序,那仍然需要单独确认接口契约。这次比较只能证明哪些内容相同、哪些内容不同,不能擅自把数组顺序宣布为无关紧要。

这次改造留下的边界

完成后,FAQ 负责请求路径和业务调用,公共流负责登录与缓存。旧版本被保留,两个新版本完成回读核对和真实调用验证,维护脚本中的注释也统一为英文。

缓存仍有明确边界:并发首次未命中可能触发多次登录,没有实现跨节点的单次刷新锁;后端提前吊销 token 也没有增加自动重试。这些都没有被包装成已经解决的问题。

这次复盘最有价值的部分,是每一层结论都能找到对应证据:协议来自已有请求,缓存结构来自当前工件检查,过期规则来自实际 token,流程可用性来自关联 MPL,而响应兼容性来自完整结果比较。下一次再遇到“接口没返回字段”,就可以沿着同样的证据链缩小范围,而不必一开始就决定由哪个系统背锅。