package com.xly.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import com.xly.service.AuditService;
import com.xly.service.AuthzService;
import com.xly.service.ConversationService;
import com.xly.service.ErpClient;
import com.xly.service.FormResolverService;
import com.xly.service.LedgerService;
import com.xly.service.OpService;
import com.xly.service.StateService;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
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 AuthzService authz;
private final ConversationService conversations;
private final FormResolverService resolver;
/** 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,
AuthzService authz, ConversationService conversations,
FormResolverService resolver) {
this.ops = ops;
this.erp = erp;
this.audit = audit;
this.mapper = mapper;
this.ledger = ledger;
this.state = state;
this.authz = authz;
this.conversations = conversations;
this.resolver = resolver;
}
/** 确认/取消的结果落事件日志(rightPush 原子,流式输出中途点确认也不覆盖对话事件)——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);
} catch (Exception e) {
log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage());
}
}
/**
* 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。
* 需登录且只能查自己的会话;只回渲染必需的字段——绝不外泄 sPayload/内部表名等。
*/
@GetMapping("/pending")
public Map pending(@RequestParam("conversationId") String conversationId,
@RequestHeader(value = "Authorization", required = false) String authToken) {
String uid = requireLogin(authToken).userId();
if (!conversations.owns(uid, conversationId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该会话");
}
Map op = ops.pending(conversationId);
if (op == null) {
return Map.of();
}
Map out = new LinkedHashMap<>();
out.put("opId", op.get("sId"));
out.put("summary", op.get("sDescription"));
out.put("status", op.get("sStatus"));
return out;
}
/**
* 确认执行:调 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) {
AgentIdentity me = requireLogin(authToken);
Map op = ops.get(id);
if (op == null) {
return result("failed", "找不到该操作", null);
}
requireOwner(me, op);
if (!"draft".equals(String.valueOf(op.get("sStatus")))) {
return result(String.valueOf(op.get("sStatus")), "该操作已处理过(" + op.get("sStatus") + ")", op);
}
// CAS 抢占:并发/重复确认只有一个能真正执行,杜绝同一张单被执行两次
if (!ops.claim(id)) {
Map cur = ops.get(id);
String st = cur == null ? "unknown" : String.valueOf(cur.get("sStatus"));
return result(st, "该操作正在处理或已处理过(" + st + ")", 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