从一句\“取消订单\“到退款通知:AI 写操作完整业务闭环

一、业务背景:企业最不敢放开的那一步,恰恰是 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 插入「订单退款通知」→ 前端通知中心可见

四个关键设计点,先列出来,后面逐个展开:

  1. 闸门拦的是「一次具体的调用」 ,不是「这个工具」------批准绑定 (userId, sessionId, toolName, 参数指纹)。
  2. 幂等靠状态守卫,不靠「再查一次状态」------用一条带条件的 UPDATE 的影响行数当闸门。
  3. 退款事件和订单取消在同一个事务里落库(Transactional Outbox),不会出现「订单取消了但事件没插进去」。
  4. 真正的副作用由业务层的调度器兜底重试,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 复位。也就是说我的系统承诺的是**「至少一次投递 + 失败可见」**,不承诺「必然成功」------任何声称「必然成功」的设计都是在骗自己。

「重复」的问题才是真问题,因为「至少一次投递」天然意味着可能重复。 三重防线:

  1. 事件层 :调度器先 claim(条件更新 WAIT→SENDING,影响行数当闸门),多实例下只有一个能处理同一条事件------这防的是「多个调度器实例重复处理」。
  2. 业务层 :handleRefund 的落库如果是幂等的(按 orderNo + eventType 唯一约束),重复执行也只是重复插一条被约束挡掉------这防的是「同一条事件被处理两次」。
  3. 链路层 :订单取消的幂等由状态守卫保证,所以同一个订单不会产生第二条 REFUND 事件------这防的是「源头上就不该有第二条」。

面试里最想听到的一句 :这三层不是冗余,它们各自挡住的是不同来源的重复,缺一层就有一条路径能漏。而且真要接真实支付通道,退款接口本身必须带幂等键(业务单号)------这是接第三方支付的铁律,因为在跨系统的场景里,你唯一能控制的只有「我发出去的请求带没带唯一标识」。


仓库 :https://github.com/maixiaowen111/order-agent-assistant

如实说明:本项目支付/退款为状态模拟 + 事件通知,未接真实支付通道。文中「通知中心落库」「Outbox 事件 + 调度器重试」为真实实现;「退款金额原路退回」为用户可见的通知文案,非真实资金流。
推荐标签:AI Agent、Spring Boot、幂等设计、Transactional Outbox、权限控制

相关推荐
啵啵啵鱼2 小时前
第三章 栈、队列和数组相关结构
java·数据结构·栈·队列
昇腾CANN2 小时前
10月16日16点直播丨基于950超节点的万亿MoE模型预训练参考实践
人工智能·昇腾·cann·cann开源
过河卒_zh15667662 小时前
再更新!人工智能安全治理指引性文件第三版发布!
人工智能·算法·aigc·生成式人工智能·算法备案
树獭哥2 小时前
Deep Agents 学习笔记(一):从 Agent Harness 到第一个 Deep Agent
人工智能·python
霸道流氓气质2 小时前
在线 AI 绘图平台:Flux 模型集成、Serverless 弹性部署与 ComfyUI 工作流的 React + Spring AI 全栈实战
人工智能·react.js·serverless
瑶山2 小时前
stable-diffusion.cpp 参数全集整理(sd-cli.exe / sd-server.exe)
java·数据库·stable diffusion·参数全集
武科大许志伟2 小时前
GPX:TSP中的两条好路线,怎样拼成一条更好的路线?
人工智能·演化计算·组合优化
CallFay云起未来2 小时前
2026年AI客服能不能减少人工回复?从知识检索到人机协同的技术实现
java·大数据·前端·人工智能·架构·文心一言
xieyan08112 小时前
HyperFrames:给一段文字让 AI 自动做视频,实测效果怎么样?
人工智能·音视频