ErpClient.java
16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
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;
/** /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<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();
}
/**
* 字段候选值({@code POST /ai/fieldOptions},见 {@code docs/erp-tasks-field-options.md}):按
* ERP 自己的下拉控件配置取候选——**行级数据权限、级联过滤(如产品限定在已选客户名下)、联动回填映射
* 都由 ERP 给**,这三样我们自己直查库时没有。
*
* <p>返回 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<String, String> context) {
if (endpointMissingUntil > System.currentTimeMillis()) {
return null; // 端点尚未上线:短路一段时间,别每次点击都去撞 404
}
try {
Map<String, Object> 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<String> 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<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;
}
}