From 83b907c4b8540484ab4024ebeea73660123756d6 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Wed, 16 Sep 2026 18:19:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A9=BA=E8=BF=90=E9=94=81?= =?UTF-8?q?=E8=88=B1=E7=AD=BE=E5=90=8D=E3=80=81=E6=8A=A5=E4=BB=B7=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=E8=90=BD=E5=BA=93=E5=92=8C=E6=9F=A5=E4=BB=B7=20Token?= =?UTF-8?q?=20=E5=A4=B1=E6=95=88=E9=87=8D=E8=AF=95=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 锁舱按签名原字节出站,查价要带上 TMS 真实 quoteId/optionNo;收到 2001 换令牌再查,避免工单建了却提示查询失败。 Co-authored-by: Cursor --- inquiry-agent/agent/channel/aibot/reply.py | 17 ++- inquiry-agent/tests/test_aibot_reply.py | 23 +++- .../inquiry/tms/InquiryTmsCabinService.java | 56 +++++++++- .../inquiry/tms/InquiryTmsQueryService.java | 22 +++- .../modules/inquiry/tms/TmsCabinHttp.java | 100 ++++++++++++++++++ .../modules/inquiry/tms/TmsGatewayClient.java | 90 ++++++++++++---- .../modules/inquiry/tms/TmsProperties.java | 3 +- .../tms/InquiryTmsCabinServiceTest.java | 16 +++ .../inquiry/tms/TmsHmacSignerTest.java | 30 ++++++ .../inquiry/tms/TmsQuoteCurrencyTest.java | 22 ++++ prompt/06-Python智能体.md | 2 +- 11 files changed, 342 insertions(+), 39 deletions(-) create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinHttp.java diff --git a/inquiry-agent/agent/channel/aibot/reply.py b/inquiry-agent/agent/channel/aibot/reply.py index cec9226..dc02839 100644 --- a/inquiry-agent/agent/channel/aibot/reply.py +++ b/inquiry-agent/agent/channel/aibot/reply.py @@ -23,8 +23,8 @@ class AibotReplyClient: """ 空运群出站客户端。 - 一次 @ 若带了 response_url,优先走流式回复。 - 存档听到附件时没有 url:改走桥主动发言。该群须先被 @ 过,企微才允许主动说。 + @ 带了 response_url:流式只收口这次 @;正文走桥主动发言,和发模板同一条通道。 + 存档听到附件时没有 url:直接走桥。该群须先被 @ 过,企微才允许主动说。 """ 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"}) if not text: return {"ok": False, "error": "empty"} + room = str(chat_id or "").strip() + # @ 当场流式回话群里经常看不见长摘要,模板却走主动发言,就会只剩一张表。 + # 工单信息必须再走桥,和模板同一条通道;流式只收口这次 @。 if self._url: - return self._send_stream(chat_id, text) - return self._send_proactive(str(chat_id or "").strip(), text) + self._send_stream(room, text.split("\n", 1)[0][:80]) + 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]: """@ 当场回话:POST 企微给的 response_url。""" diff --git a/inquiry-agent/tests/test_aibot_reply.py b/inquiry-agent/tests/test_aibot_reply.py index 59cf59e..a8ae686 100644 --- a/inquiry-agent/tests/test_aibot_reply.py +++ b/inquiry-agent/tests/test_aibot_reply.py @@ -92,12 +92,25 @@ class AibotReplyTests(unittest.TestCase): self.assertEqual(payload["chatid"], "wr_air_1") 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") fake_http = MagicMock() fake_http.post.return_value.status_code = 200 - with patch("agent.channel.aibot.reply.httpx", fake_http): - out = client.send_group(chat_id="wr_air_1", content="已激活") + fake_http.post.return_value.text = '{"status":"sent"}' + 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"]) - url, = fake_http.post.call_args.args - self.assertEqual(url, "https://example.com/reply") + urls = [c.args[0] for c in fake_http.post.call_args_list] + 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)) diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index 5457c0d..ea56717 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -26,7 +26,8 @@ import java.util.Map; * 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。 *

* 锁舱按 /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 quote = latestQuote(no); + quote = refreshLockQuote(ticket, quote); String quoteId = resolveQuoteId(quote, no); Map 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 refreshLockQuote(InquiryTicket ticket, Map quote) { + Map 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 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 live = queryService.queryFromAgent(body); + Object raw = live == null ? null : live.get("quote"); + if (!(raw instanceof Map)) { + return current; + } + Map fetched = (Map) 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 latestQuote(String workOrderNo) { Map quote = new LinkedHashMap<>(); List rows = quoteMapper.selectList(new LambdaQueryWrapper() diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index b5b1e95..cd79ae6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -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; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinHttp.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinHttp.java new file mode 100644 index 0000000..8caa836 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinHttp.java @@ -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。 + *

+ * 对照测服:同一套密钥和算法,用原字节直打 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; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java index 8bc6310..ed587e0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java @@ -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 invokeCabin(String path, Map 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 response = restTemplate.exchange( + TmsCabinHttp.CabinHttpResponse response = TmsCabinHttp.post( TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), path), - HttpMethod.POST, - new HttpEntity<>(json, headers), - String.class); - Map 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 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 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 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 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); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java index 67f1540..2b38be0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java @@ -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}") diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index 29f9a40..06894bc 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -54,6 +54,22 @@ public class InquiryTmsCabinServiceTest { assertEquals("AIR-OPT-01", option.get("optionNo")); } + @Test + public void resolveQuoteIdKeepsRealAirId() { + Map quote = new HashMap<>(); + quote.put("quoteId", "AIR-2094721692181016578-2094721692654972929"); + assertEquals("AIR-2094721692181016578-2094721692654972929", + InquiryTmsCabinService.resolveQuoteId(quote, "WO202609160015")); + } + + @Test + public void priceOnlyStillFallsBackToWorkOrder() { + Map onlyPrice = new HashMap<>(); + onlyPrice.put("total", "11792.00"); + assertEquals("QT-WO202609160015", + InquiryTmsCabinService.resolveQuoteId(onlyPrice, "WO202609160015")); + } + @Test public void resolveOptionKeepsExistingWhenPresent() { Map quote = new HashMap<>(); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsHmacSignerTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsHmacSignerTest.java index 42afe1e..aff63e0 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsHmacSignerTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsHmacSignerTest.java @@ -1,7 +1,12 @@ package org.jeecg.modules.inquiry.tms; 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.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -30,4 +35,29 @@ public class TmsHmacSignerTest { assertThrows(IllegalArgumentException.class, () -> 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 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 entity = TmsGatewayClient.rawUtf8Json(new HttpHeaders(), body); + assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), entity.getBody()); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java index 24277b7..4de1ef3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -112,4 +112,26 @@ public class TmsQuoteCurrencyTest { assertEquals("QT-AIR-KEEP", quote.get("quoteId")); assertTrue(quote.get("airOptions").contains("AIR-OPT-01")); } + + @Test + public void toAgentQuoteKeepsRealAirLockIds() { + Map option = new HashMap<>(); + option.put("optionNo", "AIR-OPT01"); + option.put("originAirportCode", "ZUH"); + option.put("destinationAirportCode", "CRK"); + Map 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 tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + assertEquals("AIR-2094721692181016578-2094721692654972929", quote.get("quoteId")); + assertTrue(quote.get("airOptions").contains("AIR-OPT01")); + assertEquals("1072", quote.get("chargeableWeightKg")); + } } diff --git a/prompt/06-Python智能体.md b/prompt/06-Python智能体.md index 24ac493..c50793b 100644 --- a/prompt/06-Python智能体.md +++ b/prompt/06-Python智能体.md @@ -8,7 +8,7 @@ 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`)。 -3. **AIBOT 客户端**:连本机 8813 桥;桥进程可仍是 Node `.cjs`。 +3. **AIBOT 客户端**:连本机桥(测试 8913)。有 `response_url` 当场回;没有则 POST 桥 `/send-group`(空运群发文件后马上说话)。激活发空运模板走 `/send-file`。桥进程可仍是 Node `.cjs`。 4. 企微 inbox 消费与 outbox 外发:放在 HTTP 进程的快循环,或 Worker 里与 soffice **隔离的槽**,不得和转 PDF 串行互堵。 不要:独立 `bridge_api` / `wecom_robot` 端口;五个 NSSM Worker。群机器人 8812 默认不做。