ErpClient.java
13 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
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;
}
}