一、业务背景:企业最不敢放开的那一步,恰恰是 AI Agent 最有价值的一步
做 AI Agent 有一道分水岭:只读 和 写。
只读的 Agent 很好做,也基本没有风险------查库存、查订单、查物流,答错了最多是答错。但它在企业里的价值天花板很低,本质是个「会说话的查询框」。
真正有价值的是写操作:取消订单、改收货地址、发起退款、调价、发券。这些动作会改变真实世界的钱和货。所以企业最怕的也是这个------模型幻觉一次、提示词被注入一次,就是真实的资损。
很多团队的处理方式是「干脆不做写操作」。这是逃避,不是解决。
我的订单 Agent 项目(order-agent-assistant)选择正面做这件事,核心思路是一句话:
模型只有「提议权」,人保留「决定权」;业务副作用只由业务层负责任地执行一次。
用自然语言说一句「帮我取消订单 2026...」,最后会走完这样一条完整闭环:
bash
用户: "帮我取消订单 2026... " → 模型决定调 cancel_order
闸门: 写操作,未批准 → 拦截 → 模型转述"需要人工确认后才能执行"
前端聊天气泡出现「批准执行」按钮 → 点击(POST /approve)
模型再次执行 cancel_order → 订单 CANCELLED + 库存恢复 + REFUND 事件
→ 通知中心生成真实退款通知
下面把这个闭环从「一句话」拆到「一行 SQL」。
二、架构与数据流
bash
Vue3 聊天面板
│ POST /query "帮我取消订单 2026..."
▼
AgentLoop (agent:8081)
│ ① 模型决策 → 决定调用 cancel_order
│ ② 写操作 → WritePermissionGate.blocks()
│ ├─ 有批准? → claim 原子抢占 → 放行
│ └─ 无批准 → 记 pending + 拦下(isError)
│ ← 模型转述"需要人工确认"
▼
前端气泡「批准执行」→ POST /approve
│ pendingStore.take() → store.approve(工具名 + 参数指纹)
▼
模型重试 cancel_order → 闸门放行
▼
order-system (8080) OrderServiceImpl.cancel() ── 单个事务 ──┐
│ ③ markCancelled 原子状态流转(幂等守卫) │
│ ④ restoreStock 原子恢复库存 │
│ ⑤ insertRefundEvent 落 REFUND 事件(Transactional Outbox)│
└──────────────────────────────────────────────────────────┘
▼
EventRetryScheduler 每 5s 扫描 → OrderEventService.handleRefund()
▼
t_notification 插入「订单退款通知」→ 前端通知中心可见
四个关键设计点,先列出来,后面逐个展开:
- 闸门拦的是「一次具体的调用」 ,不是「这个工具」------批准绑定
(userId, sessionId, toolName, 参数指纹)。 - 幂等靠状态守卫,不靠「再查一次状态」------用一条带条件的 UPDATE 的影响行数当闸门。
- 退款事件和订单取消在同一个事务里落库(Transactional Outbox),不会出现「订单取消了但事件没插进去」。
- 真正的副作用由业务层的调度器兜底重试,agent 只管决策和触发。
诚实标注:本项目里支付/退款是「状态模拟 + 事件通知」,不接真实支付通道。退款的「可靠性链路」(事件落库 → 兜底重试 → 通知落库)是真实实现,但「钱真的退回去了」这一步是模拟的。这点在简历和面试里必须如实说。
下面是这条「AI 写操作安全闭环」的整体流程图:
#mermaid-svg-OfNXULObFFb42xfe{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OfNXULObFFb42xfe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OfNXULObFFb42xfe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OfNXULObFFb42xfe .error-icon{fill:#552222;}#mermaid-svg-OfNXULObFFb42xfe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OfNXULObFFb42xfe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OfNXULObFFb42xfe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OfNXULObFFb42xfe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OfNXULObFFb42xfe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OfNXULObFFb42xfe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OfNXULObFFb42xfe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OfNXULObFFb42xfe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OfNXULObFFb42xfe .marker.cross{stroke:#333333;}#mermaid-svg-OfNXULObFFb42xfe svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OfNXULObFFb42xfe p{margin:0;}#mermaid-svg-OfNXULObFFb42xfe .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OfNXULObFFb42xfe .cluster-label text{fill:#333;}#mermaid-svg-OfNXULObFFb42xfe .cluster-label span{color:#333;}#mermaid-svg-OfNXULObFFb42xfe .cluster-label span p{background-color:transparent;}#mermaid-svg-OfNXULObFFb42xfe .label text,#mermaid-svg-OfNXULObFFb42xfe span{fill:#333;color:#333;}#mermaid-svg-OfNXULObFFb42xfe .node rect,#mermaid-svg-OfNXULObFFb42xfe .node circle,#mermaid-svg-OfNXULObFFb42xfe .node ellipse,#mermaid-svg-OfNXULObFFb42xfe .node polygon,#mermaid-svg-OfNXULObFFb42xfe .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OfNXULObFFb42xfe .rough-node .label text,#mermaid-svg-OfNXULObFFb42xfe .node .label text,#mermaid-svg-OfNXULObFFb42xfe .image-shape .label,#mermaid-svg-OfNXULObFFb42xfe .icon-shape .label{text-anchor:middle;}#mermaid-svg-OfNXULObFFb42xfe .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OfNXULObFFb42xfe .rough-node .label,#mermaid-svg-OfNXULObFFb42xfe .node .label,#mermaid-svg-OfNXULObFFb42xfe .image-shape .label,#mermaid-svg-OfNXULObFFb42xfe .icon-shape .label{text-align:center;}#mermaid-svg-OfNXULObFFb42xfe .node.clickable{cursor:pointer;}#mermaid-svg-OfNXULObFFb42xfe .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OfNXULObFFb42xfe .arrowheadPath{fill:#333333;}#mermaid-svg-OfNXULObFFb42xfe .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OfNXULObFFb42xfe .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OfNXULObFFb42xfe .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OfNXULObFFb42xfe .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OfNXULObFFb42xfe .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OfNXULObFFb42xfe .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OfNXULObFFb42xfe .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OfNXULObFFb42xfe .cluster text{fill:#333;}#mermaid-svg-OfNXULObFFb42xfe .cluster span{color:#333;}#mermaid-svg-OfNXULObFFb42xfe div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OfNXULObFFb42xfe .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OfNXULObFFb42xfe rect.text{fill:none;stroke-width:0;}#mermaid-svg-OfNXULObFFb42xfe .icon-shape,#mermaid-svg-OfNXULObFFb42xfe .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OfNXULObFFb42xfe .icon-shape p,#mermaid-svg-OfNXULObFFb42xfe .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OfNXULObFFb42xfe .icon-shape .label rect,#mermaid-svg-OfNXULObFFb42xfe .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OfNXULObFFb42xfe .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OfNXULObFFb42xfe .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OfNXULObFFb42xfe :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无批准
有批准
用户: 帮我取消订单 2026...
AgentLoop (agent:8081)
WritePermissionGate 拦截判断
记 pending + 拦下
模型转述: 需要人工确认
前端气泡「批准执行」
POST /approve 批准
模型重试 cancel_order
order-system OrderServiceImpl.cancel()
markCancelled 原子状态流转
restoreStock 原子恢复库存
insertRefundEvent 落 REFUND 事件
EventRetryScheduler 每 5s 扫描
handleRefund 生成退款通知
前端通知中心可见
三、代码实现
3.1 权限闸门:批准的是什么?
先看闸门的核心。很多人做「人工批准」时批准的是工具名,这是个致命错误:
java
/**
* 真正的权限闸门:先划边界,再给自由。
* 只读工具(readOnly=true)直接放行;写工具默认拦截。
*
* 批准模型:一次人工批准只放行「该会话最近被拦下的那一次写调用」------
* 绑定 (userId, sessionId, toolName, 参数指纹),存 Redis 带 TTL,成功执行后自动消费。
* 模型中途换订单号、换工具、换用户、换会话、或批准过期,一律重新批准。
*/
@Component
public class WritePermissionGate implements PermissionGate {
private final Set<String> writeTools;
private final WriteApprovalStore store;
private final PendingStore pendingStore;
private final ObjectMapper json = new ObjectMapper();
public WritePermissionGate(PendingStore pendingStore, WriteApprovalStore store, List<Tool> tools) {
this.pendingStore = pendingStore;
this.store = store;
// 写工具集合由 Tool.readOnly() 推导,不靠人工维护名单
this.writeTools = tools.stream()
.filter(t -> !t.readOnly())
.map(Tool::name)
.collect(Collectors.toSet());
}
@Override
public boolean blocks(ToolCall call, String sessionId, Long userId) {
if (!writeTools.contains(call.name())) {
return false; // 只读 → 放行
}
// 原子抢占批准:claim = "读+比对+删" 一步完成。
// 同一批准凭证被两个请求同时 claim,只有一个能成功------
// 这就是"批准一次性、防双写"的原子保证。
// 抢到 → 放行;没抢到(没批准 / 参数不符 / 已被别的请求抢走)→ 拦下并记 pending
if (store.claim(userId, sessionId, call.name(), fingerprint(call.args()))) {
return false;
}
pendingStore.save(userId, sessionId, call.name(), fingerprint(call.args()));
return true;
}
参数指纹是这里最容易被忽略、也最见功力的一处:
java
/** 参数指纹:Map → 规范化 JSON(TreeMap 按 key 排序,
* 保证同样的参数不管 key 顺序如何都产出同一字符串)。
* Redis Lua 的 claim 用逐字节比对指纹,两边必须完全一致;
* 序列化失败 → null → 无法匹配任何批准 → fail-closed 拦下 */
private String fingerprint(Map<String, Object> args) {
try {
return json.writeValueAsString(json.valueToTree(new TreeMap<>(args)));
} catch (JsonProcessingException e) {
return null;
}
}
为什么必须用 TreeMap 排序再序列化?因为 JSON 对象的 key 顺序在 HashMap 下不稳定,同一份参数可能产出两个不同的字符串 ,导致「我刚批准了,怎么又不认了」这种玄学 bug。而且 claim 是在 Redis Lua 里做逐字节比对的,两边必须完全一致。
而序列化失败返回 null 是刻意的 fail-closed:指纹算不出来 → 匹配不上任何批准 → 拦下。安全组件里,出错时的默认方向必须是「拒绝」,不是「放行」。
再看「批准」的入口和「业务失败回存」:
java
/** 人工批准:放行该会话最近被拦下的那一次写调用(工具名+参数指纹)。 */
public void approve(Long userId, String sessionId) {
Pending pending = pendingStore.take(userId, sessionId);
if (pending != null) {
store.approve(userId, sessionId, pending.toolName(), pending.fingerprint());
}
}
/** 批准已在 blocks() 时被原子 claim 消费(一次性)。
* 这里只处理"业务失败回存":success=false 说明写操作没生效,把批准放回,
* 允许用户原样重试;其余情况保持已消费(fail-closed)。真异常走 AgentLoop 的 catch,
* 不经过这里,批准同样保持已消费------重试需重新批准。 */
@Override
public void afterToolExecuted(ToolCall call, String sessionId, Long userId, String result) {
if (!writeTools.contains(call.name())) {
return;
}
if (isBusinessFailure(result)) {
String fp = fingerprint(call.args());
if (fp != null) {
store.approve(userId, sessionId, call.name(), fp);
}
}
}
private boolean isBusinessFailure(String result) {
return result != null && result.contains("\"success\":false");
}
「业务失败把批准放回,技术异常不放回」------这个区分非常值得说。 业务失败(比如「该订单状态已变化」「库存不足」)意味着写操作根本没生效 ,用户看到提示后原样重试一次是合理的,不该逼他再点一次批准;而技术异常(超时、连接不上)意味着执行结果未知,可能已经生效也可能没有,这时候必须保守------保持已消费,要求重新批准。
3.2 业务层:幂等靠状态守卫
闸门管住了「谁有权执行」,接下来的问题是「执行几次」。
Agent 的重试、网络的抖动、消息的重复投递,都可能导致 cancel 被调用多次。用「先查一次状态再更新」是错的 ------两个并发请求可能同时读到 WAIT_PAY。
正确做法是让数据库当唯一的裁判:
java
@Override
@Transactional(rollbackFor = Exception.class)
public void cancel(Long orderId, Long userId) {
requireUser(userId);
Order order = requireOrder(orderId);
checkOwner(order, userId); // 只能取消自己的订单
String status = order.getStatus();
// 已取消 → 幂等拒绝(友好提示);其他非法状态 → 明确拒绝
if ("CANCELLED".equals(status)) {
throw new BusinessException(400, "订单已是取消状态,无需重复操作");
}
if (!"WAIT_PAY".equals(status) && !"PAID".equals(status)) {
throw new BusinessException(400, "当前状态(" + status + ")不允许取消");
}
// 原子状态流转:只有还是 WAIT_PAY/PAID 才置 CANCELLED。
// 两个并发取消同时读到 WAIT_PAY → 只有一个 affected=1,另一个 affected=0 被拦下,
// 库存恢复天然只执行一次------幂等由「状态守卫」保证,而不是靠"再查一次状态"。
int affected = orderMapper.markCancelled(orderId);
if (affected == 0) {
throw new BusinessException(400, "订单状态已变化,请刷新后重试");
}
// 恢复库存:原子加,不先查后写(并发下查到的 stock 可能是旧的)
LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OrderItem::getOrderId, orderId);
List<OrderItem> items = orderItemMapper.selectList(wrapper);
for (OrderItem item : items) {
productMapper.restoreStock(item.getProductId(), item.getQuantity());
}
// 已支付订单取消 → 触发退款(同事务落 REFUND 事件,交给事件调度器兜底)
if ("PAID".equals(status)) {
insertRefundEvent(order);
}
log.info("订单已取消,orderNo={}", order.getOrderNo());
}
那两条原子 SQL 是整段的地基:
java
/**
* 原子状态流转:只有还是 WAIT_PAY/PAID 才置 CANCELLED,返回影响行数。
* 两个并发取消同时读到 WAIT_PAY → 只有一个 affected=1,另一个被拦下,
* 库存恢复因此天然只执行一次(幂等的关键,而不是靠"再查一次状态")。
* 手写 SQL 不会自动拼逻辑删除条件,手动加 deleted = 0。
*/
@Update("UPDATE t_order SET status = 'CANCELLED' " +
"WHERE id = #{id} AND status IN ('WAIT_PAY','PAID') AND deleted = 0")
int markCancelled(@Param("id") Long id);
/**
* 原子恢复库存:直接加,不先查再写。
* 幂等由调用方(订单取消的状态守卫)保证------只有真正把订单置为 CANCELLED 的请求才会走到这。
*/
@Update("UPDATE t_product SET stock = stock + #{quantity} " +
"WHERE id = #{id} AND deleted = 0")
int restoreStock(@Param("id") Long id, @Param("quantity") Integer quantity);
注意注释里那句 「手写 SQL 不会自动拼逻辑删除条件,手动加 deleted = 0」 ------这是 MyBatis-Plus 项目里非常经典的坑:用 updateById 时框架会自动带逻辑删除条件,一旦你手写 @Update,它就完全不管了。数据「删掉」了还能被改,是生产事故级别的 bug。
3.3 退款事件:Transactional Outbox
订单取消了,退款事件必须要么一起成功,要么一起回滚。这就是 Outbox 模式:
java
/** 已支付订单取消 → 事务内插退款事件,交给事件调度器兜底处理 */
private void insertRefundEvent(Order order) {
EventRecord record = new EventRecord();
record.setOrderNo(order.getOrderNo());
record.setEventType(EventType.REFUND.name());
record.setEventData(objectMapper.writeValueAsString(Map.of(
"userId", order.getUserId(),
"amount", order.getTotalAmount(),
"phone", order.getReceiverPhone())));
record.setStatus("WAIT");
record.setRetryCount(0);
record.setMaxRetry(3);
record.setNextRetryTime(LocalDateTime.now());
eventRecordMapper.insert(record);
}
为什么不能在 cancel 里直接调退款接口? 因为那是跨进程调用,会破坏事务边界:订单状态提交了、退款调用超时了,两边就永久不一致。Outbox 的做法是先把「要退款」这件事和订单取消写在同一个本地事务里落库,再由调度器异步、可重试地去真正执行。
调度器那侧的关键是 claim:
java
/**
* 事件重试调度器
* ② 每次最多处理 50 条,防止单次扫描耗时过长
* ③ 先 claim 再处理:条件更新 WAIT→SENDING(影响行数当闸门),多实例下谁先 claim 谁处理,
* 绝不重复处理同一条事件
* ④ 失败后:WAIT 状态 + 递增 retry_count + 指数退避 next_retry_time
* ⑤ 超过 max_retry → FAIL,人工介入(requeueFailedEvent 可复位重试)
* ⑥ 每次扫描先 reclaimStuck 回收崩溃实例遗留的 SENDING 僵尸(claimed_at 超阈值)
*/
@Scheduled(fixedDelay = 5000)
public void scanAndProcess() {
// 0. 回收崩溃实例遗留的 SENDING 僵尸,重新进入待处理池
eventRecordMapper.reclaimStuck(LocalDateTime.now().minusMinutes(STUCK_MINUTES));
...
}
reclaimStuck 这一步是「至少一次投递」能不能落地的分水岭。 如果只有 claim 没有回收:实例在处理到一半时崩了,那条事件永远卡在 SENDING,没人再碰它------静默丢消息,这是最可怕的一类 bug:不报错、不告警,只是钱没退。
最后事件真正变成用户能看到的通知:
java
/**
* 退款通知:由 agent 取消订单后插入的 REFUND 事件触发。
* agent 只管"决策和触发",真正可靠的执行(重试/幂等)交给这里的调度器链路兜底。
*/
private void handleRefund(Map<String, Object> data, String orderNo) {
Long userId = ((Number) data.get("userId")).longValue();
Object amount = data.get("amount");
Notification n = new Notification();
n.setUserId(userId);
n.setOrderNo(orderNo);
n.setTitle("订单退款通知");
n.setContent("您的订单 " + orderNo + " 已取消,退款金额 " + amount
+ " 元,预计 1-3 个工作日原路退回。");
n.setIsRead(0);
notificationMapper.insert(n);
}
「agent 不直连数据库、业务规则只有一份」是整个架构的定盘星。 agent 只负责决策和触发,状态机、库存、退款事件全在 order-system。这样即使将来接第二个入口(MCP 客户端、客服后台、定时任务),业务规则也不会分叉。
四、异常与并发边界
这一节是这篇文章的真正价值所在。上面的代码「能跑」,下面这些才是「能上线」。
① 并发双取消:库存会不会加两次? 不会。markCancelled 带 status IN ('WAIT_PAY','PAID') 条件,两个请求并发时只有一个 affected=1,另一个拿到 0 直接抛业务异常返回。恢复库存的代码在 affected=1 之后才执行,天然只跑一次。幂等的守卫在状态流转那一步,不在库存那一步------这个顺序不能反。
② 同一份批准被两个请求同时用:会不会双写? 不会。store.claim 是「读 + 比对 + 删」的一步原子操作 (Redis Lua / ConcurrentHashMap.remove(key, value) 语义)。两个并发 claim 只有一个成功,另一个直接被拦。如果先 get 再 del,中间那个窗口就是双写漏洞。
③ 模型换了个订单号重试:会顺带执行吗? 不会。批准绑定的是参数指纹,换成订单 B 的指纹就匹配不上,必须重新批准。这正是「批准(工具名)」和「批准(工具名 + 参数)」的区别------前者是摆设。
④ 同一会话连着拦下两个不同写操作,批准的是哪一个? 首拦优先。 PendingStore.save 的语义是「已有未批准的提议时不覆盖」,所以先被拦下的那个优先被放行,后一个不会顶掉前一个,必须等第一个批准或作废。这保证了人批准的一定是他第一次看到的那次操作,不会出现「批准了 A 实际放行 B」。
⑤ 写操作执行后业务失败(比如状态已变化),批准还要重新点吗? 不用。afterToolExecuted 检测到 "success":false 就把批准放回,允许原样重试。因为业务失败意味着没生效,重试是安全的。
⑥ 写操作抛技术异常(超时、连不上)呢? 保持已消费,必须重新批准。 因为执行结果未知------可能已经改成功了。这时候让「批准」继续有效,等于允许一个结果未知的操作再执行一次。在「用户体验」和「安全」冲突时,这里选了安全。
⑦ 事件调度器处理到一半崩了怎么办? reclaimStuck 每小时把超过 10 分钟的 SENDING 僵尸事件收回 WAIT 重新处理。这是「至少一次投递」的兜底,配合 handleRefund 的落库操作,宁可重试也不要静默丢失。
⑧ 重试超过 3 次呢? 置为 FAIL,停止自动重试,等人工介入(requeueFailedEvent 可复位)。自动重试必须有上限,否则一个永久失败的毒消息会一直占用调度资源。
五、面试 / 简历亮点
设计并实现「AI 写操作安全闭环」 :模型提议 → 权限闸门拦截 → 人工在浏览器批准 → 业务层幂等执行 → Outbox 事件驱动退款通知。批准绑定
(userId, sessionId, toolName, 参数指纹)四元组,claim为 Redis 原子操作保证批准一次性、防双写;参数指纹用TreeMap规范化排序保证幂等匹配,序列化失败走 fail-closed。业务层用状态守卫式原子 UPDATE (WHERE status IN (...))保证并发取消幂等、库存只恢复一次;退款走 Transactional Outbox (取消与事件同事务落库)+ 调度器 claim/指数退避/僵尸回收,保证至少一次投递。覆盖 order-system 49 + agent 146 个单测,另有三层测试与 GitHub Actions CI。
亮点密度在于:这段代码同时踩中「AI 安全」「分布式幂等」「消息可靠性」三个面试高频区,而且每一条都不是背概念,是能指着 SQL 和状态机讲清楚的。
另外,PITFALLS.md 里记的 10 个真实坑,有几个就在这条链路上:
- #4「说取消订单,模型却调了查单」:LLM 工具选择有随机性,提示词压不住,需要双保险。
- #10「批准第二个写操作,模型却去取消订单」 :批准链路把「取消订单」写死在文案里------通用机制绝不能点名具体工具。这就是从「能演示」到「可复用」的那一步。
- #9
@RequestParam Long收到非数字返回 500 :客户端输错是 4xx 问题,全局异常处理器要单独接MethodArgumentTypeMismatchException,不能掉进 500 兜底。
六、场景题暴击
Q1:你的「人工批准」放在 agent 层,如果 order-system 被别的调用方直接调用(比如客服后台批量取消),这条安全边界不就绕过了吗?
这个问题问到了架构的真问题,得诚实分层回答。
我的设计里闸门保护的是「AI 发起的写操作」,不是「所有写操作」。 逻辑是:AI 的风险特征是「不确定性」------模型可能被提示词注入、可能幻觉、可能选错工具,所以需要一个「人在回路」的确认点。而客服后台是确定性的人工操作,点击本身就是人工确认,不需要再套一层。
但这确实意味着:安全边界依赖「谁在调」。 如果将来 order-system 开放给第三个调用方,正确做法是把「调用方身份」纳入判定------比如 agent 只能用带「需批准」标记的内部接口,或者 agent 访问业务层时带一个 X-Approval-Required: true 的通道标识,由业务层强校验。
更该说的是我做了什么来防「绕过」 :agent 没有订单库的写权限 ,它只能通过 order-system 的接口做事;order-system 的内部写接口用内部密钥配对(X-Internal-Secret),外部调用方拿不到。所以「绕过闸门」的前提是先伪造内部身份,那已经是另一个层级的攻击了。
最后一句实话:单体的内部密钥不是强边界,真要严格隔离应该用 mTLS 或服务网格。这个项目的定位是「把机制讲清楚」,不是「通过等保」。
Q2:Transactional Outbox 保证的是「事件一定落库」,但保证不了「退款一定成功」。你怎么处理最终失败?(另外:怎么保证不重复退款?)
两问,分开答。
「一定落库」和「一定成功」是两个不同的承诺。 Outbox 解决的是原子性 问题:不会出现「订单取消了但没有任何地方记录要退款」。至于退款本身能不能成功,靠的是调度器的重试 + 指数退避 + 上限 + 人工介入 :max_retry=3,超了置 FAIL,requeueFailedEvent 复位。也就是说我的系统承诺的是**「至少一次投递 + 失败可见」**,不承诺「必然成功」------任何声称「必然成功」的设计都是在骗自己。
「重复」的问题才是真问题,因为「至少一次投递」天然意味着可能重复。 三重防线:
- 事件层 :调度器先
claim(条件更新WAIT→SENDING,影响行数当闸门),多实例下只有一个能处理同一条事件------这防的是「多个调度器实例重复处理」。 - 业务层 :
handleRefund的落库如果是幂等的(按orderNo + eventType唯一约束),重复执行也只是重复插一条被约束挡掉------这防的是「同一条事件被处理两次」。 - 链路层 :订单取消的幂等由状态守卫保证,所以同一个订单不会产生第二条 REFUND 事件------这防的是「源头上就不该有第二条」。
面试里最想听到的一句 :这三层不是冗余,它们各自挡住的是不同来源的重复,缺一层就有一条路径能漏。而且真要接真实支付通道,退款接口本身必须带幂等键(业务单号)------这是接第三方支付的铁律,因为在跨系统的场景里,你唯一能控制的只有「我发出去的请求带没带唯一标识」。
仓库 :https://github.com/maixiaowen111/order-agent-assistant
如实说明:本项目支付/退款为状态模拟 + 事件通知,未接真实支付通道。文中「通知中心落库」「Outbox 事件 + 调度器重试」为真实实现;「退款金额原路退回」为用户可见的通知文案,非真实资金流。
推荐标签:AI Agent、Spring Boot、幂等设计、Transactional Outbox、权限控制