AI 助手开始代表用户调用企业系统:“退掉那件蓝色夹克”“把我的预约改到周四”。读取订单无伤大雅。退款会转移资金,这才是有意思的工程问题。
以下是我会优先考虑的四种故障模式,以及我为每种情况使用的 TypeScript。示例使用 allowfy,这是一个小型开源软件包(零依赖,适用于 Web Request/Response),但这些模式适用于任何基于 webhook 的集成。
你的退款端点会暴露在互联网上。每个请求都必须证明它来自你的网关,而且是近期发出的。
方案:Allowfy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">。在解析任何 JSON 之前,针对原始请求体进行验证,使用恒定时间比较和时间窗口:
import { verifySignature } from "allowfy";
const raw = await request.text(); // raw, not request.json()
const ok = await verifySignature(process.env.ALLOWFY_WEBHOOK_SECRET!, raw, request.headers.get("allowfy-signature"));
if (!ok) return new Response("bad signature", { status: 401 });
签名载荷中的时间戳可以阻止重放:攻击者即使截获了有效请求,也无法在十分钟后再次发送它。
不要让智能体的请求直接触发退款。把流程拆开:
报价:计算出精确条款(这个订单、这个金额、这种方式)。不要做任何改动。
- 客户批准这些确切条款;你的规则决定是否还需要团队审核;
执行:按照已批准的条款办理。
import { createHandler, refuse } from "allowfy";
export const POST = createHandler({
secret: process.env.ALLOWFY_WEBHOOK_SECRET!,
quote: {
async "orders.return"({ order_id }, { customerRef }) {
const o = await db.order(order_id, customerRef);
if (!o) refuse("not_found", "Order not found", 404);
if (o.status !== "paid") refuse("not_returnable", "This order can't be returned");
return {
terms: { order_id: o.id, refund_minor: o.totalMinor, currency: o.currency },
summary: `Return ${o.item} for a ${o.totalMinor / 100} ${o.currency} refund`,
amountMinor: o.totalMinor, // lets amount rules apply
currency: o.currency,
};
},
},
// execute: below
});
如果批准后条款发生变化(例如价格更新、订单不同),原批准便不再适用,需要重新批准。该规则由网关负责执行,而你的 quote 会把条款明确下来。
典型情况:你的系统发出了退款,随后连接中断,网关还没收到回复。简单重试会导致重复退款。
两条规则可以解决这个问题:
- 每次执行都带有一个稳定的操作 ID(也会作为
Idempotency-Key发送)。每个 ID 的操作最多执行一次,重试时返回相同结果。 - 任何不明确的答复(5xx、超时)都意味着结果未知,绝不能说“失败”。网关会使用同一 ID 重试,最后一次尝试后由人工处理。
execute: {
async "orders.return"(terms, { operationId }) {
// A unique index on returns.operation_id turns a retry into a no-op.
const inserted = await db.returns.insertIfAbsent({ operation_id: operationId, order_id: terms.order_id, refund_minor: terms.refund_minor });
if (inserted) await payments.refund(terms.order_id, terms.refund_minor, { idempotencyKey: operationId });
return { summary: `Order ${terms.order_id} returned; refund on its way` };
},
},
还要注意传递给支付服务提供商的幂等键:这一保证必须贯穿整个调用链。
refuse()用于有意给出“否”的答复(已在店内退货):没有任何变更,客户会看到你的消息。其他任何抛出的错误都会变成 500,这意味着“结果未知,请重试”。
按单次请求设置限额(“退款金额不超过 100 美元时,只需客户批准”)很容易绕过。限额需要按时间计算,并按客户统计,涵盖所有智能体:
{
"action": "orders.return",
"decision": "user_approval",
"conditions": { "maxAmountMinor": 10000, "maxTotalMinor": 15000, "maxCount": 3, "windowDays": 7 },
"otherwise": "business_review"
}
有两个实现细节很重要:
**跨智能体计数。**切换助手不能重置计数器。
**按客户串行处理。**两个同时到达的请求不能都钻过限额。在 Postgres 中,使用以客户为键、作用域限定在事务内的 advisory lock 即可:SELECT pg_advisory_xact_lock(hashtextextended('limit:' || $1, 0))。
在 Supabase(Deno)上,整个处理程序只需一次调用:
import { createHandler } from "npm:allowfy";
Deno.serve(createHandler({ secret: Deno.env.get("ALLOWFY_WEBHOOK_SECRET")!, authenticate, read, quote, execute }));
在 Express 中,应在任何 JSON 请求体解析器之前挂载它(签名覆盖原始请求体):
import { createHandler, toNodeListener } from "allowfy";
app.post("/allowfy", toNodeListener(createHandler({ secret, authenticate, read, quote, execute })));
- 在原始请求体上验证签名,并设置时间窗口
- 报价和执行分开;报价不做任何改动
- 执行按操作 ID 保证幂等,直到支付服务提供商这一层
- 绝不把未知结果报告为失败
- 按客户跨时间和智能体设置限额,并串行处理
- 每个步骤都记录到某个可供导出的位置
关于网关这一侧(带签名密钥的智能体身份、DPoP 绑定令牌、客户登录、规则、审批、签名账本、紧急停止),我正在通过 Allowfy 构建。本文较长、非代码的版本发布在 Allowfy 博客。如果你想看看 AI 智能体如今如何看待你自己的域名,Allowfy Check 免费且开源。
有问题,或者有我遗漏的故障模式吗?欢迎在评论中告诉我。