package com.xly.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.config.RedisChatMemoryStore;
import com.xly.service.AuditService;
import com.xly.service.ErpClient;
import com.xly.service.LedgerService;
import com.xly.service.OpService;
import com.xly.service.StateService;
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**)—— 人在环写入闭环的执行侧。
*
*
用户在对话框点【确认】-> {@code /confirm} 才真正调 ERP 执行;点【取消】-> {@code /cancel}。
* 覆盖全部 op 类型:create(含报价这类多表主-从)、update、delete、invalid/cancelInvalid、
* examine/cancelExamine。执行完回写 {@code ai_op_queue} 状态 + 审计。
*
*
两条执行路径由 {@code erp.exec-staging.enabled} 切换:直连 ERP 通用写接口(本地默认),
* 或委托 ERP 侧暂存执行器 {@code /ai/execStaging}(生产路径,事务化、以用户身份执行)。
*
*
当前**恒同步**:确认即执行并同步返回终态。架构 §10 设计的「auto 流程异步 + 卡片三态 + SSE 终态推送」
* 尚未实现(见 docs/agent-architecture.md §18)。
*/
@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;
private final LedgerService ledger;
private final StateService state;
private final RedisChatMemoryStore memoryStore;
/** 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,
LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore) {
this.ops = ops;
this.erp = erp;
this.audit = audit;
this.mapper = mapper;
this.ledger = ledger;
this.state = state;
this.memoryStore = memoryStore;
}
/** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */
private void recordOutcome(String conv, String opId, String status, String msg, String description) {
if (conv == null || conv.isBlank() || "null".equals(conv)) {
return;
}
try {
ledger.append(conv, "cancelled".equals(status) ? "cancel" : "confirm", Map.of(
"opId", opId == null ? "" : opId,
"status", status,
"msg", msg == null ? "" : msg,
"description", description == null ? "" : description));
state.updateDocStage(conv, opId, status);
String note;
if ("executed".equals(status)) {
note = "(系统)用户已确认,操作执行成功:" + description;
} else if ("cancelled".equals(status)) {
note = "(系统)用户取消了该操作:" + description;
} else {
note = "(系统)用户确认后执行失败:" + description + (msg == null || msg.isBlank() ? "" : (",原因:" + msg));
}
memoryStore.appendTurn(conv, null, note);
} catch (Exception e) {
log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage());
}
}
/** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */
@GetMapping("/pending")
public Map pending(@RequestParam("conversationId") String conversationId) {
Map op = ops.pending(conversationId);
return op == null ? Map.of() : op;
}
/**
* 确认执行:调 ERP 执行暂存的写操作,回写状态。
* {@code Authorization} 头(可空)= 用户浏览器里的 ERP 登录 token,透传给 ERP 使执行以用户身份
* 进行;为空则回退 dev-login。绝不因用户 token 缺失而静默提权(见 ErpClient.canRelogin)。
*/
@PostMapping("/{id}/confirm")
public Map confirm(@PathVariable("id") String id,
@RequestHeader(value = "Authorization", required = false) String authToken) {
Map 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