@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.33

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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.31",
3
+ "version": "0.2.0-rc.33",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -101,6 +101,9 @@
101
101
  "schemas/booking.surface.schema.json",
102
102
  "schemas/booking.intent.schema.json",
103
103
  "ts/src/booking-surface/",
104
+ "ts/src/tenant-seam/",
105
+ "ts/src/tenant-state/",
106
+ "ts/src/llm-price-table.ts",
104
107
  "ts/scripts/skeleton-check.ts",
105
108
  "ts/scripts/skeleton-integration-test.ts",
106
109
  "ts/scripts/async-collect.ts",
@@ -115,6 +118,7 @@
115
118
  "ts/capabilities/opensky.ts",
116
119
  "ts/capabilities/flyai.ts",
117
120
  "ts/capabilities/flyai-config.ts",
121
+ "ts/capabilities/hbcli-config.ts",
118
122
  "ts/capabilities/session-search.ts",
119
123
  "ts/capabilities/session/",
120
124
  "extension/",
@@ -185,22 +185,22 @@ export function verifySecureMode(dir: string, file: string): { ok: boolean; erro
185
185
  /**
186
186
  * 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
187
187
  * 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
188
- * 0700;验证回执所在 ~/.gotry 仍严格拒绝既有 0755 目录。
188
+ * 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
189
189
  */
190
- function prepareFlyaiConfigDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
190
+ function preparePrivateDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
191
191
  if (process.platform === 'win32') return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' }
192
192
  try {
193
193
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
194
194
  const entry = lstatSync(dir)
195
- if (entry.isSymbolicLink()) return { ok: false, error: '.flyai 目录是符号链接,拒绝写入' }
196
- if (!entry.isDirectory()) return { ok: false, error: '.flyai 路径不是目录,拒绝写入' }
195
+ if (entry.isSymbolicLink()) return { ok: false, error: '配置目录是符号链接,拒绝写入' }
196
+ if (!entry.isDirectory()) return { ok: false, error: '配置路径不是目录,拒绝写入' }
197
197
  if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
198
- return { ok: false, error: '.flyai 目录不属于当前用户,拒绝写入' }
198
+ return { ok: false, error: '配置目录不属于当前用户,拒绝写入' }
199
199
  }
200
200
  if ((entry.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
201
201
  const after = lstatSync(dir)
202
- if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '.flyai 目录状态发生变化,拒绝写入' }
203
- if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '.flyai 目录所有者发生变化,拒绝写入' }
202
+ if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '配置目录状态发生变化,拒绝写入' }
203
+ if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '配置目录所有者发生变化,拒绝写入' }
204
204
  return verifySecureMode(dir, file)
205
205
  } catch (e) {
206
206
  return { ok: false, error: (e as Error).message }
@@ -259,7 +259,7 @@ export function saveFlyaiKey(key: string, opts: FlyaiConfigPaths = {}): SaveFlya
259
259
  const read = readFlyaiConfig(configPath)
260
260
  if (!read.ok) return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` }
261
261
 
262
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath)
262
+ const configDir = preparePrivateDir(dirname(configPath), configPath)
263
263
  if (!configDir.ok) return { ok: false, error: configDir.error }
264
264
  const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text })
265
265
  if (!write.ok) return { ok: false, error: write.error }
@@ -297,7 +297,7 @@ export function clearFlyaiKey(opts: FlyaiConfigPaths = {}): ClearFlyaiKeyResult
297
297
  return { ok: false, source: 'none', error: `移除文件失败:${(e as Error).message}` }
298
298
  }
299
299
  } else {
300
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath)
300
+ const configDir = preparePrivateDir(dirname(configPath), configPath)
301
301
  if (!configDir.ok) return { ok: false, source: 'none', error: configDir.error }
302
302
  const write = atomicWriteJson(configPath, next)
303
303
  if (!write.ok) return { ok: false, source: 'none', error: write.error }
@@ -355,7 +355,9 @@ export function readFlyaiVerification(homeDir: string): FlyaiVerification | unde
355
355
 
356
356
  /** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
357
357
  export function writeFlyaiVerification(entry: FlyaiVerification, homeDir: string): boolean {
358
- return atomicWriteJson(flyaiVerificationPath(homeDir), entry).ok
358
+ const path = flyaiVerificationPath(homeDir)
359
+ const prepared = preparePrivateDir(dirname(path), path)
360
+ return prepared.ok && atomicWriteJson(path, entry).ok
359
361
  }
360
362
 
361
363
  export function removeFlyaiVerification(homeDir: string): void {
@@ -32,7 +32,7 @@
32
32
  import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs'
33
33
  import { tmpdir } from 'node:os'
34
34
  import { join } from 'node:path'
35
- import { resolveFlyaiEndpoint, resolveFlyaiKey, type FlyaiKeySource } from './flyai-config.ts'
35
+ import { normalizeEndpoint, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, type FlyaiKeySource } from './flyai-config.ts'
36
36
  import { spawnBounded } from './spawn-bounded.ts'
37
37
 
38
38
  export type FlyaiKind =
@@ -239,6 +239,8 @@ export interface FlyaiResult {
239
239
  verdict: FlyaiVerdict
240
240
  /** true = 已识别的上游普通网络/HTTP 5xx/429 瞬时失败,可交给 effect 层退避重试 */
241
241
  retryable?: boolean
242
+ /** 独立于重试:只在明确上游故障时计熔断;unknown 不增失败,也不宣称恢复。 */
243
+ upstreamHealth?: 'healthy' | 'unhealthy' | 'unknown'
242
244
  /** 存在时表示请求未得到可判定的上游响应,应 fail-closed 且只执行一次。 */
243
245
  localTermination?: FlyaiLocalTermination
244
246
  kind: FlyaiKind
@@ -411,7 +413,7 @@ export function parseFlyaiItemList(stdout: string): unknown[] {
411
413
  }
412
414
 
413
415
  /** 解析 stdout 中首个完整 JSON 对象(不限 itemList;ai-search 用) */
414
- export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown> {
416
+ export function parseFlyaiJsonEnvelope(stdout: string, accepts: (envelope: Record<string, unknown>) => boolean = () => true): Record<string, unknown> {
415
417
  let start = -1
416
418
  let depth = 0
417
419
  let inString = false
@@ -435,7 +437,12 @@ export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown>
435
437
  depth -= 1
436
438
  if (depth === 0) {
437
439
  const candidate = stdout.slice(start, index + 1)
438
- try { return JSON.parse(candidate) as Record<string, unknown> } catch { start = -1; depth = 0 }
440
+ try {
441
+ const parsed = JSON.parse(candidate) as Record<string, unknown>
442
+ if (accepts(parsed)) return parsed
443
+ } catch { /* prefix logs may contain balanced non-JSON braces */ }
444
+ start = -1
445
+ depth = 0
439
446
  }
440
447
  }
441
448
  }
@@ -749,6 +756,15 @@ const KIND_LABEL: Record<FlyaiKind, string> = {
749
756
  keyword: 'keyword', ai: 'ai', 'marriott-hotel': 'marriott hotel', 'marriott-package': 'marriott package',
750
757
  }
751
758
 
759
+ /** 每种检索 × 生效 endpoint/key 隔离;Map 中只留下不可逆指纹。 */
760
+ export function flyaiBreakerScope(kind: unknown): string {
761
+ const key = resolveFlyaiKey()
762
+ const endpoint = resolveFlyaiEndpoint().url
763
+ const identity = sha256Hex(JSON.stringify([normalizeEndpoint(endpoint) ?? endpoint, key.key ?? null]))
764
+ const safeKind = typeof kind === 'string' && Object.hasOwn(KIND_LABEL, kind) ? kind : 'invalid-kind'
765
+ return `${safeKind}:${identity}`
766
+ }
767
+
752
768
  /**
753
769
  * FlyAI 官方只读检索(8 命令)— 任何失败走降级;不抛错。
754
770
  */
@@ -758,9 +774,9 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
758
774
  const envContext = await runtimeEnvNote()
759
775
  const envNote = envContext.note
760
776
  const sensitive = envContext.sensitive
761
- const safe = <T extends FlyaiResult>(result: T): T => safeFlyaiResult(result, sensitive)
777
+ const safe = (result: FlyaiResult): FlyaiResult => safeFlyaiResult({ upstreamHealth: 'unknown', retryable: false, ...result }, sensitive)
762
778
  const built = buildCliArgs(q)
763
- if (built.error) return { ...badArg(q.kind, built.error, ts), ...envNote }
779
+ if (built.error) return safe({ ...badArg(q.kind, built.error, ts), ...envNote })
764
780
 
765
781
  const prefix = q.cliPrefixArgs ?? ['-y', '@fly-ai/flyai-cli@1.0.16']
766
782
  const r = await sh(q.cliBin ?? 'npx', [...prefix, ...built.args!], {
@@ -769,7 +785,6 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
769
785
  signal: q.signal,
770
786
  })
771
787
  const latencyMs = Date.now() - started
772
- const combined = `${r.stderr}\n${r.stdout}`
773
788
 
774
789
  if (r.cancelled) {
775
790
  return safe({
@@ -797,37 +812,55 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
797
812
  return safe(localTerminationResult(q.kind, r.localTermination, r, latencyMs, ts, envNote))
798
813
  }
799
814
 
800
- if (r.error || r.code !== 0 || /HTTP\s*(4\d\d|5\d\d)/.test(combined)) {
815
+ if (r.error || r.code !== 0 || explicitUpstreamFailure(r.stderr)) {
801
816
  return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
802
817
  }
803
818
 
819
+ // Validate the response envelope before accepting inventory for any kind.
820
+ // A business status is not an HTTP status; successful data is never scanned
821
+ // for fault words (for example an AI answer quoting an HTTP error).
822
+ let envelope: Record<string, unknown>
823
+ try {
824
+ envelope = parseFlyaiJsonEnvelope(r.stdout, candidate => ['data', 'status', 'error'].some(key => Object.hasOwn(candidate, key))
825
+ || explicitUpstreamFailure(nonEmptyText(candidate.message) ?? ''))
826
+ } catch (e) {
827
+ if (explicitUpstreamFailure(r.stdout)) return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
828
+ const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
829
+ return safe({
830
+ kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
831
+ evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
832
+ error: `failed to parse flyai output (${(e as Error).message}): ${raw}`,
833
+ })
834
+ }
835
+ const systemMessage = nonEmptyText(envelope.systemMessage)
836
+ const envelopeFailure = `${nonEmptyText(envelope.message) ?? ''}\n${envelope.error == null ? '' : JSON.stringify(envelope.error)}`
837
+ if (explicitUpstreamFailure(envelopeFailure)) {
838
+ return safe(classifyUpstreamFailure(q.kind, { ...r, stdout: envelopeFailure }, latencyMs, ts, envNote))
839
+ }
840
+ if ((envelope.status !== undefined && envelope.status !== 0) || envelope.error != null) {
841
+ return safe({
842
+ kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
843
+ evidence: `[实时API:flyai@error@${ts}] upstream response status=${String(envelope.status ?? 'unknown')}`,
844
+ error: nonEmptyText(envelope.message) ?? 'FlyAI upstream response rejected the query',
845
+ ...(systemMessage ? { systemMessage } : {}),
846
+ })
847
+ }
848
+
804
849
  // ai-search:data 是自由形状,不走 itemList。
805
850
  if (q.kind === 'ai') {
806
- let envelope: Record<string, unknown>
807
- try {
808
- envelope = parseFlyaiJsonEnvelope(r.stdout)
809
- } catch (e) {
810
- const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
811
- return safe({
812
- kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
813
- evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
814
- error: `failed to parse flyai ai output (${(e as Error).message}): ${raw}`,
815
- })
816
- }
817
- const systemMessage = nonEmptyText(envelope.systemMessage)
818
- if (envelope.status !== undefined && envelope.status !== 0) {
851
+ if (!Object.hasOwn(envelope, 'data')) {
819
852
  return safe({
820
853
  kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
821
- evidence: `[实时API:flyai@error@${ts}] ai status=${String(envelope.status)}`,
822
- error: nonEmptyText(envelope.message) ?? 'ai-search upstream failure',
854
+ evidence: `[实时API:flyai@error@${ts}] malformed ai response: missing data`,
855
+ error: 'FlyAI ai response malformed: missing data member',
823
856
  ...(systemMessage ? { systemMessage } : {}),
824
857
  })
825
858
  }
826
859
  const aiData = envelope.data
827
- const empty = aiData === undefined || aiData === null || aiData === ''
860
+ const empty = aiData === null || aiData === ''
828
861
  || (Array.isArray(aiData) && aiData.length === 0)
829
862
  return safe(JSON.parse(JSON.stringify({
830
- kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai',
863
+ kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy',
831
864
  verdict: empty ? 'miss' : 'hit',
832
865
  ...(empty ? {} : { aiData }),
833
866
  ...(systemMessage ? { systemMessage } : {}),
@@ -835,22 +868,10 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
835
868
  })) as FlyaiResult)
836
869
  }
837
870
 
838
- let items: unknown[]
839
- try {
840
- items = parseFlyaiItemList(r.stdout) as RawItem[]
841
- } catch (e) {
871
+ const data = envelope.data as { itemList?: unknown } | null
872
+ if (!Array.isArray(data?.itemList)) {
842
873
  const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
843
- // Sentinel 限流:exit 0,stdout 为 {"message":"SentinelBlockException…"}
844
- if (/Sentinel/i.test(raw)) {
845
- return safe({
846
- kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
847
- retryable: false,
848
- evidence: `[实时API:flyai@error@${ts}] sentinel: ${raw}`,
849
- error: `FlyAI Sentinel 限流:${raw}`,
850
- setup: '平台 Sentinel 限流(恢复窗口未公开)——稍后重试,或改走 gotry_session_search(账号会话)。',
851
- })
852
- }
853
- const reason = (e as Error).message
874
+ const reason = 'no complete FlyAI itemList JSON object'
854
875
  return safe({
855
876
  kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
856
877
  retryable: false,
@@ -859,7 +880,17 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
859
880
  })
860
881
  }
861
882
 
862
- return safe(parseItemListResult(q.kind, items, latencyMs, ts, envNote, r.stdout))
883
+ return safe(parseItemListResult(q.kind, data.itemList, latencyMs, ts, envNote, r.stdout))
884
+ }
885
+
886
+ // Explicit protocol/transport evidence only. Bare business codes and generic
887
+ // words such as "network" or "timeout" do not diagnose an upstream outage.
888
+ const HTTP_FAILURE = /\bHTTP(?:\/[\d.]+)?\s*[:=]?\s*([45]\d\d)\b/i
889
+ 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
890
+
891
+ function explicitUpstreamFailure(text: string): boolean {
892
+ return HTTP_FAILURE.test(text) || TRANSPORT_FAILURE.test(text)
893
+ || /Invalid API key|Trial limit reached|SentinelBlockException/i.test(text)
863
894
  }
864
895
 
865
896
  /** 当前进程 key 来源/endpoint 覆盖(仅展示,与 spawn 实际生效同源) */
@@ -886,39 +917,47 @@ function classifyUpstreamFailure(
886
917
  ): FlyaiResult {
887
918
  const upstream = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim()
888
919
  const tag = (verdict: FlyaiVerdict, evidence: string, extra: Partial<FlyaiResult>): FlyaiResult =>
889
- ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, evidence, ...extra })
920
+ ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, upstreamHealth: 'unknown', evidence, ...extra })
921
+ const httpStatus = Number(HTTP_FAILURE.exec(upstream)?.[1])
890
922
 
891
- if (/Invalid API key|HTTP\s*401|\b401\b/.test(upstream)) {
923
+ if (/Invalid API key/i.test(upstream) || httpStatus === 401) {
892
924
  return tag('auth-error', `[实时API:flyai@error@${ts}] 401 invalid api key`, {
893
925
  retryable: false,
894
926
  error: `FlyAI 鉴权失败(401):${upstream.slice(0, 160)}`,
895
927
  setup: '当前 key 无效或已吊销——在本机终端运行 `gotry setup flyai` 重新设置并验证(模型无法替你输入 key);`gotry setup flyai --clear` 可清除错误 key,清除后仍可匿名试用。',
896
928
  })
897
929
  }
898
- if (/HTTP\s*403|\b403\b/.test(upstream)) {
930
+ if (httpStatus === 403) {
899
931
  return tag('forbidden', `[实时API:flyai@error@${ts}] 403 forbidden`, {
900
932
  retryable: false,
901
933
  error: `FlyAI 拒绝访问(403):${upstream.slice(0, 160)}`,
902
934
  setup: '上游 403:核对该 key 的权限范围与控制台状态;如 endpoint 被 DEBUG_FLYAI_MCP_URL 覆盖,请确认指向。',
903
935
  })
904
936
  }
905
- if (/Trial limit reached/.test(upstream)) {
937
+ if (/Trial limit reached/i.test(upstream)) {
906
938
  return tag('needs-setup', `[实时API:flyai@error@${ts}] trial quota exhausted`, {
907
939
  retryable: false,
908
940
  error: `FlyAI 匿名试用额度已用尽:${upstream.slice(0, 160)}`,
909
941
  setup: '打开 https://flyai.open.fliggy.com/console,登录后复制 API Key,本机运行 `gotry setup flyai` 配置并验证;本会话请勿盲重试 gotry_flyai_search,机/火/酒改走 gotry_session_search。',
910
942
  })
911
943
  }
912
- if (/HTTP\s*429|\b429\b/.test(upstream)) {
944
+ if (/SentinelBlockException/i.test(upstream)) {
945
+ return tag('error', `[实时API:flyai@error@${ts}] sentinel`, {
946
+ retryable: false, upstreamHealth: 'unhealthy',
947
+ error: `FlyAI Sentinel 限流:${upstream.slice(0, 160)}`,
948
+ setup: '平台 Sentinel 限流(恢复窗口未公开);稍后重试,或改走 gotry_session_search(账号会话)。',
949
+ })
950
+ }
951
+ if (httpStatus === 429) {
913
952
  return tag('rate-limited', `[实时API:flyai@error@${ts}] 429 rate limited`, {
914
- retryable: true,
953
+ retryable: true, upstreamHealth: 'unhealthy',
915
954
  error: `FlyAI 普通限流(非试用达限):${upstream.slice(0, 160)}`,
916
955
  setup: '上游普通限流:稍候再查,勿高频连发;仍 429 请稍后或换 gotry_session_search。',
917
956
  })
918
957
  }
919
- if (/timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|socket|network|fetch failed|HTTP\s*5\d\d|\b5\d\d\b/i.test(upstream)) {
958
+ if (TRANSPORT_FAILURE.test(upstream) || httpStatus >= 500) {
920
959
  return tag('error', `[实时API:flyai@error@${ts}] transient upstream`, {
921
- retryable: true,
960
+ retryable: true, upstreamHealth: 'unhealthy',
922
961
  error: `FlyAI 瞬时网络/上游错误:${upstream.slice(0, 160)}`,
923
962
  setup: '网络抖动或上游 5xx:稍后重试;持续失败请检查本机网络或改走 gotry_session_search。',
924
963
  })
@@ -982,7 +1021,7 @@ function parseItemListResult(
982
1021
  const count = parsed.options.length
983
1022
  const verdict: FlyaiVerdict = count > 0 ? 'hit' : 'miss'
984
1023
  const evidence = `[实时API:flyai@${ts}] ${count}/${items.length} ${KIND_LABEL[kind]} options`
985
- const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
1024
+ const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, upstreamHealth: 'healthy' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
986
1025
 
987
1026
  switch (kind) {
988
1027
  case 'flight':
@@ -1088,7 +1127,9 @@ function parseHotelItems(items: unknown[]): ParsedItems<FlyaiHotelOption> {
1088
1127
  }
1089
1128
  const it = raw as RawItem
1090
1129
  const name = nonEmptyText(it.name)
1091
- const star = stringField(it, 'star')
1130
+ // Unrated homestays may return whitespace (live Harbin response).
1131
+ // Only this optional descriptive field permits blank text; types stay strict.
1132
+ const star = typeof it.star === 'string' && !it.star.trim() ? undefined : stringField(it, 'star')
1092
1133
  const rawPrice = stringField(it, 'price')
1093
1134
  const rate = it.rate === undefined ? undefined : it.rate === null ? null : nonEmptyText(it.rate) ?? 'bad'
1094
1135
  const address = stringField(it, 'address')
@@ -0,0 +1,128 @@
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.ts'
6
+ import { hbcliBinCandidates } from './hbcli.ts'
7
+ import { spawnBounded } from './spawn-bounded.ts'
8
+
9
+ export const HBCLI_REGISTRATION_URL = 'https://hotelbyte.com/zh/guides/sandbox-verification'
10
+ const ENVIRONMENT = 'uat'
11
+ const ENTRY = `openapi:${ENVIRONMENT}`
12
+ export interface HbcliConfigOptions { homeDir?: string; env?: NodeJS.ProcessEnv; hbcliBin?: string }
13
+ export type HbcliSetupVerdict = 'hit' | 'miss' | 'no-suppliers'
14
+ interface Verification { schema: 'gotry.hbcli-verification.v1'; fingerprint: string; verdict: HbcliSetupVerdict; at: string }
15
+
16
+ export function hbcliCredentialRoot(options: HbcliConfigOptions): string {
17
+ return options.env?.STAICLI_HOME?.trim() || join(options.homeDir ?? homedir(), '.staicli')
18
+ }
19
+ export function hbcliCredentialPath(options: HbcliConfigOptions): string { return join(hbcliCredentialRoot(options), 'credentials.json') }
20
+ export function hbcliVerificationPath(homeDir: string): string { return join(homeDir, '.gotry', 'hbcli-verification.json') }
21
+
22
+ function safeEntry(path: string, directory: boolean): boolean {
23
+ try {
24
+ const entry = lstatSync(path)
25
+ return !entry.isSymbolicLink() && (directory ? entry.isDirectory() : entry.isFile())
26
+ && (typeof process.getuid !== 'function' || entry.uid === process.getuid())
27
+ } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' }
28
+ }
29
+ export function readHbcliCredentials(options: HbcliConfigOptions): { ok: true; data: Record<string, unknown>; bytes?: string } | { ok: false } {
30
+ const root = hbcliCredentialRoot(options), file = hbcliCredentialPath(options)
31
+ try {
32
+ if (!isAbsolute(root) || !safeEntry(root, true) || !safeEntry(file, false)) return { ok: false }
33
+ if (!existsSync(file)) return { ok: true, data: {} }
34
+ const bytes = readFileSync(file, 'utf8'), data: unknown = JSON.parse(bytes)
35
+ return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data as Record<string, unknown>, bytes } : { ok: false }
36
+ } catch { return { ok: false } }
37
+ }
38
+ function fingerprint(data: Record<string, unknown>): string | undefined {
39
+ const entry = data[ENTRY] as { appKey?: unknown; appSecret?: unknown } | undefined
40
+ return typeof entry?.appKey === 'string' && entry.appKey.trim() && typeof entry.appSecret === 'string' && entry.appSecret.trim()
41
+ ? sha256Hex(JSON.stringify([ENVIRONMENT, entry.appKey, entry.appSecret])) : undefined
42
+ }
43
+ function readReceipt(home: string): Verification | undefined {
44
+ try {
45
+ const path = hbcliVerificationPath(home)
46
+ if (!safeEntry(join(home, '.gotry'), true) || !safeEntry(path, false)) return undefined
47
+ const v = JSON.parse(readFileSync(path, 'utf8')) as Verification
48
+ return v.schema === 'gotry.hbcli-verification.v1' && typeof v.fingerprint === 'string'
49
+ && ['hit', 'miss', 'no-suppliers'].includes(v.verdict) && Number.isFinite(Date.parse(v.at)) ? v : undefined
50
+ } catch { return undefined }
51
+ }
52
+ export function hbcliSetupStatus(options: HbcliConfigOptions) {
53
+ const read = readHbcliCredentials(options), hash = read.ok ? fingerprint(read.data) : undefined
54
+ const receipt = readReceipt(options.homeDir ?? homedir())
55
+ const envToken = Boolean(options.env?.HOTELBYTE_TOKEN?.trim())
56
+ const matches = !envToken && hash && receipt?.fingerprint === hash
57
+ return { ok: true, configured: envToken || Boolean(hash), writable: read.ok && !envToken && process.platform !== 'win32',
58
+ source: envToken ? 'env' : hash ? 'config' : 'none', environment: ENVIRONMENT,
59
+ verified: Boolean(matches && receipt?.verdict !== 'no-suppliers'),
60
+ ...(matches && receipt ? { verdict: receipt.verdict, checkedAt: receipt.at } : {}), registrationUrl: HBCLI_REGISTRATION_URL }
61
+ }
62
+
63
+ function prepareDirectory(dir: string): boolean {
64
+ try {
65
+ if (process.platform === 'win32' || !isAbsolute(dir) || !safeEntry(dir, true)) return false
66
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
67
+ if (!safeEntry(dir, true)) return false
68
+ chmodSync(dir, 0o700)
69
+ return true
70
+ } catch { return false }
71
+ }
72
+ export function writeHbcliCredentials(data: Record<string, unknown>, options: HbcliConfigOptions): boolean {
73
+ const path = hbcliCredentialPath(options)
74
+ return readHbcliCredentials(options).ok && prepareDirectory(hbcliCredentialRoot(options)) && atomicWriteJson(path, data).ok
75
+ }
76
+ export function writeHbcliVerification(data: Record<string, unknown>, verdict: HbcliSetupVerdict, options: HbcliConfigOptions): boolean {
77
+ const hash = fingerprint(data), home = options.homeDir ?? homedir()
78
+ if (!hash || !prepareDirectory(join(home, '.gotry'))) return false
79
+ const entry: Verification = { schema: 'gotry.hbcli-verification.v1', fingerprint: hash, verdict, at: new Date().toISOString() }
80
+ return safeEntry(hbcliVerificationPath(home), false) && atomicWriteJson(hbcliVerificationPath(home), entry).ok
81
+ }
82
+ export function removeHbcliVerification(homeDir: string): void {
83
+ if (safeEntry(join(homeDir, '.gotry'), true) && safeEntry(hbcliVerificationPath(homeDir), false)) {
84
+ try { rmSync(hbcliVerificationPath(homeDir), { force: true }) } catch { /* best effort */ }
85
+ }
86
+ }
87
+ export function withHbcliCredentials(data: Record<string, unknown>, appKey: string, appSecret: string): Record<string, unknown> {
88
+ return { ...data, [ENTRY]: { appKey, appSecret } }
89
+ }
90
+ export function withoutHbcliCredentials(data: Record<string, unknown>): Record<string, unknown> {
91
+ const next = { ...data }; delete next[ENTRY]; return next
92
+ }
93
+
94
+ /** Verify through the existing CLI bridge, with a private disposable credential root.
95
+ * An upstream no-suppliers verdict is retained separately from a completed query.
96
+ */
97
+ export async function verifyHbcliCandidate(appKey: string, appSecret: string, options: HbcliConfigOptions, signal: AbortSignal): Promise<{ verdict: HbcliSetupVerdict | 'error' | 'cancelled'; error?: string }> {
98
+ const root = mkdtempSync(join(tmpdir(), 'gotry-hbcli-verify-'))
99
+ try {
100
+ if (!atomicWriteJson(join(root, 'credentials.json'), withHbcliCredentials({}, appKey, appSecret)).ok) {
101
+ return { verdict: 'error', error: '无法创建隔离的验证环境。' }
102
+ }
103
+ const checkIn = new Date(); checkIn.setUTCDate(checkIn.getUTCDate() + 1)
104
+ const checkOut = new Date(checkIn); checkOut.setUTCDate(checkOut.getUTCDate() + 1)
105
+ const args = ['--json', '--env', ENVIRONMENT, 'search', 'hotel-list', '--destination-name', '上海',
106
+ '--check-in', checkIn.toISOString().slice(0, 10), '--check-out', checkOut.toISOString().slice(0, 10),
107
+ '--room-occupancies', '[{"adultCount":2,"childrenAges":[]}]', '--page-size', '1']
108
+ const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), STAICLI_HOME: root, HOTELBYTE_ENV: ENVIRONMENT }
109
+ delete env.HOTELBYTE_TOKEN
110
+ let result: Awaited<ReturnType<typeof spawnBounded>> | undefined
111
+ for (const bin of hbcliBinCandidates(options.hbcliBin ?? options.env?.GOTRY_HBCLI_BIN ?? 'hbcli', options.homeDir)) {
112
+ result = await spawnBounded(bin, args, { env, signal, timeoutMs: 20_000 })
113
+ if (!/ENOENT/.test(result.error ?? '') || signal.aborted) break
114
+ }
115
+ if (signal.aborted || result?.aborted) return { verdict: 'cancelled', error: '验证已取消,旧配置保留。' }
116
+ if (!result || result.error || result.timedOut || result.drainTimedOut || result.groupReaped === false || result.code !== 0) {
117
+ return { verdict: 'error', error: 'HotelByte 验证未完成,请检查 hbcli 安装、网络和凭证;旧配置保留。' }
118
+ }
119
+ let value: { code?: unknown; list?: unknown }
120
+ try { value = JSON.parse(result.stdout) } catch { return { verdict: 'error', error: 'HotelByte 回包无法识别,旧配置保留。' } }
121
+ if (value?.code === 300010002 || value?.code === '300010002') return { verdict: 'no-suppliers' }
122
+ if (!value || (value.code !== undefined && value.code !== 0 && value.code !== '0') || !Array.isArray(value.list)
123
+ || value.list.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
124
+ return { verdict: 'error', error: 'HotelByte 未接受凭证或返回了无效的查询结果;旧配置保留。' }
125
+ }
126
+ return { verdict: value.list.length ? 'hit' : 'miss' }
127
+ } finally { rmSync(root, { recursive: true, force: true }) }
128
+ }
@@ -32,7 +32,7 @@ export interface HbcliCallOptions {
32
32
  export interface HbcliCallResult {
33
33
  /** 是否走了实时 hbcli */
34
34
  via: 'hbcli-realtime' | 'hbcli-cache' | 'hbcli-error'
35
- /** hbcli 退码 0=成功 */
35
+ /** hbcli 进程退出码;零不代表 API 业务成功 */
36
36
  exitCode: number
37
37
  /** 解析后的 JSON(若 --json 模式输出可解析;否则为空) */
38
38
  result: unknown
@@ -99,6 +99,24 @@ async function attemptHbcli(
99
99
  if (jsonStr) {
100
100
  try { result = JSON.parse(jsonStr) } catch { /* 非 JSON 输出,留给调用方处理 */ }
101
101
  }
102
+ // CLI exit status describes the process, not the API verdict. hbcli can exit
103
+ // zero with an API error envelope (for example an unconfigured supplier pool).
104
+ const code = (result as { code?: unknown } | null)?.code
105
+ const invalid = result === null
106
+ const upstreamFailure = code !== undefined && code !== 0 && code !== '0'
107
+ if (invalid || upstreamFailure) {
108
+ const msg = (result as { msg?: unknown; message?: unknown } | null)?.msg
109
+ ?? (result as { message?: unknown } | null)?.message
110
+ const detail = code === 300010002 || code === '300010002'
111
+ ? '当前账号没有可用供应商;库存查询未完成'
112
+ : typeof msg === 'string' ? msg.slice(0, 200) : 'API request failed'
113
+ return {
114
+ via: 'hbcli-error', exitCode: 0, result: null,
115
+ evidence: `[实时API:hbcli@error@${ts}]`,
116
+ stdout: r.stdout.slice(0, 2000), latencyMs,
117
+ error: invalid ? 'failed to parse hbcli output as JSON' : `hbcli upstream code ${String(code)}: ${detail}`,
118
+ }
119
+ }
102
120
  return {
103
121
  via: 'hbcli-realtime', exitCode: 0, result,
104
122
  evidence: `[实时API:hbcli@${ts}]`,
@@ -177,7 +195,7 @@ export async function searchHotels(
177
195
  if (query.checkIn) hbArgs.push('--check-in', query.checkIn)
178
196
  if (query.checkOut) hbArgs.push('--check-out', query.checkOut)
179
197
  if (query.adults) hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]))
180
- const live = await callHbcliJson(hbArgs, opts)
198
+ let live = await callHbcliJson(hbArgs, opts)
181
199
  // 取消立即结束:不读静态包、不产出命中/无结果/估算,也不计上游故障
182
200
  if (opts.signal?.aborted || /abort/i.test(live.error ?? '')) {
183
201
  const ts = new Date().toISOString()
@@ -188,6 +206,15 @@ export async function searchHotels(
188
206
  latencyMs: live.latencyMs, error: `aborted by host signal${cleanupError}`, summary: `酒店「${query.destination}」检索已取消,未产生事实`,
189
207
  }
190
208
  }
209
+ const list = (live.result as { list?: unknown } | null)?.list
210
+ if (live.via === 'hbcli-realtime' && (!Array.isArray(list)
211
+ || list.some(item => item === null || typeof item !== 'object' || Array.isArray(item)))) {
212
+ live = {
213
+ ...live, via: 'hbcli-error', result: null,
214
+ evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
215
+ error: 'invalid hotel-list response: expected list array of hotels; inventory not observed',
216
+ }
217
+ }
191
218
  if (live.via === 'hbcli-realtime') {
192
219
  // summary 即模型面(render 只透 summary):必须自带紧凑数据行 + 证据链,
193
220
  // 否则模型只看到「实时返回」占位一句话(issue #195 实锤)。
@@ -297,7 +324,7 @@ export async function checkAvail(
297
324
  * ~/.staicli/credentials.json)——hbcli 侧 customer 档是最低优先级凭据(无
298
325
  * portal/API-key 凭据时才生效),登录不劫持既有凭据。本层不透出 token 值
299
326
  * (CLI 只回 token_saved,凭据留在 CLI 仓内,不进 gotry 会话/日志)。
300
- * auth 命令族要求 hbcli 带 register 支持(npm 0.0.4 起;0.0.3 报 unknown
327
+ * auth 命令族要求 hbcli 带 register 支持(官方原生 0.0.4 起;0.0.3 报 unknown
301
328
  * command,经 hbcli-error 面优雅失败)。
302
329
  */
303
330
  export async function customerSendCode(
@@ -7,12 +7,21 @@
7
7
  * 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互。
8
8
  */
9
9
 
10
- /** 城市三字码表(小起步集;词表外 unresolved 逐字保留,不做开放式猜测——ADR-12 同款边界) */
11
- const CITY_CODES: Record<string, string> = {
10
+ import { resolveCtripFlightCity, cityResolutionHint, type CityResolution } from '../ctrip-city.ts'
11
+
12
+ /**
13
+ * 离线起步缓存;未命中由编排层查询供应商城市数据(ctrip-city.ts),不推断航线覆盖。
14
+ * 码表只做性能优化,不在主链路上(显式码/供应商解析是正路)。
15
+ * 实测校准(2026-10-14):香港=hkg/迪拜=dxb 经 list 页实证——合法组合 HTTP 200 且
16
+ * title 含航线名(「中国香港(HKG)到迪拜(阿联酋)(DXB)机票预订」),非法组合 302 跳
17
+ * international/search(该 200/302 判别式即真数据校准手段)。
18
+ */
19
+ export const CITY_CODES: Record<string, string> = {
12
20
  上海: 'sha', 北京: 'bjs', 广州: 'can', 深圳: 'szx', 成都: 'ctu', 昆明: 'kmg',
13
21
  大理: 'dlu', 丽江: 'ljg', 西安: 'sia', 杭州: 'hgh', 三亚: 'syx', 厦门: 'xmn',
14
22
  重庆: 'cqg', 青岛: 'taa', 长沙: 'csx', 武汉: 'wuh', 南京: 'nkg', 郑州: 'cgo',
15
23
  贵阳: 'kwe', 桂林: 'kwl', 西双版纳: 'jhg', 香格里拉: 'dig',
24
+ 香港: 'hkg', 迪拜: 'dxb',
16
25
  }
17
26
 
18
27
  export interface AdapterEntry {
@@ -20,16 +29,43 @@ export interface AdapterEntry {
20
29
  url?: string
21
30
  /** 词表外城市逐字保留(调用方降级无日期/无线路搜索) */
22
31
  unresolved?: string[]
32
+ error?: string
23
33
  }
24
34
 
25
- export function buildEntryUrl(from: string, to: string, depDate: string): AdapterEntry {
35
+ /** Resolve unknown names against current supplier metadata before inventory navigation. */
36
+ export async function resolveFlightEntryUrl(from: string, to: string, depDate: string,
37
+ codes: { fromCode?: string; toCode?: string } = {},
38
+ resolve: (query: string) => Promise<CityResolution> = resolveCtripFlightCity,
39
+ backDate?: string): Promise<AdapterEntry> {
40
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(depDate)) return { ok: false, unresolved: [depDate], error: '出发日期需要 YYYY-MM-DD' }
41
+ for (const c of [codes.fromCode, codes.toCode]) if (c !== undefined && !/^[a-z]{3}$/i.test(c)) return { ok: false, error: '城市/机场码必须是三个英文字母' }
42
+ const resolveCode = async (name: string, explicit?: string) => {
43
+ const known = explicit ?? (/^[a-z]{3}$/i.test(name.trim()) ? name.trim() : undefined)
44
+ if (known) return { code: known }
45
+ const result = await resolve(name)
46
+ return result.ok ? { code: result.city.code } : { error: cityResolutionHint(name, result) }
47
+ }
48
+ const [origin, destination] = await Promise.all([resolveCode(from, codes.fromCode), resolveCode(to, codes.toCode)])
49
+ if (!origin.code || !destination.code) return { ok: false, unresolved: [!origin.code ? from : '', !destination.code ? to : ''].filter(Boolean), error: [origin.error, destination.error].filter(Boolean).join(';') }
50
+ return buildEntryUrl(from, to, depDate, { fromCode: origin.code, toCode: destination.code }, backDate)
51
+ }
52
+
53
+ export function buildEntryUrl(from: string, to: string, depDate: string, codes: { fromCode?: string; toCode?: string } = {}, backDate?: string): AdapterEntry {
26
54
  const unresolved: string[] = []
27
- const fromCode = CITY_CODES[from]
28
- const toCode = CITY_CODES[to]
55
+ // 显式码优先(形状合格才采纳),词表只是缓存——任何城市都可经显式码成行。
56
+ const fromCode = codes.fromCode === undefined ? CITY_CODES[from.trim()] : /^[a-z]{3}$/i.test(codes.fromCode) ? codes.fromCode.toLowerCase() : undefined
57
+ const toCode = codes.toCode === undefined ? CITY_CODES[to.trim()] : /^[a-z]{3}$/i.test(codes.toCode) ? codes.toCode.toLowerCase() : undefined
29
58
  if (!fromCode) unresolved.push(from)
30
59
  if (!toCode) unresolved.push(to)
31
- if (unresolved.length > 0 || !/^\d{4}-\d{2}-\d{2}$/.test(depDate)) {
32
- return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [depDate] }
60
+ const badDate = !/^\d{4}-\d{2}-\d{2}$/.test(depDate)
61
+ || (backDate !== undefined && (!/^\d{4}-\d{2}-\d{2}$/.test(backDate) || backDate <= depDate))
62
+ if (unresolved.length > 0 || badDate) {
63
+ return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [badDate ? (backDate !== undefined && backDate <= depDate ? backDate : depDate) : depDate], ...(backDate !== undefined && backDate <= depDate ? { error: `回程(${backDate})须晚于去程(${depDate})` } : {}) }
64
+ }
65
+ // 往返形状(2026-10-14 GUI URL 教师实测定稿):round-{code}0-{code}0?depdate=去_回&cabin=y_s
66
+ // ——此前 roundtrip-{3字码}?depdate&retdate 的猜测形状实测 302,已废弃。
67
+ if (backDate) {
68
+ return { ok: true, url: `https://flights.ctrip.com/online/list/round-${fromCode}0-${toCode}0?depdate=${depDate}_${backDate}&cabin=y_s&adult=1&child=0&infant=0` }
33
69
  }
34
70
  return { ok: true, url: `https://flights.ctrip.com/online/list/oneway-${fromCode}-${toCode}?depdate=${depDate}` }
35
71
  }