@seasonkoh/webaz 0.1.0
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 +189 -0
- package/dist/cron-enforcement.js +83 -0
- package/dist/demo-agent.js +167 -0
- package/dist/index.js +182 -0
- package/dist/layer0-foundation/L0-1-database/schema.js +179 -0
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +183 -0
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +197 -0
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +306 -0
- package/dist/layer1-agent/L1-1-mcp-server/auth.js +21 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +1062 -0
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +217 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +678 -0
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +205 -0
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +258 -0
- package/dist/mcp.js +11 -0
- package/dist/pwa/server.js +760 -0
- package/dist/test-dispute.js +153 -0
- package/dist/test-manifest.js +61 -0
- package/dist/test-mcp-tools.js +135 -0
- package/dist/test-reputation.js +116 -0
- package/dist/test-skill-market.js +101 -0
- package/package.json +60 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3-1 · 争议引擎
|
|
3
|
+
*
|
|
4
|
+
* 核心设计原则:无歧义自动判责
|
|
5
|
+
* - 发起争议 → 被诉方 48h 内必须提交反驳证据
|
|
6
|
+
* - 被诉方超时不回应 → 协议自动判发起方胜诉
|
|
7
|
+
* - 仲裁员收到争议后 120h 内必须裁定
|
|
8
|
+
* - 仲裁员超时 → 协议默认退款给买家(买家保护原则)
|
|
9
|
+
*
|
|
10
|
+
* 覆盖模块:L3-1 争议触发、L3-2 证据收集、L3-3 超时自动判责、L3-5 处置执行
|
|
11
|
+
*/
|
|
12
|
+
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
13
|
+
import { transition } from '../../layer0-foundation/L0-2-state-machine/engine.js';
|
|
14
|
+
// ─── Schema 初始化(幂等,安全重复调用)────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* 为 disputes 表添加 L3 需要的新列
|
|
17
|
+
* 使用 try/catch 避免列已存在时报错
|
|
18
|
+
*/
|
|
19
|
+
export function initDisputeSchema(db) {
|
|
20
|
+
const newColumns = [
|
|
21
|
+
`ALTER TABLE disputes ADD COLUMN defendant_id TEXT`,
|
|
22
|
+
`ALTER TABLE disputes ADD COLUMN defendant_notes TEXT`,
|
|
23
|
+
`ALTER TABLE disputes ADD COLUMN defendant_evidence_ids TEXT DEFAULT '[]'`,
|
|
24
|
+
`ALTER TABLE disputes ADD COLUMN respond_deadline TEXT`,
|
|
25
|
+
`ALTER TABLE disputes ADD COLUMN arbitrate_deadline TEXT`,
|
|
26
|
+
`ALTER TABLE disputes ADD COLUMN ruling_type TEXT`,
|
|
27
|
+
`ALTER TABLE disputes ADD COLUMN refund_amount REAL`,
|
|
28
|
+
// Phase 1 新增:多方举证 + 责任分配
|
|
29
|
+
`ALTER TABLE disputes ADD COLUMN party_evidence_ids TEXT DEFAULT '[]'`,
|
|
30
|
+
`ALTER TABLE disputes ADD COLUMN liability_parties TEXT DEFAULT '[]'`,
|
|
31
|
+
];
|
|
32
|
+
for (const stmt of newColumns) {
|
|
33
|
+
try {
|
|
34
|
+
db.exec(stmt);
|
|
35
|
+
}
|
|
36
|
+
catch { /* 列已存在,跳过 */ }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** 任意参与方(非被告)主动提交证据 */
|
|
40
|
+
export function addPartyEvidence(db, disputeId, submitterId, description, evidenceType = 'text', fileHash) {
|
|
41
|
+
const dispute = db.prepare('SELECT * FROM disputes WHERE id = ?').get(disputeId);
|
|
42
|
+
if (!dispute)
|
|
43
|
+
return { success: false, error: '争议不存在' };
|
|
44
|
+
if (dispute.status === 'resolved' || dispute.status === 'dismissed') {
|
|
45
|
+
return { success: false, error: '该争议已结案' };
|
|
46
|
+
}
|
|
47
|
+
const order = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?')
|
|
48
|
+
.get(dispute.order_id);
|
|
49
|
+
const partyIds = [order?.buyer_id, order?.seller_id, order?.logistics_id,
|
|
50
|
+
dispute.initiator_id, dispute.defendant_id].filter(Boolean);
|
|
51
|
+
if (!partyIds.includes(submitterId)) {
|
|
52
|
+
return { success: false, error: '你不是此争议的参与方' };
|
|
53
|
+
}
|
|
54
|
+
const anchorHash = fileHash || generateAnchorHash(description);
|
|
55
|
+
const eid = generateId('evt');
|
|
56
|
+
db.prepare(`INSERT INTO evidence (id, order_id, uploader_id, type, description, file_hash) VALUES (?,?,?,?,?,?)`).run(eid, dispute.order_id, submitterId, evidenceType, description, anchorHash);
|
|
57
|
+
const existing = JSON.parse(dispute.party_evidence_ids || '[]');
|
|
58
|
+
existing.push(eid);
|
|
59
|
+
db.prepare(`UPDATE disputes SET party_evidence_ids = ? WHERE id = ?`).run(JSON.stringify(existing), disputeId);
|
|
60
|
+
return { success: true, evidenceId: eid, anchorHash };
|
|
61
|
+
}
|
|
62
|
+
// ─── L3-1 争议触发 ────────────────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* 创建争议记录
|
|
65
|
+
* 在 webaz_update_order action=dispute 之后调用,写入 disputes 表
|
|
66
|
+
*/
|
|
67
|
+
export function createDispute(db, orderId, initiatorId, reason, evidenceIds) {
|
|
68
|
+
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId);
|
|
69
|
+
if (!order)
|
|
70
|
+
return { success: false, error: `订单不存在:${orderId}` };
|
|
71
|
+
if (order.status !== 'disputed')
|
|
72
|
+
return { success: false, error: '订单尚未进入争议状态,请先调用 webaz_update_order action=dispute' };
|
|
73
|
+
// 检查是否已有进行中的争议
|
|
74
|
+
const existing = db.prepare(`SELECT id FROM disputes WHERE order_id = ? AND status NOT IN ('resolved', 'dismissed')`).get(orderId);
|
|
75
|
+
if (existing)
|
|
76
|
+
return { success: false, error: `该订单已有进行中的争议:${existing.id}` };
|
|
77
|
+
// 确定被诉方:买家发起 → 被诉卖家,卖家/物流发起 → 被诉买家
|
|
78
|
+
const initiator = db.prepare('SELECT role FROM users WHERE id = ?').get(initiatorId);
|
|
79
|
+
if (!initiator)
|
|
80
|
+
return { success: false, error: '发起方用户不存在' };
|
|
81
|
+
let defendantId;
|
|
82
|
+
if (initiator.role === 'buyer') {
|
|
83
|
+
defendantId = order.seller_id;
|
|
84
|
+
}
|
|
85
|
+
else if (initiator.role === 'seller') {
|
|
86
|
+
defendantId = order.buyer_id;
|
|
87
|
+
}
|
|
88
|
+
else if (initiator.role === 'logistics') {
|
|
89
|
+
defendantId = order.seller_id; // 物流纠纷默认与卖家
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
return { success: false, error: '此角色不能发起争议' };
|
|
93
|
+
}
|
|
94
|
+
const now = new Date();
|
|
95
|
+
const disputeId = generateId('dsp');
|
|
96
|
+
const respondDeadline = addHours(now, 48);
|
|
97
|
+
const arbitrateDeadline = addHours(now, 120);
|
|
98
|
+
db.prepare(`
|
|
99
|
+
INSERT INTO disputes (
|
|
100
|
+
id, order_id, initiator_id, defendant_id, reason, status,
|
|
101
|
+
defendant_evidence_ids, respond_deadline, arbitrate_deadline, assigned_arbitrators
|
|
102
|
+
) VALUES (?, ?, ?, ?, ?, 'open', '[]', ?, ?, '[]')
|
|
103
|
+
`).run(disputeId, orderId, initiatorId, defendantId, reason, respondDeadline, arbitrateDeadline);
|
|
104
|
+
return {
|
|
105
|
+
success: true,
|
|
106
|
+
disputeId,
|
|
107
|
+
respondDeadline,
|
|
108
|
+
message: `争议已记录(${disputeId})。被诉方有 48 小时提交反驳证据,超时协议自动判你胜诉。`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// ─── L3-2 证据收集 ────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* 被诉方提交反驳证据
|
|
114
|
+
* @param db
|
|
115
|
+
* @param disputeId 争议ID
|
|
116
|
+
* @param responderId 被诉方用户ID
|
|
117
|
+
* @param notes 反驳说明
|
|
118
|
+
* @param evidenceIds 证据ID列表
|
|
119
|
+
*/
|
|
120
|
+
export function respondToDispute(db, disputeId, responderId, notes, evidenceIds) {
|
|
121
|
+
const dispute = db.prepare('SELECT * FROM disputes WHERE id = ?').get(disputeId);
|
|
122
|
+
if (!dispute)
|
|
123
|
+
return { success: false, error: `争议不存在:${disputeId}` };
|
|
124
|
+
if (dispute.status !== 'open') {
|
|
125
|
+
return { success: false, error: `争议已不在等待回应状态(当前:${dispute.status})` };
|
|
126
|
+
}
|
|
127
|
+
if (dispute.defendant_id !== responderId) {
|
|
128
|
+
return { success: false, error: '你不是本争议的被诉方,无法提交回应' };
|
|
129
|
+
}
|
|
130
|
+
// 检查截止时间
|
|
131
|
+
if (dispute.respond_deadline && new Date() > new Date(dispute.respond_deadline)) {
|
|
132
|
+
return { success: false, error: '回应截止时间已过,协议将自动裁定' };
|
|
133
|
+
}
|
|
134
|
+
db.prepare(`
|
|
135
|
+
UPDATE disputes SET
|
|
136
|
+
defendant_notes = ?,
|
|
137
|
+
defendant_evidence_ids = ?,
|
|
138
|
+
status = 'in_review'
|
|
139
|
+
WHERE id = ?
|
|
140
|
+
`).run(notes, JSON.stringify(evidenceIds), disputeId);
|
|
141
|
+
return {
|
|
142
|
+
success: true,
|
|
143
|
+
message: '反驳证据已提交,争议进入仲裁阶段。仲裁员将在 72 小时内做出裁定。',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function arbitrateDispute(db, disputeId, arbitratorId, ruling, reason, refundAmount, liabilityParties) {
|
|
147
|
+
const dispute = db.prepare('SELECT * FROM disputes WHERE id = ?').get(disputeId);
|
|
148
|
+
if (!dispute)
|
|
149
|
+
return { success: false, error: `争议不存在:${disputeId}` };
|
|
150
|
+
if (dispute.status === 'resolved' || dispute.status === 'dismissed') {
|
|
151
|
+
return { success: false, error: '该争议已处理完毕' };
|
|
152
|
+
}
|
|
153
|
+
const arbitrator = db.prepare('SELECT role FROM users WHERE id = ?').get(arbitratorId);
|
|
154
|
+
if (!arbitrator)
|
|
155
|
+
return { success: false, error: '仲裁员不存在' };
|
|
156
|
+
if (arbitrator.role !== 'arbitrator' && arbitrator.role !== 'system') {
|
|
157
|
+
return { success: false, error: `只有仲裁员才能做出裁定,你的角色是:${arbitrator.role}` };
|
|
158
|
+
}
|
|
159
|
+
// 执行资金处置
|
|
160
|
+
const settlement = ruling === 'liability_split' && liabilityParties
|
|
161
|
+
? executeLiabilitySplit(db, dispute.order_id, liabilityParties, refundAmount)
|
|
162
|
+
: executeSettlement(db, dispute.order_id, ruling, refundAmount);
|
|
163
|
+
if (!settlement.success)
|
|
164
|
+
return { success: false, error: settlement.error };
|
|
165
|
+
// 收取仲裁费(败诉方付 1%,最低 1 WAZ)
|
|
166
|
+
const order = db.prepare('SELECT total_amount, buyer_id, seller_id FROM orders WHERE id = ?')
|
|
167
|
+
.get(dispute.order_id);
|
|
168
|
+
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
169
|
+
const arbFees = {};
|
|
170
|
+
if (order) {
|
|
171
|
+
const amt = order.total_amount;
|
|
172
|
+
if (ruling === 'refund_buyer') {
|
|
173
|
+
// 卖家败诉
|
|
174
|
+
const f = chargeArbitrationFee(db, order.seller_id, amt, arbitratorId, sysUser.id);
|
|
175
|
+
if (f.fee > 0)
|
|
176
|
+
arbFees[order.seller_id] = f.fee;
|
|
177
|
+
}
|
|
178
|
+
else if (ruling === 'release_seller') {
|
|
179
|
+
// 买家败诉
|
|
180
|
+
const f = chargeArbitrationFee(db, order.buyer_id, amt, arbitratorId, sysUser.id);
|
|
181
|
+
if (f.fee > 0)
|
|
182
|
+
arbFees[order.buyer_id] = f.fee;
|
|
183
|
+
}
|
|
184
|
+
else if (ruling === 'partial_refund') {
|
|
185
|
+
// 折中:双方各付 0.5%(最低各 0.5 WAZ,实际按 chargeArbitrationFee 的 min 逻辑)
|
|
186
|
+
const halfAmt = amt * 0.5;
|
|
187
|
+
const fb = chargeArbitrationFee(db, order.buyer_id, halfAmt, arbitratorId, sysUser.id);
|
|
188
|
+
const fs = chargeArbitrationFee(db, order.seller_id, halfAmt, arbitratorId, sysUser.id);
|
|
189
|
+
if (fb.fee > 0)
|
|
190
|
+
arbFees[order.buyer_id] = fb.fee;
|
|
191
|
+
if (fs.fee > 0)
|
|
192
|
+
arbFees[order.seller_id] = fs.fee;
|
|
193
|
+
}
|
|
194
|
+
else if (ruling === 'liability_split' && liabilityParties) {
|
|
195
|
+
// 各责任方按比例分担
|
|
196
|
+
const totalLiability = liabilityParties.reduce((s, p) => s + p.amount, 0) || amt;
|
|
197
|
+
for (const p of liabilityParties) {
|
|
198
|
+
const share = (p.amount / totalLiability) * amt;
|
|
199
|
+
const f = chargeArbitrationFee(db, p.user_id, share, arbitratorId, sysUser.id);
|
|
200
|
+
if (f.fee > 0)
|
|
201
|
+
arbFees[p.user_id] = (arbFees[p.user_id] ?? 0) + f.fee;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// 更新争议记录
|
|
206
|
+
db.prepare(`
|
|
207
|
+
UPDATE disputes SET
|
|
208
|
+
status = 'resolved',
|
|
209
|
+
verdict = ?,
|
|
210
|
+
verdict_reason = ?,
|
|
211
|
+
ruling_type = ?,
|
|
212
|
+
refund_amount = ?,
|
|
213
|
+
liability_parties = ?,
|
|
214
|
+
resolved_at = datetime('now')
|
|
215
|
+
WHERE id = ?
|
|
216
|
+
`).run(ruling, reason, ruling, refundAmount ?? null, JSON.stringify(liabilityParties ?? []), disputeId);
|
|
217
|
+
return {
|
|
218
|
+
success: true,
|
|
219
|
+
message: `裁定已执行:${getRulingDescription(ruling, refundAmount)}`,
|
|
220
|
+
settlement: {
|
|
221
|
+
...settlement.detail,
|
|
222
|
+
arbitration_fees: arbFees,
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 执行多方责任分配结算
|
|
228
|
+
*
|
|
229
|
+
* 资金流模型:
|
|
230
|
+
* A) 托管资金(买家原款):
|
|
231
|
+
* - 买家获得 actualRefund(从托管中拨还)
|
|
232
|
+
* - 卖家获得 totalAmount - actualRefund(托管剩余,若无责任则取回全额)
|
|
233
|
+
*
|
|
234
|
+
* B) 责任罚款(惩戒性):每个责任方按各自金额被扣款,扣款进入协议金库
|
|
235
|
+
* - 先扣质押,不足再扣余额
|
|
236
|
+
* - 物流方可设 insurance_cap:超出上限的部分由协议金库垫付(买家仍足额赔付)
|
|
237
|
+
*
|
|
238
|
+
* C) 卖家商品质押:
|
|
239
|
+
* - 若卖家未列入责任方,质押全额返还
|
|
240
|
+
* - 若卖家列入责任方,按责任金额比例扣罚,剩余返还
|
|
241
|
+
*
|
|
242
|
+
* 这样确保托管资金守恒(无凭空创造/销毁),责任方额外受罚(去向:sys_protocol)。
|
|
243
|
+
*/
|
|
244
|
+
function executeLiabilitySplit(db, orderId, liabilityParties, buyerRefund) {
|
|
245
|
+
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId);
|
|
246
|
+
if (!order)
|
|
247
|
+
return { success: false, error: '订单不存在' };
|
|
248
|
+
const totalAmount = order.total_amount;
|
|
249
|
+
const buyerId = order.buyer_id;
|
|
250
|
+
const sellerId = order.seller_id;
|
|
251
|
+
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
252
|
+
const product = db.prepare('SELECT stake_amount FROM products WHERE id = ?')
|
|
253
|
+
.get(order.product_id);
|
|
254
|
+
const stakeAmount = product?.stake_amount ?? 0;
|
|
255
|
+
const actualRefund = Math.min(buyerRefund ?? totalAmount, totalAmount);
|
|
256
|
+
const sellerEscrowShare = Math.round((totalAmount - actualRefund) * 100) / 100;
|
|
257
|
+
// 预先计算各责任方实际扣款(资金守恒:责任方扣款 = 协议金库收入)
|
|
258
|
+
const settled = [];
|
|
259
|
+
for (const entry of liabilityParties) {
|
|
260
|
+
const wallet = db.prepare('SELECT balance, staked FROM wallets WHERE user_id = ?')
|
|
261
|
+
.get(entry.user_id);
|
|
262
|
+
const available = (wallet?.balance ?? 0) + (wallet?.staked ?? 0);
|
|
263
|
+
let actualPenalty;
|
|
264
|
+
let insuranceCovered = 0;
|
|
265
|
+
if (entry.insurance_cap !== undefined && entry.insurance_cap < entry.amount) {
|
|
266
|
+
// 有保险上限:责任方最多赔 insurance_cap,不足部分由协议垫付
|
|
267
|
+
actualPenalty = Math.min(entry.insurance_cap, available);
|
|
268
|
+
insuranceCovered = entry.amount - entry.insurance_cap; // 协议垫付
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
// 无保险上限:以实际可用余额为上限
|
|
272
|
+
actualPenalty = Math.min(entry.amount, available);
|
|
273
|
+
insuranceCovered = entry.amount - actualPenalty; // 余额不足部分
|
|
274
|
+
}
|
|
275
|
+
settled.push({ userId: entry.user_id, role: entry.role, owed: entry.amount, actualPenalty, insuranceCovered });
|
|
276
|
+
}
|
|
277
|
+
// 卖家是否在责任方列表中
|
|
278
|
+
const sellerLiability = liabilityParties.find(p => p.user_id === sellerId);
|
|
279
|
+
db.transaction(() => {
|
|
280
|
+
// ── A. 托管拨付 ──────────────────────────────────────────────
|
|
281
|
+
// 释放买家托管,退还 actualRefund 给买家,剩余给卖家
|
|
282
|
+
db.prepare('UPDATE wallets SET escrowed = escrowed - ? WHERE user_id = ?').run(totalAmount, buyerId);
|
|
283
|
+
if (actualRefund > 0) {
|
|
284
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(actualRefund, buyerId);
|
|
285
|
+
}
|
|
286
|
+
if (sellerEscrowShare > 0) {
|
|
287
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(sellerEscrowShare, sellerId);
|
|
288
|
+
}
|
|
289
|
+
// ── B. 责任罚款 → 协议金库 ────────────────────────────────────
|
|
290
|
+
let totalToTreasury = 0;
|
|
291
|
+
for (const s of settled) {
|
|
292
|
+
if (s.actualPenalty > 0) {
|
|
293
|
+
const w = db.prepare('SELECT balance, staked FROM wallets WHERE user_id = ?')
|
|
294
|
+
.get(s.userId);
|
|
295
|
+
if (w.staked >= s.actualPenalty) {
|
|
296
|
+
db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(s.actualPenalty, s.userId);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const fromStake = w.staked;
|
|
300
|
+
const fromBalance = s.actualPenalty - fromStake;
|
|
301
|
+
db.prepare('UPDATE wallets SET staked = 0, balance = balance - ? WHERE user_id = ?').run(fromBalance, s.userId);
|
|
302
|
+
}
|
|
303
|
+
totalToTreasury += s.actualPenalty;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (totalToTreasury > 0) {
|
|
307
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(totalToTreasury, sysUser.id);
|
|
308
|
+
}
|
|
309
|
+
// ── C. 卖家商品质押处理 ───────────────────────────────────────
|
|
310
|
+
if (stakeAmount > 0) {
|
|
311
|
+
if (sellerLiability) {
|
|
312
|
+
// 卖家有责:按责任金额比例扣罚质押,剩余返还
|
|
313
|
+
const stakeForfeited = Math.min(stakeAmount, sellerLiability.amount);
|
|
314
|
+
const stakeReturn = stakeAmount - stakeForfeited;
|
|
315
|
+
db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(stakeAmount, sellerId);
|
|
316
|
+
if (stakeReturn > 0) {
|
|
317
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(stakeReturn, sellerId);
|
|
318
|
+
}
|
|
319
|
+
if (stakeForfeited > 0) {
|
|
320
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(stakeForfeited, sysUser.id);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
// 卖家无责:全额返还质押
|
|
325
|
+
db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?')
|
|
326
|
+
.run(stakeAmount, stakeAmount, sellerId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
transition(db, orderId, 'cancelled', sysUser.id, [], `争议裁定:责任分配,退款买家 ${actualRefund} WAZ`);
|
|
330
|
+
})();
|
|
331
|
+
return {
|
|
332
|
+
success: true,
|
|
333
|
+
detail: {
|
|
334
|
+
ruling: 'liability_split',
|
|
335
|
+
buyer_refund: actualRefund,
|
|
336
|
+
seller_escrow_share: sellerEscrowShare,
|
|
337
|
+
liability_breakdown: settled.map(s => ({
|
|
338
|
+
userId: s.userId, role: s.role,
|
|
339
|
+
owed: s.owed, actualPenalty: s.actualPenalty, insuranceCovered: s.insuranceCovered
|
|
340
|
+
})),
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
// ─── L3-5 资金处置执行 ────────────────────────────────────────
|
|
345
|
+
// ─── 仲裁费收取 ───────────────────────────────────────────────
|
|
346
|
+
/**
|
|
347
|
+
* 向败诉方收取仲裁费:订单金额的 1%(最低 1 WAZ)
|
|
348
|
+
* - 有人工仲裁员时:50% 给仲裁员作为激励,50% 归协议
|
|
349
|
+
* - 自动裁定时:100% 归协议
|
|
350
|
+
* - 先扣质押,质押不足再扣余额
|
|
351
|
+
*/
|
|
352
|
+
function chargeArbitrationFee(db, loserId, orderAmount, arbitratorId, sysUserId) {
|
|
353
|
+
const fee = Math.max(1, Math.round(orderAmount * 0.01 * 100) / 100);
|
|
354
|
+
const isHumanArbitrator = arbitratorId !== sysUserId;
|
|
355
|
+
const wallet = db.prepare('SELECT balance, staked FROM wallets WHERE user_id = ?')
|
|
356
|
+
.get(loserId);
|
|
357
|
+
const available = (wallet?.balance ?? 0) + (wallet?.staked ?? 0);
|
|
358
|
+
const actualFee = Math.min(fee, available);
|
|
359
|
+
if (actualFee <= 0)
|
|
360
|
+
return { fee: 0, arbitratorShare: 0, protocolShare: 0 };
|
|
361
|
+
// 扣款:先质押后余额
|
|
362
|
+
const staked = wallet?.staked ?? 0;
|
|
363
|
+
if (staked >= actualFee) {
|
|
364
|
+
db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(actualFee, loserId);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const fromBalance = actualFee - staked;
|
|
368
|
+
db.prepare('UPDATE wallets SET staked = 0, balance = balance - ? WHERE user_id = ?').run(fromBalance, loserId);
|
|
369
|
+
}
|
|
370
|
+
// 分配:人工仲裁各一半,自动裁定全归协议
|
|
371
|
+
const arbitratorShare = isHumanArbitrator ? Math.round(actualFee * 0.5 * 100) / 100 : 0;
|
|
372
|
+
const protocolShare = actualFee - arbitratorShare;
|
|
373
|
+
if (protocolShare > 0) {
|
|
374
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(protocolShare, sysUserId);
|
|
375
|
+
}
|
|
376
|
+
if (arbitratorShare > 0) {
|
|
377
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(arbitratorShare, arbitratorId);
|
|
378
|
+
}
|
|
379
|
+
return { fee: actualFee, arbitratorShare, protocolShare };
|
|
380
|
+
}
|
|
381
|
+
function executeSettlement(db, orderId, ruling, refundAmount) {
|
|
382
|
+
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId);
|
|
383
|
+
if (!order)
|
|
384
|
+
return { success: false, error: '订单不存在' };
|
|
385
|
+
const totalAmount = order.total_amount;
|
|
386
|
+
const buyerId = order.buyer_id;
|
|
387
|
+
const sellerId = order.seller_id;
|
|
388
|
+
const product = db.prepare('SELECT stake_amount FROM products WHERE id = ?')
|
|
389
|
+
.get(order.product_id);
|
|
390
|
+
const stakeAmount = product?.stake_amount ?? 0;
|
|
391
|
+
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
392
|
+
if (ruling === 'refund_buyer') {
|
|
393
|
+
// ── 买家胜诉:退全款 + 卖家损失一半质押(惩罚)──────────────
|
|
394
|
+
const penalty = Math.round(stakeAmount * 0.5 * 100) / 100;
|
|
395
|
+
const stakeReturn = stakeAmount - penalty;
|
|
396
|
+
db.transaction(() => {
|
|
397
|
+
// 退还买家托管资金
|
|
398
|
+
db.prepare('UPDATE wallets SET escrowed = escrowed - ?, balance = balance + ? WHERE user_id = ?')
|
|
399
|
+
.run(totalAmount, totalAmount, buyerId);
|
|
400
|
+
// 卖家扣押质押 → 一半补偿给买家,一半归协议
|
|
401
|
+
if (stakeAmount > 0) {
|
|
402
|
+
db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(stakeAmount, sellerId);
|
|
403
|
+
if (penalty > 0) {
|
|
404
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(penalty, buyerId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
transition(db, orderId, 'cancelled', sysUser.id, [], `争议裁定:退款买家,质押惩罚 ${penalty} WAZ`);
|
|
408
|
+
})();
|
|
409
|
+
return {
|
|
410
|
+
success: true,
|
|
411
|
+
detail: {
|
|
412
|
+
ruling: 'refund_buyer',
|
|
413
|
+
buyer_refund: totalAmount,
|
|
414
|
+
buyer_compensation: penalty,
|
|
415
|
+
seller_stake_forfeited: stakeAmount,
|
|
416
|
+
seller_stake_returned: stakeReturn,
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
else if (ruling === 'release_seller') {
|
|
421
|
+
// ── 卖家胜诉:资金释放给卖家(正常结算逻辑)──────────────────
|
|
422
|
+
const protocolFee = Math.round(totalAmount * 0.02 * 100) / 100;
|
|
423
|
+
const logisticsFee = order.logistics_id ? Math.round(totalAmount * 0.05 * 100) / 100 : 0;
|
|
424
|
+
const sellerAmount = totalAmount - protocolFee - logisticsFee;
|
|
425
|
+
db.transaction(() => {
|
|
426
|
+
db.prepare('UPDATE wallets SET escrowed = escrowed - ? WHERE user_id = ?').run(totalAmount, buyerId);
|
|
427
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(sellerAmount, sellerId);
|
|
428
|
+
if (order.logistics_id && logisticsFee > 0) {
|
|
429
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(logisticsFee, order.logistics_id);
|
|
430
|
+
}
|
|
431
|
+
// 返还卖家质押
|
|
432
|
+
if (stakeAmount > 0) {
|
|
433
|
+
db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?')
|
|
434
|
+
.run(stakeAmount, stakeAmount, sellerId);
|
|
435
|
+
}
|
|
436
|
+
transition(db, orderId, 'completed', sysUser.id, [], '争议裁定:卖家胜诉,资金释放完成');
|
|
437
|
+
})();
|
|
438
|
+
return {
|
|
439
|
+
success: true,
|
|
440
|
+
detail: {
|
|
441
|
+
ruling: 'release_seller',
|
|
442
|
+
seller_received: sellerAmount,
|
|
443
|
+
logistics_fee: logisticsFee,
|
|
444
|
+
protocol_fee: protocolFee,
|
|
445
|
+
seller_stake_returned: stakeAmount,
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
else if (ruling === 'partial_refund') {
|
|
450
|
+
// ── 折中处理:部分退款 ────────────────────────────────────────
|
|
451
|
+
const refund = refundAmount ?? Math.round(totalAmount * 0.5 * 100) / 100;
|
|
452
|
+
if (refund > totalAmount)
|
|
453
|
+
return { success: false, error: `退款金额 ${refund} 超出订单总额 ${totalAmount}` };
|
|
454
|
+
const sellerGet = Math.round((totalAmount - refund) * 100) / 100;
|
|
455
|
+
const stakeReturn = Math.round(stakeAmount * 0.5 * 100) / 100; // 质押返一半
|
|
456
|
+
db.transaction(() => {
|
|
457
|
+
db.prepare('UPDATE wallets SET escrowed = escrowed - ? WHERE user_id = ?').run(totalAmount, buyerId);
|
|
458
|
+
if (refund > 0) {
|
|
459
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(refund, buyerId);
|
|
460
|
+
}
|
|
461
|
+
if (sellerGet > 0) {
|
|
462
|
+
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(sellerGet, sellerId);
|
|
463
|
+
}
|
|
464
|
+
if (stakeAmount > 0) {
|
|
465
|
+
db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?')
|
|
466
|
+
.run(stakeAmount, stakeReturn, sellerId);
|
|
467
|
+
}
|
|
468
|
+
transition(db, orderId, 'cancelled', sysUser.id, [], `争议裁定:部分退款 ${refund} WAZ`);
|
|
469
|
+
})();
|
|
470
|
+
return {
|
|
471
|
+
success: true,
|
|
472
|
+
detail: {
|
|
473
|
+
ruling: 'partial_refund',
|
|
474
|
+
buyer_refund: refund,
|
|
475
|
+
seller_received: sellerGet,
|
|
476
|
+
seller_stake_returned: stakeReturn,
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return { success: false, error: `未知裁定类型:${ruling}` };
|
|
481
|
+
}
|
|
482
|
+
// ─── L3-3 超时自动判责 ────────────────────────────────────────
|
|
483
|
+
/**
|
|
484
|
+
* 扫描争议超时情况,自动裁定
|
|
485
|
+
* 与 checkTimeouts() 配套使用,应定期运行
|
|
486
|
+
*/
|
|
487
|
+
export function checkDisputeTimeouts(db) {
|
|
488
|
+
const now = new Date().toISOString();
|
|
489
|
+
const details = [];
|
|
490
|
+
const openDisputes = db.prepare(`SELECT * FROM disputes WHERE status IN ('open', 'in_review')`).all();
|
|
491
|
+
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
492
|
+
for (const dispute of openDisputes) {
|
|
493
|
+
if (dispute.status === 'open' && dispute.respond_deadline && now > dispute.respond_deadline) {
|
|
494
|
+
// 被告未在截止时间内回应 → 自动判发起方胜诉
|
|
495
|
+
const initiator = db.prepare('SELECT role FROM users WHERE id = ?')
|
|
496
|
+
.get(dispute.initiator_id);
|
|
497
|
+
const ruling = initiator?.role === 'buyer' ? 'refund_buyer' : 'release_seller';
|
|
498
|
+
const r = arbitrateDispute(db, dispute.id, sysUser.id, ruling, '被诉方超时未提交反驳证据,协议自动裁定');
|
|
499
|
+
if (r.success) {
|
|
500
|
+
details.push({
|
|
501
|
+
disputeId: dispute.id,
|
|
502
|
+
action: `被告超时 → ${ruling}`,
|
|
503
|
+
orderId: dispute.order_id,
|
|
504
|
+
winnerId: dispute.initiator_id,
|
|
505
|
+
loserId: dispute.defendant_id ?? undefined,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else if (dispute.status === 'in_review' && dispute.arbitrate_deadline && now > dispute.arbitrate_deadline) {
|
|
510
|
+
// 仲裁员超时未裁定 → 买家保护原则,默认退款
|
|
511
|
+
const r = arbitrateDispute(db, dispute.id, sysUser.id, 'refund_buyer', '仲裁员超时未裁定,协议默认退款买家(买家保护原则)');
|
|
512
|
+
if (r.success) {
|
|
513
|
+
// 默认退款买家 → 买家胜,被告(卖家)败
|
|
514
|
+
details.push({
|
|
515
|
+
disputeId: dispute.id,
|
|
516
|
+
action: '仲裁超时 → 默认退款买家',
|
|
517
|
+
orderId: dispute.order_id,
|
|
518
|
+
winnerId: dispute.initiator_id,
|
|
519
|
+
loserId: dispute.defendant_id ?? undefined,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { processed: details.length, details };
|
|
525
|
+
}
|
|
526
|
+
export function initEvidenceRequestSchema(db) {
|
|
527
|
+
db.exec(`
|
|
528
|
+
CREATE TABLE IF NOT EXISTS dispute_evidence_requests (
|
|
529
|
+
id TEXT PRIMARY KEY,
|
|
530
|
+
dispute_id TEXT NOT NULL,
|
|
531
|
+
requested_from_id TEXT NOT NULL,
|
|
532
|
+
evidence_types TEXT DEFAULT '["text"]',
|
|
533
|
+
description TEXT NOT NULL,
|
|
534
|
+
deadline TEXT NOT NULL,
|
|
535
|
+
status TEXT DEFAULT 'pending',
|
|
536
|
+
submitted_evidence_ids TEXT DEFAULT '[]',
|
|
537
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
538
|
+
)
|
|
539
|
+
`);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* 仲裁员向任意角色发出"补充证据"请求
|
|
543
|
+
*/
|
|
544
|
+
export function requestEvidence(db, disputeId, arbitratorId, requestedFromId, evidenceTypes, description, deadlineHours = 48) {
|
|
545
|
+
const arb = db.prepare('SELECT role FROM users WHERE id = ?').get(arbitratorId);
|
|
546
|
+
if (!arb || (arb.role !== 'arbitrator' && arb.role !== 'system')) {
|
|
547
|
+
return { success: false, error: '仅仲裁员可发出证据请求' };
|
|
548
|
+
}
|
|
549
|
+
const dispute = db.prepare('SELECT status FROM disputes WHERE id = ?').get(disputeId);
|
|
550
|
+
if (!dispute)
|
|
551
|
+
return { success: false, error: '争议不存在' };
|
|
552
|
+
if (dispute.status === 'resolved' || dispute.status === 'dismissed') {
|
|
553
|
+
return { success: false, error: '该争议已结案' };
|
|
554
|
+
}
|
|
555
|
+
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(requestedFromId);
|
|
556
|
+
if (!target)
|
|
557
|
+
return { success: false, error: '指定用户不存在' };
|
|
558
|
+
const requestId = generateId('evr');
|
|
559
|
+
db.prepare(`
|
|
560
|
+
INSERT INTO dispute_evidence_requests
|
|
561
|
+
(id, dispute_id, requested_from_id, evidence_types, description, deadline)
|
|
562
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
563
|
+
`).run(requestId, disputeId, requestedFromId, JSON.stringify(evidenceTypes), description, addHours(new Date(), deadlineHours));
|
|
564
|
+
// 若争议仍在 open 状态,自动推进到 in_review
|
|
565
|
+
if (dispute.status === 'open') {
|
|
566
|
+
db.prepare(`UPDATE disputes SET status = 'in_review' WHERE id = ?`).run(disputeId);
|
|
567
|
+
}
|
|
568
|
+
return { success: true, requestId };
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* 被要求方提交证据(响应某条请求)
|
|
572
|
+
*/
|
|
573
|
+
export function submitEvidenceForRequest(db, requestId, submitterId, evidenceType, description, fileHash) {
|
|
574
|
+
const req = db.prepare('SELECT * FROM dispute_evidence_requests WHERE id = ?').get(requestId);
|
|
575
|
+
if (!req)
|
|
576
|
+
return { success: false, error: '证据请求不存在' };
|
|
577
|
+
if (req.requested_from_id !== submitterId)
|
|
578
|
+
return { success: false, error: '你不是此请求的被要求方' };
|
|
579
|
+
if (req.status !== 'pending')
|
|
580
|
+
return { success: false, error: '此请求已关闭(已提交或已过期)' };
|
|
581
|
+
if (new Date() > new Date(req.deadline))
|
|
582
|
+
return { success: false, error: '提交截止时间已过' };
|
|
583
|
+
const dispute = db.prepare('SELECT order_id FROM disputes WHERE id = ?').get(req.dispute_id);
|
|
584
|
+
// 生成锚定哈希(Phase 0 模拟;Phase 2 替换为 IPFS CID 或链上 TX)
|
|
585
|
+
const anchorHash = fileHash || generateAnchorHash(description);
|
|
586
|
+
const eid = generateId('evt');
|
|
587
|
+
db.prepare(`
|
|
588
|
+
INSERT INTO evidence (id, order_id, uploader_id, type, description, file_hash)
|
|
589
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
590
|
+
`).run(eid, dispute.order_id, submitterId, evidenceType, description, anchorHash);
|
|
591
|
+
const current = JSON.parse(req.submitted_evidence_ids || '[]');
|
|
592
|
+
current.push(eid);
|
|
593
|
+
db.prepare(`
|
|
594
|
+
UPDATE dispute_evidence_requests
|
|
595
|
+
SET status = 'submitted', submitted_evidence_ids = ?
|
|
596
|
+
WHERE id = ?
|
|
597
|
+
`).run(JSON.stringify(current), requestId);
|
|
598
|
+
return { success: true, evidenceId: eid, anchorHash };
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* 查询争议的所有证据请求(含已提交内容)
|
|
602
|
+
*/
|
|
603
|
+
export function getEvidenceRequests(db, disputeId) {
|
|
604
|
+
const rows = db.prepare(`
|
|
605
|
+
SELECT r.*, u.name as requested_from_name, u.role as requested_from_role
|
|
606
|
+
FROM dispute_evidence_requests r
|
|
607
|
+
LEFT JOIN users u ON r.requested_from_id = u.id
|
|
608
|
+
WHERE r.dispute_id = ?
|
|
609
|
+
ORDER BY r.created_at ASC
|
|
610
|
+
`).all(disputeId);
|
|
611
|
+
return rows.map(r => {
|
|
612
|
+
const ids = JSON.parse(r.submitted_evidence_ids || '[]');
|
|
613
|
+
const items = ids.length
|
|
614
|
+
? db.prepare(`SELECT * FROM evidence WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids)
|
|
615
|
+
: [];
|
|
616
|
+
return { ...r, submitted_items: items };
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
/** 生成锚定哈希(Phase 0 模拟;Phase 2 用 IPFS/链上替换) */
|
|
620
|
+
function generateAnchorHash(content) {
|
|
621
|
+
let h = 0x811c9dc5;
|
|
622
|
+
for (let i = 0; i < content.length; i++) {
|
|
623
|
+
h ^= content.charCodeAt(i);
|
|
624
|
+
h = (h * 0x01000193) >>> 0;
|
|
625
|
+
}
|
|
626
|
+
const ts = Date.now().toString(16);
|
|
627
|
+
return `0x${h.toString(16).padStart(8, '0')}${ts}`;
|
|
628
|
+
}
|
|
629
|
+
// ─── 查询函数 ─────────────────────────────────────────────────
|
|
630
|
+
export function getDisputeDetails(db, disputeId) {
|
|
631
|
+
return db.prepare(`
|
|
632
|
+
SELECT d.*,
|
|
633
|
+
u1.name as initiator_name, u1.role as initiator_role,
|
|
634
|
+
u2.name as defendant_name, u2.role as defendant_role
|
|
635
|
+
FROM disputes d
|
|
636
|
+
LEFT JOIN users u1 ON d.initiator_id = u1.id
|
|
637
|
+
LEFT JOIN users u2 ON d.defendant_id = u2.id
|
|
638
|
+
WHERE d.id = ?
|
|
639
|
+
`).get(disputeId);
|
|
640
|
+
}
|
|
641
|
+
export function getOrderDispute(db, orderId) {
|
|
642
|
+
return db.prepare(`
|
|
643
|
+
SELECT d.*,
|
|
644
|
+
u1.name as initiator_name, u1.role as initiator_role,
|
|
645
|
+
u2.name as defendant_name, u2.role as defendant_role
|
|
646
|
+
FROM disputes d
|
|
647
|
+
LEFT JOIN users u1 ON d.initiator_id = u1.id
|
|
648
|
+
LEFT JOIN users u2 ON d.defendant_id = u2.id
|
|
649
|
+
WHERE d.order_id = ? AND d.status NOT IN ('resolved', 'dismissed')
|
|
650
|
+
ORDER BY d.created_at DESC LIMIT 1
|
|
651
|
+
`).get(orderId);
|
|
652
|
+
}
|
|
653
|
+
export function getOpenDisputes(db) {
|
|
654
|
+
return db.prepare(`
|
|
655
|
+
SELECT d.*,
|
|
656
|
+
u1.name as initiator_name, u1.role as initiator_role,
|
|
657
|
+
u2.name as defendant_name, u2.role as defendant_role,
|
|
658
|
+
o.total_amount, o.status as order_status
|
|
659
|
+
FROM disputes d
|
|
660
|
+
LEFT JOIN users u1 ON d.initiator_id = u1.id
|
|
661
|
+
LEFT JOIN users u2 ON d.defendant_id = u2.id
|
|
662
|
+
LEFT JOIN orders o ON d.order_id = o.id
|
|
663
|
+
WHERE d.status IN ('open', 'in_review')
|
|
664
|
+
ORDER BY d.created_at ASC
|
|
665
|
+
`).all();
|
|
666
|
+
}
|
|
667
|
+
// ─── 工具函数 ─────────────────────────────────────────────────
|
|
668
|
+
function addHours(date, hours) {
|
|
669
|
+
return new Date(date.getTime() + hours * 3_600_000).toISOString();
|
|
670
|
+
}
|
|
671
|
+
function getRulingDescription(ruling, refundAmount) {
|
|
672
|
+
switch (ruling) {
|
|
673
|
+
case 'refund_buyer': return `全额退款 ${refundAmount ?? ''}WAZ 给买家,扣押卖家一半保证金`;
|
|
674
|
+
case 'release_seller': return '资金释放给卖家,交易完成';
|
|
675
|
+
case 'partial_refund': return `部分退款 ${refundAmount} WAZ 给买家,余款归卖家`;
|
|
676
|
+
default: return ruling;
|
|
677
|
+
}
|
|
678
|
+
}
|