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 * 等写方法已全部退役)。保留: * */ @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; /** /ai/fieldOptions 返回 404 后的静默期(ERP 侧尚未上线时不必每次点击都撞一次)。 */ private static final long ENDPOINT_MISSING_BACKOFF_MS = 5 * 60 * 1000L; private volatile long endpointMissingUntil; 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 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(会话过期)时重登重试。 *

安全:只有 dev-login(override 为空)才允许重登;透传的用户 token 过期时 * 绝不用 dev(admin) 重登——否则会把某用户静默提权成管理员。用户 token 过期直接把 -2 返回, * 由上层向对话推「登录过期」。 */ private boolean canRelogin(String override) { return override == null || override.isBlank(); } /** * 字段候选值({@code POST /ai/fieldOptions},见 {@code docs/erp-tasks-field-options.md}):按 * ERP 自己的下拉控件配置取候选——**行级数据权限、级联过滤(如产品限定在已选客户名下)、联动回填映射 * 都由 ERP 给**,这三样我们自己直查库时没有。 * *

返回 ERP 的载荷对象(含 {@code mode});端点不存在、报错、超时一律返回 null, * 由调用方回落到本地字典查询({@link FormResolverService#fkOptionPage}),保底可用。 * * @param context 表单上已选好的其它字段值(字段名→**已绑定的 id**),供级联条件使用;可空 */ public JsonNode fieldOptions(String authToken, String formId, String field, String q, int page, int pageSize, Map context) { if (endpointMissingUntil > System.currentTimeMillis()) { return null; // 端点尚未上线:短路一段时间,别每次点击都去撞 404 } try { Map body = new LinkedHashMap<>(); body.put("sFormId", formId); body.put("sField", field); body.put("q", q == null ? "" : q); body.put("pageNum", page); body.put("pageSize", pageSize); body.put("context", context == null ? Map.of() : context); HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/ai/fieldOptions")) .header("Content-Type", "application/json;charset=UTF-8") .header("Authorization", resolveToken(authToken)) .timeout(Duration.ofSeconds(20)) .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8)) .build(); HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (resp.statusCode() == 404 || resp.statusCode() == 405) { endpointMissingUntil = System.currentTimeMillis() + ENDPOINT_MISSING_BACKOFF_MS; log.info("ERP /ai/fieldOptions 尚未上线(HTTP {}),{} 分钟内回落本地字典查询", resp.statusCode(), ENDPOINT_MISSING_BACKOFF_MS / 60000); return null; } JsonNode root = mapper.readTree(resp.body()); JsonNode payload = pickFieldOptionsPayload(root); return payload != null && payload.has("mode") ? payload : null; } catch (Exception e) { log.warn("fieldOptions failed (form={}, field={}): {}", formId, field, e.getMessage()); return null; } } /** 载荷可能在根、dataset、或 dataset.rows[0](ERP 的 Feedback 信封有几种装法)。 */ private static JsonNode pickFieldOptionsPayload(JsonNode root) { if (root == null) { return null; } if (root.has("mode")) { return root; } JsonNode ds = root.path("dataset"); if (ds.has("mode")) { return ds; } JsonNode row = ds.path("rows").path(0); return row.isMissingNode() ? null : row; } /** * 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 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> bFilter = new ArrayList<>(); if (filterField != null && !filterField.isBlank() && filterValue != null && !filterValue.isBlank()) { Map 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 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 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); } } /** * Phase D 接口位: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> data = new ArrayList<>(); if (payload.has("__tables__")) { for (JsonNode t : payload.get("__tables__")) { @SuppressWarnings("unchecked") Map 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 col = mapper.convertValue(payload, Map.class); col.put("handleType", handleType); data.add(dataItem(table, "master", col)); } Map 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 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 dataItem(String table, String name, Map col) { Map item = new LinkedHashMap<>(); item.put("sTable", table); item.put("name", name); item.put("column", List.of(col)); return item; } }