@seasonkoh/webaz 0.1.30 → 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 +202 -19
- 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 +78 -23
- package/dist/pwa/routes/admin-reports.js +40 -3
- package/dist/pwa/routes/agent-grants.js +147 -14
- 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/routes/products-create.js +33 -13
- package/dist/pwa/routes/webauthn.js +1 -1
- package/dist/pwa/server.js +7 -7
- package/dist/runtime/agent-grant-scopes.js +26 -8
- package/dist/runtime/webaz-schema-helpers.js +28 -0
- package/package.json +15 -2
|
@@ -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
|
+
];
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { transition } from '../layer0-foundation/L0-2-state-machine/engine.js';
|
|
2
|
+
/** I4 占位符黑名单:N/A、无、test、全 0、纯重复字符(≥8 长度的多为全 0/纯重复;短的由长度/正则先挡)。从严。 */
|
|
3
|
+
function isPlaceholderTracking(s) {
|
|
4
|
+
return /^n\/?a$/i.test(s) || s === '无' || /^test$/i.test(s) || /^0+$/.test(s) || /^(.)\1+$/.test(s);
|
|
5
|
+
}
|
|
6
|
+
/** I4 tracking 内容校验:非空 + trim≥8 + ^[A-Za-z0-9-]+$ + 非占位符。宁可误拒。 */
|
|
7
|
+
export function validateTrackingContent(tracking) {
|
|
8
|
+
const t = (tracking ?? '').trim();
|
|
9
|
+
if (t.length < 8)
|
|
10
|
+
return { ok: false, error: 'tracking 长度不足(需 ≥8)' };
|
|
11
|
+
if (!/^[A-Za-z0-9-]+$/.test(t))
|
|
12
|
+
return { ok: false, error: 'tracking 含非法字符(仅字母/数字/连字符)' };
|
|
13
|
+
if (isPlaceholderTracking(t))
|
|
14
|
+
return { ok: false, error: 'tracking 疑似占位符,请填真实单号' };
|
|
15
|
+
return { ok: true };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 执行 accept/ship。守卫全内置;任一不满足 → {ok:false}(不 transition)。成功 → 订单状态跃迁 + evidence(ship)。
|
|
19
|
+
* 幂等/executed_at 由调用方(approve 路径)在外层 CAS;本函数只做守卫 + transition。
|
|
20
|
+
*/
|
|
21
|
+
export function executeSellerOrderAction(db, opts) {
|
|
22
|
+
const { orderId, action, actorId, nowIso, path } = opts;
|
|
23
|
+
// P2 fail-safe:漏传/undefined → strict;仅显式 false 放宽(自履行/无单号 api_key 路径)。
|
|
24
|
+
const strictTracking = opts.strictTracking !== false;
|
|
25
|
+
const fail = (error_code, http, error) => ({ ok: false, error_code, http, error });
|
|
26
|
+
// SLA 比较用 datetime() 归一化(SQLite datetime 空格格式 vs JS ISO 'T' 直比会错 → engine.ts:223、direct-pay-timeouts 全用 datetime()<datetime()。
|
|
27
|
+
// *_expired = 1 已过 / 0 未过 / NULL 该 deadline 缺失(fail-closed 用)。
|
|
28
|
+
const order = db.prepare(`SELECT seller_id, status, accept_deadline, ship_deadline,
|
|
29
|
+
CASE WHEN accept_deadline IS NULL THEN NULL WHEN datetime(accept_deadline) < datetime(?) THEN 1 ELSE 0 END AS accept_expired,
|
|
30
|
+
CASE WHEN ship_deadline IS NULL THEN NULL WHEN datetime(ship_deadline) < datetime(?) THEN 1 ELSE 0 END AS ship_expired
|
|
31
|
+
FROM orders WHERE id = ?`).get(nowIso, nowIso, orderId);
|
|
32
|
+
if (!order)
|
|
33
|
+
return fail('ORDER_NOT_FOUND', 404, '订单不存在');
|
|
34
|
+
// 核心守卫①:归属(actor 必须是本单卖家)
|
|
35
|
+
if (order.seller_id !== actorId)
|
|
36
|
+
return fail('NOT_ORDER_SELLER', 403, '该订单不属于你');
|
|
37
|
+
// 核心守卫②③:状态前置 + SLA。P1-c(a) fail-closed:需要 SLA 的状态遇 deadline=NULL → 拒绝放行(绝不 skip),
|
|
38
|
+
// 防任何"忘写 deadline"的下单路径(如缺 ship_deadline 的 direct_p2p)静默绕过判责钟。
|
|
39
|
+
if (action === 'accept') {
|
|
40
|
+
if (order.status !== 'paid')
|
|
41
|
+
return fail('WRONG_STATUS', 409, '仅 paid 订单可接单');
|
|
42
|
+
if (order.accept_deadline == null)
|
|
43
|
+
return fail('SLA_DEADLINE_MISSING', 409, '订单缺 accept_deadline,fail-closed 拒绝执行');
|
|
44
|
+
if (order.accept_expired === 1)
|
|
45
|
+
return fail('SLA_EXPIRED', 409, '接单窗口已过');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (order.status !== 'accepted')
|
|
49
|
+
return fail('WRONG_STATUS', 409, '仅 accepted 订单可发货');
|
|
50
|
+
if (order.ship_deadline == null)
|
|
51
|
+
return fail('SLA_DEADLINE_MISSING', 409, '订单缺 ship_deadline,fail-closed 拒绝执行');
|
|
52
|
+
if (order.ship_expired === 1)
|
|
53
|
+
return fail('SLA_EXPIRED', 409, '发货窗口已过');
|
|
54
|
+
// I4(仅 strictTracking=true,即 agent-approve 路径):tracking 内容重校,不信任 request 内容
|
|
55
|
+
if (strictTracking) {
|
|
56
|
+
const v = validateTrackingContent(opts.tracking);
|
|
57
|
+
if (!v.ok)
|
|
58
|
+
return fail('INVALID_TRACKING', 400, v.error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// 核心守卫④:evidence(ship 须证据;transition 内部再校非空 → 保持 api_key "无 evidence 即拒" 现状)
|
|
62
|
+
const evidenceIds = [];
|
|
63
|
+
if (action === 'ship') {
|
|
64
|
+
const desc = strictTracking
|
|
65
|
+
? `快递单号:${String(opts.tracking)}${opts.evidenceRef ? ` · 凭证:${String(opts.evidenceRef)}` : ''}`
|
|
66
|
+
: (opts.evidenceDescription || null); // 非严格(api_key):仅当卖家提供 evidence_description 才建;缺则 transition 因证据空而拒(保持现状)
|
|
67
|
+
if (desc) {
|
|
68
|
+
const eid = opts.generateId('evt');
|
|
69
|
+
db.prepare("INSERT INTO evidence (id, order_id, uploader_id, type, description, file_hash) VALUES (?,?,?,'description',?,?)").run(eid, orderId, actorId, desc, `h_${nowIso}_${eid}`);
|
|
70
|
+
evidenceIds.push(eid);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ⑤ transition(I3:绝不 UPDATE accept_deadline/ship_deadline;transition 只改 status + 事件链)
|
|
74
|
+
const toStatus = action === 'accept' ? 'accepted' : 'shipped';
|
|
75
|
+
const r = transition(db, orderId, toStatus, actorId, evidenceIds, `${action} via executor (${path})`);
|
|
76
|
+
if (!r.success)
|
|
77
|
+
return fail('TRANSITION_FAILED', 409, r.error);
|
|
78
|
+
return { ok: true, fromStatus: order.status, toStatus };
|
|
79
|
+
}
|
|
80
|
+
class AlreadyExecErr extends Error {
|
|
81
|
+
}
|
|
82
|
+
class NotPendingErr extends Error {
|
|
83
|
+
}
|
|
84
|
+
class ExecFailErr extends Error {
|
|
85
|
+
exec;
|
|
86
|
+
constructor(e) { super(e.error_code); this.exec = e; }
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* approve 路径:CAS pending→approved(若 pending)→ 执行(strictTracking=true)→ executed_at CAS(I5 幂等键)。
|
|
90
|
+
* 执行成功:executed_at + execution_result 写入(与 transition 同一事务,并发只一次)。
|
|
91
|
+
* 执行失败(守卫/transition):【不】写 executed_at,请求保持 approved 可重试;写失败 execution_result(注解)。
|
|
92
|
+
* executed_at 已置位:幂等直接返回 already_executed。
|
|
93
|
+
* strictTracking=true 硬编码在此(agent 路径),不接受任何外部入参。
|
|
94
|
+
* P1-b 审计 fail-closed:approve / execute 的 agent_grant_auth_log 写入与状态改动【同一事务】—— 审计失败即回滚,
|
|
95
|
+
* 动作不发生(与 PR2 提交侧审计一致,绝不 fail-open 吞异常)。
|
|
96
|
+
*/
|
|
97
|
+
export function approveAndExecuteOrderAction(db, requestId, actorId, grantId, nowIso, generateId) {
|
|
98
|
+
const auditRow = (cap, outcome) => db.prepare('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)').run(grantId, actorId, cap, outcome, null);
|
|
99
|
+
const r = db.prepare("SELECT status, kind, order_id, order_action, action_params, executed_at, expires_at FROM agent_permission_requests WHERE id = ?").get(requestId);
|
|
100
|
+
if (!r || r.kind !== 'order_action')
|
|
101
|
+
return { ok: false, error_code: 'NOT_ORDER_ACTION', http: 404 };
|
|
102
|
+
if (r.executed_at)
|
|
103
|
+
return { ok: true, already_executed: true }; // 幂等:已执行
|
|
104
|
+
if (r.status !== 'pending' && r.status !== 'approved')
|
|
105
|
+
return { ok: false, error_code: 'REQUEST_NOT_APPROVABLE', http: 409 };
|
|
106
|
+
// step 1:CAS→approved + 审计,同一事务 fail-closed(仅 pending;单独提交 → 执行失败仍保持 approved 可重试)。expires_at 原子守卫。
|
|
107
|
+
if (r.status === 'pending') {
|
|
108
|
+
try {
|
|
109
|
+
db.transaction(() => {
|
|
110
|
+
const c = db.prepare("UPDATE agent_permission_requests SET status='approved', approved_at=? WHERE id=? AND status='pending' AND expires_at > ?").run(nowIso, requestId, nowIso).changes;
|
|
111
|
+
if (c !== 1)
|
|
112
|
+
throw new NotPendingErr();
|
|
113
|
+
auditRow(`order_action:approve:${r.order_id}:${r.order_action}`, 'allow'); // 审计写失败 → 抛 → 回滚 CAS(未 approve)
|
|
114
|
+
})();
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (e instanceof NotPendingErr)
|
|
118
|
+
return { ok: false, error_code: 'REQUEST_NOT_PENDING_OR_EXPIRED', http: 409 };
|
|
119
|
+
return { ok: false, error_code: 'AUDIT_WRITE_FAILED', error: e.message, http: 500 }; // 审计 fail-closed:保持 pending,未 approve/未执行
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// step 2:执行 + executed_at + 执行审计,同一事务原子 fail-closed(并发/重放/审计失败 → 回滚)
|
|
123
|
+
let params = {};
|
|
124
|
+
try {
|
|
125
|
+
params = r.action_params ? JSON.parse(r.action_params) : {};
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
params = {};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const out = db.transaction(() => {
|
|
132
|
+
const cur = db.prepare('SELECT executed_at FROM agent_permission_requests WHERE id = ?').get(requestId);
|
|
133
|
+
if (cur.executed_at)
|
|
134
|
+
throw new AlreadyExecErr();
|
|
135
|
+
const exec = executeSellerOrderAction(db, { orderId: r.order_id, action: r.order_action, actorId, nowIso, strictTracking: true, tracking: params.tracking, evidenceRef: params.evidence_ref, generateId, path: 'approve' });
|
|
136
|
+
if (!exec.ok)
|
|
137
|
+
throw new ExecFailErr(exec);
|
|
138
|
+
const e = db.prepare("UPDATE agent_permission_requests SET executed_at=?, execution_result=? WHERE id=? AND executed_at IS NULL").run(nowIso, JSON.stringify({ ok: true, from: exec.fromStatus, to: exec.toStatus }), requestId).changes;
|
|
139
|
+
if (e !== 1)
|
|
140
|
+
throw new AlreadyExecErr();
|
|
141
|
+
auditRow(`order_action:execute:${r.order_id}:${r.order_action}`, 'allow'); // 审计写失败 → 抛 → 回滚执行 + executed_at(未执行)
|
|
142
|
+
return exec;
|
|
143
|
+
})();
|
|
144
|
+
return { ok: true, order_status: out.toStatus };
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (err instanceof AlreadyExecErr)
|
|
148
|
+
return { ok: true, already_executed: true };
|
|
149
|
+
if (err instanceof ExecFailErr) {
|
|
150
|
+
try {
|
|
151
|
+
db.prepare("UPDATE agent_permission_requests SET execution_result=? WHERE id=? AND executed_at IS NULL").run(JSON.stringify({ ok: false, error_code: err.exec.error_code }), requestId);
|
|
152
|
+
}
|
|
153
|
+
catch { /* 注解 best-effort */ }
|
|
154
|
+
return { ok: false, error_code: err.exec.error_code, error: err.exec.error, http: err.exec.http }; // 请求保持 approved 可重试
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, error_code: 'EXECUTE_FAILED', error: err.message, http: 500 }; // 含执行审计写失败 → 已回滚 → 未执行,可重试
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const TTL_HOURS = 24; // 请求短 TTL(I5)
|
|
3
|
+
/** I6:仅 ship 保留 {tracking, evidence_ref};accept 无参;其它键(含任何地址)一律丢弃。 */
|
|
4
|
+
export function sanitizeOrderActionParams(action, raw) {
|
|
5
|
+
if (action !== 'ship')
|
|
6
|
+
return {};
|
|
7
|
+
const r = (raw ?? {});
|
|
8
|
+
const out = {};
|
|
9
|
+
if (r.tracking != null)
|
|
10
|
+
out.tracking = String(r.tracking);
|
|
11
|
+
if (r.evidence_ref != null)
|
|
12
|
+
out.evidence_ref = String(r.evidence_ref);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
export function computeParamsHash(orderId, action, params) {
|
|
16
|
+
return createHash('sha256').update(JSON.stringify({ order_id: orderId, action, params })).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
/** 写 pending 请求;不执行、不改订单。归属校验(seller 本人)+ D2 拒 decline + I4 提交侧 ship tracking presence + 唯一索引防双 pending。 */
|
|
19
|
+
export function createOrderActionRequest(db, opts) {
|
|
20
|
+
const { orderId, action } = opts;
|
|
21
|
+
if (action === 'decline')
|
|
22
|
+
return { ok: false, error_code: 'DECLINE_NOT_DELEGATED', error: 'decline 不可委托;请人工在 PWA 处理', http: 400 }; // D2
|
|
23
|
+
if (action !== 'accept' && action !== 'ship')
|
|
24
|
+
return { ok: false, error_code: 'BAD_ACTION', error: "action 必须为 'accept' | 'ship'", http: 400 };
|
|
25
|
+
const order = db.prepare('SELECT seller_id FROM orders WHERE id = ?').get(orderId);
|
|
26
|
+
if (!order)
|
|
27
|
+
return { ok: false, error_code: 'ORDER_NOT_FOUND', error: '订单不存在', http: 404 };
|
|
28
|
+
if (order.seller_id !== opts.humanId)
|
|
29
|
+
return { ok: false, error_code: 'NOT_ORDER_SELLER', error: '该订单不属于你', http: 403 }; // 归属
|
|
30
|
+
const params = sanitizeOrderActionParams(action, opts.rawParams); // I6:地址等被丢弃
|
|
31
|
+
if (action === 'ship' && (!params.tracking?.trim() || !params.evidence_ref?.trim())) {
|
|
32
|
+
return { ok: false, error_code: 'SHIP_TRACKING_REQUIRED', error: 'ship 请求必须带 tracking + evidence_ref', http: 400 }; // I4 提交侧(presence;内容重校在 PR3)
|
|
33
|
+
}
|
|
34
|
+
const paramsHash = computeParamsHash(orderId, action, params);
|
|
35
|
+
const id = opts.generateId('apr');
|
|
36
|
+
const expiresAt = new Date(Date.now() + TTL_HOURS * 3600_000).toISOString();
|
|
37
|
+
try {
|
|
38
|
+
db.transaction(() => {
|
|
39
|
+
db.prepare(`INSERT INTO agent_permission_requests
|
|
40
|
+
(id, human_id, grant_id, agent_label, requested_scopes, risk_level, duration, status, expires_at, kind, order_id, order_action, params_hash, action_params)
|
|
41
|
+
VALUES (?,?,?,?, '[]', 'medium', 'once', 'pending', ?, 'order_action', ?, ?, ?, ?)`)
|
|
42
|
+
.run(id, opts.humanId, opts.grantId, opts.agentLabel, expiresAt, orderId, action, paramsHash, JSON.stringify(params));
|
|
43
|
+
// I7:创建审计(不含地址;capability 只带 order_id/action/hash 前缀)
|
|
44
|
+
db.prepare('INSERT INTO agent_grant_auth_log (grant_id, human_id, capability, outcome, error_code) VALUES (?,?,?,?,?)')
|
|
45
|
+
.run(opts.grantId, opts.humanId, `order_action:request:${orderId}:${action}:${paramsHash.slice(0, 12)}`, 'allow', null);
|
|
46
|
+
})();
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
const dup = db.prepare("SELECT 1 FROM agent_permission_requests WHERE order_id=? AND order_action=? AND kind='order_action' AND status IN ('pending','approved')").get(orderId, action);
|
|
50
|
+
if (dup)
|
|
51
|
+
return { ok: false, error_code: 'DUPLICATE_ACTION_REQUEST', error: '该订单该动作已有待批准请求', http: 409 };
|
|
52
|
+
return { ok: false, error_code: 'REQUEST_FAILED', error: e.message, http: 500 };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, request_id: id, params_hash: paramsHash };
|
|
55
|
+
}
|
|
56
|
+
// PR3:approve→执行 已移入 order-action-exec.ts::approveAndExecuteOrderAction(CAS approved + 执行 + executed_at CAS)。
|
|
57
|
+
// 本模块自 PR3 起【只负责 submit】(createOrderActionRequest);approve/execute 不在此。
|
|
@@ -35,7 +35,7 @@ window.renderAdminDisputes = async function (app, opts = {}) {
|
|
|
35
35
|
<div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.product_title || '—')}</div>
|
|
36
36
|
<div style="font-size:12px;color:#6b7280;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml((d.reason || '').slice(0, 60))}</div>
|
|
37
37
|
<div style="font-size:11px;color:#6b7280;margin-top:6px">${t('原告')}: ${escHtml(d.initiator_name || '—')} → ${t('被告')}: ${escHtml(d.defendant_name || '—')}</div>
|
|
38
|
-
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-top:6px">${railBadge(d.payment_rail)}${assignChip(d)}${urgencyChip(d)}${verdictChip(d)}</div
|
|
38
|
+
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-top:6px">${window.dcChip ? window.dcChip(d) : ''}${railBadge(d.payment_rail)}${assignChip(d)}${urgencyChip(d)}${verdictChip(d)}</div>${window.dcAdminResolveBtn ? window.dcAdminResolveBtn(d) : ''}
|
|
39
39
|
<div style="font-size:11px;color:#9ca3af;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(d.id)} · ${fmtTime(d.created_at)}</div>
|
|
40
40
|
</div>
|
|
41
41
|
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end;flex-shrink:0">
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// RFC-021 PR2 — order-action 审批卡正文(app-agent-approvals.js 在 LOC ceiling 上,故拆出)。
|
|
2
|
+
// 渲染"对订单 <order_id> 执行 <accept|ship>(+tracking)"。不含任何买家地址/PII(list 端已 sanitize)。
|
|
3
|
+
// Passkey 三元组由 aaApprove 从 card 的 data-* 读取:{request_id, order_id, action, params_hash}。
|
|
4
|
+
(function () {
|
|
5
|
+
window.aaOrderWhat = function (r) {
|
|
6
|
+
var ap = r.action_params || {}
|
|
7
|
+
var actionLabel = r.order_action === 'ship' ? t('发货') : r.order_action === 'accept' ? t('接单') : String(r.order_action || '')
|
|
8
|
+
var trackingLine = (r.order_action === 'ship' && ap.tracking)
|
|
9
|
+
? '<div style="font-size:12px;color:#6b7280;margin-top:4px">' + t('物流单号') + ': <code>' + escHtml(String(ap.tracking)) + '</code></div>'
|
|
10
|
+
: ''
|
|
11
|
+
return '' +
|
|
12
|
+
'<div style="font-size:13px;color:#374151;line-height:1.7">⚡ ' + t('对订单') +
|
|
13
|
+
' <code style="font-size:12px">' + escHtml(String(r.order_id || '')) + '</code> ' + t('执行') +
|
|
14
|
+
' <b style="color:#6b21a8">' + escHtml(actionLabel) + '</b></div>' +
|
|
15
|
+
trackingLine +
|
|
16
|
+
'<div style="font-size:11px;color:#9ca3af;margin-top:2px">' + t('批准后由服务端在你授权下执行;agent 不直接执行') + '</div>'
|
|
17
|
+
}
|
|
18
|
+
})()
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// 经典脚本,全局函数;globals(GET/POST/state/shell/t/escHtml/fmtTime/navigate/loading$/toast$/requestPasskeyGate)调用时解析。
|
|
4
4
|
// 商家视角:不需要懂 scope/complete/verify —— 只看【哪个 agent · 要做什么 · 风险 · 多久】。批准需 Passkey(扩权=提权)。
|
|
5
5
|
;(function () {
|
|
6
|
-
const DURATION_LABEL = { once: '一次性', '1h': '1 小时', '24h': '24 小时', '7d': '7 天', '30d': '30 天' }
|
|
7
6
|
const RISK = {
|
|
8
7
|
low: { label: '低风险', color: '#16a34a', bg: '#dcfce7' },
|
|
9
8
|
medium: { label: '中风险', color: '#b45309', bg: '#fef3c7' },
|
|
@@ -50,11 +49,10 @@
|
|
|
50
49
|
const risk = RISK[r.risk_level] || RISK.low
|
|
51
50
|
const scopes = Array.isArray(r.requested_scopes) ? r.requested_scopes : []
|
|
52
51
|
// Prefer the human-readable bundle summary; otherwise show the individual safe-scope chips.
|
|
53
|
-
const what = r.human_summary
|
|
52
|
+
const what = r.kind === 'order_action' ? (window.aaOrderWhat ? window.aaOrderWhat(r) : '') : r.human_summary
|
|
54
53
|
? `<div style="font-size:13px;color:#374151;line-height:1.7">${escHtml(r.human_summary)}</div>`
|
|
55
54
|
: `<div style="margin-top:2px">${scopes.map(s => `<span style="display:inline-block;font-size:11px;color:#4f46e5;background:#eef2ff;padding:2px 8px;border-radius:4px;margin:2px 4px 2px 0">${escHtml(String(s))}</span>`).join('') || `<span style="font-size:12px;color:#9ca3af">${t('(无 —— 仅基础只读)')}</span>`}</div>`
|
|
56
|
-
|
|
57
|
-
return `<div class="card" style="margin-bottom:12px;padding:16px;border:1px solid #e5e7eb" data-aa-id="${escHtml(String(r.id))}">
|
|
55
|
+
return `<div class="card" style="margin-bottom:12px;padding:16px;border:1px solid #e5e7eb" data-aa-id="${escHtml(String(r.id))}" data-aa-order-id="${escHtml(String(r.order_id||''))}" data-aa-action="${escHtml(String(r.order_action||''))}" data-aa-hash="${escHtml(String(r.params_hash||''))}">
|
|
58
56
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
59
57
|
<div style="font-weight:600">${escHtml(r.agent_label || t('未命名 Agent'))} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>
|
|
60
58
|
<span style="font-size:11px;color:${risk.color};background:${risk.bg};padding:2px 8px;border-radius:999px">${t(risk.label)}</span>
|
|
@@ -62,7 +60,8 @@
|
|
|
62
60
|
<div style="font-size:11px;color:#6b7280;margin-bottom:2px">${t('它想做什么')}:</div>
|
|
63
61
|
${what}
|
|
64
62
|
${r.reason ? `<div style="font-size:12px;color:#6b7280;margin-top:8px">${t('用途(agent 自述)')}: ${escHtml(r.reason)} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>` : ''}
|
|
65
|
-
|
|
63
|
+
${window.grantDurationSelect(r.allowed_durations, r.duration, 'aa-dur-' + escHtml(String(r.id)))}
|
|
64
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:6px">${t('请求于')} ${r.created_at ? fmtTime(r.created_at) : '—'}</div>
|
|
66
65
|
<div style="display:flex;gap:8px;margin-top:12px">
|
|
67
66
|
<button class="btn btn-outline" style="flex:1;color:#dc2626;border-color:#fecaca" onclick="aaReject('${escHtml(String(r.id))}')">${t('拒绝')}</button>
|
|
68
67
|
<button class="btn btn-primary" style="flex:2" onclick="aaApprove('${escHtml(String(r.id))}')">🔑 ${t('用 Passkey 批准')}</button>
|
|
@@ -75,9 +74,9 @@
|
|
|
75
74
|
const btn = card ? card.querySelector('.btn-primary') : null; if (btn) btn.disabled = true
|
|
76
75
|
let token
|
|
77
76
|
// Passkey bound to THIS request (a token minted for request A can't approve B — server re-validates).
|
|
78
|
-
try { token = await requestPasskeyGate('agent_permission_approve', { request_id: id }) }
|
|
77
|
+
try { token = await requestPasskeyGate('agent_permission_approve', { request_id: id, order_id: (card && card.dataset.aaOrderId) || undefined, action: (card && card.dataset.aaAction) || undefined, params_hash: (card && card.dataset.aaHash) || undefined }) }
|
|
79
78
|
catch (e) { if (window.dpPromptRegisterPasskey && e && e.code === 'NO_PASSKEY_REGISTERED') { await window.dpPromptRegisterPasskey(e) } else { toast$((e && e.message) || t('Passkey 验证已取消'), 'error') } if (btn) btn.disabled = false; return }
|
|
80
|
-
const r = await POST('/agent-grants/permission-requests/' + encodeURIComponent(id) + '/approve', { webauthn_token: token }).catch(() => null)
|
|
79
|
+
const r = await POST('/agent-grants/permission-requests/' + encodeURIComponent(id) + '/approve', { webauthn_token: token, duration: window.grantDurationValue('aa-dur-' + id) }).catch(() => null)
|
|
81
80
|
if (!r || r.error) { toast$((r && r.error) || t('批准失败,请重试'), 'error'); if (btn) btn.disabled = false; return }
|
|
82
81
|
toast$(t('已批准 —— 该 agent 的权限已扩展'))
|
|
83
82
|
aaHydrate(); hydrateAgentApprovalsBadge()
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
<div><span style="color:#6b7280">${t('Agent 自称')}:</span> <strong>${escHtml(c.agent_label || t('未命名'))}</strong> <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>
|
|
75
75
|
${c.reason ? `<div><span style="color:#6b7280">${t('用途(agent 自述)')}:</span> ${escHtml(c.reason)} <span style="font-size:10px;color:#9ca3af">(${t('未验证')})</span></div>` : ''}
|
|
76
76
|
<div style="margin-top:6px"><span style="color:#6b7280">${t('请求的权限')}:</span><div style="margin-top:4px">${capHtml}</div></div>
|
|
77
|
-
|
|
77
|
+
${window.grantDurationSelect(c.allowed_durations, c.suggested_duration, 'pair-duration')}
|
|
78
78
|
</div>
|
|
79
79
|
<label style="display:flex;align-items:flex-start;gap:8px;margin:14px 0 4px;font-size:12px;color:#374151;cursor:pointer">
|
|
80
80
|
<input type="checkbox" id="pair-confirm" onchange="document.getElementById('pair-approve-btn').disabled=!this.checked" style="margin-top:2px">
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
let token
|
|
93
93
|
try { token = await requestPasskeyGate('agent_pair_approve', { user_code: _code }) }
|
|
94
94
|
catch (e) { if (window.dpPromptRegisterPasskey && e && e.code === 'NO_PASSKEY_REGISTERED') { await window.dpPromptRegisterPasskey(e) } else { toast$((e && e.message) || t('Passkey 验证已取消'), 'error') } if (btn) btn.disabled = false; return }
|
|
95
|
-
const r = await POST('/agent-grants/pair/' + encodeURIComponent(_code) + '/approve', { webauthn_token: token }).catch(() => null)
|
|
95
|
+
const r = await POST('/agent-grants/pair/' + encodeURIComponent(_code) + '/approve', { webauthn_token: token, duration: window.grantDurationValue('pair-duration') }).catch(() => null)
|
|
96
96
|
if (!r || r.error) { toast$((r && r.error) || t('批准失败,请重试'), 'error'); if (btn) btn.disabled = false; return }
|
|
97
97
|
dpairShowResult('approved', r)
|
|
98
98
|
}
|
|
@@ -71,7 +71,7 @@ window.arbTaishCard = () => `
|
|
|
71
71
|
<div style="margin-bottom:10px">
|
|
72
72
|
<div class="card" onclick="location.hash='#disputes'" style="padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px;min-height:64px">
|
|
73
73
|
<div style="font-size:22px">⚖</div>
|
|
74
|
-
<div><div style="font-weight:600;font-size:14px">${t('仲裁台')}</div><div style="font-size:12px;color:#6b7280">${t('待响应 / 仲裁中 / 已结')}</div></div>
|
|
74
|
+
<div><div style="font-weight:600;font-size:14px">${t('仲裁台')}<span class="arb-badge" style="background:#7c3aed;color:#fff;border-radius:99px;font-size:11px;padding:1px 7px;margin-left:6px;display:${(window.state && state.arbPending > 0) ? 'inline' : 'none'}">${(window.state && state.arbPending) || ''}</span></div><div style="font-size:12px;color:#6b7280">${t('待响应 / 仲裁中 / 已结')}</div></div>
|
|
75
75
|
</div>
|
|
76
76
|
</div>`
|
|
77
77
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// 统一仲裁台 · decline_contest 裁决 UI(PR3)。与 app-decline-contest-ui.js(PR2,已达 ceiling)分开新建。
|
|
2
|
+
// 仲裁员两选裁决表单 + admin 兜底裁决 + 升级/落定通知模板。全局(t/GET/POST/state/handleArbitrate/requestPasskeyGate)运行时就绪。
|
|
3
|
+
(function () {
|
|
4
|
+
// 仲裁员两选裁决表单。复用 window.handleArbitrate —— radio name/reason id/msg id 与通用裁决一致,提交 POST /api/disputes/:id/arbitrate,
|
|
5
|
+
// 后端按 dispute_type 分流到唯一 resolver(dispute CAS + COI + assignment + 终态 completed + 结算,单事务)。
|
|
6
|
+
window.dcRulingForm = function (dispute) {
|
|
7
|
+
var radio = function (val, label) {
|
|
8
|
+
return '<label style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid #e9d5ff;border-radius:6px;cursor:pointer;font-size:13px"><input type="radio" name="arb-ruling-radio" value="' + val + '" style="margin-top:2px"> <span>' + label + '</span></label>'
|
|
9
|
+
}
|
|
10
|
+
return '' +
|
|
11
|
+
'<div style="margin-top:12px;border:1px solid #e9d5ff;background:#faf5ff;border-radius:8px;padding:12px">' +
|
|
12
|
+
'<div style="font-weight:600;font-size:13px;color:#6b21a8;margin-bottom:8px">⚖ ' + t('拒单举证仲裁裁决') + '</div>' +
|
|
13
|
+
'<div id="arbitrate-msg"></div>' +
|
|
14
|
+
'<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px">' +
|
|
15
|
+
radio('decline_no_fault_upheld', t('维持无责 —— 全退买家 + 退卖家质押,零罚没')) +
|
|
16
|
+
radio('decline_fault_confirmed', t('驳回举证,判卖家违约 —— 退款买家 + 罚没质押')) +
|
|
17
|
+
'</div>' +
|
|
18
|
+
'<textarea class="form-control" id="arb-reason" rows="3" placeholder="' + t('裁定理由(必填)') + '" style="width:100%;margin-bottom:8px"></textarea>' +
|
|
19
|
+
'<button class="btn btn-primary btn-sm" style="width:auto" onclick="handleArbitrate(\'' + dispute.id + '\')">' + t('确认裁定') + '</button>' +
|
|
20
|
+
'</div>'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// admin 兜底裁决(管理面)。仅 decline_contest 且仲裁窗口(arbitrate_deadline)已过时给按钮;窗口未过后端 FALLBACK_TOO_EARLY。
|
|
24
|
+
window.dcAdminResolveBtn = function (d) {
|
|
25
|
+
if (!d || d.dispute_type !== 'decline_contest' || !d.arbitrate_deadline) return ''
|
|
26
|
+
if (new Date().toISOString() <= d.arbitrate_deadline) return '' // 仲裁员优先:窗口内不给 admin 兜底入口
|
|
27
|
+
var b = function (dec, label, bg) { return '<button class="btn btn-sm" style="width:auto;background:' + bg + ';color:#fff;font-size:12px" onclick="dcAdminResolve(\'' + d.id + '\',\'' + dec + '\')">' + label + '</button>' }
|
|
28
|
+
return '<div style="display:flex;gap:6px;margin-top:6px">🛡️ ' + b('decline_no_fault_upheld', t('兜底·维持无责'), '#16a34a') + b('decline_fault_confirmed', t('兜底·判违约'), '#dc2626') + '</div>'
|
|
29
|
+
}
|
|
30
|
+
window.dcAdminResolve = async function (disputeId, decision) {
|
|
31
|
+
var reason = window.prompt(t('裁定理由(必填)'))
|
|
32
|
+
if (!reason || !reason.trim()) return
|
|
33
|
+
var tk
|
|
34
|
+
try { tk = await requestPasskeyGate('arbitrate', { dispute_id: disputeId, action: 'decline_contest_resolve', decision: decision }) }
|
|
35
|
+
catch (e) { window.alert(t('此操作需现场真人 Passkey 验证')); return }
|
|
36
|
+
var res = await POST('/admin/disputes/' + disputeId + '/decline-contest-resolve', { decision: decision, reason: reason, webauthn_token: tk })
|
|
37
|
+
if (res && res.error) { window.alert(res.error); return }
|
|
38
|
+
window.alert(t('裁决已执行'))
|
|
39
|
+
if (typeof renderAdminDisputes === 'function') renderAdminDisputes(document.getElementById('app'), {})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 双语通知模板(升级 + 落定);运行时挂载到 NOTIF_TEMPLATES。
|
|
43
|
+
if (window.NOTIF_TEMPLATES) {
|
|
44
|
+
window.NOTIF_TEMPLATES['arb_decline_contest_escalated'] = function () {
|
|
45
|
+
return { title: '⏰ ' + t('拒单举证仲裁已超时,请尽快裁决'), body: t('一笔拒单举证仲裁已过仲裁窗口仍未裁决,已进入 admin 兜底窗口 —— 请尽快处理,否则将自动判卖家违约。') }
|
|
46
|
+
}
|
|
47
|
+
window.NOTIF_TEMPLATES['arb_decline_contest_resolved'] = function (p) {
|
|
48
|
+
var upheld = p && p.decision === 'decline_no_fault_upheld'
|
|
49
|
+
return { title: (upheld ? '✅ ' : '⚖ ') + (upheld ? t('拒单举证仲裁:维持无责') : t('拒单举证仲裁:驳回,判卖家违约')),
|
|
50
|
+
body: upheld ? t('仲裁认定卖家客观无责:买家已全额退款,卖家质押已退回,无罚没。订单已结。') : t('仲裁驳回卖家举证,判卖家违约:买家已退款,卖家质押按违约处置。订单已结。') }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// 统一仲裁台 · decline_contest(拒单举证仲裁)前端渲染 + 待办角标。
|
|
2
|
+
// app.js / app-arbitrator-admin.js 都在 LOC ceiling 上(零余量),故所有渲染逻辑集中在此(非 pinned 域文件),
|
|
3
|
+
// pinned 文件侧只做 net-zero 的 window.* 调用。全局(t / GET / state)由 app.js 等 classic script 提供,运行时已就绪。
|
|
4
|
+
(function () {
|
|
5
|
+
var isDC = function (d) { return d && d.dispute_type === 'decline_contest' }
|
|
6
|
+
|
|
7
|
+
// ① 列表行类型徽章(紫色 = 仲裁色系,镜像现有 arbitration chip)。
|
|
8
|
+
window.dcChip = function (d) {
|
|
9
|
+
return isDC(d)
|
|
10
|
+
? '<span style="display:inline-flex;align-items:center;gap:3px;background:#faf5ff;color:#6b21a8;padding:0 6px;border-radius:99px;font-size:10px;font-weight:600;margin-right:5px">⚖ ' + t('拒单举证仲裁') + '</span>'
|
|
11
|
+
: ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ② 详情页:PR3 打通专用裁决前,decline_contest 的裁决通道 fail-closed —— 用只读提示卡【替代】裁决表单(不显示会 409 的按钮)。
|
|
15
|
+
window.dcNotice = function (dispute) {
|
|
16
|
+
return '' +
|
|
17
|
+
'<div class="card" style="margin-top:12px;border:1px solid #e9d5ff;background:#faf5ff;border-radius:8px;padding:12px">' +
|
|
18
|
+
'<div style="font-weight:600;font-size:13px;color:#6b21a8;margin-bottom:4px">⚖ ' + t('拒单举证仲裁') + '</div>' +
|
|
19
|
+
'<div style="font-size:12px;color:#6b7280;line-height:1.6">' + t('卖家主张客观无责并已举证。裁决通道即将开放——届时可裁定:维持无责(全退买家+退卖家质押)或驳回(判卖家违约)。') + '</div>' +
|
|
20
|
+
'<div style="font-size:12px;color:#9ca3af;margin-top:6px">⏳ ' + t('裁决通道即将开放') + '</div>' +
|
|
21
|
+
'</div>'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ③ 待办角标:拉 pending-count → 写 state.arbPending → 就地刷新 .arb-badge(不整页重渲染,镜像 chats-badge 的 updateAggregate)。
|
|
25
|
+
window.refreshArbBadge = async function () {
|
|
26
|
+
try {
|
|
27
|
+
if (!(window.state && window.state.canArbitrate)) return
|
|
28
|
+
var r = await GET('/arbitrator/pending-count')
|
|
29
|
+
window.state.arbPending = (r && typeof r.count === 'number') ? r.count : 0
|
|
30
|
+
var n = window.state.arbPending
|
|
31
|
+
document.querySelectorAll('.arb-badge').forEach(function (b) {
|
|
32
|
+
b.textContent = n > 0 ? n : ''
|
|
33
|
+
b.style.display = n > 0 ? 'inline' : 'none'
|
|
34
|
+
})
|
|
35
|
+
} catch (e) { /* 角标非关键,静默 */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ④ 双语通知模板(app-notif-templates.js 在 ceiling 上,故在此运行时挂载;title 约定「emoji+空格」开头 → 列表当图标)。
|
|
39
|
+
if (window.NOTIF_TEMPLATES) {
|
|
40
|
+
window.NOTIF_TEMPLATES['arb_decline_contest_new'] = function () {
|
|
41
|
+
return { title: '⚖ ' + t('新的拒单举证仲裁待处理'), body: t('一笔卖家客观拒单举证已进入统一仲裁台,等待仲裁员裁决。') }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})()
|