修复空运锁舱签名、报价编号落库和查价 Token 失效重试。

锁舱按签名原字节出站,查价要带上 TMS 真实 quoteId/optionNo;收到 2001 换令牌再查,避免工单建了却提示查询失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 18:19:44 +08:00
co-authored by Cursor
parent d9029dec89
commit 83b907c4b8
11 changed files with 342 additions and 39 deletions
@@ -26,7 +26,8 @@ import java.util.Map;
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
* <p>
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
* 当前报价没有 quoteId / optionNo 时补 QT-{工单号} + AIR-OPT-01,仍出站;不因「没 TMS 报价」本地拦住。
* 锁舱必须带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
* 仍没有编号才补 QT-{工单号} + AIR-OPT-01 出站;不因「没 TMS 报价」本地拦住。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@@ -43,6 +44,9 @@ public class InquiryTmsCabinService {
@Autowired
@Lazy
private IInquiryWorkOrderService workOrderService;
@Autowired
@Lazy
private InquiryTmsQueryService queryService;
/**
* 智能体锁舱入口。
@@ -62,6 +66,7 @@ public class InquiryTmsCabinService {
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
}
Map<String, Object> quote = latestQuote(no);
quote = refreshLockQuote(ticket, quote);
String quoteId = resolveQuoteId(quote, no);
Map<String, Object> option = resolveOption(quote, optionNo);
if (StringUtils.isBlank(quoteId) || option == null) {
@@ -201,6 +206,55 @@ public class InquiryTmsCabinService {
.last("LIMIT 1"), false);
}
/**
* 库里的报价只有金额、没有 TMS 编号时,当场再查一次。
* WO015 就是这种情况:查价成功落了售价,锁舱却自编了 QT-工单号,TMS 回 2006。
*/
@SuppressWarnings("unchecked")
Map<String, Object> refreshLockQuote(InquiryTicket ticket, Map<String, Object> quote) {
Map<String, Object> current = quote == null ? new LinkedHashMap<>() : new LinkedHashMap<>(quote);
if (StringUtils.isNotBlank(text(current, "quoteId", "quote_id")) && !airOptions(current).isEmpty()) {
return current;
}
if (ticket == null || queryService == null) {
return current;
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo());
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject facts = JSON.parseObject(ticket.getFactsJson());
if (facts != null) {
body.put("facts", facts);
}
}
try {
Map<String, Object> live = queryService.queryFromAgent(body);
Object raw = live == null ? null : live.get("quote");
if (!(raw instanceof Map)) {
return current;
}
Map<String, Object> fetched = (Map<String, Object>) raw;
if (StringUtils.isBlank(text(current, "quoteId", "quote_id"))
&& StringUtils.isNotBlank(text(fetched, "quoteId", "quote_id"))) {
current.put("quoteId", text(fetched, "quoteId", "quote_id"));
}
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
current.put("airOptions", fetched.get("airOptions"));
}
if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg"));
}
log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={}",
ticket.getWorkOrderNo(),
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
airOptions(current).size());
} catch (RuntimeException ex) {
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
}
return current;
}
private Map<String, Object> latestQuote(String workOrderNo) {
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
@@ -179,6 +179,10 @@ public class InquiryTmsQueryService {
return out;
}
persistHit(workOrderNo, quote);
log.info("tms.quote persist wo={} hasQuoteId={} airOptLen={}",
workOrderNo,
StringUtils.isNotBlank(quote.get("quoteId")),
quote.get("airOptions") == null ? 0 : 1);
out.put("ok", true);
out.put("has_price", true);
out.put("classification", "HAS_PRICE");
@@ -284,19 +288,27 @@ public class InquiryTmsQueryService {
quote.put("source_label", "TMS 标准报价");
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
String quoteId = firstText(data.get("quoteId"), data.get("quote_id"));
// 真实 TMS 的 quoteId 形如 AIR-数字-数字,optionNo 是 AIR-OPT01(中间没有第二根横杠)。
String quoteId = firstText(data.get("quoteId"), data.get("quote_id"),
tms.get("quoteId"), tms.get("quote_id"));
if (StringUtils.isBlank(quoteId) && data.get("quote") instanceof Map) {
Map<?, ?> nested = (Map<?, ?>) data.get("quote");
quoteId = firstText(nested.get("quoteId"), nested.get("quote_id"));
}
if (StringUtils.isNotBlank(quoteId)) {
quote.put("quoteId", quoteId);
}
Object airOptions = data.get("airOptions");
if (airOptions == null) {
airOptions = data.get("air_options");
}
Object airOptions = first(data.get("airOptions"), data.get("air_options"),
tms.get("airOptions"), tms.get("air_options"));
if (airOptions != null) {
quote.put("airOptions", airOptions instanceof String
? String.valueOf(airOptions)
: JSON.toJSONString(airOptions));
}
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
if (StringUtils.isNotBlank(chargeable)) {
quote.put("chargeableWeightKg", chargeable);
}
return quote;
}
@@ -0,0 +1,100 @@
package org.jeecg.modules.inquiry.tms;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* 锁舱/释放 HTTP:按签名用的 UTF-8 原字节写出,不走 RestTemplate。
* <p>
* 对照测服:同一套密钥和算法,用原字节直打 TMS 能过签名(业务码变成报价不匹配);
* 主账 RestTemplate 出站仍回 1005。转换器可能改写正文或编码,导致签的和发的不是同一份。
* 本类只服务锁舱/释放;查价仍走 RestTemplate。
* 禁止:把密钥或签名原文写入日志。
*/
final class TmsCabinHttp {
private TmsCabinHttp() {
}
/**
* 与 HMAC 使用同一份 UTF-8 字节。
*/
static byte[] utf8Body(String json) {
return (json == null ? "" : json).getBytes(StandardCharsets.UTF_8);
}
/**
* POST 已签名 JSON。线程:调用方 Worker/Tomcat 线程,自带超时,不占全局锁。
*
* @return TMS 响应原文
*/
static CabinHttpResponse post(String url,
String json,
String accessToken,
String timestamp,
String nonce,
String signature,
int connectTimeoutMs,
int readTimeoutMs) throws IOException {
byte[] raw = utf8Body(json);
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
try {
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(Math.max(500, connectTimeoutMs));
conn.setReadTimeout(Math.max(1000, readTimeoutMs));
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("X-Timestamp", timestamp);
conn.setRequestProperty("X-Nonce", nonce);
conn.setRequestProperty("X-Signature", signature);
conn.setRequestProperty("Content-Length", String.valueOf(raw.length));
conn.setFixedLengthStreamingMode(raw.length);
try (OutputStream out = conn.getOutputStream()) {
out.write(raw);
out.flush();
}
int status = conn.getResponseCode();
InputStream stream = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
String text = readUtf8(stream);
return new CabinHttpResponse(status, text, raw.length);
} catch (SocketTimeoutException ex) {
throw ex;
} finally {
conn.disconnect();
}
}
private static String readUtf8(InputStream stream) throws IOException {
if (stream == null) {
return "";
}
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] chunk = new byte[2048];
int n;
while ((n = stream.read(chunk)) >= 0) {
buf.write(chunk, 0, n);
}
return new String(buf.toByteArray(), StandardCharsets.UTF_8);
}
static final class CabinHttpResponse {
final int status;
final String body;
final int bodyLen;
CabinHttpResponse(int status, String body, int bodyLen) {
this.status = status;
this.body = body == null ? "" : body;
this.bodyLen = bodyLen;
}
}
}
@@ -14,6 +14,9 @@ import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -112,7 +115,7 @@ public class TmsGatewayClient {
/**
* HMAC 出站:X-Timestamp / X-Nonce / X-Signature 与正文原字节一致。
* 锁舱/释放不缓存。连续失败走同一熔断。
* 锁舱/释放不走 RestTemplate,避免转换器改写已签名正文。不缓存。连续失败走同一熔断。
*/
private Map<String, Object> invokeCabin(String path, Map<String, Object> request) {
if (!properties.lockConfigured()) {
@@ -128,38 +131,57 @@ public class TmsGatewayClient {
for (int attempt = 1; attempt <= max; attempt++) {
long started = System.currentTimeMillis();
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(tokens.accessToken());
headers.set("X-Timestamp", timestamp);
headers.set("X-Nonce", nonce);
headers.set("X-Signature", signature);
ResponseEntity<String> response = restTemplate.exchange(
TmsCabinHttp.CabinHttpResponse response = TmsCabinHttp.post(
TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), path),
HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
Map<String, Object> body = TmsV2ResponseNormalizer.normalize(response.getBody());
json,
tokens.accessToken(),
timestamp,
nonce,
signature,
properties.getConnectTimeoutMs(),
properties.getReadTimeoutMs());
int status = response.status;
if (status == 429 || status >= 500) {
log.warn("tms.cabin http={} path={} attempt={} costMs={} bodyLen={}",
status, path, attempt, System.currentTimeMillis() - started, response.bodyLen);
last = new IllegalStateException("TMS HTTP=" + status);
if (attempt < max) {
sleepQuiet(200L * (1L << (attempt - 1)));
continue;
}
failCircuit();
throw last;
}
if (status < 200 || status >= 300) {
log.warn("tms.cabin http={} path={} attempt={} costMs={} bodyLen={}",
status, path, attempt, System.currentTimeMillis() - started, response.bodyLen);
failCircuit();
throw new IllegalStateException("TMS HTTP=" + status);
}
Map<String, Object> body = TmsV2ResponseNormalizer.normalize(response.body);
if (isTokenInvalid(body) && attempt < max) {
log.warn("tms.cabin token invalid path={} attempt={},换令牌重试", path, attempt);
tokens.invalidate();
continue;
}
consecutiveFailures.set(0);
body.put("formal", true);
body.put("source", "TMS");
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={}",
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={}",
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
body.get("code"), response.getStatusCodeValue(),
System.currentTimeMillis() - started, attempt);
body.get("code"), status,
System.currentTimeMillis() - started, attempt, response.bodyLen);
return body;
} catch (HttpStatusCodeException ex) {
int status = ex.getRawStatusCode();
log.warn("tms.cabin http={} path={} attempt={} costMs={}",
status, path, attempt, System.currentTimeMillis() - started);
last = new IllegalStateException("TMS HTTP=" + status);
if ((status == 429 || status >= 500) && attempt < max) {
} catch (SocketTimeoutException ex) {
log.warn("tms.cabin timeout path={} attempt={}", path, attempt);
last = new IllegalStateException("TMS 网络或超时");
if (attempt < max) {
sleepQuiet(200L * (1L << (attempt - 1)));
continue;
}
failCircuit();
throw last;
} catch (ResourceAccessException ex) {
} catch (IOException ex) {
log.warn("tms.cabin network path={} attempt={} err={}", path, attempt, ex.getMessage());
last = new IllegalStateException("TMS 网络或超时");
if (attempt < max) {
@@ -194,6 +216,14 @@ public class TmsGatewayClient {
new HttpEntity<>(json, headers),
String.class);
Map<String, Object> body = TmsV2ResponseNormalizer.normalize(response.getBody());
// TMS 查价常回 HTTP 200 + code=2001,不是 HTTP 401。文档要求换新令牌再打一次。
if (isTokenInvalid(body) && !refreshed401) {
log.warn("tms.query token invalid wo={} attempt={},换令牌重试",
request.get("workOrderNo"), attempt);
tokens.invalidate();
refreshed401 = true;
continue;
}
consecutiveFailures.set(0);
body.put("formal", true);
body.put("source", "TMS");
@@ -239,6 +269,11 @@ public class TmsGatewayClient {
throw last;
}
/** TMS 文档:2001 = Token 无效,须重新取令牌。 */
static boolean isTokenInvalid(Map<String, Object> body) {
return TmsV2ResponseNormalizer.number(body == null ? null : body.get("code")) == 2001;
}
private void assertCircuitAvailable() {
long until = circuitOpenUntil.get();
if (until > System.currentTimeMillis()) {
@@ -255,6 +290,17 @@ public class TmsGatewayClient {
}
}
/**
* 锁舱签名按 UTF-8 原文算,发出去必须是同一份字节。
* 用 String + application/json 时 RestTemplate 可能改编码或再序列化,TMS 回 1005。
*/
static HttpEntity<byte[]> rawUtf8Json(HttpHeaders headers, String json) {
byte[] raw = (json == null ? "" : json).getBytes(StandardCharsets.UTF_8);
headers.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
headers.setContentLength(raw.length);
return new HttpEntity<>(raw, headers);
}
private static void sleepQuiet(long ms) {
try {
Thread.sleep(ms);
@@ -60,7 +60,8 @@ public class TmsProperties {
@Value("${TMS_V2_CACHE_TTL_SECONDS:30}")
private int cacheTtlSeconds;
@Value("${TMS_V2_TOKEN_REFRESH_SKEW_SECONDS:60}")
/** 文档:到期前 300 秒刷新;收到 2001 再换新令牌重试一次。 */
@Value("${TMS_V2_TOKEN_REFRESH_SKEW_SECONDS:300}")
private int tokenRefreshSkewSeconds;
@Value("${TMS_V2_CIRCUIT_OPEN_SECONDS:30}")