@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.
@@ -0,0 +1,760 @@
1
+ /**
2
+ * PWA HTTP Server
3
+ * 把 WebAZ暴露给手机浏览器
4
+ * 端口:3000
5
+ */
6
+ import express from 'express';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { initDatabase, generateId } from '../layer0-foundation/L0-1-database/schema.js';
10
+ import { initSystemUser, transition, getOrderStatus, checkTimeouts } from '../layer0-foundation/L0-2-state-machine/engine.js';
11
+ import { initDisputeSchema, createDispute, respondToDispute, arbitrateDispute, getOrderDispute, getDisputeDetails, getOpenDisputes, checkDisputeTimeouts, initEvidenceRequestSchema, requestEvidence, submitEvidenceForRequest, getEvidenceRequests, addPartyEvidence, } from '../layer3-trust/L3-1-dispute-engine/dispute-engine.js';
12
+ import { initNotificationSchema, notifyTransition, getNotifications, getUnreadCount, markRead, setPushCallback, } from '../layer2-business/L2-6-notifications/notification-engine.js';
13
+ import { initSkillSchema, publishSkill, listSkills, getMySkills, subscribeSkill, unsubscribeSkill, getMySubscriptions, shouldAutoAccept, } from '../layer4-economics/L4-4-skill-market/skill-engine.js';
14
+ import { initReputationSchema, recordOrderReputation, recordViolationReputation, recordDisputeReputation, getReputation, getStakeDiscount, } from '../layer4-economics/L4-3-reputation/reputation-engine.js';
15
+ import { generateManifest } from '../layer0-foundation/L0-5-manifest/manifest.js';
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const db = initDatabase();
18
+ initSystemUser(db);
19
+ initDisputeSchema(db);
20
+ initNotificationSchema(db);
21
+ initSkillSchema(db);
22
+ initReputationSchema(db);
23
+ initEvidenceRequestSchema(db);
24
+ const app = express();
25
+ app.use(express.json());
26
+ // express.static は API ルートの後で登録する(順番が重要)
27
+ // ─── SSE 连接池(userId → Response)──────────────────────────
28
+ const sseClients = new Map();
29
+ setPushCallback((userId, notif) => {
30
+ const client = sseClients.get(userId);
31
+ if (client) {
32
+ try {
33
+ client.write(`data: ${JSON.stringify(notif)}\n\n`);
34
+ }
35
+ catch { }
36
+ }
37
+ });
38
+ // ─── Auth 中间件 ──────────────────────────────────────────────
39
+ function getUser(req) {
40
+ const key = req.headers.authorization?.replace('Bearer ', '') ?? req.body?.api_key;
41
+ if (!key)
42
+ return null;
43
+ return db.prepare('SELECT * FROM users WHERE api_key = ?').get(key);
44
+ }
45
+ function auth(req, res) {
46
+ const user = getUser(req);
47
+ if (!user) {
48
+ res.status(401).json({ error: '请先登录' });
49
+ return null;
50
+ }
51
+ return user;
52
+ }
53
+ function addHours(date, hours) {
54
+ return new Date(date.getTime() + hours * 3_600_000).toISOString();
55
+ }
56
+ // ─── API 路由 ─────────────────────────────────────────────────
57
+ // 注册
58
+ app.post('/api/register', (req, res) => {
59
+ const { name, role } = req.body;
60
+ const validRoles = ['buyer', 'seller', 'logistics', 'arbitrator'];
61
+ if (!name?.trim())
62
+ return void res.json({ error: '请填写名称' });
63
+ if (!validRoles.includes(role))
64
+ return void res.json({ error: '角色无效' });
65
+ const id = generateId('usr');
66
+ const apiKey = generateId('key');
67
+ db.prepare('INSERT INTO users (id, name, role, api_key) VALUES (?,?,?,?)').run(id, name.trim(), role, apiKey);
68
+ db.prepare('INSERT INTO wallets (user_id, balance) VALUES (?,1000)').run(id);
69
+ res.json({ success: true, api_key: apiKey, user_id: id, name: name.trim(), role });
70
+ });
71
+ // 当前用户信息
72
+ app.get('/api/me', (req, res) => {
73
+ const user = auth(req, res);
74
+ if (!user)
75
+ return;
76
+ const wallet = db.prepare('SELECT * FROM wallets WHERE user_id = ?').get(user.id);
77
+ res.json({ ...user, api_key: undefined, wallet });
78
+ });
79
+ // 搜索商品(声誉权重排序)
80
+ app.get('/api/products', (req, res) => {
81
+ const { q = '', category, max_price } = req.query;
82
+ let sql = `SELECT p.*, u.name as seller_name,
83
+ COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
84
+ FROM products p
85
+ JOIN users u ON p.seller_id = u.id
86
+ LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
87
+ WHERE p.status = 'active' AND p.stock > 0`;
88
+ const params = [];
89
+ if (q) {
90
+ sql += ` AND (p.title LIKE ? OR p.description LIKE ?)`;
91
+ params.push(`%${q}%`, `%${q}%`);
92
+ }
93
+ if (category) {
94
+ sql += ` AND p.category = ?`;
95
+ params.push(category);
96
+ }
97
+ if (max_price) {
98
+ sql += ` AND p.price <= ?`;
99
+ params.push(Number(max_price));
100
+ }
101
+ sql += ` ORDER BY rep_points DESC, p.created_at DESC LIMIT 30`;
102
+ res.json(db.prepare(sql).all(...params));
103
+ });
104
+ // 卖家:我的商品
105
+ app.get('/api/my-products', (req, res) => {
106
+ const user = auth(req, res);
107
+ if (!user)
108
+ return;
109
+ const products = db.prepare(`SELECT * FROM products WHERE seller_id = ? ORDER BY created_at DESC`).all(user.id);
110
+ res.json(products);
111
+ });
112
+ // 卖家:上架商品
113
+ app.post('/api/products', (req, res) => {
114
+ const user = auth(req, res);
115
+ if (!user)
116
+ return;
117
+ if (user.role !== 'seller')
118
+ return void res.json({ error: '仅卖家可上架商品' });
119
+ const { title, description, price, stock = 1, category = '' } = req.body;
120
+ if (!title || !description || !price)
121
+ return void res.json({ error: '请填写商品名、描述、价格' });
122
+ const priceNum = Number(price);
123
+ const stakeDiscount = getStakeDiscount(db, user.id);
124
+ const stakeRate = Math.max(0.05, 0.15 - stakeDiscount);
125
+ const stakeAmount = Math.round(priceNum * stakeRate * 100) / 100;
126
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
127
+ if (wallet.balance < stakeAmount) {
128
+ return void res.json({ error: `余额不足:上架需质押 ${stakeAmount} WAZ,当前余额 ${wallet.balance} WAZ` });
129
+ }
130
+ const id = generateId('prd');
131
+ db.prepare(`INSERT INTO products (id, seller_id, title, description, price, stock, category, stake_amount)
132
+ VALUES (?,?,?,?,?,?,?,?)`).run(id, user.id, title, description, priceNum, stock, category, stakeAmount);
133
+ db.prepare(`UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?`)
134
+ .run(stakeAmount, stakeAmount, user.id);
135
+ res.json({ success: true, product_id: id, stake_locked: stakeAmount });
136
+ });
137
+ // 我的订单(买家或卖家视角)
138
+ app.get('/api/orders', (req, res) => {
139
+ const user = auth(req, res);
140
+ if (!user)
141
+ return;
142
+ const orders = db.prepare(`
143
+ SELECT o.*, p.title as product_title, p.images,
144
+ ub.name as buyer_name, us.name as seller_name
145
+ FROM orders o
146
+ JOIN products p ON o.product_id = p.id
147
+ JOIN users ub ON o.buyer_id = ub.id
148
+ JOIN users us ON o.seller_id = us.id
149
+ WHERE o.buyer_id = ? OR o.seller_id = ? OR o.logistics_id = ?
150
+ ORDER BY o.created_at DESC LIMIT 50
151
+ `).all(user.id, user.id, user.id);
152
+ res.json(orders);
153
+ });
154
+ // 订单详情
155
+ app.get('/api/orders/:id', (req, res) => {
156
+ const user = auth(req, res);
157
+ if (!user)
158
+ return;
159
+ const statusInfo = getOrderStatus(db, req.params.id);
160
+ if (!statusInfo)
161
+ return void res.status(404).json({ error: '订单不存在' });
162
+ const order = statusInfo.order;
163
+ if (order.buyer_id !== user.id && order.seller_id !== user.id && order.logistics_id !== user.id && user.role !== 'arbitrator') {
164
+ return void res.status(403).json({ error: '无权查看此订单' });
165
+ }
166
+ const product = db.prepare('SELECT title, price, images FROM products WHERE id = ?').get(order.product_id);
167
+ const dispute = getOrderDispute(db, req.params.id);
168
+ // 为每条历史记录附上证据描述内容
169
+ const history = statusInfo.history.map(h => {
170
+ const ids = JSON.parse(h.evidence_ids || '[]');
171
+ const evidenceItems = ids.length
172
+ ? db.prepare(`SELECT description, type FROM evidence WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids)
173
+ : [];
174
+ return { ...h, evidence_items: evidenceItems };
175
+ });
176
+ // 物流跟踪摘要:从历史中提取所有物流操作的证据
177
+ const LOGISTICS_STEPS = ['shipped', 'picked_up', 'in_transit', 'delivered'];
178
+ const trackingInfo = history
179
+ .filter(h => LOGISTICS_STEPS.includes(h.to_status))
180
+ .map(h => ({
181
+ status: h.to_status,
182
+ actor: h.actor_name,
183
+ time: h.created_at,
184
+ evidence: h.evidence_items.map(e => e.description).filter(Boolean),
185
+ notes: h.notes,
186
+ }));
187
+ res.json({ ...statusInfo, history, product, dispute, trackingInfo });
188
+ });
189
+ // 下单
190
+ app.post('/api/orders', (req, res) => {
191
+ const user = auth(req, res);
192
+ if (!user)
193
+ return;
194
+ if (user.role !== 'buyer')
195
+ return void res.json({ error: '仅买家可下单' });
196
+ const { product_id, shipping_address, notes } = req.body;
197
+ if (!product_id || !shipping_address)
198
+ return void res.json({ error: '请提供商品ID和收货地址' });
199
+ const product = db.prepare(`SELECT p.*, u.id as seller_uid FROM products p
200
+ JOIN users u ON p.seller_id = u.id WHERE p.id = ? AND p.status = 'active'`).get(product_id);
201
+ if (!product)
202
+ return void res.json({ error: '商品不存在或已下架' });
203
+ if (product.stock < 1)
204
+ return void res.json({ error: '库存不足' });
205
+ const totalAmount = product.price;
206
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
207
+ if (wallet.balance < totalAmount)
208
+ return void res.json({ error: `余额不足:需 ${totalAmount} WAZ,当前 ${wallet.balance} WAZ` });
209
+ const now = new Date();
210
+ const orderId = generateId('ord');
211
+ db.prepare(`INSERT INTO orders (
212
+ id, product_id, buyer_id, seller_id, quantity, unit_price, total_amount, escrow_amount,
213
+ status, shipping_address, notes, pay_deadline, accept_deadline, ship_deadline,
214
+ pickup_deadline, delivery_deadline, confirm_deadline
215
+ ) VALUES (?,?,?,?,1,?,?,?,'created',?,?,?,?,?,?,?,?)`).run(orderId, product.id, user.id, product.seller_uid, totalAmount, totalAmount, totalAmount, shipping_address, notes || null, addHours(now, 24), addHours(now, 48), addHours(now, 120), addHours(now, 168), addHours(now, 336), addHours(now, 408));
216
+ db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?')
217
+ .run(totalAmount, totalAmount, user.id);
218
+ db.prepare('UPDATE products SET stock = stock - 1 WHERE id = ?').run(product.id);
219
+ transition(db, orderId, 'paid', user.id, [], '模拟支付完成');
220
+ notifyTransition(db, orderId, 'created', 'paid');
221
+ // 检查卖家是否有 auto_accept Skill
222
+ let autoAccepted = false;
223
+ if (shouldAutoAccept(db, orderId)) {
224
+ const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
225
+ if (sysUser) {
226
+ const ar = transition(db, orderId, 'accepted', sysUser.id, [], '⚡ auto_accept Skill 自动接单');
227
+ if (ar.success) {
228
+ notifyTransition(db, orderId, 'paid', 'accepted');
229
+ autoAccepted = true;
230
+ }
231
+ }
232
+ }
233
+ res.json({ success: true, order_id: orderId, total_amount: totalAmount, auto_accepted: autoAccepted || undefined });
234
+ });
235
+ // 物流公司列表(卖家发货时选择)
236
+ app.get('/api/logistics/companies', (req, res) => {
237
+ const companies = db.prepare(`SELECT id, name FROM users WHERE role = 'logistics' ORDER BY name ASC`).all();
238
+ res.json(companies);
239
+ });
240
+ // 更新订单状态(接单/发货/揽收/投递/确认/争议)
241
+ app.post('/api/orders/:id/action', (req, res) => {
242
+ const user = auth(req, res);
243
+ if (!user)
244
+ return;
245
+ const { action, notes = '', evidence_description = '', logistics_company_id = '' } = req.body;
246
+ const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id);
247
+ if (!order)
248
+ return void res.status(404).json({ error: '订单不存在' });
249
+ // 卖家发货时:绑定选择的物流公司
250
+ if (action === 'ship' && logistics_company_id) {
251
+ const logi = db.prepare(`SELECT id FROM users WHERE id = ? AND role = 'logistics'`).get(logistics_company_id);
252
+ if (!logi)
253
+ return void res.json({ error: '所选物流公司不存在' });
254
+ db.prepare('UPDATE orders SET logistics_id = ? WHERE id = ?').run(logistics_company_id, req.params.id);
255
+ }
256
+ // 物流自行揽收(卖家未指定物流时的兜底)
257
+ if (action === 'pickup' && !order.logistics_id && user.role === 'logistics') {
258
+ db.prepare('UPDATE orders SET logistics_id = ? WHERE id = ?').run(user.id, req.params.id);
259
+ }
260
+ const actionMap = {
261
+ accept: 'accepted', ship: 'shipped', pickup: 'picked_up',
262
+ transit: 'in_transit', deliver: 'delivered', confirm: 'confirmed', dispute: 'disputed'
263
+ };
264
+ const toStatus = actionMap[action];
265
+ if (!toStatus)
266
+ return void res.json({ error: `未知操作:${action}` });
267
+ // 创建证据记录
268
+ const evidenceIds = [];
269
+ if (evidence_description) {
270
+ const eid = generateId('evt');
271
+ db.prepare(`INSERT INTO evidence (id, order_id, uploader_id, type, description, file_hash)
272
+ VALUES (?,?,?,'description',?,?)`).run(eid, req.params.id, user.id, evidence_description, `hash_${Date.now()}`);
273
+ evidenceIds.push(eid);
274
+ }
275
+ const fromStatus = order.status;
276
+ const result = transition(db, req.params.id, toStatus, user.id, evidenceIds, notes);
277
+ if (!result.success)
278
+ return void res.json({ error: result.error });
279
+ // 通知相关参与方
280
+ notifyTransition(db, req.params.id, fromStatus, toStatus);
281
+ // 发起争议时写入 disputes 表
282
+ if (toStatus === 'disputed') {
283
+ createDispute(db, req.params.id, user.id, notes || evidence_description || '买家发起争议', evidenceIds);
284
+ }
285
+ // 确认收货时自动结算
286
+ if (toStatus === 'confirmed') {
287
+ const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
288
+ transition(db, req.params.id, 'completed', sysUser.id, [], '系统自动结算');
289
+ notifyTransition(db, req.params.id, 'confirmed', 'completed');
290
+ settleOrder(req.params.id);
291
+ }
292
+ res.json({ success: true, new_status: result.newStatus });
293
+ });
294
+ // 钱包
295
+ app.get('/api/wallet', (req, res) => {
296
+ const user = auth(req, res);
297
+ if (!user)
298
+ return;
299
+ const wallet = db.prepare('SELECT * FROM wallets WHERE user_id = ?').get(user.id);
300
+ res.json(wallet);
301
+ });
302
+ // 充值测试 WAZ(Phase 0 专用,最多单次 1000,余额上限 5000)
303
+ app.post('/api/wallet/topup', (req, res) => {
304
+ const user = auth(req, res);
305
+ if (!user)
306
+ return;
307
+ const amount = Math.min(1000, Math.max(1, Number(req.body?.amount) || 500));
308
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
309
+ if (wallet.balance >= 5000)
310
+ return void res.json({ error: '余额已达上限 5000 WAZ,无需充值' });
311
+ const actual = Math.min(amount, 5000 - wallet.balance);
312
+ db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(actual, user.id);
313
+ res.json({ success: true, added: actual, new_balance: wallet.balance + actual });
314
+ });
315
+ // 物流:可接订单 + 我的进行中订单
316
+ app.get('/api/logistics/orders', (req, res) => {
317
+ const user = auth(req, res);
318
+ if (!user)
319
+ return;
320
+ if (user.role !== 'logistics')
321
+ return void res.status(403).json({ error: '仅限物流角色' });
322
+ const available = db.prepare(`
323
+ SELECT o.*, p.title as product_title, p.category,
324
+ ub.name as buyer_name, us.name as seller_name
325
+ FROM orders o
326
+ JOIN products p ON o.product_id = p.id
327
+ JOIN users ub ON o.buyer_id = ub.id
328
+ JOIN users us ON o.seller_id = us.id
329
+ WHERE o.status = 'shipped' AND (o.logistics_id IS NULL OR o.logistics_id = '')
330
+ ORDER BY o.created_at ASC LIMIT 20
331
+ `).all();
332
+ const mine = db.prepare(`
333
+ SELECT o.*, p.title as product_title, p.category,
334
+ ub.name as buyer_name, us.name as seller_name
335
+ FROM orders o
336
+ JOIN products p ON o.product_id = p.id
337
+ JOIN users ub ON o.buyer_id = ub.id
338
+ JOIN users us ON o.seller_id = us.id
339
+ WHERE o.logistics_id = ? AND o.status IN ('shipped','picked_up','in_transit')
340
+ ORDER BY o.created_at ASC LIMIT 20
341
+ `).all(user.id);
342
+ res.json({ available, mine });
343
+ });
344
+ // ─── 结算 ──────────────────────────────────────────────────────
345
+ function settleOrder(orderId) {
346
+ const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId);
347
+ const total = order.total_amount;
348
+ const product = db.prepare('SELECT stake_amount FROM products WHERE id = ?').get(order.product_id);
349
+ const protocolFee = Math.round(total * 0.02 * 100) / 100;
350
+ const logisticsFee = Math.round(total * 0.05 * 100) / 100;
351
+ const promoterFee = order.promoter_id ? Math.round(total * 0.03 * 100) / 100 : 0;
352
+ const sellerAmount = total - protocolFee - logisticsFee - promoterFee;
353
+ db.prepare('UPDATE wallets SET escrowed = escrowed - ? WHERE user_id = ?').run(total, order.buyer_id);
354
+ db.prepare('UPDATE wallets SET balance = balance + ?, earned = earned + ? WHERE user_id = ?').run(sellerAmount, sellerAmount, order.seller_id);
355
+ if (order.logistics_id)
356
+ db.prepare('UPDATE wallets SET balance = balance + ?, earned = earned + ? WHERE user_id = ?').run(logisticsFee, logisticsFee, order.logistics_id);
357
+ if (order.promoter_id)
358
+ db.prepare('UPDATE wallets SET balance = balance + ?, earned = earned + ? WHERE user_id = ?').run(promoterFee, promoterFee, order.promoter_id);
359
+ db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?').run(product.stake_amount, product.stake_amount, order.seller_id);
360
+ // L4-3 声誉积分
361
+ recordOrderReputation(db, orderId);
362
+ }
363
+ // ─── 通知 API ─────────────────────────────────────────────────
364
+ // SSE 实时推送流(EventSource 不支持自定义 header,用 URL 参数传 key)
365
+ app.get('/api/notifications/stream', (req, res) => {
366
+ const key = req.query.key ?? req.headers.authorization?.replace('Bearer ', '');
367
+ const user = key ? db.prepare('SELECT * FROM users WHERE api_key = ?').get(key) : null;
368
+ if (!user)
369
+ return void res.status(401).end();
370
+ res.setHeader('Content-Type', 'text/event-stream');
371
+ res.setHeader('Cache-Control', 'no-cache');
372
+ res.setHeader('Connection', 'keep-alive');
373
+ res.flushHeaders();
374
+ sseClients.set(user.id, res);
375
+ // 连接时推送未读数
376
+ const unread = getUnreadCount(db, user.id);
377
+ res.write(`data: ${JSON.stringify({ type: 'init', unread })}\n\n`);
378
+ // 心跳保活(每 30s)
379
+ const heartbeat = setInterval(() => {
380
+ try {
381
+ res.write(': ping\n\n');
382
+ }
383
+ catch {
384
+ clearInterval(heartbeat);
385
+ }
386
+ }, 30_000);
387
+ req.on('close', () => {
388
+ sseClients.delete(user.id);
389
+ clearInterval(heartbeat);
390
+ });
391
+ });
392
+ // 获取通知列表
393
+ app.get('/api/notifications', (req, res) => {
394
+ const user = auth(req, res);
395
+ if (!user)
396
+ return;
397
+ const onlyUnread = req.query.unread === '1';
398
+ const notifs = getNotifications(db, user.id, onlyUnread);
399
+ const unread = getUnreadCount(db, user.id);
400
+ res.json({ unread, notifications: notifs });
401
+ });
402
+ // 标记已读(不传 id 则全部已读)
403
+ app.post('/api/notifications/read', (req, res) => {
404
+ const user = auth(req, res);
405
+ if (!user)
406
+ return;
407
+ markRead(db, user.id, req.body?.id);
408
+ res.json({ success: true });
409
+ });
410
+ // ─── Skill 市场 API ───────────────────────────────────────────
411
+ // 浏览 Skill 市场(公开,无需登录)
412
+ app.get('/api/skills', (req, res) => {
413
+ const user = getUser(req);
414
+ const skills = listSkills(db, {
415
+ skillType: req.query.type,
416
+ query: req.query.q,
417
+ subscriberId: user?.id,
418
+ limit: 30,
419
+ });
420
+ res.json(skills);
421
+ });
422
+ // 我发布的 Skill
423
+ app.get('/api/skills/mine', (req, res) => {
424
+ const user = auth(req, res);
425
+ if (!user)
426
+ return;
427
+ res.json(getMySkills(db, user.id));
428
+ });
429
+ // 我订阅的 Skill
430
+ app.get('/api/skills/subscriptions', (req, res) => {
431
+ const user = auth(req, res);
432
+ if (!user)
433
+ return;
434
+ res.json(getMySubscriptions(db, user.id));
435
+ });
436
+ // 发布新 Skill
437
+ app.post('/api/skills', (req, res) => {
438
+ const user = auth(req, res);
439
+ if (!user)
440
+ return;
441
+ if (user.role !== 'seller')
442
+ return void res.json({ error: '只有卖家才能发布 Skill' });
443
+ const { name, description, category, skill_type, config } = req.body;
444
+ if (!name || !description || !skill_type)
445
+ return void res.json({ error: '请填写 name、description、skill_type' });
446
+ try {
447
+ const skill = publishSkill(db, {
448
+ sellerId: user.id,
449
+ name, description, category,
450
+ skillType: skill_type,
451
+ config: config ?? {},
452
+ });
453
+ res.json({ success: true, skill });
454
+ }
455
+ catch (err) {
456
+ res.json({ error: err.message });
457
+ }
458
+ });
459
+ // 订阅 Skill
460
+ app.post('/api/skills/:id/subscribe', (req, res) => {
461
+ const user = auth(req, res);
462
+ if (!user)
463
+ return;
464
+ try {
465
+ const result = subscribeSkill(db, user.id, req.params.id, req.body?.config ?? {});
466
+ res.json(result);
467
+ }
468
+ catch (err) {
469
+ res.json({ error: err.message });
470
+ }
471
+ });
472
+ // 取消订阅 Skill
473
+ app.delete('/api/skills/:id/subscribe', (req, res) => {
474
+ const user = auth(req, res);
475
+ if (!user)
476
+ return;
477
+ unsubscribeSkill(db, user.id, req.params.id);
478
+ res.json({ success: true });
479
+ });
480
+ // ─── Protocol Manifest(L0-5)────────────────────────────────
481
+ // 公开端点:任何客户端都可发现协议规范
482
+ app.get('/api/manifest', (_req, res) => {
483
+ res.json(generateManifest(db));
484
+ });
485
+ // 声誉 API ─────────────────────────────────────────────────────
486
+ // 我的声誉
487
+ app.get('/api/reputation', (req, res) => {
488
+ const user = auth(req, res);
489
+ if (!user)
490
+ return;
491
+ const rep = getReputation(db, user.id);
492
+ res.json({
493
+ level: rep.level,
494
+ total_points: rep.total_points,
495
+ transactions_done: rep.transactions_done,
496
+ disputes_won: rep.disputes_won,
497
+ disputes_lost: rep.disputes_lost,
498
+ violations: rep.violations,
499
+ recent_events: rep.recent_events,
500
+ });
501
+ });
502
+ // 查看任意用户的声誉(公开)
503
+ app.get('/api/reputation/:userId', (req, res) => {
504
+ const rep = getReputation(db, req.params.userId);
505
+ res.json({
506
+ level: rep.level,
507
+ total_points: rep.total_points,
508
+ transactions_done: rep.transactions_done,
509
+ disputes_won: rep.disputes_won,
510
+ disputes_lost: rep.disputes_lost,
511
+ violations: rep.violations,
512
+ });
513
+ });
514
+ // ─── 争议 API(L3 PWA 接口)────────────────────────────────────
515
+ // 仲裁员:查看所有开放争议
516
+ app.get('/api/disputes', (req, res) => {
517
+ const user = auth(req, res);
518
+ if (!user)
519
+ return;
520
+ if (user.role !== 'arbitrator')
521
+ return void res.status(403).json({ error: '仅限仲裁员访问' });
522
+ res.json(getOpenDisputes(db));
523
+ });
524
+ // 争议详情(含双方证据)
525
+ app.get('/api/disputes/:id', (req, res) => {
526
+ const user = auth(req, res);
527
+ if (!user)
528
+ return;
529
+ const dispute = getDisputeDetails(db, req.params.id);
530
+ if (!dispute)
531
+ return void res.status(404).json({ error: '争议不存在' });
532
+ const role = user.role;
533
+ // 允许:发起方、被告方、物流方(关联订单的 logistics_id)、仲裁员
534
+ const orderForAuth = db.prepare('SELECT logistics_id FROM orders WHERE id = ?')
535
+ .get(dispute.order_id);
536
+ const isLogisticsParty = orderForAuth?.logistics_id === user.id;
537
+ if (dispute.initiator_id !== user.id && dispute.defendant_id !== user.id
538
+ && !isLogisticsParty && role !== 'arbitrator') {
539
+ return void res.status(403).json({ error: '无权查看此争议' });
540
+ }
541
+ // 原告证据 — 从状态机历史中取 disputed 转移时附带的证据
542
+ const hist = db.prepare(`SELECT evidence_ids FROM order_state_history WHERE order_id = ? AND to_status = 'disputed'`).get(dispute.order_id);
543
+ const plaintiffEvidenceIds = hist ? JSON.parse(hist.evidence_ids || '[]') : [];
544
+ const defEvidenceIds = JSON.parse(dispute.defendant_evidence_ids || '[]');
545
+ const fetchEvidence = (ids) => ids.length
546
+ ? db.prepare(`SELECT * FROM evidence WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids)
547
+ : [];
548
+ // 证据补充请求列表
549
+ const evidenceRequests = getEvidenceRequests(db, req.params.id);
550
+ const myPendingRequests = evidenceRequests.filter(r => r.requested_from_id === user.id && r.status === 'pending');
551
+ // 涉案参与方(仲裁员选择发证据请求的对象)
552
+ const order = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?')
553
+ .get(dispute.order_id);
554
+ const partyIds = [dispute.initiator_id, dispute.defendant_id, order?.logistics_id].filter(Boolean);
555
+ const parties = [...new Set(partyIds)].map(id => db.prepare('SELECT id, name, role FROM users WHERE id = ?').get(id)).filter(Boolean);
556
+ // 参与方主动提交的证据
557
+ const partyEvidenceIds = JSON.parse(dispute.party_evidence_ids || '[]');
558
+ // 当前用户是否参与方(用于前端判断是否显示主动举证按钮)
559
+ const orderParties = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?')
560
+ .get(dispute.order_id);
561
+ const allPartyIds = [
562
+ orderParties?.buyer_id, orderParties?.seller_id, orderParties?.logistics_id,
563
+ dispute.initiator_id, dispute.defendant_id
564
+ ].filter(Boolean);
565
+ const isParty = allPartyIds.includes(user.id);
566
+ res.json({
567
+ ...dispute,
568
+ plaintiff_evidence: fetchEvidence(plaintiffEvidenceIds),
569
+ defendant_evidence: fetchEvidence(defEvidenceIds),
570
+ party_evidence: fetchEvidence(partyEvidenceIds),
571
+ evidence_requests: evidenceRequests,
572
+ my_pending_requests: myPendingRequests,
573
+ parties,
574
+ is_party: isParty,
575
+ });
576
+ });
577
+ // 被诉方提交反驳证据
578
+ app.post('/api/disputes/:id/respond', (req, res) => {
579
+ const user = auth(req, res);
580
+ if (!user)
581
+ return;
582
+ const { notes = '', evidence_description = '' } = req.body;
583
+ const dispute = getDisputeDetails(db, req.params.id);
584
+ if (!dispute)
585
+ return void res.status(404).json({ error: '争议不存在' });
586
+ if (dispute.defendant_id !== user.id)
587
+ return void res.status(403).json({ error: '你不是本争议的被诉方' });
588
+ const evidenceIds = [];
589
+ if (evidence_description) {
590
+ const eid = generateId('evt');
591
+ db.prepare(`INSERT INTO evidence (id, order_id, uploader_id, type, description, file_hash)
592
+ VALUES (?,?,?,'description',?,?)`).run(eid, dispute.order_id, user.id, evidence_description, `hash_${Date.now()}`);
593
+ evidenceIds.push(eid);
594
+ }
595
+ const result = respondToDispute(db, req.params.id, user.id, notes || evidence_description, evidenceIds);
596
+ if (!result.success)
597
+ return void res.json({ error: result.error });
598
+ res.json({ success: true, message: result.message });
599
+ });
600
+ // 仲裁员裁定
601
+ app.post('/api/disputes/:id/arbitrate', (req, res) => {
602
+ const user = auth(req, res);
603
+ if (!user)
604
+ return;
605
+ if (user.role !== 'arbitrator')
606
+ return void res.status(403).json({ error: '仅限仲裁员' });
607
+ const { ruling, reason, refund_amount, liability_parties } = req.body;
608
+ if (!ruling || !reason)
609
+ return void res.json({ error: '请提供裁定结果(ruling)和理由(reason)' });
610
+ const validRulings = ['refund_buyer', 'release_seller', 'partial_refund', 'liability_split'];
611
+ if (!validRulings.includes(ruling)) {
612
+ return void res.json({ error: `ruling 必须是 ${validRulings.join(' / ')} 之一` });
613
+ }
614
+ if (ruling === 'liability_split') {
615
+ if (!Array.isArray(liability_parties) || liability_parties.length === 0) {
616
+ return void res.json({ error: '责任分配裁定需要提供 liability_parties 数组' });
617
+ }
618
+ for (const p of liability_parties) {
619
+ if (!p.user_id || typeof p.amount !== 'number' || p.amount < 0) {
620
+ return void res.json({ error: '每个责任方需提供 user_id 和非负 amount' });
621
+ }
622
+ }
623
+ }
624
+ const dispute = getDisputeDetails(db, req.params.id);
625
+ if (!dispute)
626
+ return void res.status(404).json({ error: '争议不存在' });
627
+ const result = arbitrateDispute(db, req.params.id, user.id, ruling, reason, refund_amount ? Number(refund_amount) : undefined, liability_parties);
628
+ if (!result.success)
629
+ return void res.json({ error: result.error });
630
+ // 争议声誉更新(责任分配时以主要责任方为败诉方)
631
+ let winnerId = null;
632
+ let loserId = null;
633
+ if (ruling === 'refund_buyer') {
634
+ winnerId = dispute.initiator_id;
635
+ loserId = dispute.defendant_id;
636
+ }
637
+ else if (ruling === 'release_seller') {
638
+ winnerId = dispute.defendant_id;
639
+ loserId = dispute.initiator_id;
640
+ }
641
+ else if (ruling === 'liability_split' && Array.isArray(liability_parties) && liability_parties.length > 0) {
642
+ // 最大责任方为败诉方
643
+ const maxLiable = liability_parties.reduce((a, b) => a.amount >= b.amount ? a : b);
644
+ loserId = maxLiable.user_id;
645
+ winnerId = dispute.initiator_id !== loserId ? dispute.initiator_id : dispute.defendant_id;
646
+ }
647
+ if (winnerId && loserId)
648
+ recordDisputeReputation(db, dispute.order_id, winnerId, loserId);
649
+ res.json({ success: true, message: result.message, settlement: result.settlement });
650
+ });
651
+ // 参与方主动提交证据
652
+ app.post('/api/disputes/:id/add-evidence', (req, res) => {
653
+ const user = auth(req, res);
654
+ if (!user)
655
+ return;
656
+ const { description, evidence_type = 'text', file_hash } = req.body;
657
+ if (!description?.trim())
658
+ return void res.json({ error: '请填写证据内容' });
659
+ const result = addPartyEvidence(db, req.params.id, user.id, description.trim(), evidence_type, file_hash);
660
+ if (!result.success)
661
+ return void res.json({ error: result.error });
662
+ res.json({ success: true, evidence_id: result.evidenceId, anchor_hash: result.anchorHash });
663
+ });
664
+ // 仲裁员:请求某方补充证据
665
+ app.post('/api/disputes/:id/request-evidence', (req, res) => {
666
+ const user = auth(req, res);
667
+ if (!user)
668
+ return;
669
+ if (user.role !== 'arbitrator')
670
+ return void res.status(403).json({ error: '仅限仲裁员' });
671
+ const { requested_from_id, evidence_types, description, deadline_hours = 48 } = req.body;
672
+ if (!requested_from_id || !description)
673
+ return void res.json({ error: '请指定被要求方和证据要求说明' });
674
+ if (!Array.isArray(evidence_types) || evidence_types.length === 0) {
675
+ return void res.json({ error: '请至少选择一种证据类型' });
676
+ }
677
+ const validTypes = ['text', 'image', 'video', 'document', 'chain_data'];
678
+ if (!evidence_types.every((t) => validTypes.includes(t))) {
679
+ return void res.json({ error: `证据类型无效,支持:${validTypes.join('/')}` });
680
+ }
681
+ const result = requestEvidence(db, req.params.id, user.id, requested_from_id, evidence_types, description, Number(deadline_hours));
682
+ if (!result.success)
683
+ return void res.json({ error: result.error });
684
+ res.json({ success: true, request_id: result.requestId });
685
+ });
686
+ // 当事人:提交指定证据请求的回应
687
+ app.post('/api/evidence-requests/:requestId/submit', (req, res) => {
688
+ const user = auth(req, res);
689
+ if (!user)
690
+ return;
691
+ const { evidence_type = 'text', description, file_hash } = req.body;
692
+ if (!description?.trim())
693
+ return void res.json({ error: '请填写证据内容' });
694
+ const result = submitEvidenceForRequest(db, req.params.requestId, user.id, evidence_type, description.trim(), file_hash);
695
+ if (!result.success)
696
+ return void res.json({ error: result.error });
697
+ res.json({ success: true, evidence_id: result.evidenceId, anchor_hash: result.anchorHash });
698
+ });
699
+ // 查询某争议的关联用户(仲裁员选择发证据请求给谁)
700
+ app.get('/api/disputes/:id/parties', (req, res) => {
701
+ const user = auth(req, res);
702
+ if (!user)
703
+ return;
704
+ const dispute = getDisputeDetails(db, req.params.id);
705
+ if (!dispute)
706
+ return void res.status(404).json({ error: '争议不存在' });
707
+ const order = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?')
708
+ .get(dispute.order_id);
709
+ const partyIds = [dispute.initiator_id, dispute.defendant_id, order?.logistics_id].filter(Boolean);
710
+ const uniqueIds = [...new Set(partyIds)];
711
+ const parties = uniqueIds.map(id => {
712
+ const u = db.prepare('SELECT id, name, role FROM users WHERE id = ?').get(id);
713
+ return u;
714
+ }).filter(Boolean);
715
+ res.json(parties);
716
+ });
717
+ // ─── 静态文件 + SPA 回退(必须在所有 API 路由之后)────────────
718
+ app.use(express.static(path.join(__dirname, 'public')));
719
+ app.get('/{*path}', (_req, res) => {
720
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
721
+ });
722
+ // ─── 自动执法(随 PWA 进程内置运行)────────────────────────────
723
+ const ENFORCE_INTERVAL_MS = 5 * 60 * 1000; // 每 5 分钟扫描一次
724
+ function runEnforcement() {
725
+ try {
726
+ const orderResult = checkTimeouts(db);
727
+ const disputeResult = checkDisputeTimeouts(db);
728
+ if (orderResult.processed > 0) {
729
+ console.log(`⚡ 订单超时判责 × ${orderResult.processed}`);
730
+ orderResult.details.forEach(d => {
731
+ console.log(` ${d.orderId} ${d.action}`);
732
+ const faultMatch = d.action.match(/→ (fault_\w+)/);
733
+ if (faultMatch)
734
+ recordViolationReputation(db, d.orderId, faultMatch[1]);
735
+ });
736
+ }
737
+ if (disputeResult.processed > 0) {
738
+ console.log(`⚡ 争议自动裁定 × ${disputeResult.processed}`);
739
+ disputeResult.details.forEach(d => {
740
+ console.log(` ${d.disputeId} ${d.action}`);
741
+ if (d.winnerId && d.loserId && d.orderId) {
742
+ recordDisputeReputation(db, d.orderId, d.winnerId, d.loserId);
743
+ }
744
+ });
745
+ }
746
+ }
747
+ catch (err) {
748
+ console.error('执法扫描出错:', err.message);
749
+ }
750
+ }
751
+ // ─── 启动 ─────────────────────────────────────────────────────
752
+ const PORT = 3000;
753
+ app.listen(PORT, () => {
754
+ console.log(`✅ WebAZ 已启动:http://localhost:${PORT}`);
755
+ console.log(` 手机访问:http://<本机IP>:${PORT}`);
756
+ // 启动时立即扫描一次,之后每 5 分钟执行
757
+ runEnforcement();
758
+ setInterval(runEnforcement, ENFORCE_INTERVAL_MS);
759
+ console.log(`⚡ 自动执法已启动(每 ${ENFORCE_INTERVAL_MS / 60000} 分钟扫描)`);
760
+ });