@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.
- 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/bridge-ledger.js +168 -67
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +148 -47
- package/dist/capabilities/session/gui-flight.js +949 -0
- package/dist/capabilities/session/gui-hotel.js +470 -0
- package/dist/capabilities/session-search.js +102 -14
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bootstrap-tests.js +23 -6
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- 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/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- 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/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +211 -6
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/gotry-web-api.js +64 -2
- package/dist/src/index.js +70 -18
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +5 -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/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +185 -67
- package/ts/capabilities/session/gui-flight.ts +1022 -0
- package/ts/capabilities/session/gui-hotel.ts +505 -0
- package/ts/capabilities/session-search.ts +130 -18
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +67 -19
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -65,6 +65,24 @@ async function attemptHbcli(bin, args, opts) {
|
|
|
65
65
|
}
|
|
66
66
|
catch { /* 非 JSON 输出,留给调用方处理 */ }
|
|
67
67
|
}
|
|
68
|
+
// CLI exit status describes the process, not the API verdict. hbcli can exit
|
|
69
|
+
// zero with an API error envelope (for example an unconfigured supplier pool).
|
|
70
|
+
const code = result?.code;
|
|
71
|
+
const invalid = result === null;
|
|
72
|
+
const upstreamFailure = code !== undefined && code !== 0 && code !== '0';
|
|
73
|
+
if (invalid || upstreamFailure) {
|
|
74
|
+
const msg = result?.msg
|
|
75
|
+
?? result?.message;
|
|
76
|
+
const detail = code === 300010002 || code === '300010002'
|
|
77
|
+
? '当前账号没有可用供应商;库存查询未完成'
|
|
78
|
+
: typeof msg === 'string' ? msg.slice(0, 200) : 'API request failed';
|
|
79
|
+
return {
|
|
80
|
+
via: 'hbcli-error', exitCode: 0, result: null,
|
|
81
|
+
evidence: `[实时API:hbcli@error@${ts}]`,
|
|
82
|
+
stdout: r.stdout.slice(0, 2000), latencyMs,
|
|
83
|
+
error: invalid ? 'failed to parse hbcli output as JSON' : `hbcli upstream code ${String(code)}: ${detail}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
68
86
|
return {
|
|
69
87
|
via: 'hbcli-realtime', exitCode: 0, result,
|
|
70
88
|
evidence: `[实时API:hbcli@${ts}]`,
|
|
@@ -138,7 +156,7 @@ export async function searchHotels(query, opts = {}) {
|
|
|
138
156
|
hbArgs.push('--check-out', query.checkOut);
|
|
139
157
|
if (query.adults)
|
|
140
158
|
hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]));
|
|
141
|
-
|
|
159
|
+
let live = await callHbcliJson(hbArgs, opts);
|
|
142
160
|
// 取消立即结束:不读静态包、不产出命中/无结果/估算,也不计上游故障
|
|
143
161
|
if (opts.signal?.aborted || /abort/i.test(live.error ?? '')) {
|
|
144
162
|
const ts = new Date().toISOString();
|
|
@@ -149,6 +167,15 @@ export async function searchHotels(query, opts = {}) {
|
|
|
149
167
|
latencyMs: live.latencyMs, error: `aborted by host signal${cleanupError}`, summary: `酒店「${query.destination}」检索已取消,未产生事实`,
|
|
150
168
|
};
|
|
151
169
|
}
|
|
170
|
+
const list = live.result?.list;
|
|
171
|
+
if (live.via === 'hbcli-realtime' && (!Array.isArray(list)
|
|
172
|
+
|| list.some(item => item === null || typeof item !== 'object' || Array.isArray(item)))) {
|
|
173
|
+
live = {
|
|
174
|
+
...live, via: 'hbcli-error', result: null,
|
|
175
|
+
evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
|
|
176
|
+
error: 'invalid hotel-list response: expected list array of hotels; inventory not observed',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
152
179
|
if (live.via === 'hbcli-realtime') {
|
|
153
180
|
// summary 即模型面(render 只透 summary):必须自带紧凑数据行 + 证据链,
|
|
154
181
|
// 否则模型只看到「实时返回」占位一句话(issue #195 实锤)。
|
|
@@ -247,7 +274,7 @@ export async function checkAvail(query, opts = {}) {
|
|
|
247
274
|
* ~/.staicli/credentials.json)——hbcli 侧 customer 档是最低优先级凭据(无
|
|
248
275
|
* portal/API-key 凭据时才生效),登录不劫持既有凭据。本层不透出 token 值
|
|
249
276
|
* (CLI 只回 token_saved,凭据留在 CLI 仓内,不进 gotry 会话/日志)。
|
|
250
|
-
* auth 命令族要求 hbcli 带 register 支持(
|
|
277
|
+
* auth 命令族要求 hbcli 带 register 支持(官方原生 0.0.4 起;0.0.3 报 unknown
|
|
251
278
|
* command,经 hbcli-error 面优雅失败)。
|
|
252
279
|
*/
|
|
253
280
|
export async function customerSendCode(email, opts = {}) {
|
|
@@ -108,7 +108,7 @@ export async function withRetry(fn, policy, onBackoff) {
|
|
|
108
108
|
export class CircuitBreaker {
|
|
109
109
|
o;
|
|
110
110
|
consecutiveFailures = 0;
|
|
111
|
-
openedAt =
|
|
111
|
+
openedAt = null;
|
|
112
112
|
generation = 0;
|
|
113
113
|
nextTokenId = 0;
|
|
114
114
|
probeToken = null;
|
|
@@ -122,9 +122,9 @@ export class CircuitBreaker {
|
|
|
122
122
|
return (this.o.now ?? Date.now)();
|
|
123
123
|
}
|
|
124
124
|
state() {
|
|
125
|
-
if (this.openedAt
|
|
125
|
+
if (this.openedAt !== null && this.now() - this.openedAt >= this.o.openMs)
|
|
126
126
|
return 'half-open';
|
|
127
|
-
if (this.openedAt
|
|
127
|
+
if (this.openedAt !== null)
|
|
128
128
|
return 'open';
|
|
129
129
|
return 'closed';
|
|
130
130
|
}
|
|
@@ -134,7 +134,7 @@ export class CircuitBreaker {
|
|
|
134
134
|
if (s === 'closed')
|
|
135
135
|
return { allowed: true, state: s, token: this.makeToken(false) };
|
|
136
136
|
if (s === 'open')
|
|
137
|
-
return { allowed: false, state: s };
|
|
137
|
+
return { allowed: false, state: s, retryAfterMs: Math.max(0, Math.min(this.o.openMs, this.o.openMs - (this.now() - this.openedAt))) };
|
|
138
138
|
// half-open:单探测语义——已有探测在途拒其余并发
|
|
139
139
|
if (this.probeToken)
|
|
140
140
|
return { allowed: false, state: s };
|
|
@@ -158,7 +158,7 @@ export class CircuitBreaker {
|
|
|
158
158
|
if (!this.owns(token))
|
|
159
159
|
return;
|
|
160
160
|
this.consecutiveFailures = 0;
|
|
161
|
-
this.openedAt =
|
|
161
|
+
this.openedAt = null;
|
|
162
162
|
if (token?.probe === true || (!token && this.probeToken !== null)) {
|
|
163
163
|
this.probeToken = null;
|
|
164
164
|
this.generation += 1;
|
|
@@ -171,7 +171,7 @@ export class CircuitBreaker {
|
|
|
171
171
|
if (token?.probe === true || (!token && this.probeToken !== null))
|
|
172
172
|
this.probeToken = null;
|
|
173
173
|
this.consecutiveFailures += 1;
|
|
174
|
-
if (this.openedAt
|
|
174
|
+
if (this.openedAt !== null || this.consecutiveFailures >= this.threshold()) {
|
|
175
175
|
this.openedAt = this.now();
|
|
176
176
|
this.generation += 1;
|
|
177
177
|
}
|
|
@@ -6,23 +6,58 @@
|
|
|
6
6
|
* (schema 实测 2026-08-28,见 data-sources.md §8)
|
|
7
7
|
* 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互。
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { resolveCtripFlightCity, cityResolutionHint } from '../ctrip-city.js';
|
|
10
|
+
/**
|
|
11
|
+
* 离线起步缓存;未命中由编排层查询供应商城市数据(ctrip-city.ts),不推断航线覆盖。
|
|
12
|
+
* 码表只做性能优化,不在主链路上(显式码/供应商解析是正路)。
|
|
13
|
+
* 实测校准(2026-10-14):香港=hkg/迪拜=dxb 经 list 页实证——合法组合 HTTP 200 且
|
|
14
|
+
* title 含航线名(「中国香港(HKG)到迪拜(阿联酋)(DXB)机票预订」),非法组合 302 跳
|
|
15
|
+
* international/search(该 200/302 判别式即真数据校准手段)。
|
|
16
|
+
*/
|
|
17
|
+
export const CITY_CODES = {
|
|
11
18
|
上海: 'sha', 北京: 'bjs', 广州: 'can', 深圳: 'szx', 成都: 'ctu', 昆明: 'kmg',
|
|
12
19
|
大理: 'dlu', 丽江: 'ljg', 西安: 'sia', 杭州: 'hgh', 三亚: 'syx', 厦门: 'xmn',
|
|
13
20
|
重庆: 'cqg', 青岛: 'taa', 长沙: 'csx', 武汉: 'wuh', 南京: 'nkg', 郑州: 'cgo',
|
|
14
21
|
贵阳: 'kwe', 桂林: 'kwl', 西双版纳: 'jhg', 香格里拉: 'dig',
|
|
22
|
+
香港: 'hkg', 迪拜: 'dxb',
|
|
15
23
|
};
|
|
16
|
-
|
|
24
|
+
/** Resolve unknown names against current supplier metadata before inventory navigation. */
|
|
25
|
+
export async function resolveFlightEntryUrl(from, to, depDate, codes = {}, resolve = resolveCtripFlightCity, backDate) {
|
|
26
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(depDate))
|
|
27
|
+
return { ok: false, unresolved: [depDate], error: '出发日期需要 YYYY-MM-DD' };
|
|
28
|
+
for (const c of [codes.fromCode, codes.toCode])
|
|
29
|
+
if (c !== undefined && !/^[a-z]{3}$/i.test(c))
|
|
30
|
+
return { ok: false, error: '城市/机场码必须是三个英文字母' };
|
|
31
|
+
const resolveCode = async (name, explicit) => {
|
|
32
|
+
const known = explicit ?? (/^[a-z]{3}$/i.test(name.trim()) ? name.trim() : undefined);
|
|
33
|
+
if (known)
|
|
34
|
+
return { code: known };
|
|
35
|
+
const result = await resolve(name);
|
|
36
|
+
return result.ok ? { code: result.city.code } : { error: cityResolutionHint(name, result) };
|
|
37
|
+
};
|
|
38
|
+
const [origin, destination] = await Promise.all([resolveCode(from, codes.fromCode), resolveCode(to, codes.toCode)]);
|
|
39
|
+
if (!origin.code || !destination.code)
|
|
40
|
+
return { ok: false, unresolved: [!origin.code ? from : '', !destination.code ? to : ''].filter(Boolean), error: [origin.error, destination.error].filter(Boolean).join(';') };
|
|
41
|
+
return buildEntryUrl(from, to, depDate, { fromCode: origin.code, toCode: destination.code }, backDate);
|
|
42
|
+
}
|
|
43
|
+
export function buildEntryUrl(from, to, depDate, codes = {}, backDate) {
|
|
17
44
|
const unresolved = [];
|
|
18
|
-
|
|
19
|
-
const
|
|
45
|
+
// 显式码优先(形状合格才采纳),词表只是缓存——任何城市都可经显式码成行。
|
|
46
|
+
const fromCode = codes.fromCode === undefined ? CITY_CODES[from.trim()] : /^[a-z]{3}$/i.test(codes.fromCode) ? codes.fromCode.toLowerCase() : undefined;
|
|
47
|
+
const toCode = codes.toCode === undefined ? CITY_CODES[to.trim()] : /^[a-z]{3}$/i.test(codes.toCode) ? codes.toCode.toLowerCase() : undefined;
|
|
20
48
|
if (!fromCode)
|
|
21
49
|
unresolved.push(from);
|
|
22
50
|
if (!toCode)
|
|
23
51
|
unresolved.push(to);
|
|
24
|
-
|
|
25
|
-
|
|
52
|
+
const badDate = !/^\d{4}-\d{2}-\d{2}$/.test(depDate)
|
|
53
|
+
|| (backDate !== undefined && (!/^\d{4}-\d{2}-\d{2}$/.test(backDate) || backDate <= depDate));
|
|
54
|
+
if (unresolved.length > 0 || badDate) {
|
|
55
|
+
return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [badDate ? (backDate !== undefined && backDate <= depDate ? backDate : depDate) : depDate], ...(backDate !== undefined && backDate <= depDate ? { error: `回程(${backDate})须晚于去程(${depDate})` } : {}) };
|
|
56
|
+
}
|
|
57
|
+
// 往返形状(2026-10-14 GUI URL 教师实测定稿):round-{code}0-{code}0?depdate=去_回&cabin=y_s
|
|
58
|
+
// ——此前 roundtrip-{3字码}?depdate&retdate 的猜测形状实测 302,已废弃。
|
|
59
|
+
if (backDate) {
|
|
60
|
+
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` };
|
|
26
61
|
}
|
|
27
62
|
return { ok: true, url: `https://flights.ctrip.com/online/list/oneway-${fromCode}-${toCode}?depdate=${depDate}` };
|
|
28
63
|
}
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互;ReadGuard 对酒店
|
|
14
14
|
* 页面照常生效(下单/支付面 URL 仍被物理拦截)。
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
import { resolveCtripHotelCity, hotelResolutionHint } from '../ctrip-city.js';
|
|
17
|
+
/** 离线兼容缓存(**实测校准 2026-09-03**:每条 id 均经 hotels.ctrip.com list 页
|
|
17
18
|
* <title> 验证——探测法曾纠出 104=平遥县(传猜成都)等 5 处错猜,表内只收
|
|
18
19
|
* 验证过的 id;词表外 unresolved 逐字保留,调用方可显式传 cityId 覆盖) */
|
|
19
|
-
const HOTEL_CITY_CODES = {
|
|
20
|
+
export const HOTEL_CITY_CODES = {
|
|
20
21
|
上海: '2', 北京: '1', 广州: '32', 深圳: '30', 成都: '28',
|
|
21
22
|
杭州: '17', 南京: '12', 苏州: '14', 无锡: '13', 扬州: '15', 镇江: '16',
|
|
22
23
|
青岛: '7', 厦门: '25', 珠海: '31', 三亚: '43', 海口: '42',
|
|
@@ -24,10 +25,22 @@ const HOTEL_CITY_CODES = {
|
|
|
24
25
|
贵阳: '38', 拉萨: '41', 乌鲁木齐: '39', 太原: '105', 长春: '158', 南通: '82',
|
|
25
26
|
敦煌: '11', 舟山: '19', 张家界: '27', 黄山市: '23', 九江: '24', 武夷山: '26',
|
|
26
27
|
迪拜: '220',
|
|
28
|
+
// Verified against the official list page and its displayed destination/date
|
|
29
|
+
// controls on 2026-10-07; https://hotels.ctrip.com/hotels/list?city=5
|
|
30
|
+
哈尔滨: '5',
|
|
27
31
|
};
|
|
32
|
+
export async function resolveHotelEntryUrl(q, resolve = resolveCtripHotelCity) {
|
|
33
|
+
if (q.cityId !== undefined)
|
|
34
|
+
return buildHotelEntryUrl(q);
|
|
35
|
+
const result = await resolve(q.to, q.country);
|
|
36
|
+
if (!result.ok)
|
|
37
|
+
return { ok: false, unresolved: [q.to], error: hotelResolutionHint(q.to, result) };
|
|
38
|
+
return buildHotelEntryUrl({ ...q, cityId: result.city.id });
|
|
39
|
+
}
|
|
28
40
|
export function buildHotelEntryUrl(q) {
|
|
29
41
|
const unresolved = [];
|
|
30
|
-
const
|
|
42
|
+
const candidate = q.cityId === undefined ? HOTEL_CITY_CODES[q.to.trim()] : String(q.cityId).trim();
|
|
43
|
+
const cityId = candidate && /^[1-9]\d{0,9}$/.test(candidate) ? candidate : undefined;
|
|
31
44
|
if (!cityId)
|
|
32
45
|
unresolved.push(q.to);
|
|
33
46
|
if (unresolved.length > 0) {
|
|
@@ -220,3 +233,39 @@ export function parseCtripHotelList(body, opts = {}) {
|
|
|
220
233
|
walk(raw, 0, new Set());
|
|
221
234
|
return out;
|
|
222
235
|
}
|
|
236
|
+
/** Only an explicit empty hotel array proves a miss; parser failure proves nothing. */
|
|
237
|
+
export function classifyCtripHotelList(body) {
|
|
238
|
+
let raw;
|
|
239
|
+
try {
|
|
240
|
+
raw = JSON.parse(body);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return { shape: 'malformed', hotels: [] };
|
|
244
|
+
}
|
|
245
|
+
const envelope = raw;
|
|
246
|
+
if (envelope?.success === false || envelope?.ok === false
|
|
247
|
+
|| (envelope?.code !== undefined && envelope.code !== 0 && envelope.code !== '0')
|
|
248
|
+
|| /^(failure|error)$/i.test(String(envelope?.ResponseStatus?.Ack ?? ''))) {
|
|
249
|
+
return { shape: 'unrecognized', hotels: [] };
|
|
250
|
+
}
|
|
251
|
+
const hotels = parseCtripHotelList(body);
|
|
252
|
+
if (hotels.length > 0)
|
|
253
|
+
return { shape: 'parsed', hotels };
|
|
254
|
+
let emptyList = false;
|
|
255
|
+
let unparsedItems = false;
|
|
256
|
+
const inspectHotelLists = (node, depth) => {
|
|
257
|
+
if (depth > 12 || node === null || typeof node !== 'object')
|
|
258
|
+
return;
|
|
259
|
+
for (const [key, value] of Object.entries(node)) {
|
|
260
|
+
if (['hotelList', 'hotelMatchInfos'].includes(key) && Array.isArray(value)) {
|
|
261
|
+
if (value.length === 0)
|
|
262
|
+
emptyList = true;
|
|
263
|
+
else
|
|
264
|
+
unparsedItems = true;
|
|
265
|
+
}
|
|
266
|
+
inspectHotelLists(value, depth + 1);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
inspectHotelLists(raw, 0);
|
|
270
|
+
return { shape: emptyList && !unparsedItems ? 'recognized-empty' : 'unrecognized', hotels: [] };
|
|
271
|
+
}
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
* (活动上下文,非权威事实)经 POST /v1/session/bridge/events 上行落账;
|
|
17
17
|
* idem_key 部分唯一索引(WHERE NOT NULL)让重试幂等:同 key 重发不重复落行。
|
|
18
18
|
*
|
|
19
|
+
* AaaS A2(#674,威胁 S-5):组织维度。作业/客户端/节律/事件各行都属于恰好一个组织
|
|
20
|
+
* (`organization` 列,缺省 `local` = 今天的单车道);客户端主键 (organization, client_id)、
|
|
21
|
+
* 节律主键 (organization, site)、事件幂等唯一索引 (organization, idem_key);
|
|
22
|
+
* 按作业 id 的更新与查询一律再带组织条件。组织永远是调用方(队列按自己的条目、
|
|
23
|
+
* 接缝解出的调用者)传入的参数,**绝不来自回包体或请求头**;每个方法先用
|
|
24
|
+
* assertOrganizationId 校验,非法组织在碰库之前就被拒。旧库(无组织列)在打开时原地迁移,
|
|
25
|
+
* 既有行归 `local`,迁移是单向的(迁移后旧版本二进制的节律/客户端写会失败)。
|
|
26
|
+
*
|
|
19
27
|
* 红线继承:本账本只存**控制状态**(job 元数据/回包/节律/客户端能力清单/事件流水),
|
|
20
28
|
* 协议面不存在 cookie 值字段(extension-tests §38 红线断言),账本亦零凭据落库。
|
|
21
29
|
*
|
|
@@ -25,6 +33,7 @@
|
|
|
25
33
|
import Database from 'better-sqlite3';
|
|
26
34
|
import { mkdirSync } from 'node:fs';
|
|
27
35
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
36
|
+
import { assertOrganizationId } from '../../src/tenant-state/ids.js';
|
|
28
37
|
/** 站点节律冷却(毫秒):与 capabilities/session-search.ts 的 MIN_INTERVAL_MS 同口径 30s;导出仅测试/文档锚点 */
|
|
29
38
|
export const BRIDGE_SITE_COOLDOWN_MS = 30_000;
|
|
30
39
|
/** stateRoot 可相对可绝对(与 state-ledger.stateDirOf 同款解析) */
|
|
@@ -35,9 +44,27 @@ function stateDirOf(stateRoot) {
|
|
|
35
44
|
export function bridgeLedgerDbPath(stateRoot) {
|
|
36
45
|
return join(stateDirOf(stateRoot), 'bridge.db');
|
|
37
46
|
}
|
|
38
|
-
|
|
47
|
+
// 主键含组织的两张表:建表与「旧库重建」共用同一份列定义,两处不会漂移
|
|
48
|
+
const CLIENTS_COLUMNS = `(
|
|
49
|
+
organization TEXT NOT NULL DEFAULT 'local',
|
|
50
|
+
client_id TEXT NOT NULL,
|
|
51
|
+
origin TEXT NOT NULL,
|
|
52
|
+
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
|
53
|
+
last_seen_at INTEGER NOT NULL,
|
|
54
|
+
login_sites_json TEXT NOT NULL DEFAULT '[]',
|
|
55
|
+
PRIMARY KEY (organization, client_id)
|
|
56
|
+
)`;
|
|
57
|
+
const PACING_COLUMNS = `(
|
|
58
|
+
organization TEXT NOT NULL DEFAULT 'local',
|
|
59
|
+
site TEXT NOT NULL,
|
|
60
|
+
last_hit_at INTEGER NOT NULL,
|
|
61
|
+
cooldown_until INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
PRIMARY KEY (organization, site)
|
|
63
|
+
)`;
|
|
64
|
+
const TABLES = `
|
|
39
65
|
CREATE TABLE IF NOT EXISTS bridge_jobs (
|
|
40
66
|
id TEXT PRIMARY KEY,
|
|
67
|
+
organization TEXT NOT NULL DEFAULT 'local',
|
|
41
68
|
kind TEXT NOT NULL,
|
|
42
69
|
site TEXT NOT NULL,
|
|
43
70
|
payload_json TEXT NOT NULL,
|
|
@@ -51,24 +78,14 @@ CREATE TABLE IF NOT EXISTS bridge_jobs (
|
|
|
51
78
|
created_at INTEGER NOT NULL,
|
|
52
79
|
updated_at INTEGER NOT NULL
|
|
53
80
|
);
|
|
54
|
-
CREATE INDEX IF NOT EXISTS bridge_jobs_status ON bridge_jobs(status);
|
|
55
81
|
|
|
56
|
-
CREATE TABLE IF NOT EXISTS bridge_clients
|
|
57
|
-
client_id TEXT PRIMARY KEY,
|
|
58
|
-
origin TEXT NOT NULL,
|
|
59
|
-
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
|
60
|
-
last_seen_at INTEGER NOT NULL,
|
|
61
|
-
login_sites_json TEXT NOT NULL DEFAULT '[]'
|
|
62
|
-
);
|
|
82
|
+
CREATE TABLE IF NOT EXISTS bridge_clients ${CLIENTS_COLUMNS};
|
|
63
83
|
|
|
64
|
-
CREATE TABLE IF NOT EXISTS bridge_pacing
|
|
65
|
-
site TEXT PRIMARY KEY,
|
|
66
|
-
last_hit_at INTEGER NOT NULL,
|
|
67
|
-
cooldown_until INTEGER NOT NULL DEFAULT 0
|
|
68
|
-
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS bridge_pacing ${PACING_COLUMNS};
|
|
69
85
|
|
|
70
86
|
CREATE TABLE IF NOT EXISTS bridge_events (
|
|
71
87
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
organization TEXT NOT NULL DEFAULT 'local',
|
|
72
89
|
ts INTEGER NOT NULL,
|
|
73
90
|
client_id TEXT NOT NULL,
|
|
74
91
|
kind TEXT NOT NULL,
|
|
@@ -77,13 +94,62 @@ CREATE TABLE IF NOT EXISTS bridge_events (
|
|
|
77
94
|
payload_json TEXT NOT NULL,
|
|
78
95
|
idem_key TEXT
|
|
79
96
|
);
|
|
80
|
-
CREATE UNIQUE INDEX IF NOT EXISTS bridge_events_idem_key
|
|
81
|
-
ON bridge_events(idem_key) WHERE idem_key IS NOT NULL;
|
|
82
97
|
`;
|
|
98
|
+
// 索引在迁移之后建:旧库的 bridge_events 在迁移前还没有 organization 列
|
|
99
|
+
const INDEXES = `
|
|
100
|
+
CREATE INDEX IF NOT EXISTS bridge_jobs_status ON bridge_jobs(status);
|
|
101
|
+
CREATE UNIQUE INDEX IF NOT EXISTS bridge_events_org_idem_key
|
|
102
|
+
ON bridge_events(organization, idem_key) WHERE idem_key IS NOT NULL;
|
|
103
|
+
`;
|
|
104
|
+
const ORGANIZATION_TABLES = ['bridge_jobs', 'bridge_clients', 'bridge_pacing', 'bridge_events'];
|
|
105
|
+
function hasOrganizationColumn(db, table) {
|
|
106
|
+
return db.pragma(`table_info(${table})`).some((column) => column.name === 'organization');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 旧库(组织维度之前)原地迁移:jobs/events 加列,clients/pacing 因主键含组织而重建;
|
|
110
|
+
* 既有行一律归 `local`。整段在一个 IMMEDIATE 事务里,拿到写锁后重新判定哪些表还需要迁移
|
|
111
|
+
* (并发打开同一个库的另一连接可能已迁完),所以幂等且无半迁移状态。
|
|
112
|
+
*/
|
|
113
|
+
function migrateOrganizationDimension(db) {
|
|
114
|
+
const pending = () => ORGANIZATION_TABLES.filter((table) => !hasOrganizationColumn(db, table));
|
|
115
|
+
if (pending().length > 0)
|
|
116
|
+
migrateTables(db, pending);
|
|
117
|
+
// The single-column unique index on idem_key of the legacy schema would make two organizations' identical keys refuse each
|
|
118
|
+
// other (the per-organization one replaces it). An older binary recreates it every time it opens the file, so a file that
|
|
119
|
+
// went new, old, new has nothing left to migrate and still holds it. Drop it on every open (a no-op when absent).
|
|
120
|
+
if (hasIndex(db, 'bridge_events_idem_key'))
|
|
121
|
+
db.transaction(() => { db.exec('DROP INDEX IF EXISTS bridge_events_idem_key'); }).immediate();
|
|
122
|
+
}
|
|
123
|
+
function hasIndex(db, name) {
|
|
124
|
+
return db.prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name=?").get(name) !== undefined;
|
|
125
|
+
}
|
|
126
|
+
function migrateTables(db, pending) {
|
|
127
|
+
db.transaction(() => {
|
|
128
|
+
for (const table of pending()) {
|
|
129
|
+
if (table === 'bridge_jobs' || table === 'bridge_events') {
|
|
130
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN organization TEXT NOT NULL DEFAULT 'local'`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
db.exec(`ALTER TABLE ${table} RENAME TO ${table}__legacy`);
|
|
134
|
+
if (table === 'bridge_clients') {
|
|
135
|
+
db.exec(`CREATE TABLE bridge_clients ${CLIENTS_COLUMNS}`);
|
|
136
|
+
db.exec(`INSERT INTO bridge_clients (organization, client_id, origin, capabilities_json, last_seen_at, login_sites_json)
|
|
137
|
+
SELECT 'local', client_id, origin, capabilities_json, last_seen_at, login_sites_json FROM bridge_clients__legacy`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
db.exec(`CREATE TABLE bridge_pacing ${PACING_COLUMNS}`);
|
|
141
|
+
db.exec(`INSERT INTO bridge_pacing (organization, site, last_hit_at, cooldown_until)
|
|
142
|
+
SELECT 'local', site, last_hit_at, cooldown_until FROM bridge_pacing__legacy`);
|
|
143
|
+
}
|
|
144
|
+
db.exec(`DROP TABLE ${table}__legacy`);
|
|
145
|
+
}
|
|
146
|
+
}).immediate();
|
|
147
|
+
}
|
|
83
148
|
/**
|
|
84
149
|
* SQLite 落地的桥作业账本(实现 extension-bridge.BridgeJobStore 契约)。
|
|
85
150
|
* 所有写方法单语句/单事务同步提交;调用方(extension-bridge)负责失败兜底
|
|
86
151
|
* (write-before-submit 的 INSERT 失败 = 提交失败,fail-closed 不入队)。
|
|
152
|
+
* 组织是每个方法的第一个参数且必填:漏传是编译错误,不是静默落进 `local`。
|
|
87
153
|
*/
|
|
88
154
|
export class BridgeLedgerStore {
|
|
89
155
|
dbPath;
|
|
@@ -95,62 +161,79 @@ export class BridgeLedgerStore {
|
|
|
95
161
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
96
162
|
this.db = new Database(dbPath);
|
|
97
163
|
this.db.pragma('journal_mode = WAL');
|
|
164
|
+
// Opening a legacy file needs the write lock once (migration, index changes). A rolling deploy can have the old process
|
|
165
|
+
// writing through it, so the open waits longer than a normal write does and then drops back.
|
|
166
|
+
this.db.pragma('busy_timeout = 30000');
|
|
167
|
+
this.db.exec(TABLES);
|
|
168
|
+
migrateOrganizationDimension(this.db);
|
|
169
|
+
this.db.exec(INDEXES);
|
|
98
170
|
this.db.pragma('busy_timeout = 5000');
|
|
99
|
-
this.db.exec(SCHEMA);
|
|
100
171
|
}
|
|
101
172
|
/** write-before-submit:入队前先落 queued(提交失败 = 调用方不得派发) */
|
|
102
|
-
insertQueuedJob(job, timeoutAt) {
|
|
173
|
+
insertQueuedJob(organization, job, timeoutAt) {
|
|
174
|
+
const org = assertOrganizationId(organization);
|
|
103
175
|
const ts = this.now();
|
|
104
|
-
this.db.prepare(`INSERT INTO bridge_jobs (id, kind, site, payload_json, status, timeout_at, attempts, created_at, updated_at)
|
|
105
|
-
VALUES (@id, @kind, @site, @payload, 'queued', @timeoutAt, 0, @ts, @ts)`).run({ id: job.jobId, kind: job.kind, site: job.site, payload: JSON.stringify(job), timeoutAt, ts });
|
|
176
|
+
this.db.prepare(`INSERT INTO bridge_jobs (id, organization, kind, site, payload_json, status, timeout_at, attempts, created_at, updated_at)
|
|
177
|
+
VALUES (@id, @organization, @kind, @site, @payload, 'queued', @timeoutAt, 0, @ts, @ts)`).run({ id: job.jobId, organization: org, kind: job.kind, site: job.site, payload: JSON.stringify(job), timeoutAt, ts });
|
|
106
178
|
}
|
|
107
179
|
/** 领取(/jobs 派发与 dispatchToParked 两处):claimed + 领取者 + attempts 递增 */
|
|
108
|
-
markClaimed(jobId, claim) {
|
|
180
|
+
markClaimed(organization, jobId, claim) {
|
|
181
|
+
const org = assertOrganizationId(organization);
|
|
109
182
|
const ts = this.now();
|
|
110
183
|
this.db.prepare(`UPDATE bridge_jobs
|
|
111
184
|
SET status='claimed', claimed_by_client=@client, claimed_origin=@origin, claimed_at=@ts, attempts=attempts+1, updated_at=@ts
|
|
112
|
-
WHERE id=@id`).run({ id: jobId, client: claim.clientId ?? null, origin: claim.origin, ts });
|
|
185
|
+
WHERE id=@id AND organization=@organization`).run({ id: jobId, organization: org, client: claim.clientId ?? null, origin: claim.origin, ts });
|
|
113
186
|
}
|
|
114
|
-
markResolved(jobId, result) {
|
|
115
|
-
|
|
187
|
+
markResolved(organization, jobId, result) {
|
|
188
|
+
const org = assertOrganizationId(organization);
|
|
189
|
+
this.db.prepare(`UPDATE bridge_jobs SET status='resolved', result_json=@result, updated_at=@ts WHERE id=@id AND organization=@organization`).run({ id: jobId, organization: org, result: JSON.stringify(result), ts: this.now() });
|
|
116
190
|
}
|
|
117
|
-
markUnresolved(jobId, note) {
|
|
191
|
+
markUnresolved(organization, jobId, note) {
|
|
192
|
+
const org = assertOrganizationId(organization);
|
|
118
193
|
const result = note ? JSON.stringify({ ok: false, reason: note.reason }) : null;
|
|
119
|
-
this.db.prepare(`UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id`).run({ id: jobId, result, ts: this.now() });
|
|
194
|
+
this.db.prepare(`UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id AND organization=@organization`).run({ id: jobId, organization: org, result, ts: this.now() });
|
|
120
195
|
}
|
|
121
|
-
markVoid(jobId) {
|
|
122
|
-
|
|
196
|
+
markVoid(organization, jobId) {
|
|
197
|
+
const org = assertOrganizationId(organization);
|
|
198
|
+
this.db.prepare(`UPDATE bridge_jobs SET status='void', updated_at=@ts WHERE id=@id AND organization=@organization`).run({ id: jobId, organization: org, ts: this.now() });
|
|
123
199
|
}
|
|
124
200
|
/**
|
|
125
201
|
* 孤儿回包落库(对账面):未知 jobId 的回包内容以 status='unresolved' 保留。
|
|
126
|
-
* - 无行(纯未知/探测回包):插 kind 取回包自报(缺省 'orphan'),site='unknown'
|
|
202
|
+
* - 无行(纯未知/探测回包):插 kind 取回包自报(缺省 'orphan'),site='unknown',归调用方组织;
|
|
127
203
|
* - 已 resolved:迟到回包不覆盖已决结果(幂等,首次裁决为准);
|
|
128
|
-
* - unresolved/void:补 result_json
|
|
204
|
+
* - unresolved/void:补 result_json——作废/超时后扩展其实跑完了,留证供对账;
|
|
205
|
+
* - 该 jobId 已属于**另一个组织**:拒绝(抛错,零写入)。队列在调用前就按组织拦下并答
|
|
206
|
+
* 与未知作业完全一样的 404,这里是第二道防线。
|
|
129
207
|
*/
|
|
130
|
-
recordOrphanResult(jobId, result) {
|
|
131
|
-
const
|
|
208
|
+
recordOrphanResult(organization, jobId, result) {
|
|
209
|
+
const org = assertOrganizationId(organization);
|
|
210
|
+
const existing = this.db.prepare(`SELECT organization, status FROM bridge_jobs WHERE id=@id`).get({ id: jobId });
|
|
132
211
|
const ts = this.now();
|
|
133
212
|
const resultJson = JSON.stringify(result);
|
|
134
213
|
if (!existing) {
|
|
135
|
-
this.db.prepare(`INSERT INTO bridge_jobs (id, kind, site, payload_json, status, result_json, attempts, created_at, updated_at)
|
|
136
|
-
VALUES (@id, @kind, 'unknown', '{}', 'unresolved', @result, 0, @ts, @ts)`).run({ id: jobId, kind: result.kind ?? 'orphan', result: resultJson, ts });
|
|
214
|
+
this.db.prepare(`INSERT INTO bridge_jobs (id, organization, kind, site, payload_json, status, result_json, attempts, created_at, updated_at)
|
|
215
|
+
VALUES (@id, @organization, @kind, 'unknown', '{}', 'unresolved', @result, 0, @ts, @ts)`).run({ id: jobId, organization: org, kind: result.kind ?? 'orphan', result: resultJson, ts });
|
|
137
216
|
return;
|
|
138
217
|
}
|
|
218
|
+
if (existing.organization !== org)
|
|
219
|
+
throw new Error('bridge-job-organization-mismatch');
|
|
139
220
|
if (existing.status === 'resolved')
|
|
140
221
|
return;
|
|
141
|
-
this.db.prepare(`UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id`).run({ id: jobId, result: resultJson, ts });
|
|
222
|
+
this.db.prepare(`UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id AND organization=@organization`).run({ id: jobId, organization: org, result: resultJson, ts });
|
|
142
223
|
}
|
|
143
224
|
/** bridge_clients upsert:/jobs 轮询体(clientId + capabilities)到达即刷新,login_sites 保序合并 */
|
|
144
|
-
upsertClient(clientId, origin, capabilities) {
|
|
225
|
+
upsertClient(organization, clientId, origin, capabilities) {
|
|
226
|
+
const org = assertOrganizationId(organization);
|
|
145
227
|
const ts = this.now();
|
|
146
|
-
this.db.prepare(`INSERT INTO bridge_clients (client_id, origin, capabilities_json, last_seen_at, login_sites_json)
|
|
147
|
-
VALUES (@clientId, @origin, @caps, @ts, '[]')
|
|
148
|
-
ON CONFLICT(client_id) DO UPDATE SET
|
|
149
|
-
origin=excluded.origin, capabilities_json=excluded.capabilities_json, last_seen_at=excluded.last_seen_at`).run({ clientId, origin, caps: JSON.stringify([...capabilities]), ts });
|
|
228
|
+
this.db.prepare(`INSERT INTO bridge_clients (organization, client_id, origin, capabilities_json, last_seen_at, login_sites_json)
|
|
229
|
+
VALUES (@organization, @clientId, @origin, @caps, @ts, '[]')
|
|
230
|
+
ON CONFLICT(organization, client_id) DO UPDATE SET
|
|
231
|
+
origin=excluded.origin, capabilities_json=excluded.capabilities_json, last_seen_at=excluded.last_seen_at`).run({ organization: org, clientId, origin, caps: JSON.stringify([...capabilities]), ts });
|
|
150
232
|
}
|
|
151
233
|
/** cookie-names 命中票据名(名字级)→ 该客户端在该站点持有登录态 */
|
|
152
|
-
recordClientLoginSite(clientId, site) {
|
|
153
|
-
const
|
|
234
|
+
recordClientLoginSite(organization, clientId, site) {
|
|
235
|
+
const org = assertOrganizationId(organization);
|
|
236
|
+
const row = this.db.prepare(`SELECT login_sites_json FROM bridge_clients WHERE organization=@organization AND client_id=@clientId`).get({ organization: org, clientId });
|
|
154
237
|
if (!row)
|
|
155
238
|
return;
|
|
156
239
|
let sites;
|
|
@@ -162,57 +245,75 @@ export class BridgeLedgerStore {
|
|
|
162
245
|
}
|
|
163
246
|
const merged = new Set(Array.isArray(sites) ? sites.filter((s) => typeof s === 'string') : []);
|
|
164
247
|
merged.add(site);
|
|
165
|
-
this.db.prepare(`UPDATE bridge_clients SET login_sites_json=@sites WHERE client_id=@clientId`).run({ clientId, sites: JSON.stringify([...merged]) });
|
|
248
|
+
this.db.prepare(`UPDATE bridge_clients SET login_sites_json=@sites WHERE organization=@organization AND client_id=@clientId`).run({ organization: org, clientId, sites: JSON.stringify([...merged]) });
|
|
166
249
|
}
|
|
167
|
-
/** 站点导航类作业提交/领取:记命中时刻并把冷却推到 now+30s(不回退既有冷却) */
|
|
168
|
-
noteSiteHit(site) {
|
|
250
|
+
/** 站点导航类作业提交/领取:记命中时刻并把冷却推到 now+30s(不回退既有冷却);节律按 组织+站点 */
|
|
251
|
+
noteSiteHit(organization, site) {
|
|
252
|
+
const org = assertOrganizationId(organization);
|
|
169
253
|
const ts = this.now();
|
|
170
|
-
this.db.prepare(`INSERT INTO bridge_pacing (site, last_hit_at, cooldown_until) VALUES (@site, @ts, @until)
|
|
171
|
-
ON CONFLICT(site) DO UPDATE SET last_hit_at=excluded.last_hit_at, cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`).run({ site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS });
|
|
254
|
+
this.db.prepare(`INSERT INTO bridge_pacing (organization, site, last_hit_at, cooldown_until) VALUES (@organization, @site, @ts, @until)
|
|
255
|
+
ON CONFLICT(organization, site) DO UPDATE SET last_hit_at=excluded.last_hit_at, cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`).run({ organization: org, site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS });
|
|
172
256
|
}
|
|
173
257
|
/** 超时结算冷却:claimed 超时的导航作业——站点可能已被打,只推冷却,不虚构 last_hit_at */
|
|
174
|
-
applyCooldown(site) {
|
|
258
|
+
applyCooldown(organization, site) {
|
|
259
|
+
const org = assertOrganizationId(organization);
|
|
175
260
|
const ts = this.now();
|
|
176
|
-
this.db.prepare(`INSERT INTO bridge_pacing (site, last_hit_at, cooldown_until) VALUES (@site, @ts, @until)
|
|
177
|
-
ON CONFLICT(site) DO UPDATE SET cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`).run({ site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS });
|
|
261
|
+
this.db.prepare(`INSERT INTO bridge_pacing (organization, site, last_hit_at, cooldown_until) VALUES (@organization, @site, @ts, @until)
|
|
262
|
+
ON CONFLICT(organization, site) DO UPDATE SET cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`).run({ organization: org, site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS });
|
|
178
263
|
}
|
|
179
|
-
pacingOf(site) {
|
|
180
|
-
const
|
|
264
|
+
pacingOf(organization, site) {
|
|
265
|
+
const org = assertOrganizationId(organization);
|
|
266
|
+
const row = this.db.prepare(`SELECT last_hit_at, cooldown_until FROM bridge_pacing WHERE organization=@organization AND site=@site`).get({ organization: org, site });
|
|
181
267
|
return row ? { lastHitAt: row.last_hit_at, cooldownUntil: row.cooldown_until } : null;
|
|
182
268
|
}
|
|
183
269
|
/**
|
|
184
270
|
* 事件上行落账(批次 B):seq 由 AUTOINCREMENT 分配,ts 取服务端落账时刻
|
|
185
271
|
* (clientTs 是扩展侧时钟,仅参与形状校验不入列——服务端时钟才是账本唯一时间轴)。
|
|
186
|
-
* 幂等:idem_key
|
|
187
|
-
* idem_key 为 NULL 的行不参与去重,多行共存
|
|
188
|
-
*
|
|
272
|
+
* 幂等:idem_key 非空且**同组织**已存在 → 不重复落行,返回 false(由调用方计入 rejected);
|
|
273
|
+
* 不同组织可以复用同一个 idem_key,互不去重。idem_key 为 NULL 的行不参与去重,多行共存
|
|
274
|
+
* (部分唯一索引)。同进程同步 API,check-then-insert 对本连接原子。
|
|
189
275
|
*/
|
|
190
|
-
insertEvent(ev) {
|
|
276
|
+
insertEvent(organization, ev) {
|
|
277
|
+
const org = assertOrganizationId(organization);
|
|
191
278
|
if (ev.idem_key !== null) {
|
|
192
|
-
const dup = this.db.prepare(`SELECT 1 FROM bridge_events WHERE idem_key
|
|
279
|
+
const dup = this.db.prepare(`SELECT 1 FROM bridge_events WHERE organization=@organization AND idem_key=@idem`).get({ organization: org, idem: ev.idem_key });
|
|
193
280
|
if (dup)
|
|
194
281
|
return false;
|
|
195
282
|
}
|
|
196
|
-
this.db.prepare(`INSERT INTO bridge_events (ts, client_id, kind, site, subject, payload_json, idem_key)
|
|
197
|
-
VALUES (@ts, @clientId, @kind, @site, @subject, @payload, @idem)`).run({ ts: this.now(), clientId: ev.client_id, kind: ev.kind, site: ev.site, subject: ev.subject, payload: ev.payload_json, idem: ev.idem_key });
|
|
283
|
+
this.db.prepare(`INSERT INTO bridge_events (organization, ts, client_id, kind, site, subject, payload_json, idem_key)
|
|
284
|
+
VALUES (@organization, @ts, @clientId, @kind, @site, @subject, @payload, @idem)`).run({ organization: org, ts: this.now(), clientId: ev.client_id, kind: ev.kind, site: ev.site, subject: ev.subject, payload: ev.payload_json, idem: ev.idem_key });
|
|
198
285
|
return true;
|
|
199
286
|
}
|
|
200
|
-
/** 事件流水读面(测试/对账)
|
|
201
|
-
listEvents() {
|
|
202
|
-
|
|
287
|
+
/** 事件流水读面(测试/对账):某组织的行,按 seq 升序 */
|
|
288
|
+
listEvents(organization) {
|
|
289
|
+
const org = assertOrganizationId(organization);
|
|
290
|
+
return this.db.prepare(`SELECT seq, organization, ts, client_id, kind, site, subject, payload_json, idem_key FROM bridge_events WHERE organization=@organization ORDER BY seq`).all({ organization: org });
|
|
203
291
|
}
|
|
292
|
+
/** 全部组织的未终态作业(开机恢复按行还原所属组织) */
|
|
204
293
|
listPendingJobs() {
|
|
205
|
-
return this.db.prepare(`SELECT id, kind, site, payload_json, status, claimed_by_client, claimed_origin, timeout_at, attempts
|
|
294
|
+
return this.db.prepare(`SELECT id, organization, kind, site, payload_json, status, claimed_by_client, claimed_origin, timeout_at, attempts
|
|
206
295
|
FROM bridge_jobs WHERE status IN ('queued','claimed')`).all();
|
|
207
296
|
}
|
|
297
|
+
/** 按 jobId 读行(任何组织;测试/对账口径,行里带 organization)。调用方有组织时用 getJobForOrganization */
|
|
208
298
|
getJob(jobId) {
|
|
209
299
|
return this.db.prepare(`SELECT * FROM bridge_jobs WHERE id=?`).get(jobId) ?? null;
|
|
210
300
|
}
|
|
211
|
-
|
|
212
|
-
|
|
301
|
+
getJobForOrganization(organization, jobId) {
|
|
302
|
+
const org = assertOrganizationId(organization);
|
|
303
|
+
return this.db.prepare(`SELECT * FROM bridge_jobs WHERE id=@id AND organization=@organization`).get({ id: jobId, organization: org }) ?? null;
|
|
304
|
+
}
|
|
305
|
+
/** 该 jobId 属于哪个组织(账本里没有这行 = null);队列用它认出「别的组织的作业」 */
|
|
306
|
+
organizationOfJob(jobId) {
|
|
307
|
+
const row = this.db.prepare(`SELECT organization FROM bridge_jobs WHERE id=?`).get(jobId);
|
|
308
|
+
return row ? row.organization : null;
|
|
309
|
+
}
|
|
310
|
+
getClient(organization, clientId) {
|
|
311
|
+
const org = assertOrganizationId(organization);
|
|
312
|
+
return this.db.prepare(`SELECT * FROM bridge_clients WHERE organization=@organization AND client_id=@clientId`).get({ organization: org, clientId }) ?? null;
|
|
213
313
|
}
|
|
214
|
-
listPacing() {
|
|
215
|
-
|
|
314
|
+
listPacing(organization) {
|
|
315
|
+
const org = assertOrganizationId(organization);
|
|
316
|
+
return this.db.prepare(`SELECT organization, site, last_hit_at, cooldown_until FROM bridge_pacing WHERE organization=@organization ORDER BY site`).all({ organization: org });
|
|
216
317
|
}
|
|
217
318
|
close() {
|
|
218
319
|
this.db.close();
|