先看结论与判断条件
- 网络重发、用户再次点击和攻击者重放不是同一问题:前两者靠业务幂等处理,后者还需要令牌时效、受众校验、持有者绑定与服务端授权。
- 幂等键标识一次业务意图,规范化请求摘要标识该意图对应的具体参数;服务端必须同时保存两者,才能区分合法重试与同键改参。
- 请求摘要必须基于双方一致的确定性字节序列。普通 JSON 文本的属性顺序、数字和字符串表示不稳定,不能直接拿原始文本互相比较。
- 服务端去重记录要有处理中、成功、可重试失败和终态失败等明确状态,并把缓存结果与请求摘要绑定,避免并发请求各自执行。
- JWT 声明验证、DPoP proof、Play Integrity requestHash 和业务幂等解决不同层面的问题,任何单一机制都不能替代其余门禁。
- WorkManager 唯一任务可以减少设备侧重复调度,但卸载重装、多设备、并发客户端和直接 API 调用仍要求服务端持有最终幂等状态。
先区分超时重发、业务重复和令牌重放
移动端调用 AI 接口时,客户端可能已经把请求送达,响应却在蜂窝网络切换、进程暂停或网关超时中丢失。此时用户看到失败并不代表服务端没有执行。如果客户端简单重发,生成任务、计费动作、配额扣减或工具调用可能再次发生;这属于传输结果未知下的业务重复风险。
用户连续点击、后台任务和前台页面同时提交,或者系统恢复后重新调度,也会产生内容相近的多个请求。它们可能属于同一个业务意图,也可能是用户确实希望再次执行。系统不能只按提示词文本去重,而要在用户创建一次操作时生成稳定 operation_id,并明确哪几次传输共享同一意图。
令牌重放是另一条风险链。攻击者取得 bearer token 后,可能从不同设备或网络构造新请求;即使服务端幂等缓存挡住某个旧幂等键,攻击者仍可换键调用其他接口。因此接口需要分别判断业务请求是否已经处理,以及提交者是否仍被授权并持有与令牌绑定的密钥或设备上下文。
| 现象 | 关键判断 | 主要控制 | 不能只依赖 |
|---|---|---|---|
| 响应丢失后重发 | 是否同一 operation_id | 幂等键与结果缓存 | 客户端超时状态 |
| 用户再次点击 | 是否新业务意图 | 界面状态与新操作确认 | 提示词相似度 |
| 前后台并发提交 | 是否共享同一任务 | 本地唯一工作和服务端去重 | 进程内锁 |
| 被盗 token 调用 | 主体与持有者是否有效 | JWT 校验、DPoP 与授权 | 幂等缓存 |
| 同键修改参数 | 摘要是否与首请求一致 | 冲突拒绝 | 返回旧结果 |
| 服务端任务重启 | 状态是否已持久化 | 原子状态机与任务恢复 | 内存缓存 |
让 operation_id 表示一次业务意图
operation_id 应在用户确认一次生成、分析或工具调用时创建,并在网络重试、应用进程恢复和后台接力中保持不变。它不能在每次 HTTP 请求前重新生成,否则服务端看到的永远是新操作。客户端要先把操作记录和参数落入可靠存储,再排队发送;只有用户明确发起新业务动作时才创建新标识。
幂等键可以由 operation_id、已认证用户、目标接口和规范化请求摘要派生,也可以使用随机不可预测值并由服务端保存关联。无论采用哪种方式,服务端唯一约束都应包含租户或用户边界和具体操作类型,避免两个用户碰巧使用相同键时互相读取结果,也避免同一键被挪到另一个接口。
不能把 access token、DPoP proof 的 jti 或当前时间直接混入业务幂等键。访问令牌可能在合法重试前刷新,DPoP proof 按请求更新,时间也会变化;把这些传输凭据写进键会使同一业务意图变成新记录。认证材料应单独验证,验证通过后再用稳定的业务身份参与去重作用域。
| 候选字段 | 是否适合 | 原因 | 处理方式 |
|---|---|---|---|
| operation_id | 适合 | 一次业务意图内保持不变 | 首次确认时持久化 |
| authenticated_subject | 适合 | 隔离不同用户和租户 | 认证后由服务端取得 |
| endpoint_operation | 适合 | 避免跨接口复用键 | 使用服务端规范名称 |
| canonical_request_hash | 适合 | 发现同键改参 | 双方按固定规则重算 |
| access_token | 不适合 | 刷新后合法值会变化 | 只用于认证校验 |
| 当前时间或随机重试号 | 不适合 | 每次传输都会变化 | 记录到尝试日志 |
先规范化请求,再计算请求摘要
同一个 JSON 对象可以有不同属性顺序、空白和转义形式,部分数字还存在多种文本表示。若客户端对自己发送的原始文本求摘要,而网关解析后重新序列化,服务端很可能得到另一个结果。RFC 8785 JSON Canonicalization Scheme 解决的是确定性表示问题,使参与方可以对同一数据模型得到稳定字节序列。
规范化范围要覆盖真正影响业务结果的字段,例如模型、输入、工具选择、温度策略、输出格式和已选择的知识库版本。网络超时、客户端诊断、token 和 proof 等传输信息不应混进业务摘要。服务端仍需对字段做类型、长度和允许值验证,不能因为摘要匹配就接受未授权模型或工具。
若客户端语言没有经过核对的 JCS 实现,可以让服务端成为摘要权威:客户端提交 operation_id、幂等键和结构化请求,服务端验证并规范化后计算 request_hash,再把该哈希固定到幂等记录。客户端可保存自己计算的值用于诊断,但冲突判断以服务端的确定性规则为准,规则版本也要随记录保存。
| 字段类别 | 是否进入业务摘要 | 示例 | 理由 |
|---|---|---|---|
| 业务输入 | 进入 | 模型、提示、工具参数 | 决定执行结果 |
| 操作身份 | 进入 | operation_id 与操作类型 | 区分不同业务意图 |
| 授权主体 | 进入作用域 | 用户或租户标识 | 隔离结果所有权 |
| 访问令牌 | 不进入 | JWT 字符串 | 刷新会改变传输凭据 |
| DPoP proof | 不进入 | 每次请求的新 proof | 用于持有者验证 |
| 网络诊断 | 不进入 | 重试次数和链路类型 | 只描述传输尝试 |
服务端用原子幂等状态机决定执行还是回放结果
服务端收到请求后,先完成认证与基础授权,再以 subject、operation 和 idempotency_key 原子创建记录。记录已经存在时,必须比较 request_hash:相同哈希表示同一业务请求的重传,可以返回处理中状态或已缓存结果;不同哈希表示调用方复用了旧键却改变参数,应返回冲突而不是执行新任务或泄露旧结果。
第一次请求创建 pending 后,只有取得执行租约的工作者能把状态推进到 running。任务完成时,结果引用、计费或配额事实与终态应在可恢复事务边界内写入。若执行器在外部模型已接受请求后崩溃,系统仍需依赖下游操作标识、供应商请求号或对账流程判断结果,不能盲目再次调用并假定无副作用。
失败也要分类。输入无效、权限不足等终态错误可以缓存并在相同请求重放时返回;临时网络失败可标记 retryable,但重试仍使用原 operation_id 和幂等键。记录保留时间不能短于业务可能重试和对账的窗口;到期后再次收到旧键时,应依据业务风险决定拒绝、查归档或视为新请求。
| 状态 | 允许动作 | 重发响应 | 禁止动作 |
|---|---|---|---|
| pending | 取得执行租约 | 返回已接收 | 创建第二条任务 |
| running | 续租或受控恢复 | 返回处理中 | 并发调用下游 |
| succeeded | 读取不可变结果 | 返回相同结果引用 | 再次扣减或生成 |
| failed_terminal | 返回固定错误 | 返回同一失败 | 自动换键重试 |
| failed_retryable | 按原键重试 | 返回可重试状态 | 改变业务参数 |
| conflict | 保留审计事件 | 拒绝同键改参 | 覆盖原摘要 |
短期 JWT 先验证签发者、受众、时效与权限
RFC 9068 的 JWT access token profile 给资源服务器提供 issuer、audience、expiry、scope 和客户端标识等可验证声明。AI 接口收到 token 后,应校验签名算法与密钥来源、签发者、自己的受众、有效期和所需 scope,再把已验证 subject 用作幂等作用域。只解码 JWT 或只看未过期都不是授权。
令牌保持较短有效期可以缩短泄露后的可用窗口,但具体时长要由会话、设备能力和业务风险决定。移动端不应把长期访问令牌写进日志、请求正文或幂等记录;刷新令牌和访问令牌要使用平台安全存储,并在账号退出、设备丢失或风险事件中具备撤销和重新认证路径。
即使 token 声明和签名都有效,资源服务器仍需检查请求中的模型、工具、知识库和数据范围是否属于该主体。幂等命中也不能跳过认证,因为缓存结果可能包含敏感输出。每次重放查询都要重新确认调用者仍有权读取该 operation 的状态和结果,只是不会再次执行产生副作用的业务动作。
| 门禁 | 检查内容 | 失败结果 | 边界 |
|---|---|---|---|
| 签名 | 算法、密钥与签发者 | 拒绝请求 | 不证明设备可信 |
| audience | 包含当前 AI 资源服务 | 拒绝跨服务 token | 不能替代 scope |
| expiry | 当前时刻仍有效 | 要求刷新或登录 | 时长由风险决定 |
| scope | 覆盖目标操作 | 拒绝越权功能 | 还要检查对象权限 |
| subject | 身份稳定且未禁用 | 拒绝读取结果 | 用于幂等隔离 |
| client 标识 | 符合授权策略 | 拒绝未知客户端 | 不是设备真实性证明 |
用 DPoP 限制被盗访问令牌在别处重放
bearer token 的问题是拿到字符串的人通常就能尝试使用。RFC 9449 DPoP 让客户端为请求生成 proof,并把 access token 绑定到客户端持有的密钥。资源服务器验证 proof 的签名、目标 URI、HTTP 方法、时间、唯一标识和 token 关联后,单独窃取 access token 的攻击者缺少私钥,重放难度随之增加。
DPoP proof 与业务幂等键生命周期不同。合法网络重试仍使用同一个 operation_id 和幂等键,但每次 HTTP 尝试生成新的 proof,并使用新的 proof jti。服务端保存短期 proof 重放记录;同一个 proof 再次出现应拒绝,而同一业务请求带新 proof 到达时则进入幂等状态机,返回原结果或继续原任务。
DPoP 不能证明手机没有被控制,也不能替代权限、短期 token 和请求摘要。私钥如果能被恶意进程调用,攻击者仍可能生成新 proof;代理和 URL 规范化配置错误也会造成校验偏差。实现时要采用成熟库,严格确认 htu 与 htm,并记录拒绝原因而不记录私钥或完整敏感 token。
| 属性 | DPoP proof | 业务幂等键 | 组合方式 |
|---|---|---|---|
| 目标 | 证明请求方持有密钥 | 标识一次业务意图 | 先认证再去重 |
| 每次重试 | 生成新 proof | 保持原值 | 新传输映射原任务 |
| 服务端保存 | 短期 jti 重放记录 | 任务状态与结果 | 分别设置生命周期 |
| 参数绑定 | 方法、URI 与 token | 业务摘要和主体 | 不可互相替代 |
| 失败含义 | 持有者验证失败 | 重复或同键冲突 | 返回不同错误类别 |
| 安全边界 | 不证明设备绝对可信 | 不阻止 token 被盗 | 配合授权与风控 |
把 Play Integrity requestHash 绑定到同一业务请求
Play Integrity 标准请求允许应用把 requestHash 放入请求上下文,服务端收到完整性响应后,对自己看到的业务请求按同样规则重算并比较。这能减少完整性 verdict 被挪用到另一组参数的风险。用于计算 requestHash 的字节必须稳定,因此应复用已经定义的规范化业务请求,而不是临时拼接容易歧义的字符串。
完整性检查与幂等状态机的顺序要明确。服务端先完成 token 和 proof 验证,解析并验证业务字段,再计算 canonical request hash,核对完整性响应中的绑定值,随后才创建或查询幂等记录。若幂等记录已成功,也应按风险策略重新确认当前调用者授权,不能因为返回缓存结果就完全跳过身份和完整性上下文。
完整性 verdict 是服务端决策信号,不是绝对可信根。设备状态、账号活动、请求频率、模型权限和历史风险仍需组合判断;缺失或不满足策略时可以限制高风险工具、要求重新认证或拒绝请求。文章没有声称某种 verdict 能阻断全部重放,真实效果只能由项目配置、设备覆盖和服务端日志验证。
让 WorkManager 重试复用操作记录而不是制造新请求
Android WorkManager 的 unique work 和 ExistingWorkPolicy 可以减少设备侧同名后台任务重复排队,并定义保留、替换或追加行为。任务名应由稳定 operation_id 派生,Worker 从本地操作表读取幂等键和业务参数;网络失败返回重试时不生成新键,也不把尝试次数写进业务摘要。
唯一任务只管理当前应用安装和 WorkManager 数据库中的调度关系。用户多设备登录、应用数据清除、卸载重装、前台直接请求和服务端恢复都可能绕过本地唯一性。因此客户端去重用于降低噪声和改善体验,服务端的原子幂等记录才是防止重复业务执行的最终门禁。
取消也需要业务语义。停止本地 Worker 只表示设备不再继续轮询,并不保证已提交的服务端任务停止。客户端应调用具备 operation_id 的取消接口,由服务端根据状态决定能否取消,并返回可审计结果;重新进入页面时查询原状态,而不是默认创建另一个生成任务。
import hashlib
import json
import sys
from pathlib import Path
if len(sys.argv) != 3:
raise SystemExit('usage: check_idempotency.py request.json ledger.json')
request_path = Path(sys.argv[1])
ledger_path = Path(sys.argv[2])
if not request_path.is_file() or not ledger_path.is_file():
raise SystemExit('request or ledger file is missing')
request = json.loads(request_path.read_text(encoding='utf-8'))
ledger = json.loads(ledger_path.read_text(encoding='utf-8'))
required = {'subject', 'operation', 'operation_id', 'payload', 'idempotency_key'}
if not required <= request.keys():
raise SystemExit('request is missing required fields')
if not isinstance(request['payload'], dict):
raise SystemExit('payload must be an object')
if any(isinstance(value, float) for value in request['payload'].values()):
raise SystemExit('example profile rejects floating-point payload values')
canonical_input = {
'subject': str(request['subject']),
'operation': str(request['operation']),
'operation_id': str(request['operation_id']),
'payload': request['payload'],
}
canonical = json.dumps(
canonical_input,
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
).encode('utf-8')
request_hash = hashlib.sha256(canonical).hexdigest()
expected_key = hashlib.sha256(
(canonical_input['subject'] + '|' + canonical_input['operation'] + '|' +
canonical_input['operation_id']).encode('utf-8')
).hexdigest()
if request['idempotency_key'] != expected_key:
raise SystemExit('idempotency key does not match the stable operation identity')
scope = canonical_input['subject'] + ':' + canonical_input['operation'] + ':' + expected_key
existing = ledger.get(scope)
if existing is None:
print(json.dumps({'decision': 'accept_new', 'request_hash': request_hash}, ensure_ascii=False))
elif existing.get('request_hash') != request_hash:
raise SystemExit('same idempotency key was reused with different parameters')
elif existing.get('status') == 'succeeded':
print(json.dumps({'decision': 'replay_result', 'result_ref': existing.get('result_ref')}, ensure_ascii=False))
else:
print(json.dumps({'decision': 'resume_existing', 'status': existing.get('status')}, ensure_ascii=False))按允许路径和拒绝路径验收整条请求链
验收至少覆盖同一请求在响应丢失后重发、并发双击、进程重启、后台接力、token 刷新和多设备提交。每个场景都核对 operation_id、幂等键、request_hash、认证 subject、下游请求号、任务状态和最终结果引用,确认合法重试返回同一状态或结果,而不是再次生成、重复计费或消耗第二份配额。
拒绝路径同样重要:同键改参应冲突,过期或错误受众 token 应拒绝,重复 DPoP proof 应拒绝,requestHash 不匹配应阻止进入业务执行,另一个用户查询原 operation 应看不到结果。测试数据必须使用公开或脱敏样本,日志只保留必要摘要和错误类别,不能把完整提示、token 或 proof 当作排障便利长期保存。
准备御盾移动 AI 接口评估时,可整理操作创建点、前后台重试路径、服务端去重表结构、token 发行与刷新方式、DPoP 或等价持有者控制、完整性绑定、下游模型调用回执及取消语义。通过御盾中央平台提交这些边界后,项目人员可据此确定需要保护的客户端代码、必须留在服务端的授权判断和待补证的真实请求链。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| DPoP 可以把访问令牌绑定到客户端持有的密钥,并让每次 HTTP 请求携带可验证 proof。 | RFC 9449 OAuth DPoP 定义 proof、token 绑定和资源服务器验证要求。 | DPoP 不证明设备可信,也不能替代短有效期、业务授权、幂等记录或风险判断。 |
| OAuth 部署需要防范令牌重放、授权码注入和不安全授权流程等已知风险。 | RFC 9700 OAuth Security BCP 汇总 OAuth 安全最佳当前实践。 | 该 BCP 不决定具体 AI 模型、工具或商业配额的授权规则。 |
| JWT access token 可向资源服务器提供签发者、受众、时效、scope 和客户端等声明。 | RFC 9068 JWT access token profile 定义相应声明和验证语义。 | JWT 格式不保证签发策略安全、令牌足够短命或设备未被控制。 |
| Play Integrity 标准请求可用 requestHash 把完整性响应关联到业务请求。 | Play Integrity standard requests 说明 requestHash 的生成与服务端核对流程。 | 完整性信号不是绝对可信根,最终允许、限制或拒绝仍由服务端结合业务上下文决定。 |
| 参与摘要或签名的 JSON 数据需要确定性的属性排序、字符串和数字表示。 | RFC 8785 JSON Canonicalization Scheme 定义 JSON 的规范化表示。 | JCS 只解决确定性字节表示,不提供认证、业务授权、令牌绑定或重放存储。 |
| WorkManager 可以通过唯一任务名和 ExistingWorkPolicy 管理重复持久任务。 | Manage WorkManager work 说明唯一工作、取消、停止和更新等管理语义。 | 设备侧唯一工作不覆盖多设备、应用重装或直接 API 调用,不能替代服务端幂等。 |
| 服务端应把幂等键与认证主体、操作类型、请求摘要和结果状态共同保存。 | 工程判断:只存幂等键无法区分跨用户碰撞、跨接口误用和同键改参。 | 具体唯一索引、事务和保留周期取决于数据库、下游副作用和业务恢复窗口。 |
| 合法网络重试应复用业务幂等键,同时为新的传输尝试生成新的 DPoP proof。 | 工程判断:业务意图与持有者证明处在不同生命周期,需要分别去重和验证。 | 实现仍须遵循采用的 OAuth 与 DPoP 库,并通过真实代理、URL 和时钟配置测试。 |
工程常见问题
移动端每次重试都生成新的幂等键可以吗?
不可以用于同一业务操作。新的键会让服务端把重发当成新任务。operation_id 和幂等键应在首次确认时持久化,只有用户明确创建新业务意图时才更换。
同样的提示词是否一定应该返回缓存结果?
不一定。提示词相同可能是用户希望再次执行。幂等判断要依赖 operation_id、主体、接口和请求摘要,而不是文本相似度或内容完全相等。
JWT 没过期是否就能继续调用 AI 接口?
不能只看过期时间。资源服务器还要验证签名、签发者、受众、scope、subject 和对象权限,并结合账号状态、请求风险及持有者证明做决定。
DPoP 的 jti 能否直接作为业务幂等键?
不适合。每次 HTTP 尝试应使用新的 proof 和 jti,而同一业务重试需要保持原幂等键。两者分别解决 proof 重放和业务重复执行。
WorkManager 使用 unique work 后还需要服务端去重吗?
需要。unique work 只覆盖当前安装中的本地调度,多设备、重装、前台调用和服务端恢复仍可能产生重复请求,最终业务门禁必须在服务端。
申请御盾移动 AI 请求链评估需要准备什么?
准备 operation 创建点、前后台重试、幂等表结构、JWT 校验、持有者控制、完整性绑定、下游调用回执和取消流程,再通过御盾中央平台提交。