ErpClient.java 13 KB
package com.xly.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * ERP 后端(xlyEntry)薄 HTTP 客户端——rearch3 收权后**只读**(§3:AI 侧唯一的写 = ai_op_queue,
 * 执行/审计全部移交 ERP 侧;原 addUpdateDelBusinessData/updatebInvalid/doExamine/execStaging
 * 等写方法已全部退役)。保留:
 * <ul>
 *   <li><b>token 解析(架构 §7)</b>:每次调用优先用调用方**透传的用户 ERP 会话 token**
 *       (per-request、不长期存、绝不进 prompt);只有在完全没有用户 token 时才退回配置的 dev 账号
 *       {@code /checklogin} 登录并缓存。<b>用户 token 过期不会用 dev 账号重登</b>(见 {@code canRelogin}),
 *       避免静默提权;只有 dev-login 自己的会话过期(code=-2)才自动重登一次重试。</li>
 *   <li><b>whoami</b>:token 服务端内省(身份唯一来源,fail-closed)。</li>
 *   <li><b>读</b>:通用表单读接口 {@code getBusinessDataByFormcustomId}(只传分页/过滤参数)。</li>
 *   <li><b>getUuid</b>:为 create 载荷预生成主键(非业务写入)。</li>
 *   <li><b>checkBusinessData(Phase D 接口位)</b>:ERP dry-run 校验(独立事务强制回滚,validate-only),
 *       ERP 侧完工前由 {@code erp.dry-run.enabled=false} 关闭。</li>
 * </ul>
 */
@Service
public class ErpClient {

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

    private final ObjectMapper mapper;
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();

    @Value("${erp.baseurl}")
    private String baseUrl;
    /** 仅本地开发可为 true;生产必须 false(默认)——否则空 token 会静默以 dev 账号(管理员)执行。 */
    @Value("${erp.dev-login.enabled:false}")
    private boolean devLoginEnabled;
    @Value("${erp.dev-login.brand:}")
    private String brand;
    @Value("${erp.dev-login.subsidiary:}")
    private String subsidiary;
    @Value("${erp.dev-login.username:}")
    private String username;
    @Value("${erp.dev-login.password:}")
    private String password;

    private volatile String cachedToken;

    public ErpClient(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    /** 是否允许 dev-login 兜底(生产 false → 所有无 token 调用 fail-closed)。 */
    public boolean devLoginEnabled() {
        return devLoginEnabled;
    }

    /** 用配置的 dev 账号登录 ERP,缓存返回的 Authorization token。仅 dev-login 开启时可用。 */
    private synchronized String login() {
        if (!devLoginEnabled) {
            throw new IllegalStateException("缺少用户登录 token(dev-login 已禁用)");
        }
        try {
            String url = baseUrl + "/checklogin/" + brand + "/" + subsidiary;
            String body = mapper.writeValueAsString(Map.of("username", username, "password", password));
            HttpRequest req = HttpRequest.newBuilder(URI.create(url))
                    .header("Content-Type", "application/json;charset=UTF-8")
                    .timeout(Duration.ofSeconds(20))
                    .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
                    .build();
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            JsonNode root = mapper.readTree(resp.body());
            if (root.path("code").asInt() != 1) {
                throw new IllegalStateException("ERP 登录失败: " + root.path("msg").asText());
            }
            String token = root.path("dataset").path("rows").path(0).path("token").asText(null);
            if (token == null || token.isBlank()) {
                throw new IllegalStateException("ERP 登录未返回 token");
            }
            cachedToken = token;
            log.info("ERP dev-login ok (user={}), token cached", username);
            return token;
        } catch (Exception e) {
            throw new RuntimeException("ERP 登录异常: " + e.getMessage(), e);
        }
    }

    private String token() {
        String t = cachedToken;
        return (t != null && !t.isBlank()) ? t : login();
    }

    /**
     * 拼进 URL 的 id(formId/moduleId)必须是纯 id 形态。这些值可能来自模型输出,
     * 直接拼接会让 {@code ?} / {@code &} / {@code /} 改写请求(加参数、换路径)。非法直接拒绝。
     */
    private static String safeId(String id) {
        String s = id == null ? "" : id.trim();
        if (s.isEmpty() || s.length() > 64 || !s.matches("[A-Za-z0-9_.-]+")) {
            throw new IllegalArgumentException("非法的 id 参数: " + id);
        }
        return s;
    }

    /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */
    private String resolveToken(String override) {
        return (override != null && !override.isBlank()) ? override : token();
    }

    /**
     * 是否允许在 code=-2(会话过期)时重登重试。
     * <p><b>安全</b>:只有 dev-login(override 为空)才允许重登;透传的用户 token 过期时
     * <b>绝不</b>用 dev(admin) 重登——否则会把某用户静默提权成管理员。用户 token 过期直接把 -2 返回,
     * 由上层向对话推「登录过期」。
     */
    private boolean canRelogin(String override) {
        return override == null || override.isBlank();
    }

    /**
     * token 内省:把透传的用户 token 换成 ERP **服务端认定**的身份(/ai/whoami,@CurrentUser 解析)。
     * 返回 {sId,sUserNo,sUserName,sType,sBrandsId,sSubsidiaryId};token 无效/过期返回 null。
     */
    public JsonNode whoami(String authToken) {
        if (authToken == null || authToken.isBlank()) {
            return null;
        }
        try {
            HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/ai/whoami"))
                    .header("Content-Type", "application/json;charset=UTF-8")
                    .header("Authorization", authToken)
                    .timeout(Duration.ofSeconds(20))
                    .GET()
                    .build();
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            JsonNode root = mapper.readTree(resp.body());
            JsonNode row = root.has("sId") ? root : root.path("dataset").path("rows").path(0);
            String uid = row.path("sId").asText("");
            return uid.isBlank() ? null : row;
        } catch (Exception e) {
            log.warn("whoami failed: {}", e.getMessage());
            return null;
        }
    }

    /**
     * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。
     * {@code authToken} 为透传的用户 token(可空 → dev-login)。dev-login 会话过期(code=-2)时自动重登重试;
     * 用户 token 过期不重登(见 {@link #canRelogin})。
     */
    public JsonNode readForm(String authToken, String formId, String moduleId, int page, int pageSize,
                             String filterField, String filterValue) {
        JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken));
        if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
            login();
            root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken));
        }
        return root;
    }

    private JsonNode doRead(String formId, String moduleId, int page, int pageSize,
                            String filterField, String filterValue, String tok) {
        try {
            String url = baseUrl + "/business/getBusinessDataByFormcustomId/" + safeId(formId)
                    + "?sModelsId=" + safeId(moduleId) + "&sName=";
            List<Map<String, Object>> bFilter = new ArrayList<>();
            if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) {
                Map<String, Object> f = new LinkedHashMap<>();
                f.put("bFilterName", filterField);
                f.put("bFilterCondition", "like");
                f.put("bFilterValue", filterValue.trim());
                bFilter.add(f);
            }
            String body = mapper.writeValueAsString(Map.of(
                    "pageNum", page, "pageSize", pageSize, "bFilter", bFilter));
            HttpRequest req = HttpRequest.newBuilder(URI.create(url))
                    .header("Content-Type", "application/json;charset=UTF-8")
                    .header("Authorization", tok)
                    .timeout(Duration.ofSeconds(30))
                    .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
                    .build();
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            return mapper.readTree(resp.body());
        } catch (Exception e) {
            throw new RuntimeException("ERP 读取异常: " + e.getMessage(), e);
        }
    }

    /** 取一个新主键 uuid(ERP `/getUuid`;为 create 载荷预生成主键,非业务写入)。 */
    public String newUuid(String authToken) {
        try {
            HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid"))
                    .header("Authorization", resolveToken(authToken))
                    .timeout(Duration.ofSeconds(15))
                    .GET()
                    .build();
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            JsonNode root = mapper.readTree(resp.body());
            return root.path("dataset").path("rows").path(0).asText(null);
        } catch (Exception e) {
            throw new RuntimeException("取 uuid 失败: " + e.getMessage(), e);
        }
    }

    /**
     * <b>Phase D 接口位</b>:ERP dry-run 校验 {@code /business/checkBusinessData}(复用真实校验链、
     * REQUIRES_NEW 独立事务强制回滚,validate-only)。请求体与 addUpdateDelBusinessData 同形。
     * {@code payloadJson} = 列->值 JSON(create 可含 __tables__ 多表);{@code handleType} = add|update|del。
     * ⚠️ ERP 侧该端点尚未提交上线(他人负责);调用方须由 {@code erp.dry-run.enabled} 开关保护。
     */
    public JsonNode checkBusinessData(String authToken, String moduleId, String table,
                                      String payloadJson, String handleType) {
        try {
            String url = baseUrl + "/business/checkBusinessData?sModelsId=" + safeId(moduleId);
            JsonNode payload = mapper.readTree(payloadJson);
            List<Map<String, Object>> data = new ArrayList<>();
            if (payload.has("__tables__")) {
                for (JsonNode t : payload.get("__tables__")) {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> col = mapper.convertValue(t.get("column"), Map.class);
                    col.put("handleType", handleType);
                    data.add(dataItem(t.path("sTable").asText(""), t.path("name").asText("master"), col));
                }
            } else {
                @SuppressWarnings("unchecked")
                Map<String, Object> col = mapper.convertValue(payload, Map.class);
                col.put("handleType", handleType);
                data.add(dataItem(table, "master", col));
            }
            Map<String, Object> body = new LinkedHashMap<>();
            body.put("sModelsId", moduleId);
            body.put("data", data);
            HttpRequest req = HttpRequest.newBuilder(URI.create(url))
                    .header("Content-Type", "application/json;charset=UTF-8")
                    .header("Authorization", resolveToken(authToken))
                    .timeout(Duration.ofSeconds(40))
                    .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8))
                    .build();
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            return mapper.readTree(resp.body());
        } catch (Exception e) {
            throw new RuntimeException("ERP 校验异常: " + e.getMessage(), e);
        }
    }

    private Map<String, Object> dataItem(String table, String name, Map<String, Object> col) {
        Map<String, Object> item = new LinkedHashMap<>();
        item.put("sTable", table);
        item.put("name", name);
        item.put("column", List.of(col));
        return item;
    }
}