跳到主要内容

GraphQL API

每个向外提供 API 的 DeviceChain 服务都使用 GraphQL。 本页列出端点、获取 Schema 的方法、所有变更遵循的约定,以及每个请求受到的限制。

状态

DeviceChain 处于预发布阶段,Schema 仍会演进。发布的 Schema 文件是权威参考。 默认禁用内省,参见探索 Schema。

下载 Schema​

所有 Schema 都在这里发布,根据服务启动时解析的文件生成:

索引/schema/index.json:列出所有功能区、认证平面、端点和 Schema 文件
Schema/schema/<area>.graphql;对提供另外两个平面的功能区,还包含 -admin 和 -settings 文件

请先查看索引。它集中列出每个功能区的 Schema 所属认证平面,以及该平面接受的令牌。 每个发布的 Schema 文件开头也有注释,说明自身的平面、端点和令牌。 认证平面很重要:向租户开发者提供管理员变更接口,会让他们面对永远无法获得授权的调用。

文件以纯文本提供,允许跨域访问,可以直接获取:

curl -s https://docs.devicechain.io/schema/index.json | jq '.areas[] | {area, endpoint}'
curl -s https://docs.devicechain.io/schema/device-management.graphql

端点​

Ingress 将 /api/<area>/graphql 路由到各功能区服务,并移除前缀, 让请求到达服务自身的 /graphql。因此以下每个端点都是 https://<your-host>/api/<area>/graphql:

功能区覆盖内容
user-management认证:login、selectTenant、refresh,以及租户自身的治理视图
device-management设备、设备类型、配置文件、资产、区域、客户、组、关系、告警、凭据、检测规则编写
event-management时序事件查询:events、locationEvents、measurementEvents、alertEvents、bucketedMeasurements
device-state实时最后已知状态:latestMeasurements、latestLocation、deviceStates;以及把事件源明确声明在线的设备转回推断在线状态的 demoteAssertedPresence
command-delivery命令派发:createCommand、cancelCommand、设备群批次(createCommandBatch、cancelCommandBatch)、命令历史
event-processing检测规则验证、回放预览、规则健康状况
dashboard-management仪表盘增删改查和版本管理
outbound-connectors每租户出站连接器增删改查
notification-management通知渠道和策略
ai-inference支持自然语言规则编写的单一调用 inferRuleCandidate;仅在启用可选推理服务时存在

另有三个端点位于独立的身份令牌平面,而非租户平面,只授权给超级用户或运维人员:

端点覆盖内容
/api/user-management/admin/graphql实例管理 API:身份目录、成员关系、角色目录、租户注册表和等级
/api/user-management/settings/graphql实例设置
/api/ai-inference/admin/graphql运维人员注册的推理提供商

数据平面服务使用基于能力的授权:每个解析器检查调用者租户令牌携带的特定权限,例如 device:write。 部分权限可能与直觉不同:

  • 读取设备凭据需要 device:write,不是 device:read。
  • latestLocation 需要 location:read,其他同属 device-state 的接口需要 state:read。
  • demoteAssertedPresence 需要 state:demote,不同于前两者。 默认没有角色直接声明它;只有持有 * 超级权限的角色,例如预置的 tenant-admin,无需明确授权就能使用。 它是事件流水线之外唯一写入实时状态投影的接口,一次调用会影响整个事件源的设备。

sparkplug-ingest 和 lwm2m-ingest 完全不提供 GraphQL,且有意不进入 /api 路由。 event-sources 虽有路由,但只返回占位 Schema;设备通过传输协议接入它,而不是通过该 API。

通过 WebSocket 订阅​

提供 GraphQL 订阅的服务也在其 GraphQL 端点接受 WebSocket。 客户端必须协商 graphql-transport-ws 子协议,并在 connection_init 载荷中发送访问令牌, 格式为 {"Authorization": "Bearer <token>"} 或 {"token": "<token>"}。 令牌在连接打开时检查一次。

  • **WebSocket 只运行订阅。**通过它发送查询或变更会被拒绝,返回错误 only subscription operations are accepted over a WebSocket; send queries and mutations over HTTP, 不执行任何操作。查询和变更应通过 HTTP 请求发送。
  • **访问令牌过期时,连接以代码 4401 关闭。**要继续接收数据,需要用新令牌建立新连接并重新订阅。 @devicechain/client 会自动这样重试一次:已建立的连接因 4401 关闭后, 它重新解析令牌、重连、重新订阅,并通过 connected(true) 向接收端报告重连。 .NET SDK 会让 SubscribeAsync 抛出包含关闭代码的异常;需要重新订阅才能继续。
  • 没有订阅的服务拒绝协议升级,返回 HTTP 400(this service offers no GraphQL subscriptions)。

服务器无法解析的订阅会得到语法错误,文档指定了其中不存在的操作时也会得到对应错误。 两者只影响该操作,连接保持打开。

查询事件​

event-management 提供持久事件历史的读取查询。 每个查询接受搜索条件,包括设备、事件类型、发生时间范围、关系锚点({type, token})和分页,并返回分页结果:

query {
measurementEvents(criteria: {
pageNumber: 1, pageSize: 50,
deviceToken: "sensor-001",
startTime: "2026-06-01T00:00:00Z",
endTime: "2026-06-24T00:00:00Z",
anchor: { type: "customer", token: "acme-corp" }
}) {
results { deviceToken occurredTime name value }
pagination { totalRecords }
}
}
  • 所有实体都以 token 命名,包括锚点内部。
  • 两个时间边界都包含端点,按 occurredTime 筛选:即设备报告的时刻,而非平台存储的时刻。
  • 结果按从新到旧排列。
  • 页码从 1 开始。

所有事件查询都自动限定于租户:结果仅包含调用者的租户,没有解析到租户的查询会被拒绝。

事件的 processedTime 是平台收到它的时刻。 使用平台代理的 MQTT 时,这是代理存储消息的时刻;event-sources 中断后,这可能远早于事件被处理的时间。 其他传输中,它是接入服务收取事件的时刻。

**measurementEvents 不按测量名称过滤。**其条件没有 name 字段, 所以无法直接表达“该设备的温度读数”。可以在客户端按 results[].name 过滤, 或使用接受 name、返回时间桶的 bucketedMeasurements:

query {
bucketedMeasurements(criteria: {
deviceToken: "sensor-001",
name: "temperature",
startTime: "2026-06-01T00:00:00Z",
endTime: "2026-06-24T00:00:00Z",
intervalSeconds: 300
}) { bucketStart name avg min max sum count }
}
深度回填的读数不会出现在 bucketedMeasurements 中

现在写入,但按设备控制的 occurredTime 标为 30 天前的读数, 会由 measurementEvents 返回,却不由 bucketedMeasurements 返回,也没有错误提示。 这只影响缓冲超过一个月的设备,或时钟存在如此大偏差的设备。 参见回填读数和汇总。

回填读数和汇总​

intervalSeconds 为 60 的整倍数且没有锚点过滤的分桶查询,使用预聚合汇总,而非原始读数。 汇总会更新最近 30 天的窗口。更早的数据只在数据库创建时物化过一次。

现在写入但标记为 30 天前的读数落在两者之间:对刷新窗口而言太旧,对一次性处理而言太晚。 原始历史完整,所以 measurementEvents 会返回它;bucketedMeasurements 则不会显示。

这个边界看的是读数被标记为多么久远的过去,而非数据本身有多旧。 回填一小时、一天或三周的读数会在一分钟内被纳入,没有问题。 小于一分钟的间隔,以及按锚点限定的查询使用原始读数,不受影响。

探索 Schema​

**默认禁用内省。**未额外设置的生产部署不暴露内省接口。 如果 GraphQL 客户端指向端点并期待自动发现文档,内省查询会被拒绝。

因此有两种方式读取 Schema。

已发布的 Schema 文件,见下载 Schema。 这是可靠方式:无需运行中的实例,也无需令牌,尤其适合仍在评估 DeviceChain 的阶段。 每次文档构建都会根据各服务的 Schema 源文件生成,因此不会与服务解析的 Schema 偏离。 也可以读取仓库中提交的源文件。每个端点对应一个 .graphql 文件: 租户 API 使用 schema.graphql;同时提供身份令牌 API 的功能区还包含 admin_schema.graphql 和 settings_schema.graphql。

**开发实例上的内省。**在服务上设置 DC_GRAPHQL_DEV_TOOLS=true 以启用。 仅应在开发实例这样设置;默认关闭是有意的。 任何无法解析为布尔值的值都会被视为禁用,不作猜测。启用后可以使用通常的查询:

query {
__schema {
types { name kind }
}
}

启用开发工具还会在每个服务的 /graphiql 提供 GraphiQL 浏览器。 通过 Ingress 访问时是 /api/<area>/graphiql;直接端口转发到 Pod 时是 /graphiql。 浏览器向自身访问路径对应的端点提交请求,因此 Ingress、端口转发和控制台开发代理三种路由都可用。 在 v0.12.0 之前,页面能够加载,但所有查询都会失败,因为它指向没有任何服务提供的路径。

约定​

  • 除内部 ID 外,还通过人类可读的 token 定位实体。
  • 列表查询接受带分页的搜索条件输入。
  • 变更遵循 create* / update* / delete* 命名方式。

更新写入记录的哪些部分​

每个 update* 变更都是部分更新,而且只有一个约定。 它们使用专用的 *UpdateRequest,绝不使用对应 create* 的输入,并区分三种状态,而非两种:

字段发送内容存储值的变化
不发送,字段缺失保持原样
明确的 null清除
一个值设置为该值

个别字段仍可能不同:不允许清除的必需引用、只写密钥、根本不在更新输入中的字段。 这些在默认规则不适用的情况中列出。 自动化任何操作前请先阅读。

改名只是改名:

# Changes the name. The description, the externalId, the metadata and the device's
# type are all left exactly as they were, because none of them is mentioned.
mutation {
updateDevice(token: "sensor-001", request: { name: "Cold store probe" }) {
token
name
}
}

**只发送打算修改的内容。**完整替换 API 容易让人形成读取记录、再把整个记录提交回去的习惯, 但这里不应这样做。它增加工作量,也扩大覆盖并发编辑的窗口;对只写 secret 字段而言, 甚至会造成破坏,参见下方警告。

部分更新缩小了并发冲突,但没有消除冲突。 修改不同字段的两个写入者不再相互覆盖,修改同一字段仍会覆盖。 updateDashboard、updateConnector、updateAiProvider 和 updateNotificationPolicy 接受可选的 expectedUpdatedAt;存储时间戳在读取后发生变化时会拒绝写入。 传入最近读取的 updatedAt,或省略以采用最后写入者获胜的语义。 不要直接发送 create* 响应中的 updatedAt:先重新读取记录,否则即使没有其他修改,第一次受保护更新也可能被判定过时。

token 参数指定记录​

每个 update* 都通过参数而非载荷指定记录,且参数决定写入哪条记录。 除两个特例外都是 token: String!;updateRole 还接受 scope: String!,由 scope 与 token 共同定位角色。 updateOauthClient 改用 clientId: String!;updateProfile 不接受定位参数,因为它修改当前登录身份。

载荷不携带 token。每个部分更新输入都没有 token 字段, 因此载荷 token 与参数不一致的情况无法表达:Schema 会拒绝它。

以前有两种其他行为,现在都已消失:

  • 载荷 token 必须与参数一致;不一致时拒绝,空值视为“未指定”。最后两个采用这种行为的变更也已转换。
  • 载荷 token 表示记录的新 token,过去用于重命名配置文件、连接器、提供商和通知渠道。 现在四者都有独立的重命名变更,平台最后一个携带 token 的更新输入也随之消失。

所有更新现在有两点一致:载荷 token 不能再清空记录,也不能让变更写入 token: 指定记录之外的记录。

重命名记录​

四种记录过去通过在完整替换更新载荷中发送不同 token 来重命名。 现在各自有专用变更,新 token 只可能有一种含义:

renameDeviceProfile(token: String!, newToken: String!): DeviceProfile!
renameConnector(token: String!, newToken: String!): Connector!
renameAiProvider(token: String!, newToken: String!): AiProvider!
renameNotificationChannel(token: String!, newToken: String!): NotificationChannel!

四者遵循同一约定:

  • 空白 newToken(空字符串或只有空白)会被拒绝,避免存在无法定位的记录。
  • 重命名为当前已有 token 是幂等成功,返回该记录,因此部分失败后的重试安全。
  • 同类其他记录已占用的 token 会被明确拒绝,extensions.code 为 CONFLICT, 无论冲突来自查询,还是抢先完成的并发重命名。参见必须唯一的值。
  • 所需权限与对应更新相同:重命名是编辑记录,不是新的操作类型。

这些记录一直设计为可以重命名,因为依赖者通过内部 ID 而非 token 引用它们。 包括渠道的投递密钥和策略规则保存的渠道 ID、连接器凭据、提供商 API 密钥及其等级授权和各租户模型分配。 重命名不会使这些引用成为孤儿。

重命名仍会影响两项内容,请事先检查:

  • 规则动作通过 token 指定连接器,因此指向已重命名连接器的规则必须更新引用。
  • renameDeviceProfile 在配置文件已发布或被设备类型采用后会直接拒绝重命名, 因为从此发布规则和设备名册都通过 token 指定它。

updateNotificationPolicy 不需要重命名变更:没有对象按策略 token 引用它, 所以通过创建新策略并删除旧策略来迁移。

地理围栏的 token 不可变。updateGeoFence 过去协调两个 token 并拒绝不一致。 现在输入不携带 token,所以没有请求能要求重命名。 原因不变:检测规则在已编译表达式中通过 token 指定围栏,而本服务无法重写这些表达式, 重命名会让所有规则引用不存在的围栏,却返回成功。 需要不同 token 的围栏时,先创建新的,再删除旧的。 反过来做,可能失去已保留的顶点容量,导致无法重新创建围栏。

此行为已改变

本版本之前,更新中的 token 处理既不统一,也不安全,两类失败都返回成功。 参见token 处理如何改变。

token 处理如何改变​

过去大多数 update* 变更按载荷 token 定位记录,完全忽略参数。 如果 token: 指定一个实体,request.token 指定另一个,会无声更新后者并返回它。 其余变更虽然遵循参数,但会把载荷 token 写入存储值,因此载荷仍然移动了记录。 空载荷 token 会清空记录的 token,让仍存在的行无法被定位;token: String! 允许这种值,因为 "" 是有效的非空 String。

依赖载荷指定记录的客户端,现在会收到错误,而不是写错行。 在更新中发送 token: "" 的客户端,现在也会收到错误,而不是破坏记录身份: 重命名变更会拒绝它;部分更新则由 Schema 拒绝,因为输入没有可发送 token 的字段。 还有一组变更过去会忽略空 token,即“必须一致”规则,它们也已全部转换。

默认规则不适用的情况​

以下是本版本 API 的字段级例外。最后两行描述字段类别并举例,不逐项穷举。 发布的 Schema 中每个字段的注释都会说明是否可清除。 不是例外的字段遵循前述三种状态:缺失则保持,null 清除,值则设置。

字段省略后的行为
updateNotificationChannel、updateConnector、updateAiProvider 的 secret**保留。**发送值会轮换;null 或空字符串会删除。密钥无法读回,因此省略就是“保持凭据”。webhook 配置声明 bearer 或 header 认证的通知渠道不允许没有密钥
updateTenantTier 的 config**保留。**清除等级设置会改变该等级所有租户的配额,省略不能清除;发送 null 或 {} 才会清除
updateEntityGroup 的 selector省略则保留。与大多数部分更新字段不同,它不能清除:拒绝 null,因为无选择器的动态组不匹配任何实体且无法修复。静态组完全拒绝选择器
updateDashboard 的 definition省略则保留,允许只改名而不重发文档。与上述 selector 一样,不能清除:拒绝 null,因为没有定义的仪表盘无效。无效定义会拒绝整个更新,所以同时发送的改名也不应用
updateProvisioningProfile 的 credentialType**不在更新输入中。**目前自动注册只能签发一种凭据类型,因此该字段只能重复已存储值。以前省略它的任何更新都会把它重置为 ACCESS_TOKEN
设备配置文件或实体组的 activeVersion无影响:这里完全不可写,只能通过发布和回滚改变
updateEntityGroup 的 memberType / membershipMode**不在更新输入中。**两者均属于身份,不能表达修改,而非提交后拒绝
updateTenant 上的租户治理覆盖值保留。发送 null 会移除覆盖,表示先继承等级,再继承平台默认值,绝非零,也不是“无限制”
记录存在所必需的字段,例如 updateConnector 的 type 和 config;updateAiProvider 的 kind、model、enabled;updateNotificationChannel 的 channelType 和 enabled;updateNotificationPolicy 的 enabled;updateTenant 的 tierToken;updateOauthClient 的 redirectUris 和 scopes;以及值得了解的字段中列出的必需引用和字段省略则保留。明确的 null 会被拒绝,不会清除
与另一字段一起验证的字段,例如 updateAiProvider 的 endpoint,以及 updateDetectionRule 的 entityGroupToken / entityGroupVersion省略则保留。只有与另一字段值组合后仍有效时,null 才能清除:没有默认地址的提供商类型拒绝 endpoint: null;只清除规则组范围的一半而不清除另一半也会被拒绝
空字符串不表示“保持原样”

对每个只写 secret 字段,"" 会删除存储的凭据,变更返回成功。 唯一例外是 webhook 配置声明 bearer 或 header 认证的通知渠道,此时拒绝整个更新。 填入所有字段的客户端会删除本来不想修改的凭据,失去凭据的连接器每次出站派发都会认证失败。 **省略该字段。**参见密钥和空字符串。

密钥和空字符串​

密钥无法读回,因此没有内容可以重发;API 的规则是省略即保留。 “读取记录、改一处、全部提交回去”是完整替换 API 培养的习惯,针对那种 API 编写的客户端仍会这样做。 填满所有字段意味着给原本不想修改的凭据发送 secret: "",从而删除它。

null 同样删除凭据。这是平台对 null 的一般含义,不是例外:null 清除指定字段。 这些字段过去采用相反的规则,null 保留、只有 "" 删除;现在已不再如此。

文本去除首尾空格,凭据不处理​

名称、描述以及类似的显示文本,例如名字、姓氏、图标、单位、等级颜色,存储时都会去除首尾空格。 空值或纯空白值会清除它,读回为 null。创建和更新都如此, 因此重新发送读取到的值对遵循此规则的内容不是修改。 旧版本保存的带首尾空格的值,例如人名,会在更新第一次指定该字段时去除空格。 metadata、渠道 config、仪表盘 definition 等结构化文本不作这种处理。

设备凭据的 credentialValue 同样不去除空格:按照发送内容原样存储,包含所有空格, 因为设备逐字节提供密码。只有空 credentialValue 或更新时明确的 null 才表示不存储密码;无密码的凭据无法认证。

哪些变更是部分更新​

**全部都是。**转换按功能区逐步完成,现在已全部完成。 本节记录各功能区的变化,供针对旧行为编写客户端的人了解。

**device-management。**每个 update* 使用专用 *UpdateRequest:

updateDeviceType · updateDevice · updateAssetType · updateAsset · updateCustomerType · updateCustomer · updateAreaType · updateArea · updateMetricDefinition · updateCommandDefinition · updateDetectionRule · updateGeoFence · updateEntityGroup · updateDeviceCredential · updateProvisioningProfile · updateEntityRelationshipType · updateDeviceProfile

**出站连接器和 AI 推理。**各自转换了一个更新:updateConnector 和 updateAiProvider。 原来通过载荷 token 重命名的能力都迁移到专用重命名变更,没有被移除。 两者都保留可选 expectedUpdatedAt。 分别对 type/config 和 kind/endpoint 这一对字段,按记录更新后的值一起验证。 指定其中一个字段时,会重新检查已存储的另一个;使记录无法使用的变更会在写入时拒绝,而非第一次使用时才失败。

**notification-management。**两个 update* 变更均已转换:updateNotificationChannel 和 updateNotificationPolicy。 发送策略前应了解三点:

  • rules 可选,省略则规则集完全不变:保留同样的行,不重建副本。 以前它必填,每次更新都替换全部规则;只改名也会删除并重建所有规则,省略 rules 则会清空策略并返回成功。 仍可完整替换:发送列表即可。null 或 [] 都会清空规则集;对列表来说,它们是同一请求的两种写法。
  • **deviceTypeToken 完全不在更新输入中。**非空值在写入时被拒绝, 因为派发器跳过按设备类型限定的策略;接受它会得到不投递任何内容却返回成功的策略。 因此该字段除了无操作外,没有任何可接受请求。它仍在创建输入中,拒绝时会解释原因。
  • **接受可选 expectedUpdatedAt。**发送最后读取的 updatedAt,或更新返回的值; 如果此后任何人修改策略(包括规则),会拒绝更新且不写入任何内容。省略仍表示最后写入者获胜。

dashboard-management。updateDashboard 接受 DashboardUpdateRequest,完全不携带 token。 特别之处是 definition:字段可空是为了能够省略,允许只改名而不重发完整文档; 明确的 null 会被拒绝,因为无定义的仪表盘无效。保留可选 expectedUpdatedAt 前置条件。 完全不指定字段的更新不写入任何内容,连 updatedAt 也不写; 但其过时的前置条件仍会被拒绝为过时写入,不带 extensions.code。

**user-management。**每个 update* 同样接受专用请求:

updateRole · updateTenant · updateTenantTier · updateOauthClient · updateProfile

**这就是全部更新接口。**没有任何 update* 使用对应 create* 的输入, 因此本页不再提供供你逐项核对的变更清单。早期版本曾两次提供: 先是未转换功能区名单,后来是因两种约定并存而以签名为准的规则。 两者都在前提消失后过时。现在由覆盖整个 API 的三种状态 和字段级例外取代。

检查具体输入在 Schema 中声明的字段

一个约定不表示每种输入都接受每个字段。 有些字段有意不在 *UpdateRequest 中,有些接受值却拒绝 null。 前者以下载的 Schema为准,后者由例外表 和各字段的 Schema 注释说明。参见更新输入能够表达什么。

更新输入能够表达什么​

更新能够表达的内容,就是其 *UpdateRequest 声明的内容。 部分字段有意缺失,例如 updateNotificationPolicy 的 deviceTypeToken、 updateEntityGroup 的 memberType、updateProvisioningProfile 的 credentialType,因为它们没有任何可接受的请求。 其他字段接受值但拒绝 null,例外表和每个字段的 Schema 注释都会说明。 这与变更使用哪种约定无关,因为现在只有一种。

user-management 的行为已改变

四个 user-management 更新过去会写入输入声明的所有字段。 重新运行旧客户端前,先阅读 user-management 变化,从 updateTenant 开始。

user-management 变化​

updateRole、updateTenant、updateTenantTier 和 updateOauthClient 过去会写入其输入声明的每个字段,所以只指定 name 的请求会清空其余字段并返回被清空的记录。 首先重新检查 updateTenant:省略治理覆盖值过去会删除它,因此重命名租户会移除运维人员设置的全部上限。 现在省略保持不变,只有明确的 null 才移除。

updateTenant 的 tierToken 变为可选。 省略则租户保留当前等级;明确的 null 被拒绝,因为每个租户必须有等级。

authorities、redirectUris 和 scopes 变为可空列表([String!],不是 [String!]!),因此有了缺失状态:

  • 省略则保持不变。
  • 发送列表则完整替换。
  • null 和 [] 都表示“清空”。

角色权限可以清空,因为可以创建不授予任何权限的角色。 OAuth 客户端的重定向 URI 和 scope 不可清空:空重定向允许列表不匹配任何地址,客户端永远无法完成授权。

updateProfile 现在接受 request: ProfileUpdateRequest!,不再直接接受 firstName / lastName 参数。 只写入发送的名称。名称与其他显示文本一样去除首尾空格;""、纯空白值或 null 会清除它,读回为 null。

已转换变更中值得了解的字段​

  • 必需引用不能清除。updateAsset 的 assetTypeToken,以及设备、客户、区域上的同类字段, 发送值则重新指向实体,不发送则保持原样。明确的 null 被拒绝,因为这些实体不能处于“无类型”状态。 未知 token 也被拒绝,而且完全拒绝:不写入任何内容。
  • updateDeviceType 的 profileToken 可以清除,因为设备类型可以没有设备配置文件。 在旧的完整替换形式下,改类型名称时省略它会解除配置文件关联,无声撤销该类型所有设备的位置能力声明并返回成功。 现在省略保留当前配置文件;null 或空 token 会解除关联。 检测规则可选的 entityGroupToken 也可清除,但必须成对操作: 对 entityGroupToken 和 entityGroupVersion 都发送 null。 清除一项却保留另一项会被拒绝,因为范围同时需要两项。
  • **必需字段即使不是引用,也不能清除。**指标的 dataType、凭据的 credentialType 和 enabled、 规则的 definition 和 enabled、围栏的 geometry、自动注册配置的 provisionKey 和 provisionSecret: 发送值则修改,省略则保持,明确的 null 被拒绝。 这防止一种不可见失败:把 enabled: null 转成 false 会禁用凭据或停用规则并返回成功, 而 false 又是完全合法的明确值。
  • 省略密钥现在会保留它。updateDeviceCredential 的 credentialValue 和 updateProvisioningProfile 的 provisionSecret 过去会在任何未重发它们的更新中被清空。 设备或整批自动注册设备会在下次连接时离线,而破坏它们的编辑却返回 200。

发送 metadata 时会完整替换,null 会清除。 它在 Schema 中是不透明 JSON 字符串,而非映射,因此没有按键合并可选;API 从来不能单独定位某个键。

输入验证​

**Schema 未定义的输入字段会被拒绝。**发送未声明字段会使整个请求失败, 错误明确指出该字段,并建议可能想使用的已声明字段:

{
"errors": [{
"message": "Variable \"request\" has invalid value.\nField \"deviceProfileToken\" is not defined by type \"DeviceTypeCreateRequest\". Did you mean \"profileToken\"?"
}]
}

无论值作为查询中的字面量发送,还是通过变量提供,都遵循此规则。

这不只是拼写检查。无声丢弃的字段与已应用的字段无法区分:变更返回成功, 却只得到部分配置的实体,没有任何提示指出值丢失。 拒绝未知字段,才能让成功响应表示整个输入都被理解。

token 可以包含什么​

每个实体 token 和租户 ID 都必须匹配:

^[A-Za-z0-9][A-Za-z0-9_-]*$

即字母(大小写均可)、数字、连字符和下划线,以字母或数字开头,最多 128 个字符。 其他内容在创建和更新写入时都会被拒绝,而且在任何存储之前拒绝。

这是安全规则,而不是风格要求,因此范围很窄。 token 会拼入基础设施命名空间:租户 ID 成为 NATS subject 中按 . 拆分还原的片段, 设备 token 成为 MQTT 主题片段。. 会改变 subject 分段,*、>、+ 和 # 会注入能够跨租户匹配的通配符。有意允许大写,因为设备序列号、VIN 等机器提供的标识符通常大写。

集成者最常想到的标识符恰好会被拒绝:sensor.001、MAC 地址 AA:BB:CC:DD:EE:FF、 plant/line-2、任何带空格的内容。把这些放进 externalId。 它是不透明值,没有格式约束,存在时在租户内唯一。 为实体选择一个 token,并在旁边保留设备原有标识符。

控制台按每实体类型的模板自动生成 token,所以这种问题在那里很少出现,主要影响 API 和脚本化自动注册。

必须唯一的值​

某些值必须唯一:租户内的 token、设备 externalId、配置文件内的命令键、身份电子邮件、 每个身份与租户之间唯一的成员关系。导致重复的创建、更新或重命名会被拒绝, 错误的 extensions.code 为 CONFLICT:

{
"errors": [{
"message": "the request conflicts with an existing record: a value that must be unique is already in use",
"path": ["createDeviceType"],
"extensions": { "code": "CONFLICT" }
}]
}

按代码而非消息分支。如果服务有更具体的说明,例如重命名为已占用 token,消息会使用自己的句子,但代码相同。 数据库自身的冲突文本会被上面的句子取代,所以消息不暴露数据库索引或列名称。 服务自己的说明可能重复你发送的 token。

CONFLICT 表示写入与必须唯一的值冲突。通常是你发送的值,也可能是服务器写入时分配的值, 例如两个并发发布竞争同一记录的下一个版本号;后一种情况重试会成功。 所以仅凭该代码不能认定请求的记录已存在。只有唯一值本身由你提供时才有此含义,例如正在创建的租户 token。

一些类似的拒绝不携带 CONFLICT:

  • 因读取后记录发生变化而拒绝保存(“modified by another writer; reload and try again”)是过时写入,不是重复。
  • 已删除租户的 token 会保留到删除完成。在该 token 创建租户会被拒绝,但没有 CONFLICT,因为它不是由可用租户占有。
  • 用已有 token 创建命令不会拒绝,而是返回原命令。参见发送命令。
  • 删除仍被其他记录引用的记录会携带 REFERENCE_VIOLATION。参见引用或值被拒绝。

引用或值被拒绝​

另外两种拒绝也使用相同响应方式,各有自己的代码。

REFERENCE_VIOLATION 表示写入因记录间的引用关系被拒绝。

删除仍被其他记录引用的记录会这样拒绝,例如设备类型仍使用的设备配置文件、 设备仍使用的设备类型、租户仍采用的等级、仍有成员关系的租户、仍有授权的 AI 提供商、 策略规则仍引用的通知渠道。消息由服务自行生成,说明哪些内容仍引用记录,可能重复你发送的 token:

entity is still referenced and cannot be deleted: 1 device type(s) reference device profile "rover"

删除或重新分配仍然引用该记录的内容后,再重试。

如果写入被数据库而非服务自身检查拒绝,消息为:

the request refers to a record that does not exist, or removes one that other records still refer to

当检查与写入之间记录变化时可能发生,例如写入引用的记录在执行期间被删除。 重新加载后重试,就会得到服务自己的说明,或写入成功。 如果相同请求持续收到此响应,说明 API 中看不到的内容仍引用该记录。 请附上请求时间报告,服务器日志会记录导致拒绝的引用。

指定不存在记录的写入,通常在写入前被服务查询拒绝,消息指出 token,但没有代码。 只有该记录在写入进行期间被删除时,才携带 REFERENCE_VIOLATION。

INVALID_VALUE 表示请求包含记录不允许的值,而服务未在写入前发现。消息为:

the request contains a value this record does not allow

请修改该值。重复发送相同请求通常仍会得到相同响应。

两种情况都会替换数据库自身文本,因此这两句话不包含表、列或约束名称,也不重复发送的值。 两种代码都不是 CONFLICT,所以把 CONFLICT 当作“记录已存在”的代码不会把它们当作成功。 同时涉及唯一值和其中一种问题的拒绝,会携带 REFERENCE_VIOLATION 或 INVALID_VALUE,绝不携带 CONFLICT, 消息使用对应代码的句子,而非值已占用的句子。

请求限制​

每个 GraphQL 端点都会在执行前拒绝过大或工作量过多的请求。 唯一例外是凭据检查上限,它在执行期间生效,参见每请求凭据检查。

所有服务使用相同限制,可通过表中环境变量逐服务修改。 值缺失、不是数字或小于 1 时回退到默认值;这些限制都不能关闭。

限制默认值变量拒绝内容
请求正文4 MiBDC_GRAPHQL_MAX_BODY_BYTES整个 HTTP 正文,包括变量。返回 HTTP 400。
查询长度100,000 字节DC_GRAPHQL_MAX_QUERY_LENGTH查询字符串本身。
嵌套深度15DC_GRAPHQL_MAX_DEPTH超过此深度的嵌套选择。
每查询根字段数20DC_GRAPHQL_MAX_QUERY_ROOT_FIELDS顶层字段数超过此值的查询操作。
每变更根字段数5DC_GRAPHQL_MAX_MUTATION_ROOT_FIELDS顶层字段数超过此值的变更操作。
每请求凭据检查数1DC_GRAPHQL_MAX_CREDENTIAL_CHECKS一次请求中超过此数的密码检查,见下文。

除了正文限制和凭据检查限制,被拒绝的请求会返回 HTTP 200,errors 中只有一项,无 data,且不执行任何内容。 凭据检查限制只拒绝超限检查,各自返回错误,其他请求内容仍执行。 根字段拒绝携带 extensions.code: TOO_MANY_ROOT_FIELDS:

{
"errors": [{
"message": "mutation (anonymous) selects 6 root fields; the maximum is 5",
"extensions": { "code": "TOO_MANY_ROOT_FIELDS" }
}]
}

根字段按响应键计数:

  • 每个别名算独立字段。
  • 通过片段到达的字段,与直接展开书写一样计数。
  • 重复的相同键算一个字段。
  • 不评估 @skip 和 @include,因此条件字段无论是否执行都计数。
  • 文档中每个操作都计数,不仅是 operationName 选择的操作。
  • 规则同时适用于 WebSocket 和 HTTP。

变更限制更严格,因为变更字段依次执行。 否则一个请求可以包含数百个昂贵变更的别名副本。 控制台、仪表盘应用、SDK、dcctl 和 MCP 服务器每个请求发送一个变更字段,最多两个查询字段。 限制只针对顶层字段,嵌套字段的别名不计数。

仅接受 GraphQL 语法​

**文档必须使用 GraphQL 自身语法。**包含以下任一内容的文档会因语法错误被拒绝,不执行任何内容:

  • // 或 /* */ 注释。应使用 # 注释。
  • 反引号字符串或单引号字符。
  • 结束 """ 紧跟反斜杠的块字符串,即 \""" 转义。 此转义符合 GraphQL 规范,但服务器从未按规范读取它,因此应通过变量发送此类文本。
  • 字符串后直接紧跟引号,例如 "x""y"。 GraphQL 将它解读为两个相邻字符串;服务器过去会把它误读为块字符串开始。只有 """ 才能开始块字符串。

WebSocket 同样遵循此规则:服务器无法读取的订阅会得到语法错误,而非仅订阅提示。

每请求凭据检查​

无论如何书写,一次请求只能检查有限个密码,默认一个。 限额以内的 login 字段正常执行。同一请求中其余 login(例如另一个别名)不执行: 不检查密码,不查询内容,也不记录审计日志。 它会得到自己的错误,而非密码是否正确的结果;其他字段仍返回数据:

{
"errors": [{
"message": "this request has already made its credential checks; send one sign-in per request",
"path": ["a2"],
"extensions": { "code": "TOO_MANY_CREDENTIAL_CHECKS" }
}]
}

这不依赖文档的写法,所以通过根字段限制的文档仍受约束。 拒绝发生在查看电子邮件地址之前,因此账户是否存在都得到同样结果。 DeviceChain 提供的每个客户端每次请求只发送一次登录,所以不受影响。

DC_GRAPHQL_MAX_CREDENTIAL_CHECKS 可以逐服务提高限额,与其他限制一样不能关闭。 拒绝计入 devicechain_usermanagement_credential_checks_total,标记 outcome="request_budget"。

登录退避​

失败的密码登录会减缓针对同一电子邮件地址的后续尝试,适用于 login 和 OAuth 登录表单。

  • 每个地址最初五次失败尝试立即评估。
  • 之后等待 1 秒才评估下一次,然后 2 秒、4 秒,逐次翻倍,最多 5 分钟。
  • 成功登录会重置计数;最后一次被评估的尝试之后安静 10 分钟也会重置。

计数属于输入的地址,无论该地址是否存在账户,因此延迟不会泄露已注册地址。 服务的所有副本共用该计数。

知道地址的人可以阻止其所有者登录

只要有人持续向一个地址提交错误密码,所有者即使提供正确密码也会因限流被拒绝。 攻击停止后,最多经过一次不超过 5 分钟的等待,所有者就能重新登录。 参见让地址持续处于退避状态。

让地址持续处于退避状态​

计数按地址保留,不按地址与网络位置组合保留,避免攻击者通过多台机器分散猜测而取得新额度。 代价是任何知道电子邮件地址的人都可以持续为它提交错误密码。 持续这样做时,每个评估机会都由他们占用,所有者即使密码正确也被限流拒绝。 这不是永久锁定。devicechain_usermanagement_credential_checks_total 的 outcome="throttled" 指标显示账户何时以这种方式被阻止。

OAuth 客户端密钥​

**OAuth 客户端密钥不实施退避。**管理 API 创建的客户端密钥具有 256 位随机性,无法通过猜测找到, 而客户端 ID 是公开的,出现在每个授权 URL 中。对客户端密钥实施退避不能保护它, 反而让任何人都能阻止某个机密客户端,进而阻止通过它的所有登录。 通过配置预置的客户端也应使用同样强的密钥。公共客户端完全没有密钥。

等待期间的尝试​

等待期间的尝试完全不评估:不检查密码,不记录审计日志。 它返回独立错误,而非密码错误,因为密码可能是正确的:

{
"errors": [{
"message": "too many failed sign-in attempts; try again in 8 seconds",
"path": ["login"],
"extensions": { "code": "THROTTLED", "retryAfterSeconds": 8 }
}]
}

如果服务无法访问保留计数的存储,会完全拒绝检查密码,而不是不计数地检查。 此时 login 错误携带 extensions.code: UNAVAILABLE。 应把它视为服务中断,而非凭据被拒绝。OAuth 令牌端点不使用该存储,所以客户端认证仍工作。

尝试存储已满时​

存储大小固定,每个被尝试的地址占用一个位置 10 分钟,无论账户是否存在。 针对足够多不同地址发送登录请求的人可以填满它。

存储已满时,登录仍可使用。密码仍正常检查并返回结果,但不记录新的失败, 所以尚未等待的地址不会被减速,直到旧条目过期。 已经等待的地址仍等待,但最多到当前等待结束,即最多 5 分钟。 此后它的失败也不再计数,所以存储满期间被攻击的账户不受退避保护。

这是有意的:如果改为拒绝所有登录,任何能填满存储的人都能把实例所有用户挡在外面。 猜测仍受到每请求字段上限和每次密码检查成本的限制。

这样检查的每次尝试都计入 devicechain_usermanagement_credential_checks_total, 标记 outcome="store_full"。启用 chart 告警规则时,只要出现这种情况, CredentialAttemptStoreFull 告警就会触发。持续期间 user-management 日志每分钟最多记录一次警告。

告警触发时,很可能有人正在尝试许多地址:

  1. 找到登录流量来源,在上游阻止它。
  2. 如果流量合法,提高 instance.config.infrastructure.nats.kvStateMaxBytes。 此大小适用于每个状态桶,因此确认 JetStream 卷有足够空间容纳增加的容量。

Schema 稳定后,将根据它们生成详细的逐类型参考页面。