常见是这样一幕:一个已经跑了半个月的 API 数据源,某天开始稳定报 401。密钥没换,地址没改,第三方那边也没发公告。最后定位到的是「请求参数」里那条手写的Authorization——它和鉴权配置注入的同名 Header 撞在了一起,被后者盖掉,于是真正发出去的凭证成了一串早就作废的旧值。
问题不在于谁覆盖谁,而在于很多人把 API 数据源的鉴权理解成"填一个 Header"。在 SagooIoT 里它不是:五种鉴权类型、两套完全不同的凭证生命周期、一层按数据源维度缓存的换票结果——任何一层理解偏了,对外表现都是同一个 401。
这篇按运行时的真实顺序把这条路径拆开:从数据源怎么建,到票在哪取、缓存按什么维度走、哪些操作会把缓存清掉,最后是四个高频报错各自对应到哪里。
一、先划定范围:这次只讲「api导入」
数据中心的数据源目前有三类:
| 类型 | 说明 |
|---|---|
| api导入 | 定时拉取第三方 HTTP/HTTPS 接口 |
| 设备 | 绑定产品或设备,节点映射物模型属性(时序入库) |
| 数据库 | 直连 MySQL / MSSQL,表或自定义 SQL,支持增量与定时同步 |
入口统一在数据中心 → 数据源 → 新增,数据来源选「api导入」。建一个 API 源的标准动作是七步:
- 填写数据源标识、名称、描述
- 数据来源选择api导入
- 配置请求方法(
get/post/put)、业务 URL、更新时间(cron) - 配置鉴权——下文全部落在这条线上
- 可选:配置请求参数组(Header / Body / Query)
- 保存后配置数据节点(JSON 路径映射)
- 预览查询确认有数据,再发布数据源
平台跑这条源的顺序固定是一句话:
(可选)取 Token → 注入 Header/Query → 调用业务 API → 按数据节点映射入库其中"取 Token"是可选的,有没有这一步只取决于第 4 步选了哪种类型。而"注入"发生在"参数组装之后"——这个先后关系后面单独说,它是绝大多数"配了两处、只有一处生效"问题的根因。
「更新时间」用 cron 表达式,六段式,比如五分钟一轮就是0 */5 * * * *。写法与生成工具在定时任务那篇里已经讲过,这里不重复。
二、五种类型,按「凭证会不会过期」分成两类
界面上的选项有五个,文档把它们平铺成一张表:
| 类型 | 界面选项 | 是否调用鉴权接口 | 是否缓存 Token | 适用场景 |
|---|---|---|---|---|
none | 无 | 否 | — | 无需鉴权,或自行在请求参数写死 Header |
bearer | Bearer Token | 否 | — | 长期有效的 Bearer Token |
apikey | API Key | 否 | — | 固定 API Key(Header 或 Query) |
oauth2 | OAuth2 | 是(按缓存) | 是 | 标准 OAuth2(client_credentials / password) |
custom_token | 自定义 Token | 是(按缓存) | 是 | 非标准登录/换票接口返回 Token |
平铺的五种不好记,但按"凭证会不会过期"一切两半,后面所有差异就都能推出来了:
- 静态凭证:
none/bearer/apikey。凭证是配置里的一段固定文本,平台只负责把它塞进请求,不会去打任何额外接口。 - 动态凭证:
oauth2/custom_token。凭证要先从对方的鉴权接口换回来,换回来的票有生命周期,会过期。正因为会过期,才需要在平台侧缓存。
这条分界线解释了一件事:为什么"缓存规则"只对oauth2/custom_token有意义。静态凭证没有票可缓存——它就在配置里躺着。
三、静态的三种:不换票,但各有各的错法
无(none)不是"没有鉴权能力",而是"不启用平台鉴权"。此时行为与静态请求一致,你仍然可以在「请求参数」里手动补一个 Header:
| 参数类型 | 参数标题 | 参数名 | 参数值 |
|---|---|---|---|
| header | 授权 | Authorization | Bearer eyJhbGciOi… |
调试期这么写很自然,但它只适合调试。凭证是死的,第三方一换票你就得回来改配置,而且这段文本在配置里没有任何脱敏处理。
Bearer Token(bearer)的坑很窄,但命中率极高:Token 字段里只填 token 本身,不要带Bearer前缀。前缀是平台注入时加的,你写了Bearer,发出去的就成了Authorization: Bearer Bearer eyJ...。
{"method":"get","url":"https://api.example.com/v1/devices","cronExpression":"0 */5 * * * *","auth":{"type":"bearer","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}API Key(apikey)有三个字段,其中 Key 名称有默认值:
| 字段 | 默认 | 必填 | 说明 |
|---|---|---|---|
| Key 名称 | X-API-Key | 否 | 参数名 |
| API Key | — | 是 | 密钥值 |
| 注入位置 | header | 否 | header或query |
{"auth":{"type":"apikey","apiKey":"sk_live_abc123","apiKeyName":"X-API-Key","apiKeyIn":"header"}}选query时等价于GET /sensors?api_key=sk_live_abc123,apiKeyName换成api_key即可。注意这会把你以为很私密的 Key 写进 URL,会进对方的访问日志,也会进链路上任何一台代理的日志。能走 Header 就走 Header。
四、OAuth2:标准换票,但注入模板被锁死了
oauth2面向的是符合标准的 Token 接口,响应长这样:
{"access_token":"xxxx","token_type":"Bearer","expires_in":7200}平台拿到后的动作是三步:
POSTToken 地址,Content-Type: application/x-www-form-urlencoded- 读响应里的
access_token/expires_in - 缓存,然后对业务请求注入
Authorization: Bearer <access_token>
支持两种授权类型,字段差异如下:
| 字段 | 默认 | 必填 | 说明 |
|---|---|---|---|
| 授权类型 | client_credentials | 否 | 或password |
| Token 地址 | — | 是 | 获取 token 的 URL |
| Client ID | — | 视类型 | 客户端 ID |
| Client Secret | — | 视类型 | 客户端密钥 |
| Scope | 空 | 否 | 权限范围 |
| 用户名 / 密码 | — | password 时 | 仅 password 模式 |
{"method":"get","url":"https://api.example.com/v1/orders","cronExpression":"0 */10 * * * *","auth":{"type":"oauth2","grantType":"client_credentials","tokenURL":"https://auth.example.com/oauth/token","clientId":"my-client","clientSecret":"my-secret","scope":"read"}}这里有一个容易忽略的约束,文档专门用提示块标了出来:管理端 OAuth2 表单不单独暴露注入模板字段,固定按 Bearer Header 注入。
把这句话翻译成选型规则,就是一条硬分界线:只要对方要求的不是"Authorization 头 + Bearer 前缀",oauth2就直接出局。比如对方要X-Access-Token: xxx,或者前缀是Token而不是Bearer,标准类型帮不了你,必须换custom_token。这一点在选择类型的时候就要判断,等到 401 才发现,成本高得多。
五、自定义 Token:把三层锁死的自由度还给你
custom_token面向的是"响应不是标准格式"的登录/换票接口,比如 token 藏在data.access_token或result.token里。
| 字段 | 默认 | 必填 | 说明 |
|---|---|---|---|
| 鉴权请求方法 | POST | 否 | GET / POST |
| 鉴权请求地址 | — | 是 | 登录或换票 URL |
| Token 字段路径 | access_token | 建议 | gjson 点路径,如data.token |
| 有效秒数 | — | 建议 | 响应无expires_in时的兜底(界面常填 7200) |
| 注入参数名 | Authorization | 否 | 写入业务请求的参数名 |
| 注入模板 | Bearer {{token}} | 否 | 用{{token}}占位 |
| 注入位置 | header | 否 | header/query |
| 鉴权请求 Body | {} | 视接口 | JSON 对象,保留数字/布尔原始类型 |
相比oauth2,它多出的正是三个自由度:取哪(tokenPath)、注到哪(injectName+injectType)、长什么样(injectTpl)。
一个"先登录、再带着 token 访问业务接口"的完整配置:
| 项 | 值 |
|---|---|
| 鉴权类型 | 自定义 Token |
| 鉴权请求方法 | POST |
| 鉴权请求地址 | https://iot-vendor.com/api/login |
| Token 字段路径 | data.access_token |
| 有效秒数 | 3600 |
| 注入参数名 | Authorization |
| 注入模板 | Bearer {{token}} |
| 注入位置 | Header |
| 鉴权请求 Body | {"username":"admin","password":"Secret123"} |
| 业务 URL | https://iot-vendor.com/api/devices |
{"method":"get","url":"https://iot-vendor.com/api/devices","cronExpression":"0 */5 * * * *","auth":{"type":"custom_token","tokenMethod":"POST","tokenReqURL":"https://iot-vendor.com/api/login","tokenBody":{"username":"admin","password":"Secret123"},"tokenPath":"data.access_token","expiresIn":3600,"injectType":"header","injectName":"Authorization","injectTpl":"Bearer {{token}}"}}把最后三行换掉,就能适配别的形态。对方要X-Access-Token: xxx,那就injectName=X-Access-Token、injectTpl={{token}};对方把票放在 Query 里,就injectType=query、injectName=access_token、injectTpl={{token}}。同一套取票逻辑,只是注入形状不同。
有两个细节值得单独拎出来。
第一,tokenBody保留数字/布尔原始类型。文档特意强调这一点,是因为这类参数很容易被当成字符串处理——{"port": 8080}被序列化成{"port": "8080"}之后,一部分接口会直接拒掉,报错信息通常只说"参数错误",不会告诉你类型错了。填 Body 的时候按 JSON 的原始类型写。
第二,鉴权请求的额外 Header 目前没有界面入口。有些换票接口要求带X-App-Id之类的标识,文档给的路径是"通过接口保存时写入tokenHeaders":
{"auth":{"type":"custom_token","tokenMethod":"POST","tokenReqURL":"https://vendor.com/login","tokenHeaders":{"Content-Type":"application/json","X-App-Id":"app001"},"tokenBody":{"user":"admin","pwd":"123456"},"tokenPath":"data.token","expiresIn":7200}}这条值得记下来:它是一个文档承认的界面缺口。遇到"换票接口要额外 Header"的需求时,别在界面上反复找。
六、注入顺序:为什么"两处都写"一定只有一处生效
文档对参数与鉴权的关系给了一句很关键的描述:
鉴权注入发生在参数组装之后:同名 Header 以鉴权写入为准。
拆开看,这句能推出三件事。
一、同名必被覆盖,而且是静默覆盖。你在「请求参数」里写Authorization,同时鉴权类型选了custom_token,最终发出去的一定是鉴权注入的那一份。整个过程不报错、不提示、界面上两处都还在——所以排查的时候容易盯着配置看很久,却想不到发出去的请求和配置长得不一样。
二、不同名可以共存。鉴权注入的是Authorization,而你在请求参数里加X-Trace-Id、Accept-Language这类,两者互不干扰——覆盖只发生在同名的情况下。
三、同一轮同步内,多组参数共用同一张票。请求参数组是"一组对应一次业务请求",多组就是多次拉取(分页、按站点轮询这类需求都靠它)。文档明确说"不会因多组参数重复打鉴权接口",因为缓存维度是数据源级的,一轮同步里的所有请求打的是同一张票。
文档 FAQ 里对这一条的结论是"可以,但不建议":动态鉴权就交给鉴权配置,别在两个地方同时维护凭证。理由很实际——两处凭证不可能同时保持新鲜,其中一处迟早会变成那个 401 的来源。
七、Token 缓存:五个参数决定它怎么工作
缓存规则的完整定义只有五行,但每一行都有工程含义:
| 项 | 说明 |
|---|---|
| 维度 | 按数据源sourceId |
| 位置 | 进程内存(单机有效;多实例各自缓存) |
| 有效期优先级 | ① 响应expires_in→ ② 配置「有效秒数」→ ③ 默认 3600 秒 |
| 提前失效 | 约提前 60 秒视为过期,避免边界失效 |
| 清缓存 | 编辑并保存数据源;或进程重启 |
维度按sourceId。缓存挂在数据源上,不是挂在"第三方系统"上。两个数据源连的是同一家第三方、用同一套 client 凭证,平台也会各取一次票,互相看不见对方的缓存。想省票只能合并数据源,但代价是把两个本来独立的业务口径塞进同一个参数组里——多数情况下不值得,一次多余的换票请求,远比分不清口径便宜。
位置在进程内存。文档括注里那句"多实例各自缓存"是这一行最容易被扫过去的六个字,它意味着一件很具体的事:实例数就是换票倍率。三副本部署,第三方看到的换票请求量就是单机的三倍。如果对方的换票接口带频率限制或按次计费,这里会撞墙——而排查的人往往先从数据源配置上找,很难想到要去看部署拓扑。这是集群部署时要单独确认的一条。
有效期三档优先级。响应里带expires_in最准,直接沿用;响应没带,才轮到配置里的「有效秒数」;两者都没有就落到默认 3600 秒。
这里有个可以推出来的差异:custom_token的表单里有「有效秒数」这一格,oauth2的表单里没有。所以对oauth2来说只有两档——响应给expires_in就按响应走,不给就直接落到 3600 秒。第三方如果既不给expires_in、票又活不到一小时,就一定会在某一轮同步上撞到过期的票。这种接口用custom_token反而更可控,因为你能自己填兜底值。(这一条是依字段表推的,文档没有直说。)
提前 60 秒失效。这是为了避开"取的时候还活着、用的时候刚死"的边界。但它有一个副作用:提前量是固定的 60 秒,意味着第三方给的有效期如果本来就短于 60 秒,这张票等于没有缓存——每次同步都会重新换票。这就是 FAQ 第一条"为什么每次都在打鉴权接口"里"有效期过短"那半句的由来。
清缓存的两种路径。编辑并保存数据源,或者进程重启。注意"编辑并保存"不区分改了什么:你只改了个 cron 表达式、只改了个描述,保存下去票一样会清。这不影响正确性——下一次同步重新取一张就是——但如果第三方对换票有频率限制,改配置这个动作就值得攒着做,别一小时里改十遍存十遍。
八、和平台其他缓存对照:它是留在进程里的那一个
如果只看数据源这一节的文档,容易以为"Token 存在内存里"是平台的通行做法。不是。
在开源主仓库里,平台级的缓存有一整套明确的键常量,集中在internal/consts/cache.go:
// CacheSysDict 字典缓存菜单KEYCacheSysDict="SystemCache:sysDict"// CacheSysRole 角色缓存keyCacheSysRole="SystemCache:sysRole"//CacheSysMenu 系统菜单CacheSysMenu="SystemCache:sysMenu"//CacheUserAuthorize 用户权限CacheUserAuthorize="SystemCache:userAuthorize"//CacheDeviceOnline 下面的是网络部分用到的CacheDeviceOnline="networkDeviceOnline"// 告警规则CacheAlarmRule="AlarmRule:rule"字典、角色、菜单、用户权限、设备在线状态、告警规则——都在这套带前缀的键体系里,而且这套体系是可以切走的。manifest/config/config.example.yaml里给了缓存适配器:
#缓存cache:prefix:"SagooIot_Sys:"#缓存前缀adapter:"redis"# 缓存驱动方式,支持:memory|redis|file,不填默认memoryfileDir:"./storage/cache"# 文件缓存路径,adapter=file时必填memory|redis|file三选一。也就是说,平台里绝大多数缓存可以从进程内存切到 Redis,多实例部署时把这一项改成redis就解决了共享问题。
而 API 数据源的 Token 缓存,文档明确写的是"进程内存(单机有效)",不在上面那套键体系里。
这不太像遗漏,更像一个有意的取舍:Token 是短命的、廉价的、丢了重新换一张就行的凭证,为它引入一次跨进程的缓存读写(甚至一层 Redis 依赖)不划算——尤其在cache.adapter=memory的单机部署下,Redis 可能压根没装。但它把"多实例 = 多份票"这件事固定了下来,代价在运维侧,只能靠部署时知道这件事来消化。
九、SSRF:Token 地址也在检查范围内
数据源配置里最容易让人误判成"网络不通"的一类失败,来自 SSRF 策略。
官方文档对这一块的描述是:业务 URL 与 Token URL都可能受system.ssrf策略限制。这句话里最值得注意的正是"Token URL"——很多人以为安全检查只针对业务接口,实际上换票地址走的是同一道闸。
system.ssrf的字段在配置文档里是这样的:
system:ssrf:enabled:falseallowPrivateIP:falseallowLocalhost:falsewhitelist:"api.example.com,*.internal.corp"blacklist:"evil.com"按字面就能推出最常见的撞墙场景:数据源指向内网的 MES 或 ERP,而allowPrivateIP是 false,请求在出门之前就被拦掉了。这种情况下报的是"URL 安全检查失败"之类的字样,跟超时、DNS 失败长得不一样,但功能表现上都是"这个源取不到数"。
具体怎么开关、跟哪些节互相依赖,部署那篇已经讲过一遍,这里只留一句结论:配 API 数据源时,业务地址和换票地址要一起放进白名单,别只放一个。
需要交代边界:上面这段配置是按官方文档写的。开源 main 分支的manifest/config/config.example.yaml只有 147 行,system节里依次是name、version、description、enablePProf、pprofPort、ipMethod、isDemo、isCluster、deviceCacheData、pluginsPath、upload——没有ssrf,也没有文档里提到的其他几个新节。也就是说这份样例对应的是较早版本,凡是"哪些配置节存在"的问题,都以官方文档为准。
十、这个模块在开源仓库里的位置
写这篇之前把主仓库过了一遍,有一件事需要交代清楚,免得读者按图索骥去找实现,最后扑空。
manifest/sql/init.sql里,/api/v1/source/*相关的sys_api记录一共 42 条,挂在三个父节点下:
| 父节点 | 名称 | 子接口数 |
|---|---|---|
| 303 | 数据源 | 22 |
| 342 | 数据建模 | 19 |
| 409 | 动态数据展示 | 1 |
42 条记录全部status=0(停用)、全部is_deleted=1。数据中心的一级菜单(sys_menuid 27,路径/config/datahub)同样是is_deleted=1。
再看代码侧:internal/logic目录下没有datahub,api/v1目录下也没有。前端的接口声明倒是完整的——src/api/datahub/index.ts里有/source/api/add、/source/api/edit、/source/search、/source/detail、/source/deploy、/source/undeploy、/source/node/*、/source/template/*一整套,但src/views下没有对应的页面目录。
结论跟数据中心其他几层一样:菜单和接口记录都在,实现不在开源仓库。所以本文里凡是"平台会怎么做"的描述,依据都是官方文档;能从开源代码里核对的只有两处——上一节的缓存键体系与配置样例,以及这一节的接口记录清单。做技术选型评估时,这个边界建议先摸清楚,别把文档描述的能力直接当成"仓库里能读到的东西"。
十一、排障:四个报错分别对应哪里
把文档 FAQ 的四条按"现象 → 最可能的原因 → 先看哪里"整理成一张表,排查时按这个顺序走:
| 现象 | 最可能的原因 | 先看哪里 |
|---|---|---|
| 每次同步都在打鉴权接口 | 有效期过短;expiresIn填得过小;多实例各自缓存;或刚保存过数据源把缓存清了 | 第三方响应里的expires_in、配置的「有效秒数」、部署实例数 |
| 预览报「鉴权接口未返回 token」 | tokenPath与真实响应不匹配 | 拿 Postman 打一次换票接口,对着响应结构改路径——{"data":{"token":"xxx"}}就该填data.token |
| 业务接口 401 | 票过期;注入模板前缀不对(有些接口要Token {{token}}而不是Bearer {{token}});注入位置选错 | 鉴权配置的三行:injectTpl、injectType、injectName |
| 「URL 安全检查失败」 | SSRF 策略拦了业务 URL 或 Token URL | system.ssrf的enabled、allowPrivateIP、whitelist |
第一条和第三条容易互相掩盖:频繁换票会让 401 消失,于是"每次都在打鉴权接口"被当成正常现象接受下来,直到对方的换票接口限流才暴露出来。
十二、落地顺序
最后把文档给的动作清单按依赖关系重排一下,每一步都对应一个可验证的状态:
- 先用 Postman 或 curl 把鉴权接口和业务接口各自打通——它们都能单独通,才说明问题不在第三方侧
- 在平台新建 API 数据源,选鉴权类型。判断依据只有一条:对方要的凭证会不会过期、注入形状是不是标准 Bearer Header
- 保存后点查询预览返回的 JSON——这一步能看到原始的响应结构,
tokenPath该填什么基本一眼就定了 - 配置数据节点,把 JSON 路径映射成字段
- 发布数据源,观察它是否按 cron 正常入库(发布与停用是两个独立接口,
/source/deploy与/source/undeploy) - 之后每次改密钥、改密码、改鉴权方式,保存即清缓存,下一轮同步会重新取票——这一条不用额外操作
十三、项目地址
- 开源仓库:https://github.com/sagoo-cloud
- 官方文档:https://iotdoc.sagoo.cn
数据源是数据中心链条的第一环,也是唯一一环"要跟外部世界打交道"的地方。它要处理的事情里,最难的不是拉数据,是拉之前那一张会不会过期的票。把鉴权类型、缓存维度、注入顺序这三件事分开想清楚,401 这类问题基本就只剩第三方自己的锅了。