修复空运锁舱签名、报价编号落库和查价 Token 失效重试。
锁舱按签名原字节出站,查价要带上 TMS 真实 quoteId/optionNo;收到 2001 换令牌再查,避免工单建了却提示查询失败。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,8 +23,8 @@ class AibotReplyClient:
|
|||||||
"""
|
"""
|
||||||
空运群出站客户端。
|
空运群出站客户端。
|
||||||
|
|
||||||
一次 @ 若带了 response_url,优先走流式回复。
|
@ 带了 response_url:流式只收口这次 @;正文走桥主动发言,和发模板同一条通道。
|
||||||
存档听到附件时没有 url:改走桥主动发言。该群须先被 @ 过,企微才允许主动说。
|
存档听到附件时没有 url:直接走桥。该群须先被 @ 过,企微才允许主动说。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, response_url: str = "", timeout_sec: float = 8.0) -> None:
|
def __init__(self, response_url: str = "", timeout_sec: float = 8.0) -> None:
|
||||||
@@ -44,9 +44,18 @@ class AibotReplyClient:
|
|||||||
self.sent.append({"chat_id": chat_id, "content": text, "kind": "text"})
|
self.sent.append({"chat_id": chat_id, "content": text, "kind": "text"})
|
||||||
if not text:
|
if not text:
|
||||||
return {"ok": False, "error": "empty"}
|
return {"ok": False, "error": "empty"}
|
||||||
|
room = str(chat_id or "").strip()
|
||||||
|
# @ 当场流式回话群里经常看不见长摘要,模板却走主动发言,就会只剩一张表。
|
||||||
|
# 工单信息必须再走桥,和模板同一条通道;流式只收口这次 @。
|
||||||
if self._url:
|
if self._url:
|
||||||
return self._send_stream(chat_id, text)
|
self._send_stream(room, text.split("\n", 1)[0][:80])
|
||||||
return self._send_proactive(str(chat_id or "").strip(), text)
|
if room:
|
||||||
|
out = self._send_proactive(room, text)
|
||||||
|
if out.get("ok") or not self._url:
|
||||||
|
return out
|
||||||
|
if self._url:
|
||||||
|
return self._send_stream(room, text)
|
||||||
|
return {"ok": False, "error": "no_chat_id"}
|
||||||
|
|
||||||
def _send_stream(self, chat_id: str, text: str) -> dict[str, Any]:
|
def _send_stream(self, chat_id: str, text: str) -> dict[str, Any]:
|
||||||
"""@ 当场回话:POST 企微给的 response_url。"""
|
"""@ 当场回话:POST 企微给的 response_url。"""
|
||||||
|
|||||||
@@ -92,12 +92,25 @@ class AibotReplyTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload["chatid"], "wr_air_1")
|
self.assertEqual(payload["chatid"], "wr_air_1")
|
||||||
self.assertEqual(payload["filename"], "WO1_quote.xlsx")
|
self.assertEqual(payload["filename"], "WO1_quote.xlsx")
|
||||||
|
|
||||||
def test_stream_url_does_not_hit_bridge(self) -> None:
|
def test_at_reply_puts_brief_on_same_channel_as_file(self) -> None:
|
||||||
|
"""
|
||||||
|
@ 当场流式回话群里经常只看得见后面的文件。
|
||||||
|
工单信息必须再走桥主动发言,和模板同一条通道。
|
||||||
|
"""
|
||||||
client = AibotReplyClient("https://example.com/reply")
|
client = AibotReplyClient("https://example.com/reply")
|
||||||
fake_http = MagicMock()
|
fake_http = MagicMock()
|
||||||
fake_http.post.return_value.status_code = 200
|
fake_http.post.return_value.status_code = 200
|
||||||
with patch("agent.channel.aibot.reply.httpx", fake_http):
|
fake_http.post.return_value.text = '{"status":"sent"}'
|
||||||
out = client.send_group(chat_id="wr_air_1", content="已激活")
|
settings = MagicMock()
|
||||||
|
settings.wecom_aibot_bridge_url = "http://127.0.0.1:8913"
|
||||||
|
settings.wecom_aibot_bridge_token = "tok"
|
||||||
|
brief = "工单号:WO202609160014\n询价字段:\n起运地:ZUH"
|
||||||
|
with patch("agent.channel.aibot.reply.httpx", fake_http), patch(
|
||||||
|
"agent.channel.aibot.reply.get_settings", return_value=settings
|
||||||
|
):
|
||||||
|
out = client.send_group(chat_id="wr_air_1", content=brief)
|
||||||
self.assertTrue(out["ok"])
|
self.assertTrue(out["ok"])
|
||||||
url, = fake_http.post.call_args.args
|
urls = [c.args[0] for c in fake_http.post.call_args_list]
|
||||||
self.assertEqual(url, "https://example.com/reply")
|
self.assertTrue(any(u.endswith("/send") or u.endswith("/send-group") for u in urls))
|
||||||
|
payloads = [c.kwargs.get("json") or {} for c in fake_http.post.call_args_list]
|
||||||
|
self.assertTrue(any(p.get("content") == brief for p in payloads))
|
||||||
|
|||||||
+55
-1
@@ -26,7 +26,8 @@ import java.util.Map;
|
|||||||
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
|
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
|
||||||
* <p>
|
* <p>
|
||||||
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
|
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
|
||||||
* 当前报价没有 quoteId / optionNo 时补 QT-{工单号} + AIR-OPT-01,仍出站;不因「没 TMS 报价」本地拦住。
|
* 锁舱必须带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
|
||||||
|
* 仍没有编号才补 QT-{工单号} + AIR-OPT-01 出站;不因「没 TMS 报价」本地拦住。
|
||||||
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
|
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
|
||||||
* 禁止:按 userid 伪造报价;单测打真实 TMS。
|
* 禁止:按 userid 伪造报价;单测打真实 TMS。
|
||||||
*/
|
*/
|
||||||
@@ -43,6 +44,9 @@ public class InquiryTmsCabinService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
@Lazy
|
@Lazy
|
||||||
private IInquiryWorkOrderService workOrderService;
|
private IInquiryWorkOrderService workOrderService;
|
||||||
|
@Autowired
|
||||||
|
@Lazy
|
||||||
|
private InquiryTmsQueryService queryService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 智能体锁舱入口。
|
* 智能体锁舱入口。
|
||||||
@@ -62,6 +66,7 @@ public class InquiryTmsCabinService {
|
|||||||
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
|
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
|
||||||
}
|
}
|
||||||
Map<String, Object> quote = latestQuote(no);
|
Map<String, Object> quote = latestQuote(no);
|
||||||
|
quote = refreshLockQuote(ticket, quote);
|
||||||
String quoteId = resolveQuoteId(quote, no);
|
String quoteId = resolveQuoteId(quote, no);
|
||||||
Map<String, Object> option = resolveOption(quote, optionNo);
|
Map<String, Object> option = resolveOption(quote, optionNo);
|
||||||
if (StringUtils.isBlank(quoteId) || option == null) {
|
if (StringUtils.isBlank(quoteId) || option == null) {
|
||||||
@@ -201,6 +206,55 @@ public class InquiryTmsCabinService {
|
|||||||
.last("LIMIT 1"), false);
|
.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) {
|
private Map<String, Object> latestQuote(String workOrderNo) {
|
||||||
Map<String, Object> quote = new LinkedHashMap<>();
|
Map<String, Object> quote = new LinkedHashMap<>();
|
||||||
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||||
|
|||||||
+17
-5
@@ -179,6 +179,10 @@ public class InquiryTmsQueryService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
persistHit(workOrderNo, quote);
|
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("ok", true);
|
||||||
out.put("has_price", true);
|
out.put("has_price", true);
|
||||||
out.put("classification", "HAS_PRICE");
|
out.put("classification", "HAS_PRICE");
|
||||||
@@ -284,19 +288,27 @@ public class InquiryTmsQueryService {
|
|||||||
quote.put("source_label", "TMS 标准报价");
|
quote.put("source_label", "TMS 标准报价");
|
||||||
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
|
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
|
||||||
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
|
// 锁舱必须带着 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)) {
|
if (StringUtils.isNotBlank(quoteId)) {
|
||||||
quote.put("quoteId", quoteId);
|
quote.put("quoteId", quoteId);
|
||||||
}
|
}
|
||||||
Object airOptions = data.get("airOptions");
|
Object airOptions = first(data.get("airOptions"), data.get("air_options"),
|
||||||
if (airOptions == null) {
|
tms.get("airOptions"), tms.get("air_options"));
|
||||||
airOptions = data.get("air_options");
|
|
||||||
}
|
|
||||||
if (airOptions != null) {
|
if (airOptions != null) {
|
||||||
quote.put("airOptions", airOptions instanceof String
|
quote.put("airOptions", airOptions instanceof String
|
||||||
? String.valueOf(airOptions)
|
? String.valueOf(airOptions)
|
||||||
: JSON.toJSONString(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;
|
return quote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+100
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-26
@@ -14,6 +14,9 @@ import org.springframework.web.client.HttpStatusCodeException;
|
|||||||
import org.springframework.web.client.ResourceAccessException;
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
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.time.Duration;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -112,7 +115,7 @@ public class TmsGatewayClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* HMAC 出站:X-Timestamp / X-Nonce / X-Signature 与正文原字节一致。
|
* HMAC 出站:X-Timestamp / X-Nonce / X-Signature 与正文原字节一致。
|
||||||
* 锁舱/释放不缓存。连续失败走同一熔断。
|
* 锁舱/释放不走 RestTemplate,避免转换器改写已签名正文。不缓存。连续失败走同一熔断。
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> invokeCabin(String path, Map<String, Object> request) {
|
private Map<String, Object> invokeCabin(String path, Map<String, Object> request) {
|
||||||
if (!properties.lockConfigured()) {
|
if (!properties.lockConfigured()) {
|
||||||
@@ -128,38 +131,57 @@ public class TmsGatewayClient {
|
|||||||
for (int attempt = 1; attempt <= max; attempt++) {
|
for (int attempt = 1; attempt <= max; attempt++) {
|
||||||
long started = System.currentTimeMillis();
|
long started = System.currentTimeMillis();
|
||||||
try {
|
try {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
TmsCabinHttp.CabinHttpResponse response = TmsCabinHttp.post(
|
||||||
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(
|
|
||||||
TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), path),
|
TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), path),
|
||||||
HttpMethod.POST,
|
json,
|
||||||
new HttpEntity<>(json, headers),
|
tokens.accessToken(),
|
||||||
String.class);
|
timestamp,
|
||||||
Map<String, Object> body = TmsV2ResponseNormalizer.normalize(response.getBody());
|
nonce,
|
||||||
consecutiveFailures.set(0);
|
signature,
|
||||||
body.put("formal", true);
|
properties.getConnectTimeoutMs(),
|
||||||
body.put("source", "TMS");
|
properties.getReadTimeoutMs());
|
||||||
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={}",
|
int status = response.status;
|
||||||
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
|
if (status == 429 || status >= 500) {
|
||||||
body.get("code"), response.getStatusCodeValue(),
|
log.warn("tms.cabin http={} path={} attempt={} costMs={} bodyLen={}",
|
||||||
System.currentTimeMillis() - started, attempt);
|
status, path, attempt, System.currentTimeMillis() - started, 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);
|
last = new IllegalStateException("TMS HTTP=" + status);
|
||||||
if ((status == 429 || status >= 500) && attempt < max) {
|
if (attempt < max) {
|
||||||
sleepQuiet(200L * (1L << (attempt - 1)));
|
sleepQuiet(200L * (1L << (attempt - 1)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
failCircuit();
|
failCircuit();
|
||||||
throw last;
|
throw last;
|
||||||
} catch (ResourceAccessException ex) {
|
}
|
||||||
|
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={} bodyLen={}",
|
||||||
|
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
|
||||||
|
body.get("code"), status,
|
||||||
|
System.currentTimeMillis() - started, attempt, response.bodyLen);
|
||||||
|
return body;
|
||||||
|
} 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 (IOException ex) {
|
||||||
log.warn("tms.cabin network path={} attempt={} err={}", path, attempt, ex.getMessage());
|
log.warn("tms.cabin network path={} attempt={} err={}", path, attempt, ex.getMessage());
|
||||||
last = new IllegalStateException("TMS 网络或超时");
|
last = new IllegalStateException("TMS 网络或超时");
|
||||||
if (attempt < max) {
|
if (attempt < max) {
|
||||||
@@ -194,6 +216,14 @@ public class TmsGatewayClient {
|
|||||||
new HttpEntity<>(json, headers),
|
new HttpEntity<>(json, headers),
|
||||||
String.class);
|
String.class);
|
||||||
Map<String, Object> body = TmsV2ResponseNormalizer.normalize(response.getBody());
|
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);
|
consecutiveFailures.set(0);
|
||||||
body.put("formal", true);
|
body.put("formal", true);
|
||||||
body.put("source", "TMS");
|
body.put("source", "TMS");
|
||||||
@@ -239,6 +269,11 @@ public class TmsGatewayClient {
|
|||||||
throw last;
|
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() {
|
private void assertCircuitAvailable() {
|
||||||
long until = circuitOpenUntil.get();
|
long until = circuitOpenUntil.get();
|
||||||
if (until > System.currentTimeMillis()) {
|
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) {
|
private static void sleepQuiet(long ms) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(ms);
|
Thread.sleep(ms);
|
||||||
|
|||||||
+2
-1
@@ -60,7 +60,8 @@ public class TmsProperties {
|
|||||||
@Value("${TMS_V2_CACHE_TTL_SECONDS:30}")
|
@Value("${TMS_V2_CACHE_TTL_SECONDS:30}")
|
||||||
private int cacheTtlSeconds;
|
private int cacheTtlSeconds;
|
||||||
|
|
||||||
@Value("${TMS_V2_TOKEN_REFRESH_SKEW_SECONDS:60}")
|
/** 文档:到期前 300 秒刷新;收到 2001 再换新令牌重试一次。 */
|
||||||
|
@Value("${TMS_V2_TOKEN_REFRESH_SKEW_SECONDS:300}")
|
||||||
private int tokenRefreshSkewSeconds;
|
private int tokenRefreshSkewSeconds;
|
||||||
|
|
||||||
@Value("${TMS_V2_CIRCUIT_OPEN_SECONDS:30}")
|
@Value("${TMS_V2_CIRCUIT_OPEN_SECONDS:30}")
|
||||||
|
|||||||
+16
@@ -54,6 +54,22 @@ public class InquiryTmsCabinServiceTest {
|
|||||||
assertEquals("AIR-OPT-01", option.get("optionNo"));
|
assertEquals("AIR-OPT-01", option.get("optionNo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resolveQuoteIdKeepsRealAirId() {
|
||||||
|
Map<String, Object> quote = new HashMap<>();
|
||||||
|
quote.put("quoteId", "AIR-2094721692181016578-2094721692654972929");
|
||||||
|
assertEquals("AIR-2094721692181016578-2094721692654972929",
|
||||||
|
InquiryTmsCabinService.resolveQuoteId(quote, "WO202609160015"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void priceOnlyStillFallsBackToWorkOrder() {
|
||||||
|
Map<String, Object> onlyPrice = new HashMap<>();
|
||||||
|
onlyPrice.put("total", "11792.00");
|
||||||
|
assertEquals("QT-WO202609160015",
|
||||||
|
InquiryTmsCabinService.resolveQuoteId(onlyPrice, "WO202609160015"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void resolveOptionKeepsExistingWhenPresent() {
|
public void resolveOptionKeepsExistingWhenPresent() {
|
||||||
Map<String, Object> quote = new HashMap<>();
|
Map<String, Object> quote = new HashMap<>();
|
||||||
|
|||||||
+30
@@ -1,7 +1,12 @@
|
|||||||
package org.jeecg.modules.inquiry.tms;
|
package org.jeecg.modules.inquiry.tms;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
@@ -30,4 +35,29 @@ public class TmsHmacSignerTest {
|
|||||||
assertThrows(IllegalArgumentException.class,
|
assertThrows(IllegalArgumentException.class,
|
||||||
() -> TmsHmacSigner.sign("", "1", "n", "{}"));
|
() -> TmsHmacSigner.sign("", "1", "n", "{}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void chineseBodyStableUtf8() {
|
||||||
|
String body = "{\"instructionText\":\"WO202609160014锁舱\"}";
|
||||||
|
String first = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", body);
|
||||||
|
String second = TmsHmacSigner.sign("unit-secret", "1710000000000", "nonce-1", body);
|
||||||
|
assertEquals(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void tokenInvalidRecognizes2001() {
|
||||||
|
java.util.Map<String, Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("code", 2001);
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(TmsGatewayClient.isTokenInvalid(body));
|
||||||
|
body.put("code", 1);
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(TmsGatewayClient.isTokenInvalid(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void cabinBodyBytesMatchSignedUtf8() {
|
||||||
|
String body = "{\"instructionText\":\"WO202609160014锁舱\"}";
|
||||||
|
assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), TmsCabinHttp.utf8Body(body));
|
||||||
|
HttpEntity<byte[]> entity = TmsGatewayClient.rawUtf8Json(new HttpHeaders(), body);
|
||||||
|
assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), entity.getBody());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -112,4 +112,26 @@ public class TmsQuoteCurrencyTest {
|
|||||||
assertEquals("QT-AIR-KEEP", quote.get("quoteId"));
|
assertEquals("QT-AIR-KEEP", quote.get("quoteId"));
|
||||||
assertTrue(quote.get("airOptions").contains("AIR-OPT-01"));
|
assertTrue(quote.get("airOptions").contains("AIR-OPT-01"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void toAgentQuoteKeepsRealAirLockIds() {
|
||||||
|
Map<String, Object> option = new HashMap<>();
|
||||||
|
option.put("optionNo", "AIR-OPT01");
|
||||||
|
option.put("originAirportCode", "ZUH");
|
||||||
|
option.put("destinationAirportCode", "CRK");
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("quoteFound", true);
|
||||||
|
data.put("quoteId", "AIR-2094721692181016578-2094721692654972929");
|
||||||
|
data.put("totalSalePrice", "11792.00");
|
||||||
|
data.put("currency", "CNY");
|
||||||
|
data.put("chargeableWeightKg", 1072);
|
||||||
|
data.put("airOptions", Collections.singletonList(option));
|
||||||
|
Map<String, Object> tms = new HashMap<>();
|
||||||
|
tms.put("code", 1);
|
||||||
|
tms.put("tmsData", data);
|
||||||
|
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||||
|
assertEquals("AIR-2094721692181016578-2094721692654972929", quote.get("quoteId"));
|
||||||
|
assertTrue(quote.get("airOptions").contains("AIR-OPT01"));
|
||||||
|
assertEquals("1072", quote.get("chargeableWeightKg"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
1. **一个 HTTP**:合并现 8810/8809/8811。询价入口、H5、主账唤醒、企微应用回调。请求线程只验签、入 inbox/任务表、返回;禁止同步 LibreOffice / 等完整 LLM。
|
1. **一个 HTTP**:合并现 8810/8809/8811。询价入口、H5、主账唤醒、企微应用回调。请求线程只验签、入 inbox/任务表、返回;禁止同步 LibreOffice / 等完整 LLM。
|
||||||
2. **一个 Worker**:消费 LLM、识别、报价(Excel→PDF)、微盛存档。队列 + **按任务认领**(处理中/超时回收)+ 重试。并发:LibreOffice **1 槽**,其余 HTTP 类 **2~3 槽**。Windows 服务只起一份;**禁止** Redis 全局 Worker 单例锁(现网 `wecom:worker:singleton`)。
|
2. **一个 Worker**:消费 LLM、识别、报价(Excel→PDF)、微盛存档。队列 + **按任务认领**(处理中/超时回收)+ 重试。并发:LibreOffice **1 槽**,其余 HTTP 类 **2~3 槽**。Windows 服务只起一份;**禁止** Redis 全局 Worker 单例锁(现网 `wecom:worker:singleton`)。
|
||||||
3. **AIBOT 客户端**:连本机 8813 桥;桥进程可仍是 Node `.cjs`。
|
3. **AIBOT 客户端**:连本机桥(测试 8913)。有 `response_url` 当场回;没有则 POST 桥 `/send-group`(空运群发文件后马上说话)。激活发空运模板走 `/send-file`。桥进程可仍是 Node `.cjs`。
|
||||||
4. 企微 inbox 消费与 outbox 外发:放在 HTTP 进程的快循环,或 Worker 里与 soffice **隔离的槽**,不得和转 PDF 串行互堵。
|
4. 企微 inbox 消费与 outbox 外发:放在 HTTP 进程的快循环,或 Worker 里与 soffice **隔离的槽**,不得和转 PDF 串行互堵。
|
||||||
|
|
||||||
不要:独立 `bridge_api` / `wecom_robot` 端口;五个 NSSM Worker。群机器人 8812 默认不做。
|
不要:独立 `bridge_api` / `wecom_robot` 端口;五个 NSSM Worker。群机器人 8812 默认不做。
|
||||||
|
|||||||
Reference in New Issue
Block a user