@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +8 -1
- package/dist/capabilities/session/gui-flight.js +737 -0
- package/dist/capabilities/session/gui-hotel.js +413 -0
- package/dist/capabilities/session-search.js +98 -11
- package/dist/scripts/bootstrap-tests.js +23 -6
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/session-tests.js +177 -6
- package/dist/src/gotry-web-api.js +64 -2
- package/dist/src/index.js +40 -18
- package/extension/manifest.json +2 -2
- package/package.json +2 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +9 -2
- package/ts/capabilities/session/gui-flight.ts +792 -0
- package/ts/capabilities/session/gui-hotel.ts +451 -0
- package/ts/capabilities/session-search.ts +115 -15
- package/ts/src/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +41 -19
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
* 本层只让「每条通道」更可替换、可熔断、可 mock。
|
|
24
24
|
*/
|
|
25
25
|
import { CircuitBreaker, withRetry, } from './resilience.js';
|
|
26
|
-
import { flyaiSearch } from './flyai.js';
|
|
26
|
+
import { flyaiBreakerScope, flyaiSearch } from './flyai.js';
|
|
27
27
|
import { checkAvail, hotelRates, searchHotels } from './hbcli.js';
|
|
28
|
-
import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch } from './session-search.js';
|
|
28
|
+
import { sessionFlightSearch, sessionFlightGuiSearch, sessionHotelGuiSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch } from './session-search.js';
|
|
29
29
|
import { geocodePlace, getClimate, getForecast } from './weather.js';
|
|
30
30
|
import { verifyFlight } from './opensky.js';
|
|
31
31
|
import { anythingSearch } from './anything.js';
|
|
@@ -86,8 +86,12 @@ const DEFAULT_HANDLERS = {
|
|
|
86
86
|
HBCLI_CHECK_AVAIL: (p) => checkAvail({ ratePkgId: p.ratePkgId }, { hbcliBin: p.hbcliBin, timeoutMs: p.timeoutMs }),
|
|
87
87
|
/** 用户本人登录态会话检索(浏览器解译通道:扩展桥默认 + cdp 显式后备;ReadGuard+节律闸) */
|
|
88
88
|
SESSION_FLIGHT_SEARCH: (p) => sessionFlightSearch(p),
|
|
89
|
+
/** 携程机票 GUI 车道(2026-10-14 实装;gotry 隔离匿名窗口驱动 PC 表单,往返/任意城市原生;匿名公开价) */
|
|
90
|
+
SESSION_FLIGHT_GUI_SEARCH: (p) => sessionFlightGuiSearch(p),
|
|
89
91
|
/** 用户本人登录态会话酒店检索(2026-09-03 实装;同浏览器解译通道,风控红线同款) */
|
|
90
92
|
SESSION_HOTEL_SEARCH: (p) => sessionHotelSearch(p),
|
|
93
|
+
/** 携程酒店 GUI 车道(2026-10-14 实装,#664;匿名窗口驱动表单,cityId 码表退出主链路) */
|
|
94
|
+
SESSION_HOTEL_GUI_SEARCH: (p) => sessionHotelGuiSearch(p),
|
|
91
95
|
/** 12306 余票会话检索(2026-09-03 实装;公开查询面,无登录闸;同浏览器解译通道) */
|
|
92
96
|
SESSION_TRAIN_SEARCH: (p) => sessionTrainSearch(p),
|
|
93
97
|
/** Dida 供应商门户实时价会话检索(2026-09-09 实装;hotel-be portal integration 迁移线) */
|
|
@@ -133,8 +137,8 @@ const hbcliTimeout = (r) => {
|
|
|
133
137
|
const HBCLI_RETRY = { maxAttempts: 2, baseDelayMs: 300, maxDelayMs: 1_000 };
|
|
134
138
|
/**
|
|
135
139
|
* 渠道韧性策略表(权威面;docs/design/effect-interpreter.md §3 同表逐行有依据):
|
|
136
|
-
* - FLYAI
|
|
137
|
-
*
|
|
140
|
+
* - FLYAI:明确瞬时故障重试 1 次;同 kind/endpoint/key 连续 3 次明确
|
|
141
|
+
* 上游健康故障(含 Sentinel)熔断 60s;业务、配置与未知失败不计熔断;
|
|
138
142
|
* - HBCLI:仅 timeout 类失败重试 1 次(冷启动建后端 session 可超时,重试即恢复);
|
|
139
143
|
* ENOENT/退码类永不重试(上游契约「候选路径是切换不是重试」),熔断同上;
|
|
140
144
|
* RATES/CHECK_AVAIL 同族,且价格面无静态降级(fail-closed,不估算房价——
|
|
@@ -159,7 +163,12 @@ const SPECS = {
|
|
|
159
163
|
// Only explicit transient classification permits retry; terminal failures stay final.
|
|
160
164
|
return result?.retryable === true;
|
|
161
165
|
},
|
|
162
|
-
isFailure:
|
|
166
|
+
isFailure: r => r?.upstreamHealth === 'unhealthy',
|
|
167
|
+
isNeutral: r => {
|
|
168
|
+
const health = r?.upstreamHealth;
|
|
169
|
+
return health !== 'healthy' && health !== 'unhealthy';
|
|
170
|
+
},
|
|
171
|
+
breakerScope: p => flyaiBreakerScope(p?.kind),
|
|
163
172
|
},
|
|
164
173
|
HBCLI_HOTEL_SEARCH: {
|
|
165
174
|
channel: 'cli',
|
|
@@ -190,12 +199,24 @@ const SPECS = {
|
|
|
190
199
|
breaker: null,
|
|
191
200
|
isFailure: defaultIsFailure,
|
|
192
201
|
},
|
|
202
|
+
SESSION_FLIGHT_GUI_SEARCH: {
|
|
203
|
+
channel: 'browser',
|
|
204
|
+
retry: null,
|
|
205
|
+
breaker: null,
|
|
206
|
+
isFailure: defaultIsFailure,
|
|
207
|
+
},
|
|
193
208
|
SESSION_HOTEL_SEARCH: {
|
|
194
209
|
channel: 'browser',
|
|
195
210
|
retry: null,
|
|
196
211
|
breaker: null,
|
|
197
212
|
isFailure: defaultIsFailure,
|
|
198
213
|
},
|
|
214
|
+
SESSION_HOTEL_GUI_SEARCH: {
|
|
215
|
+
channel: 'browser',
|
|
216
|
+
retry: null,
|
|
217
|
+
breaker: null,
|
|
218
|
+
isFailure: defaultIsFailure,
|
|
219
|
+
},
|
|
199
220
|
SESSION_TRAIN_SEARCH: {
|
|
200
221
|
channel: 'browser',
|
|
201
222
|
retry: null,
|
|
@@ -286,9 +307,12 @@ export function declinedObservation(effect, trace) {
|
|
|
286
307
|
summary: trace.declined === 'aborted'
|
|
287
308
|
? `${effect} 检索已取消,未产生新的查询结果。`
|
|
288
309
|
: trace.declined === 'circuit-open'
|
|
289
|
-
?
|
|
310
|
+
? trace.breaker === 'half-open'
|
|
311
|
+
? `${effect} 通道的恢复探测正在进行;等待当前探测结果后再查询,或使用其他工具。`
|
|
312
|
+
: `${effect} 通道被断路器开启保护(连续上游故障达到阈值,冷却中);${typeof trace.retryAfterMs === 'number' ? `约 ${Math.ceil(trace.retryAfterMs / 1_000)} 秒后可发起恢复探测` : '稍后可发起恢复探测'},当前可使用其他工具。`
|
|
290
313
|
: `${effect} 未登记效应(effect_interpreter.v1 注册表外,生产解译器拒绝)`,
|
|
291
314
|
evidence: trace.evidence.join(';'),
|
|
315
|
+
...(trace.retryAfterMs !== undefined ? { retryAfterMs: trace.retryAfterMs } : {}),
|
|
292
316
|
};
|
|
293
317
|
}
|
|
294
318
|
const defaultBreakers = new Map();
|
|
@@ -314,16 +338,18 @@ export function makeProductionInterpreter(opts = {}) {
|
|
|
314
338
|
const br = (() => {
|
|
315
339
|
if (!spec.breaker)
|
|
316
340
|
return null;
|
|
317
|
-
const
|
|
341
|
+
const key = spec.breakerScope ? `${fx.effect}:${spec.breakerScope(fx.params)}` : fx.effect;
|
|
342
|
+
const existing = breakers.get(key);
|
|
318
343
|
if (existing)
|
|
319
344
|
return existing;
|
|
320
345
|
const created = new CircuitBreaker({ ...spec.breaker, ...(opts.now ? { now: opts.now } : {}) });
|
|
321
|
-
breakers.set(
|
|
346
|
+
breakers.set(key, created);
|
|
322
347
|
return created;
|
|
323
348
|
})();
|
|
324
349
|
const gate = br?.canAttempt() ?? { allowed: true, state: 'closed', token: undefined };
|
|
325
350
|
if (!gate.allowed) {
|
|
326
|
-
const
|
|
351
|
+
const retryAfterMs = 'retryAfterMs' in gate ? gate.retryAfterMs : undefined;
|
|
352
|
+
const trace = { effect: fx.effect, channel: spec.channel, attempts: 0, backoffMs: 0, breaker: gate.state, declined: 'circuit-open', ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), evidence: [`[效应:${fx.effect}@${ts}] breaker=${gate.state} 拒绝${retryAfterMs !== undefined ? ` retryAfterMs=${retryAfterMs}` : '(恢复探测在途)'}`] };
|
|
327
353
|
return { result: null, trace };
|
|
328
354
|
}
|
|
329
355
|
const policy = spec.retry
|
|
@@ -346,9 +372,16 @@ export function makeProductionInterpreter(opts = {}) {
|
|
|
346
372
|
};
|
|
347
373
|
return { result: null, trace: abortedTrace };
|
|
348
374
|
}
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
375
|
+
const neutral = spec.isNeutral?.(outcome.result, outcome.error) === true;
|
|
376
|
+
const failed = !neutral && (outcome.error != null || spec.isFailure(outcome.result));
|
|
377
|
+
if (br) {
|
|
378
|
+
if (neutral)
|
|
379
|
+
br.releaseProbe(gate.token);
|
|
380
|
+
else if (failed)
|
|
381
|
+
br.onFailure(gate.token);
|
|
382
|
+
else
|
|
383
|
+
br.onSuccess(gate.token);
|
|
384
|
+
}
|
|
352
385
|
const breakerState = br?.state() ?? 'off';
|
|
353
386
|
const evidence = [`[效应:${fx.effect}@${ts}] attempts=${outcome.attempts} backoff=${outcome.backoffMs}ms breaker=${breakerState}`];
|
|
354
387
|
return {
|
|
@@ -134,9 +134,9 @@ export function verifySecureMode(dir, file) {
|
|
|
134
134
|
/**
|
|
135
135
|
* 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
|
|
136
136
|
* 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
|
|
137
|
-
* 0700
|
|
137
|
+
* 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
|
|
138
138
|
*/
|
|
139
|
-
function
|
|
139
|
+
function preparePrivateDir(dir, file) {
|
|
140
140
|
if (process.platform === 'win32')
|
|
141
141
|
return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' };
|
|
142
142
|
try {
|
|
@@ -144,19 +144,19 @@ function prepareFlyaiConfigDir(dir, file) {
|
|
|
144
144
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
145
145
|
const entry = lstatSync(dir);
|
|
146
146
|
if (entry.isSymbolicLink())
|
|
147
|
-
return { ok: false, error: '
|
|
147
|
+
return { ok: false, error: '配置目录是符号链接,拒绝写入' };
|
|
148
148
|
if (!entry.isDirectory())
|
|
149
|
-
return { ok: false, error: '
|
|
149
|
+
return { ok: false, error: '配置路径不是目录,拒绝写入' };
|
|
150
150
|
if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
|
|
151
|
-
return { ok: false, error: '
|
|
151
|
+
return { ok: false, error: '配置目录不属于当前用户,拒绝写入' };
|
|
152
152
|
}
|
|
153
153
|
if ((entry.mode & 0o777) !== 0o700)
|
|
154
154
|
chmodSync(dir, 0o700);
|
|
155
155
|
const after = lstatSync(dir);
|
|
156
156
|
if (after.isSymbolicLink() || !after.isDirectory())
|
|
157
|
-
return { ok: false, error: '
|
|
157
|
+
return { ok: false, error: '配置目录状态发生变化,拒绝写入' };
|
|
158
158
|
if (typeof process.getuid === 'function' && after.uid !== process.getuid())
|
|
159
|
-
return { ok: false, error: '
|
|
159
|
+
return { ok: false, error: '配置目录所有者发生变化,拒绝写入' };
|
|
160
160
|
return verifySecureMode(dir, file);
|
|
161
161
|
}
|
|
162
162
|
catch (e) {
|
|
@@ -225,7 +225,7 @@ export function saveFlyaiKey(key, opts = {}) {
|
|
|
225
225
|
const read = readFlyaiConfig(configPath);
|
|
226
226
|
if (!read.ok)
|
|
227
227
|
return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` };
|
|
228
|
-
const configDir =
|
|
228
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath);
|
|
229
229
|
if (!configDir.ok)
|
|
230
230
|
return { ok: false, error: configDir.error };
|
|
231
231
|
const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text });
|
|
@@ -267,7 +267,7 @@ export function clearFlyaiKey(opts = {}) {
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
else {
|
|
270
|
-
const configDir =
|
|
270
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath);
|
|
271
271
|
if (!configDir.ok)
|
|
272
272
|
return { ok: false, source: 'none', error: configDir.error };
|
|
273
273
|
const write = atomicWriteJson(configPath, next);
|
|
@@ -303,7 +303,9 @@ export function readFlyaiVerification(homeDir) {
|
|
|
303
303
|
}
|
|
304
304
|
/** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
|
|
305
305
|
export function writeFlyaiVerification(entry, homeDir) {
|
|
306
|
-
|
|
306
|
+
const path = flyaiVerificationPath(homeDir);
|
|
307
|
+
const prepared = preparePrivateDir(dirname(path), path);
|
|
308
|
+
return prepared.ok && atomicWriteJson(path, entry).ok;
|
|
307
309
|
}
|
|
308
310
|
export function removeFlyaiVerification(homeDir) {
|
|
309
311
|
try {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs';
|
|
32
32
|
import { tmpdir } from 'node:os';
|
|
33
33
|
import { join } from 'node:path';
|
|
34
|
-
import { resolveFlyaiEndpoint, resolveFlyaiKey } from './flyai-config.js';
|
|
34
|
+
import { normalizeEndpoint, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex } from './flyai-config.js';
|
|
35
35
|
import { spawnBounded } from './spawn-bounded.js';
|
|
36
36
|
function nonEmptyText(value) {
|
|
37
37
|
if (typeof value !== 'string')
|
|
@@ -132,7 +132,7 @@ export function parseFlyaiItemList(stdout) {
|
|
|
132
132
|
: 'no complete FlyAI itemList JSON object');
|
|
133
133
|
}
|
|
134
134
|
/** 解析 stdout 中首个完整 JSON 对象(不限 itemList;ai-search 用) */
|
|
135
|
-
export function parseFlyaiJsonEnvelope(stdout) {
|
|
135
|
+
export function parseFlyaiJsonEnvelope(stdout, accepts = () => true) {
|
|
136
136
|
let start = -1;
|
|
137
137
|
let depth = 0;
|
|
138
138
|
let inString = false;
|
|
@@ -166,12 +166,13 @@ export function parseFlyaiJsonEnvelope(stdout) {
|
|
|
166
166
|
if (depth === 0) {
|
|
167
167
|
const candidate = stdout.slice(start, index + 1);
|
|
168
168
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
start = -1;
|
|
173
|
-
depth = 0;
|
|
169
|
+
const parsed = JSON.parse(candidate);
|
|
170
|
+
if (accepts(parsed))
|
|
171
|
+
return parsed;
|
|
174
172
|
}
|
|
173
|
+
catch { /* prefix logs may contain balanced non-JSON braces */ }
|
|
174
|
+
start = -1;
|
|
175
|
+
depth = 0;
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
}
|
|
@@ -463,6 +464,14 @@ const KIND_LABEL = {
|
|
|
463
464
|
flight: 'flight', train: 'train', hotel: 'hotel', poi: 'poi',
|
|
464
465
|
keyword: 'keyword', ai: 'ai', 'marriott-hotel': 'marriott hotel', 'marriott-package': 'marriott package',
|
|
465
466
|
};
|
|
467
|
+
/** 每种检索 × 生效 endpoint/key 隔离;Map 中只留下不可逆指纹。 */
|
|
468
|
+
export function flyaiBreakerScope(kind) {
|
|
469
|
+
const key = resolveFlyaiKey();
|
|
470
|
+
const endpoint = resolveFlyaiEndpoint().url;
|
|
471
|
+
const identity = sha256Hex(JSON.stringify([normalizeEndpoint(endpoint) ?? endpoint, key.key ?? null]));
|
|
472
|
+
const safeKind = typeof kind === 'string' && Object.hasOwn(KIND_LABEL, kind) ? kind : 'invalid-kind';
|
|
473
|
+
return `${safeKind}:${identity}`;
|
|
474
|
+
}
|
|
466
475
|
/**
|
|
467
476
|
* FlyAI 官方只读检索(8 命令)— 任何失败走降级;不抛错。
|
|
468
477
|
*/
|
|
@@ -472,10 +481,10 @@ export async function flyaiSearch(q) {
|
|
|
472
481
|
const envContext = await runtimeEnvNote();
|
|
473
482
|
const envNote = envContext.note;
|
|
474
483
|
const sensitive = envContext.sensitive;
|
|
475
|
-
const safe = (result) => safeFlyaiResult(result, sensitive);
|
|
484
|
+
const safe = (result) => safeFlyaiResult({ upstreamHealth: 'unknown', retryable: false, ...result }, sensitive);
|
|
476
485
|
const built = buildCliArgs(q);
|
|
477
486
|
if (built.error)
|
|
478
|
-
return { ...badArg(q.kind, built.error, ts), ...envNote };
|
|
487
|
+
return safe({ ...badArg(q.kind, built.error, ts), ...envNote });
|
|
479
488
|
const prefix = q.cliPrefixArgs ?? ['-y', '@fly-ai/flyai-cli@1.0.16'];
|
|
480
489
|
const r = await sh(q.cliBin ?? 'npx', [...prefix, ...built.args], {
|
|
481
490
|
timeoutMs: q.timeoutMs ?? 30_000,
|
|
@@ -483,7 +492,6 @@ export async function flyaiSearch(q) {
|
|
|
483
492
|
signal: q.signal,
|
|
484
493
|
});
|
|
485
494
|
const latencyMs = Date.now() - started;
|
|
486
|
-
const combined = `${r.stderr}\n${r.stdout}`;
|
|
487
495
|
if (r.cancelled) {
|
|
488
496
|
return safe({
|
|
489
497
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'cancelled',
|
|
@@ -507,60 +515,65 @@ export async function flyaiSearch(q) {
|
|
|
507
515
|
if (r.localTermination) {
|
|
508
516
|
return safe(localTerminationResult(q.kind, r.localTermination, r, latencyMs, ts, envNote));
|
|
509
517
|
}
|
|
510
|
-
if (r.error || r.code !== 0 ||
|
|
518
|
+
if (r.error || r.code !== 0 || explicitUpstreamFailure(r.stderr)) {
|
|
511
519
|
return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote));
|
|
512
520
|
}
|
|
521
|
+
// Validate the response envelope before accepting inventory for any kind.
|
|
522
|
+
// A business status is not an HTTP status; successful data is never scanned
|
|
523
|
+
// for fault words (for example an AI answer quoting an HTTP error).
|
|
524
|
+
let envelope;
|
|
525
|
+
try {
|
|
526
|
+
envelope = parseFlyaiJsonEnvelope(r.stdout, candidate => ['data', 'status', 'error'].some(key => Object.hasOwn(candidate, key))
|
|
527
|
+
|| explicitUpstreamFailure(nonEmptyText(candidate.message) ?? ''));
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
if (explicitUpstreamFailure(r.stdout))
|
|
531
|
+
return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote));
|
|
532
|
+
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
|
|
533
|
+
return safe({
|
|
534
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
535
|
+
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
536
|
+
error: `failed to parse flyai output (${e.message}): ${raw}`,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const systemMessage = nonEmptyText(envelope.systemMessage);
|
|
540
|
+
const envelopeFailure = `${nonEmptyText(envelope.message) ?? ''}\n${envelope.error == null ? '' : JSON.stringify(envelope.error)}`;
|
|
541
|
+
if (explicitUpstreamFailure(envelopeFailure)) {
|
|
542
|
+
return safe(classifyUpstreamFailure(q.kind, { ...r, stdout: envelopeFailure }, latencyMs, ts, envNote));
|
|
543
|
+
}
|
|
544
|
+
if ((envelope.status !== undefined && envelope.status !== 0) || envelope.error != null) {
|
|
545
|
+
return safe({
|
|
546
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
547
|
+
evidence: `[实时API:flyai@error@${ts}] upstream response status=${String(envelope.status ?? 'unknown')}`,
|
|
548
|
+
error: nonEmptyText(envelope.message) ?? 'FlyAI upstream response rejected the query',
|
|
549
|
+
...(systemMessage ? { systemMessage } : {}),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
513
552
|
// ai-search:data 是自由形状,不走 itemList。
|
|
514
553
|
if (q.kind === 'ai') {
|
|
515
|
-
|
|
516
|
-
try {
|
|
517
|
-
envelope = parseFlyaiJsonEnvelope(r.stdout);
|
|
518
|
-
}
|
|
519
|
-
catch (e) {
|
|
520
|
-
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
|
|
521
|
-
return safe({
|
|
522
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
523
|
-
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
524
|
-
error: `failed to parse flyai ai output (${e.message}): ${raw}`,
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
const systemMessage = nonEmptyText(envelope.systemMessage);
|
|
528
|
-
if (envelope.status !== undefined && envelope.status !== 0) {
|
|
554
|
+
if (!Object.hasOwn(envelope, 'data')) {
|
|
529
555
|
return safe({
|
|
530
556
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
531
|
-
evidence: `[实时API:flyai@error@${ts}] ai
|
|
532
|
-
error:
|
|
557
|
+
evidence: `[实时API:flyai@error@${ts}] malformed ai response: missing data`,
|
|
558
|
+
error: 'FlyAI ai response malformed: missing data member',
|
|
533
559
|
...(systemMessage ? { systemMessage } : {}),
|
|
534
560
|
});
|
|
535
561
|
}
|
|
536
562
|
const aiData = envelope.data;
|
|
537
|
-
const empty = aiData ===
|
|
563
|
+
const empty = aiData === null || aiData === ''
|
|
538
564
|
|| (Array.isArray(aiData) && aiData.length === 0);
|
|
539
565
|
return safe(JSON.parse(JSON.stringify({
|
|
540
|
-
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai',
|
|
566
|
+
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy',
|
|
541
567
|
verdict: empty ? 'miss' : 'hit',
|
|
542
568
|
...(empty ? {} : { aiData }),
|
|
543
569
|
...(systemMessage ? { systemMessage } : {}),
|
|
544
570
|
evidence: `[实时API:flyai@${ts}] ai-search ${empty ? 'empty' : 'data returned'}`,
|
|
545
571
|
})));
|
|
546
572
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
items = parseFlyaiItemList(r.stdout);
|
|
550
|
-
}
|
|
551
|
-
catch (e) {
|
|
573
|
+
const data = envelope.data;
|
|
574
|
+
if (!Array.isArray(data?.itemList)) {
|
|
552
575
|
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
|
|
553
|
-
|
|
554
|
-
if (/Sentinel/i.test(raw)) {
|
|
555
|
-
return safe({
|
|
556
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
557
|
-
retryable: false,
|
|
558
|
-
evidence: `[实时API:flyai@error@${ts}] sentinel: ${raw}`,
|
|
559
|
-
error: `FlyAI Sentinel 限流:${raw}`,
|
|
560
|
-
setup: '平台 Sentinel 限流(恢复窗口未公开)——稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
const reason = e.message;
|
|
576
|
+
const reason = 'no complete FlyAI itemList JSON object';
|
|
564
577
|
return safe({
|
|
565
578
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
566
579
|
retryable: false,
|
|
@@ -568,7 +581,15 @@ export async function flyaiSearch(q) {
|
|
|
568
581
|
error: `failed to parse flyai output (${reason}): ${raw}`,
|
|
569
582
|
});
|
|
570
583
|
}
|
|
571
|
-
return safe(parseItemListResult(q.kind,
|
|
584
|
+
return safe(parseItemListResult(q.kind, data.itemList, latencyMs, ts, envNote, r.stdout));
|
|
585
|
+
}
|
|
586
|
+
// Explicit protocol/transport evidence only. Bare business codes and generic
|
|
587
|
+
// words such as "network" or "timeout" do not diagnose an upstream outage.
|
|
588
|
+
const HTTP_FAILURE = /\bHTTP(?:\/[\d.]+)?\s*[:=]?\s*([45]\d\d)\b/i;
|
|
589
|
+
const TRANSPORT_FAILURE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN)\b|socket hang up|fetch failed|network (?:error|failure|unreachable)|(?:request|connection) (?:timed out|timeout|reset|refused)/i;
|
|
590
|
+
function explicitUpstreamFailure(text) {
|
|
591
|
+
return HTTP_FAILURE.test(text) || TRANSPORT_FAILURE.test(text)
|
|
592
|
+
|| /Invalid API key|Trial limit reached|SentinelBlockException/i.test(text);
|
|
572
593
|
}
|
|
573
594
|
/** 当前进程 key 来源/endpoint 覆盖(仅展示,与 spawn 实际生效同源) */
|
|
574
595
|
async function runtimeEnvNote() {
|
|
@@ -587,38 +608,46 @@ async function runtimeEnvNote() {
|
|
|
587
608
|
/** 非 0 退出 / HTTP 4xx-5xx 文本 → 故障闭集分类 */
|
|
588
609
|
function classifyUpstreamFailure(kind, r, latencyMs, ts, envNote) {
|
|
589
610
|
const upstream = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim();
|
|
590
|
-
const tag = (verdict, evidence, extra) => ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, evidence, ...extra });
|
|
591
|
-
|
|
611
|
+
const tag = (verdict, evidence, extra) => ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, upstreamHealth: 'unknown', evidence, ...extra });
|
|
612
|
+
const httpStatus = Number(HTTP_FAILURE.exec(upstream)?.[1]);
|
|
613
|
+
if (/Invalid API key/i.test(upstream) || httpStatus === 401) {
|
|
592
614
|
return tag('auth-error', `[实时API:flyai@error@${ts}] 401 invalid api key`, {
|
|
593
615
|
retryable: false,
|
|
594
616
|
error: `FlyAI 鉴权失败(401):${upstream.slice(0, 160)}`,
|
|
595
617
|
setup: '当前 key 无效或已吊销——在本机终端运行 `gotry setup flyai` 重新设置并验证(模型无法替你输入 key);`gotry setup flyai --clear` 可清除错误 key,清除后仍可匿名试用。',
|
|
596
618
|
});
|
|
597
619
|
}
|
|
598
|
-
if (
|
|
620
|
+
if (httpStatus === 403) {
|
|
599
621
|
return tag('forbidden', `[实时API:flyai@error@${ts}] 403 forbidden`, {
|
|
600
622
|
retryable: false,
|
|
601
623
|
error: `FlyAI 拒绝访问(403):${upstream.slice(0, 160)}`,
|
|
602
624
|
setup: '上游 403:核对该 key 的权限范围与控制台状态;如 endpoint 被 DEBUG_FLYAI_MCP_URL 覆盖,请确认指向。',
|
|
603
625
|
});
|
|
604
626
|
}
|
|
605
|
-
if (/Trial limit reached
|
|
627
|
+
if (/Trial limit reached/i.test(upstream)) {
|
|
606
628
|
return tag('needs-setup', `[实时API:flyai@error@${ts}] trial quota exhausted`, {
|
|
607
629
|
retryable: false,
|
|
608
630
|
error: `FlyAI 匿名试用额度已用尽:${upstream.slice(0, 160)}`,
|
|
609
631
|
setup: '打开 https://flyai.open.fliggy.com/console,登录后复制 API Key,本机运行 `gotry setup flyai` 配置并验证;本会话请勿盲重试 gotry_flyai_search,机/火/酒改走 gotry_session_search。',
|
|
610
632
|
});
|
|
611
633
|
}
|
|
612
|
-
if (/
|
|
634
|
+
if (/SentinelBlockException/i.test(upstream)) {
|
|
635
|
+
return tag('error', `[实时API:flyai@error@${ts}] sentinel`, {
|
|
636
|
+
retryable: false, upstreamHealth: 'unhealthy',
|
|
637
|
+
error: `FlyAI Sentinel 限流:${upstream.slice(0, 160)}`,
|
|
638
|
+
setup: '平台 Sentinel 限流(恢复窗口未公开);稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (httpStatus === 429) {
|
|
613
642
|
return tag('rate-limited', `[实时API:flyai@error@${ts}] 429 rate limited`, {
|
|
614
|
-
retryable: true,
|
|
643
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
615
644
|
error: `FlyAI 普通限流(非试用达限):${upstream.slice(0, 160)}`,
|
|
616
645
|
setup: '上游普通限流:稍候再查,勿高频连发;仍 429 请稍后或换 gotry_session_search。',
|
|
617
646
|
});
|
|
618
647
|
}
|
|
619
|
-
if (
|
|
648
|
+
if (TRANSPORT_FAILURE.test(upstream) || httpStatus >= 500) {
|
|
620
649
|
return tag('error', `[实时API:flyai@error@${ts}] transient upstream`, {
|
|
621
|
-
retryable: true,
|
|
650
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
622
651
|
error: `FlyAI 瞬时网络/上游错误:${upstream.slice(0, 160)}`,
|
|
623
652
|
setup: '网络抖动或上游 5xx:稍后重试;持续失败请检查本机网络或改走 gotry_session_search。',
|
|
624
653
|
});
|
|
@@ -676,7 +705,7 @@ function parseItemListResult(kind, items, latencyMs, ts, envNote, rawStdout) {
|
|
|
676
705
|
const count = parsed.options.length;
|
|
677
706
|
const verdict = count > 0 ? 'hit' : 'miss';
|
|
678
707
|
const evidence = `[实时API:flyai@${ts}] ${count}/${items.length} ${KIND_LABEL[kind]} options`;
|
|
679
|
-
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai', verdict, evidence, ...(systemMessage ? { systemMessage } : {}) };
|
|
708
|
+
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy', verdict, evidence, ...(systemMessage ? { systemMessage } : {}) };
|
|
680
709
|
switch (kind) {
|
|
681
710
|
case 'flight':
|
|
682
711
|
case 'train':
|
|
@@ -781,7 +810,9 @@ function parseHotelItems(items) {
|
|
|
781
810
|
}
|
|
782
811
|
const it = raw;
|
|
783
812
|
const name = nonEmptyText(it.name);
|
|
784
|
-
|
|
813
|
+
// Unrated homestays may return whitespace (live Harbin response).
|
|
814
|
+
// Only this optional descriptive field permits blank text; types stay strict.
|
|
815
|
+
const star = typeof it.star === 'string' && !it.star.trim() ? undefined : stringField(it, 'star');
|
|
785
816
|
const rawPrice = stringField(it, 'price');
|
|
786
817
|
const rate = it.rate === undefined ? undefined : it.rate === null ? null : nonEmptyText(it.rate) ?? 'bad';
|
|
787
818
|
const address = stringField(it, 'address');
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Official hbcli credential-file contract; no credentials enter tool results. */
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
import { atomicWriteJson, sha256Hex } from './flyai-config.js';
|
|
6
|
+
import { hbcliBinCandidates } from './hbcli.js';
|
|
7
|
+
import { spawnBounded } from './spawn-bounded.js';
|
|
8
|
+
export const HBCLI_REGISTRATION_URL = 'https://hotelbyte.com/zh/guides/sandbox-verification';
|
|
9
|
+
const ENVIRONMENT = 'uat';
|
|
10
|
+
const ENTRY = `openapi:${ENVIRONMENT}`;
|
|
11
|
+
export function hbcliCredentialRoot(options) {
|
|
12
|
+
return options.env?.STAICLI_HOME?.trim() || join(options.homeDir ?? homedir(), '.staicli');
|
|
13
|
+
}
|
|
14
|
+
export function hbcliCredentialPath(options) { return join(hbcliCredentialRoot(options), 'credentials.json'); }
|
|
15
|
+
export function hbcliVerificationPath(homeDir) { return join(homeDir, '.gotry', 'hbcli-verification.json'); }
|
|
16
|
+
function safeEntry(path, directory) {
|
|
17
|
+
try {
|
|
18
|
+
const entry = lstatSync(path);
|
|
19
|
+
return !entry.isSymbolicLink() && (directory ? entry.isDirectory() : entry.isFile())
|
|
20
|
+
&& (typeof process.getuid !== 'function' || entry.uid === process.getuid());
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return e.code === 'ENOENT';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function readHbcliCredentials(options) {
|
|
27
|
+
const root = hbcliCredentialRoot(options), file = hbcliCredentialPath(options);
|
|
28
|
+
try {
|
|
29
|
+
if (!isAbsolute(root) || !safeEntry(root, true) || !safeEntry(file, false))
|
|
30
|
+
return { ok: false };
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return { ok: true, data: {} };
|
|
33
|
+
const bytes = readFileSync(file, 'utf8'), data = JSON.parse(bytes);
|
|
34
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data, bytes } : { ok: false };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { ok: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function fingerprint(data) {
|
|
41
|
+
const entry = data[ENTRY];
|
|
42
|
+
return typeof entry?.appKey === 'string' && entry.appKey.trim() && typeof entry.appSecret === 'string' && entry.appSecret.trim()
|
|
43
|
+
? sha256Hex(JSON.stringify([ENVIRONMENT, entry.appKey, entry.appSecret])) : undefined;
|
|
44
|
+
}
|
|
45
|
+
function readReceipt(home) {
|
|
46
|
+
try {
|
|
47
|
+
const path = hbcliVerificationPath(home);
|
|
48
|
+
if (!safeEntry(join(home, '.gotry'), true) || !safeEntry(path, false))
|
|
49
|
+
return undefined;
|
|
50
|
+
const v = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
return v.schema === 'gotry.hbcli-verification.v1' && typeof v.fingerprint === 'string'
|
|
52
|
+
&& ['hit', 'miss', 'no-suppliers'].includes(v.verdict) && Number.isFinite(Date.parse(v.at)) ? v : undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function hbcliSetupStatus(options) {
|
|
59
|
+
const read = readHbcliCredentials(options), hash = read.ok ? fingerprint(read.data) : undefined;
|
|
60
|
+
const receipt = readReceipt(options.homeDir ?? homedir());
|
|
61
|
+
const envToken = Boolean(options.env?.HOTELBYTE_TOKEN?.trim());
|
|
62
|
+
const matches = !envToken && hash && receipt?.fingerprint === hash;
|
|
63
|
+
return { ok: true, configured: envToken || Boolean(hash), writable: read.ok && !envToken && process.platform !== 'win32',
|
|
64
|
+
source: envToken ? 'env' : hash ? 'config' : 'none', environment: ENVIRONMENT,
|
|
65
|
+
verified: Boolean(matches && receipt?.verdict !== 'no-suppliers'),
|
|
66
|
+
...(matches && receipt ? { verdict: receipt.verdict, checkedAt: receipt.at } : {}), registrationUrl: HBCLI_REGISTRATION_URL };
|
|
67
|
+
}
|
|
68
|
+
function prepareDirectory(dir) {
|
|
69
|
+
try {
|
|
70
|
+
if (process.platform === 'win32' || !isAbsolute(dir) || !safeEntry(dir, true))
|
|
71
|
+
return false;
|
|
72
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
if (!safeEntry(dir, true))
|
|
74
|
+
return false;
|
|
75
|
+
chmodSync(dir, 0o700);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function writeHbcliCredentials(data, options) {
|
|
83
|
+
const path = hbcliCredentialPath(options);
|
|
84
|
+
return readHbcliCredentials(options).ok && prepareDirectory(hbcliCredentialRoot(options)) && atomicWriteJson(path, data).ok;
|
|
85
|
+
}
|
|
86
|
+
export function writeHbcliVerification(data, verdict, options) {
|
|
87
|
+
const hash = fingerprint(data), home = options.homeDir ?? homedir();
|
|
88
|
+
if (!hash || !prepareDirectory(join(home, '.gotry')))
|
|
89
|
+
return false;
|
|
90
|
+
const entry = { schema: 'gotry.hbcli-verification.v1', fingerprint: hash, verdict, at: new Date().toISOString() };
|
|
91
|
+
return safeEntry(hbcliVerificationPath(home), false) && atomicWriteJson(hbcliVerificationPath(home), entry).ok;
|
|
92
|
+
}
|
|
93
|
+
export function removeHbcliVerification(homeDir) {
|
|
94
|
+
if (safeEntry(join(homeDir, '.gotry'), true) && safeEntry(hbcliVerificationPath(homeDir), false)) {
|
|
95
|
+
try {
|
|
96
|
+
rmSync(hbcliVerificationPath(homeDir), { force: true });
|
|
97
|
+
}
|
|
98
|
+
catch { /* best effort */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function withHbcliCredentials(data, appKey, appSecret) {
|
|
102
|
+
return { ...data, [ENTRY]: { appKey, appSecret } };
|
|
103
|
+
}
|
|
104
|
+
export function withoutHbcliCredentials(data) {
|
|
105
|
+
const next = { ...data };
|
|
106
|
+
delete next[ENTRY];
|
|
107
|
+
return next;
|
|
108
|
+
}
|
|
109
|
+
/** Verify through the existing CLI bridge, with a private disposable credential root.
|
|
110
|
+
* An upstream no-suppliers verdict is retained separately from a completed query.
|
|
111
|
+
*/
|
|
112
|
+
export async function verifyHbcliCandidate(appKey, appSecret, options, signal) {
|
|
113
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-hbcli-verify-'));
|
|
114
|
+
try {
|
|
115
|
+
if (!atomicWriteJson(join(root, 'credentials.json'), withHbcliCredentials({}, appKey, appSecret)).ok) {
|
|
116
|
+
return { verdict: 'error', error: '无法创建隔离的验证环境。' };
|
|
117
|
+
}
|
|
118
|
+
const checkIn = new Date();
|
|
119
|
+
checkIn.setUTCDate(checkIn.getUTCDate() + 1);
|
|
120
|
+
const checkOut = new Date(checkIn);
|
|
121
|
+
checkOut.setUTCDate(checkOut.getUTCDate() + 1);
|
|
122
|
+
const args = ['--json', '--env', ENVIRONMENT, 'search', 'hotel-list', '--destination-name', '上海',
|
|
123
|
+
'--check-in', checkIn.toISOString().slice(0, 10), '--check-out', checkOut.toISOString().slice(0, 10),
|
|
124
|
+
'--room-occupancies', '[{"adultCount":2,"childrenAges":[]}]', '--page-size', '1'];
|
|
125
|
+
const env = { ...(options.env ?? process.env), STAICLI_HOME: root, HOTELBYTE_ENV: ENVIRONMENT };
|
|
126
|
+
delete env.HOTELBYTE_TOKEN;
|
|
127
|
+
let result;
|
|
128
|
+
for (const bin of hbcliBinCandidates(options.hbcliBin ?? options.env?.GOTRY_HBCLI_BIN ?? 'hbcli', options.homeDir)) {
|
|
129
|
+
result = await spawnBounded(bin, args, { env, signal, timeoutMs: 20_000 });
|
|
130
|
+
if (!/ENOENT/.test(result.error ?? '') || signal.aborted)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (signal.aborted || result?.aborted)
|
|
134
|
+
return { verdict: 'cancelled', error: '验证已取消,旧配置保留。' };
|
|
135
|
+
if (!result || result.error || result.timedOut || result.drainTimedOut || result.groupReaped === false || result.code !== 0) {
|
|
136
|
+
return { verdict: 'error', error: 'HotelByte 验证未完成,请检查 hbcli 安装、网络和凭证;旧配置保留。' };
|
|
137
|
+
}
|
|
138
|
+
let value;
|
|
139
|
+
try {
|
|
140
|
+
value = JSON.parse(result.stdout);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { verdict: 'error', error: 'HotelByte 回包无法识别,旧配置保留。' };
|
|
144
|
+
}
|
|
145
|
+
if (value?.code === 300010002 || value?.code === '300010002')
|
|
146
|
+
return { verdict: 'no-suppliers' };
|
|
147
|
+
if (!value || (value.code !== undefined && value.code !== 0 && value.code !== '0') || !Array.isArray(value.list)
|
|
148
|
+
|| value.list.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
|
|
149
|
+
return { verdict: 'error', error: 'HotelByte 未接受凭证或返回了无效的查询结果;旧配置保留。' };
|
|
150
|
+
}
|
|
151
|
+
return { verdict: value.list.length ? 'hit' : 'miss' };
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
rmSync(root, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|