OpController.java 8.5 KB
package com.xly.web;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.service.AuditService;
import com.xly.service.ErpClient;
import com.xly.service.OpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * 写操作确认端点(**确定性、不经过 LLM**)—— 人在环写入闭环的执行侧。
 *
 * <p>用户在对话框点【确认】-> {@code /confirm} 才真正调 ERP 执行;点【取消】-> {@code /cancel}。
 * v1 为同步执行(update 很快):直接调 ERP 表单更新接口,回写 ai_op_queue 状态并返回结果。
 */
@RestController
@RequestMapping("/api/agent/op")
public class OpController {

    private static final Logger log = LoggerFactory.getLogger(OpController.class);

    private final OpService ops;
    private final ErpClient erp;
    private final AuditService audit;
    private final ObjectMapper mapper;

    /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */
    @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}")
    private boolean execStagingEnabled;

    public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper) {
        this.ops = ops;
        this.erp = erp;
        this.audit = audit;
        this.mapper = mapper;
    }

    /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */
    @GetMapping("/pending")
    public Map<String, Object> pending(@RequestParam("conversationId") String conversationId) {
        Map<String, Object> op = ops.pending(conversationId);
        return op == null ? Map.of() : op;
    }

    /**
     * 确认执行:调 ERP 执行暂存的写操作,回写状态。
     * <p>{@code Authorization} 头(可空)= 用户浏览器里的 ERP 登录 token,透传给 ERP 使执行以用户身份
     * 进行;为空则回退 dev-login。绝不因用户 token 缺失而静默提权(见 ErpClient.canRelogin)。
     */
    @PostMapping("/{id}/confirm")
    public Map<String, Object> confirm(@PathVariable("id") String id,
                                       @RequestHeader(value = "Authorization", required = false) String authToken) {
        Map<String, Object> op = ops.get(id);
        if (op == null) {
            return result("failed", "找不到该操作", null);
        }
        if (!"draft".equals(String.valueOf(op.get("sStatus")))) {
            return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op);
        }
        String uid = str(op.get("sUserId"));
        String conv = str(op.get("sConversationId"));
        String target = str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId"));
        String detail = str(op.get("sDescription"));

        // 生产路径:委托 ERP 侧暂存执行器(它以用户身份执行、事务化并自行回写 ai_op_queue 状态)。
        if (execStagingEnabled) {
            return confirmViaExecutor(id, op, authToken, uid, conv, target, detail);
        }
        try {
            String opType = str(op.get("sOpType"));
            JsonNode r;
            if ("create".equals(opType)) {
                JsonNode payload = mapper.readTree(str(op.get("sPayload")));
                if (payload.has("__tables__")) { // 报价等多表主-从创建
                    @SuppressWarnings("unchecked")
                    List<Map<String, Object>> tables = mapper.convertValue(payload.get("__tables__"), List.class);
                    r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables);
                } else {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> columns = mapper.convertValue(payload, Map.class);
                    r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns);
                }
            } else if ("delete".equals(opType)) {
                r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")));
            } else if ("invalid".equals(opType)) {
                boolean cancel = "cancel".equals(str(op.get("sNewValue"))); // cancel=复原/取消作废,toVoid=作废
                r = erp.invalidForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), cancel);
            } else if ("examine".equals(opType)) {
                int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核
                r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag);
            } else {
                r = erp.updateForm(authToken,
                        str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")),
                        str(op.get("sField")), str(op.get("sNewValue")));
            }
            int code = r.path("code").asInt(0);
            if (code == 1) {
                ops.setStatus(id, "executed", "操作成功");
                audit.log(uid, conv, "confirm", target, detail, true, "executed");
                return result("executed", "已执行:" + op.get("sDescription"), op);
            }
            String msg = r.path("msg").asText("执行失败");
            ops.setStatus(id, "failed", msg);
            audit.log(uid, conv, "confirm", target, detail, false, msg);
            return result("failed", msg, op);
        } catch (Exception e) {
            log.warn("confirm op {} failed", id, e);
            ops.setStatus(id, "failed", e.getMessage());
            audit.log(uid, conv, "confirm", target, detail, false, e.getMessage());
            return result("failed", "执行异常:" + e.getMessage(), op);
        }
    }

    /** 委托 ERP 侧暂存执行器执行(§10);执行器自行回写 ai_op_queue 状态,这里只映射结果 + 审计。 */
    private Map<String, Object> confirmViaExecutor(String id, Map<String, Object> op, String authToken,
                                                   String uid, String conv, String target, String detail) {
        try {
            JsonNode r = erp.execStaging(authToken, id);
            String st = r.path("status").asText("failed");
            String msg = r.path("msg").asText("");
            boolean ok = "executed".equals(st);
            audit.log(uid, conv, "confirm", target, detail, ok, "execStaging:" + st + (msg.isEmpty() ? "" : (" " + msg)));
            return result(ok ? "executed" : "failed",
                    ok ? ("已执行:" + op.get("sDescription")) : (msg.isEmpty() ? "执行失败" : msg), op);
        } catch (Exception e) {
            log.warn("execStaging confirm op {} failed", id, e);
            audit.log(uid, conv, "confirm", target, detail, false, e.getMessage());
            return result("failed", "执行异常:" + e.getMessage(), op);
        }
    }

    /** 取消。 */
    @PostMapping("/{id}/cancel")
    public Map<String, Object> cancel(@PathVariable("id") String id) {
        Map<String, Object> op = ops.get(id);
        if (op != null && "draft".equals(String.valueOf(op.get("sStatus")))) {
            ops.setStatus(id, "cancelled", "用户取消");
            audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel",
                    str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")),
                    str(op.get("sDescription")), true, "cancelled");
        }
        return result("cancelled", "已取消", op);
    }

    private Map<String, Object> result(String status, String msg, Map<String, Object> op) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("status", status);
        m.put("msg", msg);
        if (op != null) {
            m.put("opId", op.get("sId"));
            m.put("description", op.get("sDescription"));
        }
        return m;
    }

    private static String str(Object o) {
        return o == null ? null : o.toString();
    }
}