@seasonkoh/webaz 0.1.31 → 0.1.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 +3 -1
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +4 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +133 -2
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +96 -0
- package/dist/layer3-trust/L3-1-dispute-engine/decline-contest-resolve.js +110 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +109 -0
- package/dist/pwa/agent-order-minimal-view.js +29 -0
- package/dist/pwa/order-action-exec.js +158 -0
- package/dist/pwa/order-action-request.js +57 -0
- package/dist/pwa/public/app-admin-disputes.js +1 -1
- package/dist/pwa/public/app-agent-approvals-order.js +18 -0
- package/dist/pwa/public/app-agent-approvals.js +6 -7
- package/dist/pwa/public/app-agent-pair.js +2 -2
- package/dist/pwa/public/app-arbitrator-admin.js +1 -1
- package/dist/pwa/public/app-decline-contest-ruling.js +53 -0
- package/dist/pwa/public/app-decline-contest-ui.js +44 -0
- package/dist/pwa/public/app-grant-duration.js +20 -0
- package/dist/pwa/public/app.js +9 -9
- package/dist/pwa/public/i18n.js +24 -1
- package/dist/pwa/public/index.html +4 -0
- package/dist/pwa/public/openapi.json +68 -23
- package/dist/pwa/routes/admin-reports.js +40 -3
- package/dist/pwa/routes/agent-grants.js +117 -12
- package/dist/pwa/routes/disputes-read.js +17 -1
- package/dist/pwa/routes/disputes-write.js +42 -73
- package/dist/pwa/routes/orders-action.js +46 -3
- package/dist/pwa/server.js +3 -3
- package/dist/runtime/agent-grant-scopes.js +26 -8
- package/dist/runtime/webaz-schema-helpers.js +28 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -47,9 +47,11 @@ Open the PWA, register a local account, and walk a full order → ship → confi
|
|
|
47
47
|
WebAZ ships an **MCP server**. MCP is an open standard, so it works with **any MCP-capable client** — Claude Desktop, Claude Code, Codex, Cursor, or your own agent. Add the server to your client's MCP config:
|
|
48
48
|
|
|
49
49
|
```json
|
|
50
|
-
{ "mcpServers": { "webaz": { "command": "npx", "args": ["-y", "@seasonkoh/webaz"] } } }
|
|
50
|
+
{ "mcpServers": { "webaz": { "command": "npx", "args": ["-y", "@seasonkoh/webaz@latest"] } } }
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
> **Updating:** MCP clients don't auto-update servers, and `npx` caches by version — so a client restart alone may keep running an older cached build. To pick up a new release: clear the cache (`rm -rf ~/.npm/_npx`) and restart the client. To pin a specific version instead of tracking latest, use `@seasonkoh/webaz@0.1.32`.
|
|
54
|
+
|
|
53
55
|
- 🟢 **Network read-only (default, zero-config):** with no `WEBAZ_API_KEY`, public reads (`webaz_search` / leaderboard / price history / browse) hit the **live shared network** at [webaz.xyz](https://webaz.xyz) — nothing local. Transactional tools (register / order / list / fulfill) need a key.
|
|
54
56
|
- 🟢 **Network (full):** register at [webaz.xyz](https://webaz.xyz) (invite + Passkey = an accountable human), copy your `api_key`, and set it as `env.WEBAZ_API_KEY` → the agent can transact on the **live shared network**. (`webaz_register` never self-creates an account — accounts require a real human + Passkey, by protocol.)
|
|
55
57
|
- 🟡 **Sandbox (explicit, dev/demo):** set `WEBAZ_MODE=sandbox` → a **private local SQLite playground, isolated from the live network** — safe to try the whole order flow offline. (Opt-in; the default with no key is Network read-only, not sandbox.)
|
|
@@ -473,7 +473,7 @@ function findActiveDeadlineTransition(order, now) {
|
|
|
473
473
|
return null;
|
|
474
474
|
}
|
|
475
475
|
/** 获取当前有效的截止时间 */
|
|
476
|
-
function getActiveDeadline(order, db) {
|
|
476
|
+
export function getActiveDeadline(order, db) {
|
|
477
477
|
// QA 轮 7 P1:旧表 picked_up 状态没 deadline → agent 不知道下一步多久前要做完
|
|
478
478
|
// 修:picked_up 状态视为"已揽收,等运输/投递",下一个 deadline 是 delivery_deadline
|
|
479
479
|
// QA 轮 7 P1(另一条):disputed 状态下没读 dispute_cases 的 arbitrate_deadline → agent 不知道仲裁还有多久
|
|
@@ -34,6 +34,10 @@ export const NETWORK_TOOLS = new Set([
|
|
|
34
34
|
'webaz_get_status',
|
|
35
35
|
'webaz_feedback',
|
|
36
36
|
'webaz_contribute',
|
|
37
|
+
// RFC-021 fulfillment-agent grant-wired order tools (read minimal + submit action-request).
|
|
38
|
+
// Grant-only (no api_key path); grants live on webaz.xyz so they must be network-reachable.
|
|
39
|
+
'webaz_get_agent_order',
|
|
40
|
+
'webaz_order_action_request',
|
|
37
41
|
// Batch 1(只读 + 低危自身写):走 webaz.xyz Bearer api_key。
|
|
38
42
|
'webaz_notifications',
|
|
39
43
|
'webaz_nearby',
|
|
@@ -258,6 +258,7 @@ export async function handlePair(args) {
|
|
|
258
258
|
capabilities: caps,
|
|
259
259
|
agent_label: typeof args.agent_label === 'string' ? args.agent_label : undefined,
|
|
260
260
|
reason: typeof args.reason === 'string' ? args.reason : undefined, // free-text reason only
|
|
261
|
+
duration: typeof args.duration === 'string' ? args.duration : undefined, // SUGGESTED grant lifetime; the human picks/overrides at approve
|
|
261
262
|
},
|
|
262
263
|
});
|
|
263
264
|
if (resp.error)
|
|
@@ -269,7 +270,9 @@ export async function handlePair(args) {
|
|
|
269
270
|
user_code: resp.user_code,
|
|
270
271
|
approve_url: `${WEBAZ_API_URL}${String(resp.approve_url || '')}`,
|
|
271
272
|
expires_at: resp.expires_at,
|
|
272
|
-
|
|
273
|
+
suggested_duration: resp.suggested_duration,
|
|
274
|
+
allowed_durations: resp.allowed_durations,
|
|
275
|
+
next: `Ask the human to open approve_url (logged in at webaz.xyz) and approve — they choose the grant duration (your suggested_duration is pre-selected; allowed: ${Array.isArray(resp.allowed_durations) ? resp.allowed_durations.join('/') : 'once/1h/24h/7d/30d'}). Then call webaz_pair again with action="complete".`,
|
|
273
276
|
capabilities_requested: caps.map(c => c.capability),
|
|
274
277
|
note: 'Safe scopes only. The credential is delivered once on completion and stored in your OS secret store — it is never shown here.',
|
|
275
278
|
};
|
|
@@ -548,7 +551,7 @@ NOTE: this consumes the grant only on safe read paths; no business tool and no r
|
|
|
548
551
|
capabilities: { type: 'array', items: { type: 'string' }, description: 'action="start": requested SAFE scopes (default: read_public, search). Non-safe scopes are rejected.' },
|
|
549
552
|
bundle: { type: 'string', description: 'action="request": a permission bundle key (e.g. "catalog_agent") — a named all-safe scope set the human approves as one thing.' },
|
|
550
553
|
scopes: { type: 'array', items: { type: 'string' }, description: 'action="request": individual SAFE scopes to request (alternative to bundle).' },
|
|
551
|
-
duration: { type: 'string', enum: ['
|
|
554
|
+
duration: { type: 'string', enum: ['1h', '24h', '7d', '30d'], description: 'SUGGESTED grant lifetime for action="start" (initial pairing) or action="request" (expansion). Safe scopes may be long-term (up to 30d); the human PICKS/overrides it at approve time and their choice wins. (No single-use "once" yet.)' },
|
|
552
555
|
agent_label: { type: 'string', description: 'Human-friendly name for this agent (shown in the consent screen)' },
|
|
553
556
|
reason: { type: 'string', description: 'Free-text reason shown to the human (you cannot relabel the scopes)' },
|
|
554
557
|
},
|
|
@@ -1714,6 +1717,39 @@ Coordinates + records only — NO merge/reward; acceptance (done) = human mainta
|
|
|
1714
1717
|
},
|
|
1715
1718
|
},
|
|
1716
1719
|
},
|
|
1720
|
+
{
|
|
1721
|
+
name: 'webaz_get_agent_order',
|
|
1722
|
+
description: `Grant-wired MINIMAL order read for a fulfillment agent (safe scope seller_orders_read_minimal). Reads the seller's OWN orders via a paired delegation grant (webaz_pair — fulfillment_agent bundle), NOT an api_key. Returns a minimal projection only — order_id / status / next_actor / deadline / amount / item_ref — with NO buyer address / contact / PII and no execution.
|
|
1723
|
+
|
|
1724
|
+
- order_id given → that one order; omitted → the seller's order list.
|
|
1725
|
+
- No grant paired → GRANT_REQUIRED (run webaz_pair action="start"). Missing scope → structured PERMISSION_REQUIRED (request via webaz_pair action="request" bundle="fulfillment_agent", have the human approve, then retry).
|
|
1726
|
+
- To ACT on an order (accept/ship), use webaz_order_action_request (submit → human Passkey approves → server executes).`,
|
|
1727
|
+
inputSchema: {
|
|
1728
|
+
type: 'object',
|
|
1729
|
+
properties: {
|
|
1730
|
+
order_id: { type: 'string', description: "Order ID. Omit to list the seller's orders (minimal projection)." },
|
|
1731
|
+
},
|
|
1732
|
+
required: [],
|
|
1733
|
+
},
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
name: 'webaz_order_action_request',
|
|
1737
|
+
description: `Grant-wired order-action SUBMIT for a fulfillment agent (safe scope order_action_request). Submits an accept/ship request into the seller's HUMAN approval queue — it does NOT execute. The order action runs only after the human approves with their Passkey. Uses a paired delegation grant (webaz_pair — fulfillment_agent), NOT an api_key.
|
|
1738
|
+
|
|
1739
|
+
- action="accept" (a paid order) | action="ship" (an accepted order; action_params needs { tracking, evidence_ref }).
|
|
1740
|
+
- Returns request_id + approval_url — tell the human to open it and approve with their Passkey; only then does the server execute the action.
|
|
1741
|
+
- decline is NOT delegable — the backend returns DECLINE_NOT_DELEGATED; a human must decline in the PWA.
|
|
1742
|
+
- No grant → GRANT_REQUIRED. Missing scope → structured PERMISSION_REQUIRED (request via webaz_pair action="request" bundle="fulfillment_agent"). No buyer address/PII in params.`,
|
|
1743
|
+
inputSchema: {
|
|
1744
|
+
type: 'object',
|
|
1745
|
+
properties: {
|
|
1746
|
+
order_id: { type: 'string', description: 'Order ID' },
|
|
1747
|
+
action: { type: 'string', enum: ['accept', 'ship'], description: 'accept a paid order, or ship an accepted order' },
|
|
1748
|
+
action_params: { type: 'object', description: 'ship needs { tracking, evidence_ref }; accept needs none. Never put buyer address/PII here.' },
|
|
1749
|
+
},
|
|
1750
|
+
required: ['order_id', 'action'],
|
|
1751
|
+
},
|
|
1752
|
+
},
|
|
1717
1753
|
];
|
|
1718
1754
|
// ─── 工具处理函数 ─────────────────────────────────────────────
|
|
1719
1755
|
// RFC-004: webaz_feedback — agent-native "use → build" 反馈(双模;仅 NETWORK 能送达)
|
|
@@ -1892,8 +1928,45 @@ export async function handleContribute(args) {
|
|
|
1892
1928
|
}
|
|
1893
1929
|
return { error: 'unknown action: ' + action };
|
|
1894
1930
|
}
|
|
1931
|
+
/** Minimal semver "a < b" for plain x.y.z release strings (our versions carry no pre-release tags).
|
|
1932
|
+
* NUMERIC per-part — must not string-compare (else '0.1.9' > '0.1.10' is wrong). */
|
|
1933
|
+
export function isOlderVersion(a, b) {
|
|
1934
|
+
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
|
|
1935
|
+
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
|
|
1936
|
+
for (let i = 0; i < 3; i++) {
|
|
1937
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
1938
|
+
if (x !== y)
|
|
1939
|
+
return x < y;
|
|
1940
|
+
}
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
/** Best-effort "is a newer MCP published?" nudge for webaz_info. npm is the source of truth (the live server
|
|
1944
|
+
* version can LAG a publish), so we ask the registry directly. Network-gated + short timeout + never throws —
|
|
1945
|
+
* a missing/slow check must never break webaz_info. */
|
|
1946
|
+
async function checkMcpVersion() {
|
|
1947
|
+
const current = SOFTWARE_VERSION;
|
|
1948
|
+
if (!isNetworkMode())
|
|
1949
|
+
return { mcp_version: current, update_check: 'skipped (sandbox mode — no external calls)' };
|
|
1950
|
+
try {
|
|
1951
|
+
const r = await fetch('https://registry.npmjs.org/@seasonkoh/webaz/latest', { signal: AbortSignal.timeout(2500) });
|
|
1952
|
+
if (!r.ok)
|
|
1953
|
+
return { mcp_version: current, update_check: `unavailable (npm ${r.status})` };
|
|
1954
|
+
const latest = String((await r.json()).version || '');
|
|
1955
|
+
const stale = !!latest && isOlderVersion(current, latest);
|
|
1956
|
+
return {
|
|
1957
|
+
mcp_version: current,
|
|
1958
|
+
latest_version: latest || undefined,
|
|
1959
|
+
update_available: stale,
|
|
1960
|
+
...(stale ? { update_note: `A newer WebAZ MCP is published (${latest} > your ${current}). Update your connector to "@seasonkoh/webaz@latest" (or @${latest}), then FULLY restart the client (Cmd+Q) and open a NEW conversation — a conversation's MCP tool set is fixed at connect time, so an in-place refresh will NOT pick up new tools like webaz_pair.` } : {}),
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
catch (e) {
|
|
1964
|
+
return { mcp_version: current, update_check: `unavailable (${e.message})` };
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1895
1967
|
async function handleInfo() {
|
|
1896
1968
|
const summary = getManifestSummary();
|
|
1969
|
+
const mcp = await checkMcpVersion();
|
|
1897
1970
|
// RFC-003 Batch 0:NETWORK 模式下,best-effort 拉 webaz.xyz 的 live 协议状态,
|
|
1898
1971
|
// 让带 key 的 agent 拿到【真网络】数字,而非只看本机本地 live_stats(下方仍保留并标注为本地)。
|
|
1899
1972
|
let network_live = null;
|
|
@@ -1944,6 +2017,7 @@ async function handleInfo() {
|
|
|
1944
2017
|
})();
|
|
1945
2018
|
return {
|
|
1946
2019
|
...summary,
|
|
2020
|
+
mcp, // client MCP version + best-effort npm update check (mcp_version / latest_version / update_available / update_note)
|
|
1947
2021
|
// 诚实化披露 — 排在 stats / quick_start 之前,让 agent 先建立正确预期。
|
|
1948
2022
|
// 第三方尽调 agent 抓到过的破绽:live_stats 是本地库数字、引导词推 agent 拉新。
|
|
1949
2023
|
// 此处主动披露真实状态(pre-launch / 数据来源),不抗辩;agent 自己判断如何处理。
|
|
@@ -2448,6 +2522,57 @@ async function handleVerifyPrice(args) {
|
|
|
2448
2522
|
next: `调用 webaz_place_order 时传入 session_token="${token}" 确保以此价格成交`,
|
|
2449
2523
|
};
|
|
2450
2524
|
}
|
|
2525
|
+
// RFC-021 fulfillment-agent grant-wired order tools. PURE WRAPPERS over already-live endpoints —
|
|
2526
|
+
// no backend/executor/projection/money change. Grant-only (these endpoints require a gtk_ grant
|
|
2527
|
+
// bearer via requireAgentGrantScope; an api_key does not resolve them). Replicates the
|
|
2528
|
+
// resolveGrantCredential → apiCall pattern (samizdat of handleListProduct's grant path).
|
|
2529
|
+
export async function handleGetAgentOrder(args) {
|
|
2530
|
+
// Wraps GET /api/agent/orders(/:id) (safe scope seller_orders_read_minimal). Passes the minimal
|
|
2531
|
+
// projection through UNCHANGED — no field reshaped/added, nothing persisted; PII never enters here.
|
|
2532
|
+
if (!isNetworkMode())
|
|
2533
|
+
return { error: 'a delegation grant requires NETWORK mode (grants live on webaz.xyz)', error_code: 'GRANT_REQUIRES_NETWORK' };
|
|
2534
|
+
const cred = resolveGrantCredential();
|
|
2535
|
+
if (!cred)
|
|
2536
|
+
return { error: 'a delegation grant is required — run webaz_pair action="start", have the human approve the fulfillment_agent bundle, then retry.', error_code: 'GRANT_REQUIRED' };
|
|
2537
|
+
const orderId = (typeof args.order_id === 'string' && args.order_id) ? args.order_id : '';
|
|
2538
|
+
const path = orderId ? `/api/agent/orders/${encodeURIComponent(orderId)}` : '/api/agent/orders';
|
|
2539
|
+
const r = await apiCall(path, { method: 'GET', apiKey: cred.token });
|
|
2540
|
+
if (r.error_code === 'PERMISSION_REQUIRED')
|
|
2541
|
+
return { ...r, retry_after_approval: true, hint: 'Your grant lacks seller_orders_read_minimal. Run webaz_pair action="request" bundle="fulfillment_agent", have the human approve, then retry.' };
|
|
2542
|
+
return r;
|
|
2543
|
+
}
|
|
2544
|
+
export async function handleOrderActionRequest(args) {
|
|
2545
|
+
// Wraps POST /api/agent/orders/:id/action-request (safe scope order_action_request). SUBMIT-ONLY:
|
|
2546
|
+
// writes a pending request to the human approval queue; NEVER executes (execution needs a human
|
|
2547
|
+
// Passkey approval). decline is not delegable — the backend returns DECLINE_NOT_DELEGATED and we
|
|
2548
|
+
// pass it through untouched. Client-side forward-allowlist keeps PII off the wire (see below).
|
|
2549
|
+
if (!isNetworkMode())
|
|
2550
|
+
return { error: 'a delegation grant requires NETWORK mode (grants live on webaz.xyz)', error_code: 'GRANT_REQUIRES_NETWORK' };
|
|
2551
|
+
const cred = resolveGrantCredential();
|
|
2552
|
+
if (!cred)
|
|
2553
|
+
return { error: 'a delegation grant is required — run webaz_pair action="start", have the human approve the fulfillment_agent bundle, then retry.', error_code: 'GRANT_REQUIRED' };
|
|
2554
|
+
const orderId = (typeof args.order_id === 'string' && args.order_id) ? args.order_id : '';
|
|
2555
|
+
if (!orderId)
|
|
2556
|
+
return { error: 'order_id is required', error_code: 'ORDER_ID_REQUIRED' };
|
|
2557
|
+
const body = { action: args.action };
|
|
2558
|
+
// Client-side forward-allowlist (defense-in-depth): only `ship` carries params, and only
|
|
2559
|
+
// {tracking, evidence_ref} — so PII (address/recipient/phone) NEVER leaves the agent, not even
|
|
2560
|
+
// in the outbound request body. `accept` forwards no params. Canonical allowlist = the backend's
|
|
2561
|
+
// sanitizeOrderActionParams (order-action-request.ts) — keep these two fields in sync.
|
|
2562
|
+
if (args.action === 'ship' && args.action_params && typeof args.action_params === 'object') {
|
|
2563
|
+
const p = args.action_params;
|
|
2564
|
+
const clean = {};
|
|
2565
|
+
if (p.tracking != null)
|
|
2566
|
+
clean.tracking = p.tracking;
|
|
2567
|
+
if (p.evidence_ref != null)
|
|
2568
|
+
clean.evidence_ref = p.evidence_ref;
|
|
2569
|
+
body.action_params = clean;
|
|
2570
|
+
}
|
|
2571
|
+
const r = await apiCall(`/api/agent/orders/${encodeURIComponent(orderId)}/action-request`, { method: 'POST', apiKey: cred.token, body });
|
|
2572
|
+
if (r.error_code === 'PERMISSION_REQUIRED')
|
|
2573
|
+
return { ...r, retry_after_approval: true, hint: 'Your grant lacks order_action_request. Run webaz_pair action="request" bundle="fulfillment_agent", have the human approve, then retry.' };
|
|
2574
|
+
return r;
|
|
2575
|
+
}
|
|
2451
2576
|
export async function handleListProduct(args) {
|
|
2452
2577
|
// Wave 3 audit P0: 加 action 分发 — agent 卖家能完整管理目录(不止 create)
|
|
2453
2578
|
const action = args.action || 'create';
|
|
@@ -5277,6 +5402,12 @@ export async function startMCPServer() {
|
|
|
5277
5402
|
case 'webaz_get_status':
|
|
5278
5403
|
result = await handleGetStatus(args);
|
|
5279
5404
|
break;
|
|
5405
|
+
case 'webaz_get_agent_order':
|
|
5406
|
+
result = await handleGetAgentOrder(args);
|
|
5407
|
+
break;
|
|
5408
|
+
case 'webaz_order_action_request':
|
|
5409
|
+
result = await handleOrderActionRequest(args);
|
|
5410
|
+
break;
|
|
5280
5411
|
case 'webaz_feedback':
|
|
5281
5412
|
result = await handleFeedback(args);
|
|
5282
5413
|
break;
|
|
@@ -275,6 +275,102 @@ opts) {
|
|
|
275
275
|
pushCallback?.(userId, notif);
|
|
276
276
|
return notif;
|
|
277
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* 仲裁 admin 收件人:root(admin_type='root' → 隐含 ['all'])或 admin_permissions 含 'all'/'arbitration' 的 admin。
|
|
280
|
+
* 与争议读写授权边界(requireAdminPermission(...,'arbitration'))一致 —— content/support 等无 arbitration 权限的
|
|
281
|
+
* admin【不】收到争议通知。复刻 hasAdminPermission 逻辑,不 import server 层。列缺失(fresh-DB)→ 空集。
|
|
282
|
+
*/
|
|
283
|
+
export function resolveArbitrationAdminIds(db) {
|
|
284
|
+
const ids = [];
|
|
285
|
+
try {
|
|
286
|
+
const rows = db.prepare("SELECT id, admin_type, admin_permissions FROM users WHERE role = 'admin'").all();
|
|
287
|
+
for (const r of rows) {
|
|
288
|
+
if (r.admin_type === 'root') {
|
|
289
|
+
ids.push(r.id);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
let perms = [];
|
|
293
|
+
try {
|
|
294
|
+
perms = JSON.parse(r.admin_permissions || '[]');
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
perms = [];
|
|
298
|
+
}
|
|
299
|
+
if (Array.isArray(perms) && (perms.includes('all') || perms.includes('arbitration')))
|
|
300
|
+
ids.push(r.id);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch { /* fresh-DB 无 admin_type/permissions 列 → 空集 */ }
|
|
304
|
+
return ids;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 统一仲裁台:一笔卖家客观拒单举证并入 disputes 后,通知全体 active 仲裁员 + 仲裁 admin(管理面)有新案待处理。
|
|
308
|
+
* 幂等去重:按 (order_id, type='arb:decline_contest_new', user_id) 存在性探测 —— 新建行、backfill 补建、
|
|
309
|
+
* 重复调用皆只对每个收件人发一次,不重复轰炸(镜像 scanDeadlineReminders 的 (order_id,type) 去重)。
|
|
310
|
+
* 收件人为 active arbitrator_whitelist(唯一仲裁能力源)+ users.role='admin'(监督/兜底)。
|
|
311
|
+
* 同步 better-sqlite3(engine 层);调用方(contest 路由 / backfill 脚本)传入同一个 db 句柄。
|
|
312
|
+
*/
|
|
313
|
+
export function notifyDeclineContestCase(db, orderId, disputeId) {
|
|
314
|
+
const TYPE = 'arb:decline_contest_new';
|
|
315
|
+
const ids = new Set();
|
|
316
|
+
try {
|
|
317
|
+
db.prepare("SELECT user_id AS id FROM arbitrator_whitelist WHERE status IS NULL OR status = 'active'").all().forEach(r => ids.add(r.id));
|
|
318
|
+
}
|
|
319
|
+
catch { /* fresh-DB 无表 */ }
|
|
320
|
+
resolveArbitrationAdminIds(db).forEach(id => ids.add(id)); // 仅仲裁 admin(root / all / arbitration),不含 content/support
|
|
321
|
+
let notified = 0, skipped = 0;
|
|
322
|
+
for (const uid of ids) {
|
|
323
|
+
const exists = db.prepare('SELECT 1 FROM notifications WHERE order_id = ? AND type = ? AND user_id = ? LIMIT 1').get(orderId, TYPE, uid);
|
|
324
|
+
if (exists) {
|
|
325
|
+
skipped++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
createNotification(db, uid, orderId, TYPE, '新的拒单举证仲裁待处理', '一笔卖家客观拒单举证已进入统一仲裁台,等待仲裁员裁决。', { templateKey: 'arb_decline_contest_new', params: { order_id: orderId, dispute_id: disputeId } });
|
|
329
|
+
notified++;
|
|
330
|
+
}
|
|
331
|
+
return { notified, skipped };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 四段式超时升级:仲裁窗口过后仍无裁决 → 通知 active 仲裁员 + 仲裁 admin 尽快处理(admin 可 override)。
|
|
335
|
+
* 与 [[notifyDeclineContestCase]] 同一收件人 + 同一 (order_id, type, user_id) 去重键,type='arb:decline_contest_escalated' 只发一次。
|
|
336
|
+
*/
|
|
337
|
+
export function notifyDeclineContestEscalated(db, orderId) {
|
|
338
|
+
const TYPE = 'arb:decline_contest_escalated';
|
|
339
|
+
const ids = new Set();
|
|
340
|
+
try {
|
|
341
|
+
db.prepare("SELECT user_id AS id FROM arbitrator_whitelist WHERE status IS NULL OR status = 'active'").all().forEach(r => ids.add(r.id));
|
|
342
|
+
}
|
|
343
|
+
catch { /* */ }
|
|
344
|
+
resolveArbitrationAdminIds(db).forEach(id => ids.add(id));
|
|
345
|
+
let notified = 0, skipped = 0;
|
|
346
|
+
for (const uid of ids) {
|
|
347
|
+
if (db.prepare('SELECT 1 FROM notifications WHERE order_id = ? AND type = ? AND user_id = ? LIMIT 1').get(orderId, TYPE, uid)) {
|
|
348
|
+
skipped++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
createNotification(db, uid, orderId, TYPE, '拒单举证仲裁已超时,请尽快裁决', '一笔拒单举证仲裁已过仲裁窗口仍未裁决,已进入 admin 兜底窗口 —— 请仲裁员或管理员尽快处理,否则将自动判卖家违约。', { templateKey: 'arb_decline_contest_escalated', params: { order_id: orderId } });
|
|
352
|
+
notified++;
|
|
353
|
+
}
|
|
354
|
+
return { notified, skipped };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 裁决落定后通知买卖双方结果(维持无责 / 驳回判违约)。非去重(每案只落定一次);买卖各一条。
|
|
358
|
+
*/
|
|
359
|
+
export function notifyDeclineContestResolved(db, orderId, decision) {
|
|
360
|
+
const o = db.prepare('SELECT buyer_id, seller_id FROM orders WHERE id = ?').get(orderId);
|
|
361
|
+
if (!o)
|
|
362
|
+
return;
|
|
363
|
+
const upheld = decision === 'decline_no_fault_upheld';
|
|
364
|
+
const title = upheld ? '拒单举证仲裁:维持无责' : '拒单举证仲裁:驳回,判卖家违约';
|
|
365
|
+
const body = upheld
|
|
366
|
+
? '仲裁认定卖家客观无责:买家已全额退款,卖家质押已退回,无罚没。订单已结。'
|
|
367
|
+
: '仲裁驳回卖家举证,判卖家违约:买家已退款,卖家质押按违约处置。订单已结。';
|
|
368
|
+
for (const uid of [o.buyer_id, o.seller_id]) {
|
|
369
|
+
if (!uid)
|
|
370
|
+
continue;
|
|
371
|
+
createNotification(db, uid, orderId, 'arb:decline_contest_resolved', title, body, { templateKey: 'arb_decline_contest_resolved', params: { order_id: orderId, decision } });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
278
374
|
// ─── 查询 ─────────────────────────────────────────────────────
|
|
279
375
|
// RFC-016 Phase 1:纯读 → 异步 seam。db 参数保留(签名兼容),内部走 dbAll/dbOne(同实例,setSeamDb)。
|
|
280
376
|
// 调用点全部已确认不在 db.transaction 内(notifications.ts:43/60/61 + mcp server.ts:2770/2771)。
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { transition, settleFault, settleDeclinedNoFault } from '../../layer0-foundation/L0-2-state-machine/engine.js';
|
|
2
|
+
import { arbitratorHasConflict } from '../../pwa/arbitrator-lifecycle.js';
|
|
3
|
+
const SYS = 'sys_protocol'; // 终态转移执行者(role=system);真实裁决人记在 dispute
|
|
4
|
+
export class DcResolveError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
http;
|
|
7
|
+
constructor(code, message, http = 400) { super(message); this.code = code; this.http = http; }
|
|
8
|
+
}
|
|
9
|
+
function appendAudit(existing, entry) {
|
|
10
|
+
let log = [];
|
|
11
|
+
try {
|
|
12
|
+
const p = JSON.parse(existing || '[]');
|
|
13
|
+
if (Array.isArray(p))
|
|
14
|
+
log = p;
|
|
15
|
+
}
|
|
16
|
+
catch { /* 破损 → 重置为空,不丢新条目 */ }
|
|
17
|
+
log.push(entry);
|
|
18
|
+
return JSON.stringify(log);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 唯一裁决器。decision 对 timeout_auto 会被强制为 decline_fault_confirmed(硬兜底判卖家违约)。
|
|
22
|
+
* @throws DcResolveError(.http 供路由映射状态码);事务整体回滚。
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDeclineContestDispute(db, disputeId, actorId, decision, reason, source) {
|
|
25
|
+
if (decision !== 'decline_no_fault_upheld' && decision !== 'decline_fault_confirmed') {
|
|
26
|
+
throw new DcResolveError('BAD_DECISION', "decision 必须为 'decline_no_fault_upheld'(维持无责) 或 'decline_fault_confirmed'(驳回判违约)", 400);
|
|
27
|
+
}
|
|
28
|
+
if (!reason || !String(reason).trim())
|
|
29
|
+
throw new DcResolveError('REASON_REQUIRED', '必须提供裁决理由', 400);
|
|
30
|
+
const run = db.transaction(() => {
|
|
31
|
+
const dispute = db.prepare('SELECT id, order_id, initiator_id, defendant_id, status, dispute_type, arbitrate_deadline, assigned_arbitrators, audit_log FROM disputes WHERE id = ?').get(disputeId);
|
|
32
|
+
if (!dispute)
|
|
33
|
+
throw new DcResolveError('DISPUTE_NOT_FOUND', '争议不存在', 404);
|
|
34
|
+
if (dispute.dispute_type !== 'decline_contest')
|
|
35
|
+
throw new DcResolveError('NOT_DECLINE_CONTEST', '本争议不是拒单举证仲裁', 400);
|
|
36
|
+
if (dispute.status !== 'open' && dispute.status !== 'in_review')
|
|
37
|
+
throw new DcResolveError('ALREADY_RULED', '本案已裁决', 409);
|
|
38
|
+
const order = db.prepare('SELECT id, buyer_id, seller_id, status, decline_objective_pending, decline_contested, settled_fault_at FROM orders WHERE id = ?').get(dispute.order_id);
|
|
39
|
+
if (!order)
|
|
40
|
+
throw new DcResolveError('ORDER_NOT_FOUND', '订单不存在', 404);
|
|
41
|
+
if (order.status !== 'fault_seller' || Number(order.decline_objective_pending) !== 1 || Number(order.decline_contested) !== 1 || order.settled_fault_at) {
|
|
42
|
+
throw new DcResolveError('ORDER_NOT_RESOLVABLE', '订单不是可裁决的【已举证客观拒单临时判责】状态', 409);
|
|
43
|
+
}
|
|
44
|
+
// ── ② 按 source 授权 ──
|
|
45
|
+
let effectiveDecision = decision;
|
|
46
|
+
const audit = { at: new Date().toISOString(), source, actor: actorId, decision };
|
|
47
|
+
if (source === 'arbitrator') {
|
|
48
|
+
if (arbitratorHasConflict(db, order.id, dispute.initiator_id, dispute.defendant_id, actorId))
|
|
49
|
+
throw new DcResolveError('ARBITRATOR_CONFLICT_OF_INTEREST', '你是本案当事方,不可仲裁(利益冲突)', 403);
|
|
50
|
+
// assignment CAS:首个仲裁员抢占;已分配他人 → 拒
|
|
51
|
+
let assigned = [];
|
|
52
|
+
try {
|
|
53
|
+
assigned = JSON.parse(dispute.assigned_arbitrators || '[]');
|
|
54
|
+
}
|
|
55
|
+
catch { /* */ }
|
|
56
|
+
if (assigned.length === 0) {
|
|
57
|
+
const claim = db.prepare("UPDATE disputes SET assigned_arbitrators = ? WHERE id = ? AND (assigned_arbitrators IS NULL OR assigned_arbitrators = '[]')").run(JSON.stringify([actorId]), disputeId);
|
|
58
|
+
if (claim.changes === 0) {
|
|
59
|
+
const fresh = db.prepare('SELECT assigned_arbitrators FROM disputes WHERE id = ?').get(disputeId);
|
|
60
|
+
try {
|
|
61
|
+
assigned = JSON.parse(fresh?.assigned_arbitrators || '[]');
|
|
62
|
+
}
|
|
63
|
+
catch { /* */ }
|
|
64
|
+
}
|
|
65
|
+
else
|
|
66
|
+
assigned = [actorId];
|
|
67
|
+
}
|
|
68
|
+
if (!assigned.includes(actorId))
|
|
69
|
+
throw new DcResolveError('NOT_ASSIGNED_ARBITRATOR', '本案已分配给其他仲裁员', 409);
|
|
70
|
+
}
|
|
71
|
+
else if (source === 'admin_fallback') {
|
|
72
|
+
if (arbitratorHasConflict(db, order.id, dispute.initiator_id, dispute.defendant_id, actorId))
|
|
73
|
+
throw new DcResolveError('ARBITRATOR_CONFLICT_OF_INTEREST', '你是本案当事方,不可裁决(利益冲突)', 403);
|
|
74
|
+
// §3 仲裁员优先:仅仲裁窗口(arbitrate_deadline)过后 admin 才可 override
|
|
75
|
+
if (!dispute.arbitrate_deadline || new Date().toISOString() <= dispute.arbitrate_deadline)
|
|
76
|
+
throw new DcResolveError('FALLBACK_TOO_EARLY', '仲裁窗口未过,admin 兜底裁决尚不可用(仲裁员优先)', 409);
|
|
77
|
+
audit.resolved_by_admin_override = actorId; // 不占用 assigned_arbitrators,以 override 记录
|
|
78
|
+
}
|
|
79
|
+
else { // timeout_auto
|
|
80
|
+
effectiveDecision = 'decline_fault_confirmed'; // 硬兜底:一律判卖家违约(卖家担客观无责举证责任)
|
|
81
|
+
audit.decision = effectiveDecision;
|
|
82
|
+
audit.auto_resolved_by_timeout = true;
|
|
83
|
+
}
|
|
84
|
+
// ── ① dispute CAS:抢占裁决权(并发第二人 → 0 行 → throw,不结算)──
|
|
85
|
+
const cas = db.prepare("UPDATE disputes SET status='resolved', ruling_type=?, verdict_reason=?, resolved_at=datetime('now'), audit_log=? WHERE id=? AND status IN ('open','in_review')")
|
|
86
|
+
.run(effectiveDecision, reason, appendAudit(dispute.audit_log, audit), disputeId);
|
|
87
|
+
if (cas.changes === 0)
|
|
88
|
+
throw new DcResolveError('ALREADY_RULED', '本案已被裁决(并发抢占)', 409);
|
|
89
|
+
// ── ③④ 终态转移 + 结算(用 SYS 执行状态机移动;每步失败即 throw 回滚)──
|
|
90
|
+
const orderId = order.id;
|
|
91
|
+
if (effectiveDecision === 'decline_no_fault_upheld') {
|
|
92
|
+
const t1 = transition(db, orderId, 'declined_nofault', SYS, [], `拒单举证仲裁维持无责:${reason}`);
|
|
93
|
+
if (!t1.success)
|
|
94
|
+
throw new DcResolveError('TRANSITION_FAILED', `转 declined_nofault 失败:${t1.error}`, 500);
|
|
95
|
+
settleDeclinedNoFault(db, orderId); // 全退买家 + 退卖家质押 + 回补库存(settled_fault_at 幂等)
|
|
96
|
+
const t2 = transition(db, orderId, 'completed', SYS, [], '客观无责裁定结算完成');
|
|
97
|
+
if (!t2.success)
|
|
98
|
+
throw new DcResolveError('TRANSITION_FAILED', `转 completed 失败:${t2.error}`, 500);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
settleFault(db, orderId, 'fault_seller'); // 退款买家 + 罚没卖家质押(settled_fault_at 幂等)
|
|
102
|
+
const t = transition(db, orderId, 'completed', SYS, [], `拒单举证仲裁驳回·判卖家违约:${reason}`);
|
|
103
|
+
if (!t.success)
|
|
104
|
+
throw new DcResolveError('TRANSITION_FAILED', `转 completed 失败:${t.error}`, 500);
|
|
105
|
+
}
|
|
106
|
+
db.prepare('UPDATE orders SET decline_objective_pending=0, decline_contested=0 WHERE id=?').run(orderId);
|
|
107
|
+
return { orderId, decision: effectiveDecision, source, buyerId: order.buyer_id, sellerId: order.seller_id };
|
|
108
|
+
});
|
|
109
|
+
return run();
|
|
110
|
+
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
16
16
|
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 seam(纯读)
|
|
17
17
|
import { transition } from '../../layer0-foundation/L0-2-state-machine/engine.js';
|
|
18
|
+
import { resolveDeclineContestDispute } from './decline-contest-resolve.js'; // PR3 唯一裁决器(超时硬兜底入口)
|
|
19
|
+
import { notifyDeclineContestEscalated } from '../../layer2-business/L2-6-notifications/notification-engine.js'; // 四段式升级通知
|
|
18
20
|
// RFC-014 PR5 — 争议资金处置走整数 base-units + 绝对值落库 + allocate 精确拆分。
|
|
19
21
|
import { toUnits, toDecimal, mulRate, allocate } from '../../money.js';
|
|
20
22
|
import { applyWalletDelta, debitStakeThenBalance, walletUnits } from '../../ledger.js';
|
|
@@ -41,6 +43,9 @@ export function initDisputeSchema(db) {
|
|
|
41
43
|
`ALTER TABLE disputes ADD COLUMN auto_judge_paused_until INTEGER`,
|
|
42
44
|
`ALTER TABLE disputes ADD COLUMN auto_judge_pause_reason TEXT`,
|
|
43
45
|
`ALTER TABLE disputes ADD COLUMN audit_log TEXT DEFAULT '[]'`,
|
|
46
|
+
// 2026-07 统一仲裁台:案子类型标签。默认 'buyer_dispute'(既有买家/直付/退货争议);
|
|
47
|
+
// 'decline_contest' = RFC-007 卖家客观拒单举证并入 disputes(UI 显示"拒单举证仲裁",裁决走专用两选 + settleFault/settleDeclinedNoFault)。
|
|
48
|
+
`ALTER TABLE disputes ADD COLUMN dispute_type TEXT DEFAULT 'buyer_dispute'`,
|
|
44
49
|
];
|
|
45
50
|
for (const stmt of newColumns) {
|
|
46
51
|
try {
|
|
@@ -48,6 +53,11 @@ export function initDisputeSchema(db) {
|
|
|
48
53
|
}
|
|
49
54
|
catch { /* 列已存在,跳过 */ }
|
|
50
55
|
}
|
|
56
|
+
// 幂等硬约束:一个订单最多一条 decline_contest dispute(防重复建行/重复结算)。部分唯一索引 —— 仅约束该类型行。
|
|
57
|
+
try {
|
|
58
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ux_disputes_decline_contest_order ON disputes(order_id) WHERE dispute_type = 'decline_contest'`);
|
|
59
|
+
}
|
|
60
|
+
catch { /* 已存在,跳过 */ }
|
|
51
61
|
}
|
|
52
62
|
/** 任意参与方(非被告)主动提交证据 */
|
|
53
63
|
export function addPartyEvidence(db, disputeId, submitterId, description, evidenceType = 'text', fileHash) {
|
|
@@ -121,6 +131,56 @@ export function createDispute(db, orderId, initiatorId, reason, evidenceIds) {
|
|
|
121
131
|
message: `争议已记录(${disputeId})。被诉方有 48 小时提交反驳证据,超时协议自动判你胜诉。`,
|
|
122
132
|
};
|
|
123
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* RFC-007 → 统一仲裁台:为【已举证的客观拒单】订单建一条 decline_contest dispute 行。
|
|
136
|
+
*
|
|
137
|
+
* 单一真相源:contest_decline 路由(新单)与 backfill 脚本(历史 stuck 单,如 ord_54fa)都调此函数。
|
|
138
|
+
* 设计约束:
|
|
139
|
+
* - 不改 order.status(订单仍 fault_seller + 标志位);dispute 行只是并入统一仲裁台的把手。
|
|
140
|
+
* - initiator = 卖家(发起举证、承担"客观无责"举证责任);defendant = 买家(受影响方)。COI 由 arbitratorHasConflict
|
|
141
|
+
* 统一覆盖(买/卖/物流/发起/被告均不可裁,PR3 复用)。
|
|
142
|
+
* - 幂等:一个订单至多一条 decline_contest dispute —— 存在性检查 + 部分唯一索引(ux_disputes_decline_contest_order)
|
|
143
|
+
* 双保险;并发建行由唯一索引兜底,重复调用返回既有行(existing=true),绝不建第二条。
|
|
144
|
+
* - PR1 fail-closed:此行建好后【不可被通用自动裁决结算、不可被通用 arbitrate 误裁】(见 checkDisputeTimeouts /
|
|
145
|
+
* arbitrate 路由的 decline_contest 分支),直到 PR3 打通专用两选裁决 + settleFault/settleDeclinedNoFault。
|
|
146
|
+
*/
|
|
147
|
+
export function createDeclineContestDispute(db, orderId) {
|
|
148
|
+
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId);
|
|
149
|
+
if (!order)
|
|
150
|
+
return { success: false, error: `订单不存在:${orderId}` };
|
|
151
|
+
// 仅【已举证的临时判责】合格:fault_seller + provisional pending + contested + 未结算。
|
|
152
|
+
if (order.status !== 'fault_seller' || Number(order.decline_objective_pending) !== 1 || Number(order.decline_contested) !== 1 || order.settled_fault_at) {
|
|
153
|
+
return { success: false, error: '订单不是【已举证的客观拒单临时判责】状态,不建仲裁行' };
|
|
154
|
+
}
|
|
155
|
+
// 幂等①:存在性检查。
|
|
156
|
+
const existing = db.prepare("SELECT id FROM disputes WHERE order_id = ? AND dispute_type = 'decline_contest'").get(orderId);
|
|
157
|
+
if (existing)
|
|
158
|
+
return { success: true, disputeId: existing.id, existing: true };
|
|
159
|
+
const sellerId = order.seller_id;
|
|
160
|
+
const buyerId = order.buyer_id;
|
|
161
|
+
const now = new Date();
|
|
162
|
+
const disputeId = generateId('dsp');
|
|
163
|
+
const respondDeadline = addHours(now, 48); // 买家反驳窗口(PR3 定义超时四段式;PR1 仅建行,不驱动自动裁决)
|
|
164
|
+
const arbitrateDeadline = addHours(now, 120); // 仲裁窗口
|
|
165
|
+
const reasonCode = String(order.decline_reason_code || 'objective_decline');
|
|
166
|
+
const reason = `卖家客观拒单举证仲裁(${reasonCode}):卖家主张客观无责,请仲裁维持(免责·全退买家+退卖家质押)或驳回(判卖家违约)。`;
|
|
167
|
+
try {
|
|
168
|
+
db.prepare(`
|
|
169
|
+
INSERT INTO disputes (
|
|
170
|
+
id, order_id, initiator_id, defendant_id, reason, status, dispute_type,
|
|
171
|
+
defendant_evidence_ids, respond_deadline, arbitrate_deadline, assigned_arbitrators
|
|
172
|
+
) VALUES (?, ?, ?, ?, ?, 'open', 'decline_contest', '[]', ?, ?, '[]')
|
|
173
|
+
`).run(disputeId, orderId, sellerId, buyerId, reason, respondDeadline, arbitrateDeadline);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
// 幂等②:并发建行撞唯一索引 → 返回既有行,不报错、不建第二条。
|
|
177
|
+
const race = db.prepare("SELECT id FROM disputes WHERE order_id = ? AND dispute_type = 'decline_contest'").get(orderId);
|
|
178
|
+
if (race)
|
|
179
|
+
return { success: true, disputeId: race.id, existing: true };
|
|
180
|
+
return { success: false, error: e.message };
|
|
181
|
+
}
|
|
182
|
+
return { success: true, disputeId };
|
|
183
|
+
}
|
|
124
184
|
// ─── L3-2 证据收集 ────────────────────────────────────────────
|
|
125
185
|
/**
|
|
126
186
|
* 被诉方提交反驳证据
|
|
@@ -616,6 +676,33 @@ export function checkDisputeTimeouts(db) {
|
|
|
616
676
|
const openDisputes = db.prepare(`SELECT * FROM disputes WHERE status IN ('open', 'in_review')`).all();
|
|
617
677
|
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
618
678
|
for (const dispute of openDisputes) {
|
|
679
|
+
// PR3 四段式超时:decline_contest 不走通用自动裁决,走专用兜底。
|
|
680
|
+
// 仲裁窗口(arbitrate_deadline)内 → 不动;过窗口且 ≤+48h → 升级通知(去重,只发一次),等仲裁员/admin;
|
|
681
|
+
// 过 +48h admin fallback → 硬兜底判卖家违约(resolver 强制 decline_fault_confirmed + auto_resolved_by_timeout)。
|
|
682
|
+
if (dispute.dispute_type === 'decline_contest') {
|
|
683
|
+
const ad = dispute.arbitrate_deadline;
|
|
684
|
+
if (!ad || now <= ad)
|
|
685
|
+
continue; // 仲裁窗口内
|
|
686
|
+
const fallbackDeadline = addHours(new Date(ad), 48); // admin fallback 截止 = arbitrate_deadline + 48h
|
|
687
|
+
if (now <= fallbackDeadline) { // 段3:升级通知(去重),等人裁
|
|
688
|
+
try {
|
|
689
|
+
notifyDeclineContestEscalated(db, dispute.order_id);
|
|
690
|
+
}
|
|
691
|
+
catch (e) {
|
|
692
|
+
console.warn('[dc-escalate]', e.message);
|
|
693
|
+
}
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
// 段4:硬兜底 —— 判卖家违约。走与仲裁员完全相同的 resolver(dispute CAS + 终态 completed + 结算,单事务)。
|
|
697
|
+
try {
|
|
698
|
+
resolveDeclineContestDispute(db, dispute.id, 'sys_protocol', 'decline_fault_confirmed', '仲裁窗口 + admin 兜底窗口均超时,协议自动判卖家违约(卖家担客观无责举证责任)', 'timeout_auto');
|
|
699
|
+
details.push({ disputeId: dispute.id, action: 'decline_contest 超时自动判违约', orderId: dispute.order_id });
|
|
700
|
+
}
|
|
701
|
+
catch (e) {
|
|
702
|
+
console.warn('[dc-timeout-auto]', e.message);
|
|
703
|
+
}
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
619
706
|
// task #1093 stage 6: skip auto-judge if arbitrator paused the clock (playbook §2.1)
|
|
620
707
|
// Pause expires automatically when auto_judge_paused_until passes; no resume needed
|
|
621
708
|
// for the clock to thaw — explicit resume just clears the field eagerly + audit log.
|
|
@@ -804,6 +891,28 @@ export async function getOpenDisputes(_db) {
|
|
|
804
891
|
ORDER BY d.created_at ASC
|
|
805
892
|
`);
|
|
806
893
|
}
|
|
894
|
+
/* PR2:decline_contest 现已进入统一仲裁员队列(SELECT d.* 带出 dispute_type,前端渲染"拒单举证仲裁"标签)。
|
|
895
|
+
裁决仍 fail-closed(checkDisputeTimeouts 跳过 + arbitrate 路由 409),PR3 打通专用两选裁决。 */
|
|
896
|
+
/**
|
|
897
|
+
* 仲裁员历史裁决:近期已结(resolved/dismissed)争议。与 [[getOpenDisputes]] 同 DTO 形状(前端 compactRow 通用)。
|
|
898
|
+
* `/api/disputes` 路由把它拼在 open+in_review 之后 → 前端"已结"sub-tab + todayDone KPI 有数据(此前恒空)。
|
|
899
|
+
* getOpenDisputes 语义(仅 open)不变(MCP 也用它);历史用本函数,按 resolved_at 倒序,LIMIT 防拉全表。
|
|
900
|
+
*/
|
|
901
|
+
export async function getRecentResolvedDisputes(_db, limit = 50) {
|
|
902
|
+
return await dbAll(`
|
|
903
|
+
SELECT d.*,
|
|
904
|
+
u1.name as initiator_name, u1.role as initiator_role,
|
|
905
|
+
u2.name as defendant_name, u2.role as defendant_role,
|
|
906
|
+
o.total_amount, o.status as order_status, o.payment_rail
|
|
907
|
+
FROM disputes d
|
|
908
|
+
LEFT JOIN users u1 ON d.initiator_id = u1.id
|
|
909
|
+
LEFT JOIN users u2 ON d.defendant_id = u2.id
|
|
910
|
+
LEFT JOIN orders o ON d.order_id = o.id
|
|
911
|
+
WHERE d.status IN ('resolved', 'dismissed')
|
|
912
|
+
ORDER BY d.resolved_at DESC, d.created_at DESC
|
|
913
|
+
LIMIT ?
|
|
914
|
+
`, [limit]);
|
|
915
|
+
}
|
|
807
916
|
// ─── 工具函数 ─────────────────────────────────────────────────
|
|
808
917
|
function addHours(date, hours) {
|
|
809
918
|
return new Date(date.getTime() + hours * 3_600_000).toISOString();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getActiveDeadline } from '../layer0-foundation/L0-2-state-machine/engine.js';
|
|
2
|
+
import { CURRENT_RESPONSIBLE, CURRENT_RESPONSIBLE_SELF_FULFILL } from '../layer0-foundation/L0-2-state-machine/transitions.js';
|
|
3
|
+
/** 调用方须只 SELECT 非 PII 列:id, status, total_amount, product_id, logistics_id, 及各 *_deadline 列。 */
|
|
4
|
+
export function minimalSellerOrderView(order, db) {
|
|
5
|
+
const status = String(order.status ?? '');
|
|
6
|
+
const isSelfFulfill = !order.logistics_id;
|
|
7
|
+
const table = (isSelfFulfill ? CURRENT_RESPONSIBLE_SELF_FULFILL : CURRENT_RESPONSIBLE);
|
|
8
|
+
let deadline = null;
|
|
9
|
+
try {
|
|
10
|
+
deadline = getActiveDeadline(order, db)?.deadline ?? null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
deadline = null;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
order_id: String(order.id ?? ''),
|
|
17
|
+
status,
|
|
18
|
+
next_actor: table[status] ?? null,
|
|
19
|
+
deadline,
|
|
20
|
+
amount: order.total_amount == null ? null : Number(order.total_amount),
|
|
21
|
+
item_ref: order.product_id == null ? null : String(order.product_id),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** 最小化读只取这些【非 PII】列(供路由 SELECT + 测试断言 SELECT 不含 PII)。 */
|
|
25
|
+
export const MINIMAL_ORDER_COLUMNS = [
|
|
26
|
+
'id', 'status', 'total_amount', 'product_id', 'logistics_id',
|
|
27
|
+
'pending_accept_deadline', 'pay_deadline', 'accept_deadline', 'ship_deadline',
|
|
28
|
+
'pickup_deadline', 'delivery_deadline', 'confirm_deadline',
|
|
29
|
+
];
|