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
* 等写方法已全部退役)。保留:
*
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