@seasonkoh/webaz 0.1.7 → 0.1.9

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.
Files changed (153) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +156 -20
  3. package/dist/layer0-foundation/L0-1-database/schema.js +5 -4
  4. package/dist/layer0-foundation/L0-2-state-machine/engine.js +228 -7
  5. package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +156 -0
  6. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +53 -12
  7. package/dist/layer0-foundation/L0-5-manifest/manifest.js +14 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/auth.js +1 -1
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +3691 -714
  10. package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +324 -0
  11. package/dist/layer1-agent/L1-2-identity/agent-passport.js +100 -0
  12. package/dist/layer2-business/L2-6-notifications/notification-engine.js +72 -5
  13. package/dist/layer2-business/L2-7-snf/snf-engine.js +287 -0
  14. package/dist/layer2-business/L2-anchor-registry/anchor-registry.js +396 -0
  15. package/dist/layer2-business/L2-notes/note-photo-storage.js +133 -0
  16. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +6 -6
  17. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +246 -0
  18. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +95 -1
  19. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +31 -2
  20. package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +358 -0
  21. package/dist/pwa/public/app.js +31947 -0
  22. package/dist/pwa/public/i18n.js +5751 -0
  23. package/dist/pwa/public/icon.svg +11 -0
  24. package/dist/pwa/public/index.html +21 -0
  25. package/dist/pwa/public/manifest.json +48 -0
  26. package/dist/pwa/public/openapi.json +5946 -0
  27. package/dist/pwa/public/style.css +535 -0
  28. package/dist/pwa/public/sw.js +63 -0
  29. package/dist/pwa/public/vendor/jsQR.js +10102 -0
  30. package/dist/pwa/public/webaz-logo.png +0 -0
  31. package/dist/pwa/routes/account-deletion.js +53 -0
  32. package/dist/pwa/routes/addresses.js +105 -0
  33. package/dist/pwa/routes/admin-admins.js +151 -0
  34. package/dist/pwa/routes/admin-analytics.js +253 -0
  35. package/dist/pwa/routes/admin-atomic.js +21 -0
  36. package/dist/pwa/routes/admin-catalog.js +64 -0
  37. package/dist/pwa/routes/admin-editor-picks.js +45 -0
  38. package/dist/pwa/routes/admin-events.js +60 -0
  39. package/dist/pwa/routes/admin-health.js +66 -0
  40. package/dist/pwa/routes/admin-moderation.js +120 -0
  41. package/dist/pwa/routes/admin-ops.js +179 -0
  42. package/dist/pwa/routes/admin-protocol-params.js +79 -0
  43. package/dist/pwa/routes/admin-reports.js +154 -0
  44. package/dist/pwa/routes/admin-tokenomics.js +113 -0
  45. package/dist/pwa/routes/admin-users-lifecycle.js +237 -0
  46. package/dist/pwa/routes/admin-users-query.js +390 -0
  47. package/dist/pwa/routes/admin-verifier-flow.js +126 -0
  48. package/dist/pwa/routes/admin-verifier-whitelist.js +111 -0
  49. package/dist/pwa/routes/admin-wallet-ops.js +66 -0
  50. package/dist/pwa/routes/agent-buy.js +215 -0
  51. package/dist/pwa/routes/agent-governance.js +341 -0
  52. package/dist/pwa/routes/agent-reputation.js +34 -0
  53. package/dist/pwa/routes/ai.js +101 -0
  54. package/dist/pwa/routes/analytics.js +272 -0
  55. package/dist/pwa/routes/anchors.js +169 -0
  56. package/dist/pwa/routes/announcements.js +110 -0
  57. package/dist/pwa/routes/arbitrator.js +117 -0
  58. package/dist/pwa/routes/auction.js +436 -0
  59. package/dist/pwa/routes/auth-login.js +40 -0
  60. package/dist/pwa/routes/auth-read.js +66 -0
  61. package/dist/pwa/routes/auth-register.js +138 -0
  62. package/dist/pwa/routes/auth-sessions.js +62 -0
  63. package/dist/pwa/routes/blocklist.js +60 -0
  64. package/dist/pwa/routes/buyer-feeds.js +224 -0
  65. package/dist/pwa/routes/cart.js +155 -0
  66. package/dist/pwa/routes/charity.js +816 -0
  67. package/dist/pwa/routes/chat.js +318 -0
  68. package/dist/pwa/routes/checkin-tasks.js +122 -0
  69. package/dist/pwa/routes/checkout-helpers.js +85 -0
  70. package/dist/pwa/routes/claim-initiators.js +88 -0
  71. package/dist/pwa/routes/claim-verify.js +615 -0
  72. package/dist/pwa/routes/claim-voting.js +114 -0
  73. package/dist/pwa/routes/claim-withdrawals.js +20 -0
  74. package/dist/pwa/routes/coupons.js +165 -0
  75. package/dist/pwa/routes/dashboards.js +99 -0
  76. package/dist/pwa/routes/dispute-cases.js +267 -0
  77. package/dist/pwa/routes/disputes-read.js +358 -0
  78. package/dist/pwa/routes/disputes-write.js +475 -0
  79. package/dist/pwa/routes/evidence.js +86 -0
  80. package/dist/pwa/routes/external-anchors.js +107 -0
  81. package/dist/pwa/routes/feedback.js +270 -0
  82. package/dist/pwa/routes/flash-sales.js +130 -0
  83. package/dist/pwa/routes/follows.js +103 -0
  84. package/dist/pwa/routes/group-buys.js +208 -0
  85. package/dist/pwa/routes/growth.js +199 -0
  86. package/dist/pwa/routes/import-product.js +153 -0
  87. package/dist/pwa/routes/kyc.js +40 -0
  88. package/dist/pwa/routes/leaderboard.js +149 -0
  89. package/dist/pwa/routes/listings.js +281 -0
  90. package/dist/pwa/routes/logistics.js +35 -0
  91. package/dist/pwa/routes/manifests.js +126 -0
  92. package/dist/pwa/routes/me-data.js +101 -0
  93. package/dist/pwa/routes/notifications.js +48 -0
  94. package/dist/pwa/routes/offers.js +96 -0
  95. package/dist/pwa/routes/orders-action.js +285 -0
  96. package/dist/pwa/routes/orders-create.js +339 -0
  97. package/dist/pwa/routes/orders-read.js +180 -0
  98. package/dist/pwa/routes/p2p-products.js +178 -0
  99. package/dist/pwa/routes/payments-governance.js +311 -0
  100. package/dist/pwa/routes/peers.js +34 -0
  101. package/dist/pwa/routes/pin-receipts.js +39 -0
  102. package/dist/pwa/routes/products-aliases.js +119 -0
  103. package/dist/pwa/routes/products-claims.js +60 -0
  104. package/dist/pwa/routes/products-create.js +206 -0
  105. package/dist/pwa/routes/products-crud.js +73 -0
  106. package/dist/pwa/routes/products-links.js +129 -0
  107. package/dist/pwa/routes/products-list.js +424 -0
  108. package/dist/pwa/routes/products-meta.js +155 -0
  109. package/dist/pwa/routes/products-update.js +125 -0
  110. package/dist/pwa/routes/profile-credentials.js +105 -0
  111. package/dist/pwa/routes/profile-identity.js +174 -0
  112. package/dist/pwa/routes/profile-location.js +35 -0
  113. package/dist/pwa/routes/profile-placement.js +70 -0
  114. package/dist/pwa/routes/profile-prefs.js +93 -0
  115. package/dist/pwa/routes/promoter.js +208 -0
  116. package/dist/pwa/routes/public-utils.js +170 -0
  117. package/dist/pwa/routes/push.js +54 -0
  118. package/dist/pwa/routes/ratings.js +220 -0
  119. package/dist/pwa/routes/recover-key.js +100 -0
  120. package/dist/pwa/routes/referral.js +58 -0
  121. package/dist/pwa/routes/reputation.js +34 -0
  122. package/dist/pwa/routes/returns.js +493 -0
  123. package/dist/pwa/routes/reviews.js +81 -0
  124. package/dist/pwa/routes/rfqs.js +443 -0
  125. package/dist/pwa/routes/search.js +172 -0
  126. package/dist/pwa/routes/secondhand.js +278 -0
  127. package/dist/pwa/routes/seller-quota.js +225 -0
  128. package/dist/pwa/routes/share-redirects.js +164 -0
  129. package/dist/pwa/routes/shareables-interactions.js +212 -0
  130. package/dist/pwa/routes/shareables.js +470 -0
  131. package/dist/pwa/routes/shops.js +98 -0
  132. package/dist/pwa/routes/signaling.js +43 -0
  133. package/dist/pwa/routes/skill-market.js +173 -0
  134. package/dist/pwa/routes/skills.js +174 -0
  135. package/dist/pwa/routes/snf.js +126 -0
  136. package/dist/pwa/routes/tags.js +47 -0
  137. package/dist/pwa/routes/trial.js +333 -0
  138. package/dist/pwa/routes/trusted-kpi.js +87 -0
  139. package/dist/pwa/routes/url-claim.js +113 -0
  140. package/dist/pwa/routes/users-public.js +317 -0
  141. package/dist/pwa/routes/variants.js +156 -0
  142. package/dist/pwa/routes/verifier-user.js +107 -0
  143. package/dist/pwa/routes/verify-tasks.js +120 -0
  144. package/dist/pwa/routes/waitlist.js +65 -0
  145. package/dist/pwa/routes/wallet-read.js +218 -0
  146. package/dist/pwa/routes/wallet-write.js +273 -0
  147. package/dist/pwa/routes/webauthn.js +188 -0
  148. package/dist/pwa/routes/webhooks.js +162 -0
  149. package/dist/pwa/routes/welcome.js +226 -0
  150. package/dist/pwa/routes/wishlist-qa.js +135 -0
  151. package/dist/pwa/security/ssrf.js +110 -0
  152. package/dist/pwa/server.js +9679 -698
  153. package/package.json +11 -4
@@ -0,0 +1,133 @@
1
+ // 笔记图片 — 内容寻址 blob 存储(复用 evidence-storage 模式)
2
+ // 原则:买家无 P2P 节点 → server 持有 blob + hash;图片 hash 跨笔记唯一(防剽窃)
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ import { createHash } from 'crypto';
7
+ const NOTE_PHOTO_DIR = path.join(os.homedir(), '.webaz', 'note-photos');
8
+ if (!fs.existsSync(NOTE_PHOTO_DIR))
9
+ fs.mkdirSync(NOTE_PHOTO_DIR, { recursive: true });
10
+ export const NOTE_PHOTO_MAX_BYTES = 5 * 1024 * 1024; // 5MB / 张 — 比证据更小(笔记图典型 JPG)
11
+ export const NOTE_PHOTO_ALLOWED_MIME = new Set([
12
+ 'image/jpeg', 'image/png', 'image/webp',
13
+ ]);
14
+ function blobPathFor(hash) {
15
+ const sub = hash.slice(0, 2);
16
+ const dir = path.join(NOTE_PHOTO_DIR, sub);
17
+ if (!fs.existsSync(dir))
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ return path.join(dir, hash);
20
+ }
21
+ export function blobPathForHash(hash) {
22
+ return blobPathFor(hash);
23
+ }
24
+ export function noteBlobExists(hash) {
25
+ return fs.existsSync(blobPathFor(hash));
26
+ }
27
+ // 写 blob,返回是否 dedup(已存在则不重写)
28
+ export function writeNotePhoto(blob, declaredHash, mime) {
29
+ if (!blob || blob.length === 0)
30
+ throw new Error('photo_empty');
31
+ if (blob.length > NOTE_PHOTO_MAX_BYTES)
32
+ throw new Error('photo_too_large');
33
+ if (!NOTE_PHOTO_ALLOWED_MIME.has(mime))
34
+ throw new Error('photo_mime_not_allowed');
35
+ const actualHash = createHash('sha256').update(blob).digest('hex');
36
+ if (actualHash !== declaredHash)
37
+ throw new Error('photo_hash_mismatch');
38
+ const bp = blobPathFor(actualHash);
39
+ if (fs.existsSync(bp))
40
+ return { hash: actualHash, dedup: true, size: blob.length };
41
+ fs.writeFileSync(bp, blob);
42
+ return { hash: actualHash, dedup: false, size: blob.length };
43
+ }
44
+ export function readNotePhoto(hash) {
45
+ if (!/^[0-9a-f]{64}$/.test(hash))
46
+ throw new Error('photo_bad_hash');
47
+ const bp = blobPathFor(hash);
48
+ if (!fs.existsSync(bp))
49
+ throw new Error('photo_not_found');
50
+ const blob = fs.readFileSync(bp);
51
+ // 完整性二次校验
52
+ const actualHash = createHash('sha256').update(blob).digest('hex');
53
+ if (actualHash !== hash)
54
+ throw new Error('photo_corrupted');
55
+ // mime 用 magic byte 嗅探(不依赖 db 字段)
56
+ const mime = sniffImageMime(blob);
57
+ return { blob, mime };
58
+ }
59
+ function sniffImageMime(b) {
60
+ if (b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF)
61
+ return 'image/jpeg';
62
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47)
63
+ return 'image/png';
64
+ if (b.length >= 12 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50)
65
+ return 'image/webp';
66
+ return 'application/octet-stream';
67
+ }
68
+ // 清理孤儿 blob — disk 上有但 note_photo_index 没引用的 hash
69
+ // graceMs:刚上传但还没创建笔记的图片需要保留窗口(默认 1 小时)
70
+ // - 用户先 POST /api/notes/photo 拿 hash
71
+ // - 再 POST /api/shareables 把 hash 写进 note_photo_index
72
+ // - 中间窗口(典型 几十秒)误删会让上传白费
73
+ // 由 cron 每天调一次(与 evidence cleanup 同节奏)
74
+ export function cleanupOrphanNotePhotos(db, graceMs = 60 * 60 * 1000) {
75
+ if (!fs.existsSync(NOTE_PHOTO_DIR))
76
+ return { swept: 0, bytes: 0 };
77
+ let swept = 0;
78
+ let bytes = 0;
79
+ const now = Date.now();
80
+ let subs;
81
+ try {
82
+ subs = fs.readdirSync(NOTE_PHOTO_DIR);
83
+ }
84
+ catch {
85
+ return { swept: 0, bytes: 0 };
86
+ }
87
+ for (const sub of subs) {
88
+ const subPath = path.join(NOTE_PHOTO_DIR, sub);
89
+ let stat;
90
+ try {
91
+ stat = fs.statSync(subPath);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ if (!stat.isDirectory())
97
+ continue;
98
+ let files;
99
+ try {
100
+ files = fs.readdirSync(subPath);
101
+ }
102
+ catch {
103
+ continue;
104
+ }
105
+ for (const fname of files) {
106
+ if (!/^[0-9a-f]{64}$/.test(fname))
107
+ continue; // 跳过非内容寻址文件
108
+ const fp = path.join(subPath, fname);
109
+ let fstat;
110
+ try {
111
+ fstat = fs.statSync(fp);
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ // Math.max(0, ...) 防文件系统亚毫秒级时间戳偏差导致 age 为负
117
+ // 否则 graceMs=0 时永远 skip(mtime 取自 stat 可能比 Date.now() 大几十微秒)
118
+ const age = Math.max(0, now - fstat.mtimeMs);
119
+ if (age < graceMs)
120
+ continue; // grace 窗口内保留
121
+ const row = db.prepare(`SELECT 1 FROM note_photo_index WHERE hash = ?`).get(fname);
122
+ if (!row) {
123
+ try {
124
+ bytes += fstat.size;
125
+ fs.unlinkSync(fp);
126
+ swept++;
127
+ }
128
+ catch { /* 并发清理或权限错误 */ }
129
+ }
130
+ }
131
+ }
132
+ return { swept, bytes };
133
+ }
@@ -140,7 +140,7 @@ export function respondToDispute(db, disputeId, responderId, notes, evidenceIds)
140
140
  `).run(notes, JSON.stringify(evidenceIds), disputeId);
141
141
  return {
142
142
  success: true,
143
- message: '反驳证据已提交,争议进入仲裁阶段。仲裁员将在 72 小时内做出裁定。',
143
+ message: '反驳证据已提交,争议进入仲裁阶段。仲裁员将在 120 小时(5 天)内做出裁定,超时协议自动判初诉方胜。',
144
144
  };
145
145
  }
146
146
  export function arbitrateDispute(db, disputeId, arbitratorId, ruling, reason, refundAmount, liabilityParties, liablePartyId // 指定责任方 user_id(用于 partial_refund 第三方责任场景)
@@ -333,7 +333,7 @@ function executeLiabilitySplit(db, orderId, liabilityParties, buyerRefund) {
333
333
  .run(stakeAmount, stakeAmount, sellerId);
334
334
  }
335
335
  }
336
- transition(db, orderId, 'cancelled', sysUser.id, [], `争议裁定:责任分配,退款买家 ${actualRefund} WAZ`);
336
+ transition(db, orderId, 'refunded_partial', sysUser.id, [], `争议裁定:责任分配,退款买家 ${actualRefund} WAZ`);
337
337
  })();
338
338
  return {
339
339
  success: true,
@@ -411,7 +411,7 @@ function executeSettlement(db, orderId, ruling, refundAmount, liablePartyId) {
411
411
  db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(penalty, buyerId);
412
412
  }
413
413
  }
414
- transition(db, orderId, 'cancelled', sysUser.id, [], `争议裁定:退款买家,质押惩罚 ${penalty} WAZ`);
414
+ transition(db, orderId, 'refunded_full', sysUser.id, [], `争议裁定:退款买家,质押惩罚 ${penalty} WAZ`);
415
415
  })();
416
416
  return {
417
417
  success: true,
@@ -440,7 +440,7 @@ function executeSettlement(db, orderId, ruling, refundAmount, liablePartyId) {
440
440
  db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?')
441
441
  .run(stakeAmount, stakeAmount, sellerId);
442
442
  }
443
- transition(db, orderId, 'completed', sysUser.id, [], '争议裁定:卖家胜诉,资金释放完成');
443
+ transition(db, orderId, 'resolved_for_seller', sysUser.id, [], '争议裁定:卖家胜诉,资金释放完成');
444
444
  })();
445
445
  return {
446
446
  success: true,
@@ -493,7 +493,7 @@ function executeSettlement(db, orderId, ruling, refundAmount, liablePartyId) {
493
493
  // 4. 赔偿金给买家
494
494
  db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(actualRefund, buyerId);
495
495
  }
496
- transition(db, orderId, 'completed', sysUser.id, [], `争议裁定:第三方责任赔偿 ${actualRefund} WAZ,卖家全额结算`);
496
+ transition(db, orderId, 'refunded_partial', sysUser.id, [], `争议裁定:第三方责任赔偿 ${actualRefund} WAZ,卖家全额结算`);
497
497
  })();
498
498
  return {
499
499
  success: true,
@@ -524,7 +524,7 @@ function executeSettlement(db, orderId, ruling, refundAmount, liablePartyId) {
524
524
  db.prepare('UPDATE wallets SET staked = staked - ?, balance = balance + ? WHERE user_id = ?')
525
525
  .run(stakeAmount, stakeReturn, sellerId);
526
526
  }
527
- transition(db, orderId, 'cancelled', sysUser.id, [], `争议裁定:部分退款 ${refund} WAZ`);
527
+ transition(db, orderId, 'refunded_partial', sysUser.id, [], `争议裁定:部分退款 ${refund} WAZ`);
528
528
  })();
529
529
  return {
530
530
  success: true,
@@ -0,0 +1,246 @@
1
+ // L0-4 证据上传通道 — 内容寻址 blob 存储 + HMAC 签名 + 哈希再校验 + TTL 清理
2
+ // 原则:信息谁创造谁存储;server 只存 blob + hash + sig;过期自动清理
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ import { createHash, createHmac } from 'crypto';
7
+ import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
8
+ const EVIDENCE_DIR = path.join(os.homedir(), '.webaz', 'evidence');
9
+ if (!fs.existsSync(EVIDENCE_DIR))
10
+ fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
11
+ export const EVIDENCE_MAX_BYTES = 20 * 1024 * 1024;
12
+ export const EVIDENCE_ALLOWED_MIME = new Set([
13
+ 'image/jpeg', 'image/png', 'image/webp', 'image/gif',
14
+ 'video/mp4', 'video/webm', 'video/quicktime',
15
+ 'application/pdf',
16
+ 'text/plain',
17
+ ]);
18
+ export const EVIDENCE_TTL_DAYS_AFTER_RESOLVED = 90;
19
+ function blobPathFor(hash) {
20
+ const sub = hash.slice(0, 2);
21
+ const dir = path.join(EVIDENCE_DIR, sub);
22
+ if (!fs.existsSync(dir))
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ return path.join(dir, hash);
25
+ }
26
+ export function ensureEvidenceColumns(db) {
27
+ const cols = [
28
+ `ALTER TABLE evidence ADD COLUMN size INTEGER DEFAULT 0`,
29
+ `ALTER TABLE evidence ADD COLUMN mime TEXT`,
30
+ `ALTER TABLE evidence ADD COLUMN sig TEXT`,
31
+ `ALTER TABLE evidence ADD COLUMN dispute_id TEXT`,
32
+ `ALTER TABLE evidence ADD COLUMN expires_at TEXT`,
33
+ `ALTER TABLE evidence ADD COLUMN withdrawn_at TEXT`,
34
+ `ALTER TABLE evidence ADD COLUMN filename TEXT`,
35
+ // 跨窗反诈一致性:W4 仲裁陈述/证据描述也跑 detectFraud
36
+ `ALTER TABLE evidence ADD COLUMN flag_reasons TEXT`,
37
+ ];
38
+ for (const stmt of cols) {
39
+ try {
40
+ db.exec(stmt);
41
+ }
42
+ catch { /* 列已存在 */ }
43
+ }
44
+ try {
45
+ db.exec('CREATE INDEX IF NOT EXISTS idx_evidence_dispute ON evidence(dispute_id)');
46
+ }
47
+ catch { }
48
+ try {
49
+ db.exec('CREATE INDEX IF NOT EXISTS idx_evidence_uploader ON evidence(uploader_id)');
50
+ }
51
+ catch { }
52
+ try {
53
+ db.exec('CREATE INDEX IF NOT EXISTS idx_evidence_expires ON evidence(expires_at)');
54
+ }
55
+ catch { }
56
+ }
57
+ function canonicalEvidenceMeta(m) {
58
+ return [m.uploaderId, m.disputeId, m.hash, String(m.size), m.mime, m.description].join('|');
59
+ }
60
+ function isPartyOrArbitrator(db, disputeId, userId) {
61
+ const dispute = db.prepare('SELECT order_id, initiator_id, defendant_id, assigned_arbitrators FROM disputes WHERE id = ?').get(disputeId);
62
+ if (!dispute)
63
+ return false;
64
+ const order = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?').get(dispute.order_id);
65
+ const arbitrators = JSON.parse(dispute.assigned_arbitrators || '[]');
66
+ const parties = new Set([
67
+ order?.buyer_id, order?.seller_id, order?.logistics_id,
68
+ dispute.initiator_id, dispute.defendant_id,
69
+ ...arbitrators,
70
+ ].filter(Boolean));
71
+ return parties.has(userId);
72
+ }
73
+ export function uploadEvidence(db, args) {
74
+ if (!args.blob || args.blob.length === 0)
75
+ throw new Error('evidence_empty');
76
+ if (args.blob.length > EVIDENCE_MAX_BYTES)
77
+ throw new Error('evidence_too_large');
78
+ if (!EVIDENCE_ALLOWED_MIME.has(args.mime))
79
+ throw new Error('evidence_mime_not_allowed');
80
+ if (!args.description || args.description.length < 4)
81
+ throw new Error('evidence_description_too_short');
82
+ if (args.description.length > 500)
83
+ throw new Error('evidence_description_too_long');
84
+ const actualHash = createHash('sha256').update(args.blob).digest('hex');
85
+ if (actualHash !== args.declaredHash)
86
+ throw new Error('evidence_hash_mismatch');
87
+ const dispute = db.prepare('SELECT order_id, status FROM disputes WHERE id = ?').get(args.disputeId);
88
+ if (!dispute)
89
+ throw new Error('dispute_not_found');
90
+ if (dispute.status === 'resolved' || dispute.status === 'dismissed') {
91
+ throw new Error('dispute_already_closed');
92
+ }
93
+ if (!isPartyOrArbitrator(db, args.disputeId, args.uploaderId)) {
94
+ throw new Error('not_dispute_party');
95
+ }
96
+ const sig = createHmac('sha256', args.uploaderApiKey)
97
+ .update(canonicalEvidenceMeta({
98
+ uploaderId: args.uploaderId,
99
+ disputeId: args.disputeId,
100
+ hash: actualHash,
101
+ size: args.blob.length,
102
+ mime: args.mime,
103
+ description: args.description,
104
+ })).digest('hex');
105
+ const bp = blobPathFor(actualHash);
106
+ const blobExists = fs.existsSync(bp);
107
+ const evType = args.mime.startsWith('image/') ? 'photo'
108
+ : args.mime.startsWith('video/') ? 'video'
109
+ : args.mime === 'application/pdf' ? 'document'
110
+ : 'document';
111
+ const eid = generateId('evt');
112
+ // 审计加固(A):DB INSERT + party_evidence_ids 更新一个事务;blob 落盘放到 commit 后
113
+ // 这样 INSERT 失败 → blob 没写 → 无孤儿;blob 写失败 → tombstone row
114
+ db.transaction(() => {
115
+ db.prepare(`
116
+ INSERT INTO evidence (id, order_id, uploader_id, type, description, file_path, file_hash, size, mime, sig, dispute_id, filename)
117
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
118
+ `).run(eid, dispute.order_id, args.uploaderId, evType, args.description, `evidence/${actualHash.slice(0, 2)}/${actualHash}`, actualHash, args.blob.length, args.mime, sig, args.disputeId, args.filename || null);
119
+ const d = db.prepare('SELECT party_evidence_ids FROM disputes WHERE id = ?').get(args.disputeId);
120
+ if (d) {
121
+ const arr = JSON.parse(d.party_evidence_ids || '[]');
122
+ arr.push(eid);
123
+ db.prepare('UPDATE disputes SET party_evidence_ids = ? WHERE id = ?').run(JSON.stringify(arr), args.disputeId);
124
+ }
125
+ })();
126
+ // INSERT 已提交后再落盘;写失败 → 立刻 tombstone(withdrawn_at)防止 readEvidence 返 evidence_blob_missing 给参与方
127
+ if (!blobExists) {
128
+ try {
129
+ fs.writeFileSync(bp, args.blob);
130
+ }
131
+ catch (e) {
132
+ db.prepare(`UPDATE evidence SET withdrawn_at = datetime('now') WHERE id = ?`).run(eid);
133
+ throw new Error('evidence_blob_write_failed');
134
+ }
135
+ }
136
+ return { id: eid, hash: actualHash, sig, dedup: blobExists, size: args.blob.length };
137
+ }
138
+ export function readEvidenceBlob(db, evidenceId, requesterId) {
139
+ const ev = db.prepare('SELECT id, dispute_id, file_hash, mime, filename, withdrawn_at FROM evidence WHERE id = ?').get(evidenceId);
140
+ if (!ev)
141
+ throw new Error('evidence_not_found');
142
+ if (ev.withdrawn_at)
143
+ throw new Error('evidence_withdrawn');
144
+ if (!isPartyOrArbitrator(db, ev.dispute_id, requesterId)) {
145
+ throw new Error('not_dispute_party');
146
+ }
147
+ const bp = blobPathFor(ev.file_hash);
148
+ if (!fs.existsSync(bp))
149
+ throw new Error('evidence_blob_missing');
150
+ const blob = fs.readFileSync(bp);
151
+ const actualHash = createHash('sha256').update(blob).digest('hex');
152
+ if (actualHash !== ev.file_hash)
153
+ throw new Error('evidence_corrupted');
154
+ return {
155
+ blob,
156
+ mime: ev.mime || 'application/octet-stream',
157
+ filename: ev.filename || null,
158
+ hash: ev.file_hash,
159
+ };
160
+ }
161
+ export function withdrawEvidence(db, evidenceId, uploaderId) {
162
+ const ev = db.prepare('SELECT uploader_id, dispute_id, withdrawn_at FROM evidence WHERE id = ?').get(evidenceId);
163
+ if (!ev)
164
+ throw new Error('evidence_not_found');
165
+ if (ev.uploader_id !== uploaderId)
166
+ throw new Error('not_uploader');
167
+ if (ev.withdrawn_at)
168
+ return;
169
+ const dispute = db.prepare('SELECT status FROM disputes WHERE id = ?').get(ev.dispute_id);
170
+ if (dispute && (dispute.status === 'resolved' || dispute.status === 'dismissed')) {
171
+ throw new Error('dispute_closed_cannot_withdraw');
172
+ }
173
+ db.prepare(`UPDATE evidence SET withdrawn_at = datetime('now') WHERE id = ?`).run(evidenceId);
174
+ const d = db.prepare('SELECT party_evidence_ids FROM disputes WHERE id = ?').get(ev.dispute_id);
175
+ if (d) {
176
+ const arr = JSON.parse(d.party_evidence_ids || '[]');
177
+ const filtered = arr.filter(x => x !== evidenceId);
178
+ db.prepare('UPDATE disputes SET party_evidence_ids = ? WHERE id = ?').run(JSON.stringify(filtered), ev.dispute_id);
179
+ }
180
+ }
181
+ export function listEvidence(db, disputeId, requesterId) {
182
+ if (!isPartyOrArbitrator(db, disputeId, requesterId)) {
183
+ throw new Error('not_dispute_party');
184
+ }
185
+ return db.prepare(`
186
+ SELECT id, uploader_id, type, description, file_hash, size, mime, sig, filename, created_at, withdrawn_at
187
+ FROM evidence
188
+ WHERE dispute_id = ?
189
+ ORDER BY created_at ASC
190
+ `).all(disputeId);
191
+ }
192
+ export function markEvidenceExpiry(db, disputeId) {
193
+ const exp = new Date(Date.now() + EVIDENCE_TTL_DAYS_AFTER_RESOLVED * 24 * 3600 * 1000).toISOString();
194
+ db.prepare(`UPDATE evidence SET expires_at = ? WHERE dispute_id = ? AND expires_at IS NULL`).run(exp, disputeId);
195
+ }
196
+ export function cleanupExpiredEvidence(db) {
197
+ const candidates = db.prepare(`
198
+ SELECT DISTINCT file_hash FROM evidence
199
+ WHERE (withdrawn_at IS NOT NULL OR (expires_at IS NOT NULL AND datetime(expires_at) < datetime('now')))
200
+ AND file_hash IS NOT NULL
201
+ `).all();
202
+ let swept = 0;
203
+ let bytes = 0;
204
+ for (const c of candidates) {
205
+ const live = db.prepare(`
206
+ SELECT 1 FROM evidence
207
+ WHERE file_hash = ?
208
+ AND withdrawn_at IS NULL
209
+ AND (expires_at IS NULL OR datetime(expires_at) >= datetime('now'))
210
+ LIMIT 1
211
+ `).get(c.file_hash);
212
+ if (live)
213
+ continue;
214
+ const bp = blobPathFor(c.file_hash);
215
+ if (fs.existsSync(bp)) {
216
+ try {
217
+ bytes += fs.statSync(bp).size;
218
+ fs.unlinkSync(bp);
219
+ swept++;
220
+ }
221
+ catch { /* concurrent cleanup */ }
222
+ }
223
+ }
224
+ return { swept, bytes };
225
+ }
226
+ export function verifyEvidenceSig(db, evidenceId) {
227
+ const ev = db.prepare(`
228
+ SELECT e.id, e.uploader_id, e.dispute_id, e.file_hash, e.size, e.mime, e.description, e.sig,
229
+ u.api_key
230
+ FROM evidence e JOIN users u ON u.id = e.uploader_id
231
+ WHERE e.id = ?
232
+ `).get(evidenceId);
233
+ if (!ev)
234
+ return { ok: false, reason: 'evidence_not_found' };
235
+ if (!ev.sig)
236
+ return { ok: false, reason: 'no_sig' };
237
+ const expected = createHmac('sha256', ev.api_key).update(canonicalEvidenceMeta({
238
+ uploaderId: ev.uploader_id,
239
+ disputeId: ev.dispute_id,
240
+ hash: ev.file_hash,
241
+ size: ev.size,
242
+ mime: ev.mime,
243
+ description: ev.description,
244
+ })).digest('hex');
245
+ return expected === ev.sig ? { ok: true } : { ok: false, reason: 'sig_mismatch' };
246
+ }
@@ -41,6 +41,49 @@ export function initReputationSchema(db) {
41
41
 
42
42
  CREATE INDEX IF NOT EXISTS idx_rep_events_user ON reputation_events(user_id, created_at DESC);
43
43
  `);
44
+ // 衰减时间戳列(月衰减幂等保护)
45
+ try {
46
+ db.exec(`ALTER TABLE reputation_scores ADD COLUMN last_decay_at TEXT`);
47
+ }
48
+ catch { }
49
+ }
50
+ // ─── 月衰减 — 防躺平 ─────────────────────────────────────────
51
+ // 月降 2%,年损 ≈22%;equilibrium 在月赚 100 分时 = 5000(Legend tier)
52
+ // 启动时调用,last_decay_at ≥25 天才会触发 — 重启幂等
53
+ const DECAY_RATE = 0.02;
54
+ const DECAY_MIN_INTERVAL_DAYS = 25;
55
+ export function applyDecayIfDue(db, opts) {
56
+ if (!opts?.force) {
57
+ // 检查上次衰减时间 — 任一卖家 last_decay_at < 25 天则跳过
58
+ const recent = db.prepare(`
59
+ SELECT MAX(last_decay_at) as latest FROM reputation_scores
60
+ WHERE last_decay_at IS NOT NULL
61
+ `).get();
62
+ if (recent?.latest) {
63
+ const lastMs = new Date(recent.latest.replace(' ', 'T') + 'Z').getTime();
64
+ const daysSince = (Date.now() - lastMs) / 86400_000;
65
+ if (daysSince < DECAY_MIN_INTERVAL_DAYS) {
66
+ return { applied: false, affected: 0, rate: DECAY_RATE, reason: `last_decay ${daysSince.toFixed(1)}d ago — skip` };
67
+ }
68
+ }
69
+ }
70
+ const rows = db.prepare(`SELECT user_id, total_points, level FROM reputation_scores WHERE total_points > 0`).all();
71
+ let affected = 0;
72
+ const tx = db.transaction(() => {
73
+ for (const r of rows) {
74
+ const newTotal = Math.max(0, Math.floor(r.total_points * (1 - DECAY_RATE)));
75
+ if (newTotal === r.total_points)
76
+ continue; // 0 分跳过
77
+ const newLevel = getLevelWithHysteresis(newTotal, r.level).key;
78
+ db.prepare(`UPDATE reputation_scores SET total_points = ?, level = ?, last_decay_at = datetime('now'), updated_at = datetime('now') WHERE user_id = ?`)
79
+ .run(newTotal, newLevel, r.user_id);
80
+ affected++;
81
+ }
82
+ // 也给 last_decay_at 已 NULL 的 0 分卖家盖个戳,避免下次重跑全表
83
+ db.prepare(`UPDATE reputation_scores SET last_decay_at = datetime('now') WHERE last_decay_at IS NULL`).run();
84
+ });
85
+ tx();
86
+ return { applied: true, affected, rate: DECAY_RATE };
44
87
  }
45
88
  export const LEVELS = [
46
89
  { key: 'new', label: '新手', icon: '🌱', minPoints: 0, stakeDiscount: 0, searchBoost: 0, badge: '' },
@@ -57,6 +100,27 @@ export function getLevel(points) {
57
100
  }
58
101
  return level;
59
102
  }
103
+ // 落级缓冲(hysteresis)— 防一次违约直接掉等级
104
+ // 升级用 minPoints,落级须低于 (minPoints - buffer);buffer 约 5–10% 当前下限
105
+ const FALL_BUFFER = {
106
+ new: 0,
107
+ trusted: 50, // 200 升 → 150 落
108
+ quality: 100, // 800 升 → 700 落
109
+ star: 150, // 2000 升 → 1850 落
110
+ legend: 250, // 5000 升 → 4750 落
111
+ };
112
+ export function getLevelWithHysteresis(points, currentLevel) {
113
+ const target = getLevel(points);
114
+ if (!currentLevel || currentLevel === 'new')
115
+ return target;
116
+ const curIdx = LEVELS.findIndex(l => l.key === currentLevel);
117
+ const tgtIdx = LEVELS.findIndex(l => l.key === target.key);
118
+ if (tgtIdx >= curIdx)
119
+ return target; // 升级 / 不变:直通
120
+ // 降级:仅当 points 跌破 (currentMin - buffer) 时真落,否则维持当前等级
121
+ const cur = LEVELS[curIdx];
122
+ return points >= cur.minPoints - (FALL_BUFFER[cur.key] || 0) ? cur : target;
123
+ }
60
124
  const EVENT_POINTS = {
61
125
  order_completed: (role) => role === 'seller' ? 10 : role === 'logistics' ? 8 : 5,
62
126
  fast_accept: () => 5,
@@ -66,6 +130,12 @@ const EVENT_POINTS = {
66
130
  dispute_won: () => 8,
67
131
  dispute_lost: () => -25,
68
132
  timeout_violation: () => -40,
133
+ claim_upheld_against: () => -15,
134
+ claim_dismissed_false: () => -10,
135
+ claim_correct: () => 5,
136
+ rating_received_good: () => 3,
137
+ rating_received_neutral: () => 0,
138
+ rating_received_bad: () => -5,
69
139
  };
70
140
  // ─── 写入声誉事件 ─────────────────────────────────────────────
71
141
  export function recordRepEvent(db, userId, eventType, reason, orderId, role) {
@@ -95,7 +165,7 @@ export function recordRepEvent(db, userId, eventType, reason, orderId, role) {
95
165
  violations = violations + ?,
96
166
  level = ?,
97
167
  updated_at = datetime('now')
98
- WHERE user_id = ?`).run(newTotal, isDone ? 1 : 0, isDisputeWon ? 1 : 0, isDisputeLost ? 1 : 0, isViolation ? 1 : 0, getLevel(newTotal).key, userId);
168
+ WHERE user_id = ?`).run(newTotal, isDone ? 1 : 0, isDisputeWon ? 1 : 0, isDisputeLost ? 1 : 0, isViolation ? 1 : 0, getLevelWithHysteresis(newTotal, existing.level).key, userId);
99
169
  }
100
170
  }
101
171
  // ─── 订单结算时一次性记录所有声誉事件 ────────────────────────
@@ -163,6 +233,30 @@ export function recordViolationReputation(db, orderId, faultStatus) {
163
233
  if (faultStatus === 'fault_buyer' && order.buyer_id)
164
234
  recordRepEvent(db, order.buyer_id, 'timeout_violation', '超时违约(买家)', orderId);
165
235
  }
236
+ // ─── 评价反哺声誉 ─────────────────────────────────────────────
237
+ // L2-5 评价系统钩子:1-5 星 → 4 档信号
238
+ // 5/4 星 → rating_received_good (+3)
239
+ // 3 星 → rating_received_neutral ( 0),仅留档
240
+ // 2/1 星 → rating_received_bad (-5)
241
+ // 给 reviewer 自己也记 0 分入账事件,防"打低分换分"且为审计提供痕迹
242
+ export function recordRatingReputation(db, args) {
243
+ const { orderId, revieweeId, revieweeRole, stars } = args;
244
+ let eventType;
245
+ let reason;
246
+ if (stars >= 4) {
247
+ eventType = 'rating_received_good';
248
+ reason = `收到 ${stars} 星好评`;
249
+ }
250
+ else if (stars === 3) {
251
+ eventType = 'rating_received_neutral';
252
+ reason = '收到 3 星中评';
253
+ }
254
+ else {
255
+ eventType = 'rating_received_bad';
256
+ reason = `收到 ${stars} 星差评`;
257
+ }
258
+ recordRepEvent(db, revieweeId, eventType, reason, orderId, revieweeRole);
259
+ }
166
260
  // ─── 争议结算时记录声誉 ───────────────────────────────────────
167
261
  export function recordDisputeReputation(db, orderId, winnerId, loserId) {
168
262
  recordRepEvent(db, winnerId, 'dispute_won', '争议胜诉', orderId);
@@ -58,34 +58,61 @@ export function initSkillSchema(db) {
58
58
  CREATE INDEX IF NOT EXISTS idx_sub_user ON skill_subscriptions(user_id, active);
59
59
  `);
60
60
  }
61
- // Skill 类型对应的元信息(描述给 Agent 看)
61
+ // Skill 类型对应的元信息(描述给 Agent 看;双语支持)
62
62
  export const SKILL_TYPE_META = {
63
63
  catalog_sync: {
64
64
  label: '目录同步',
65
+ label_en: 'Catalog sync',
65
66
  icon: '🔄',
66
67
  description: '将卖家的外部商品目录(Amazon/Shopify/自定义)同步到 DCP,买家 Agent 订阅后可优先发现这些商品。',
68
+ description_en: 'Sync seller external catalogs (Amazon/Shopify/custom) to DCP — subscribed buyer agents discover these first.',
67
69
  },
68
70
  auto_accept: {
69
71
  label: '自动接单',
72
+ label_en: 'Auto accept',
70
73
  icon: '⚡',
71
74
  description: '买家下单后卖家立即自动接受(无需手动确认),减少买家等待,提升转化率。',
75
+ description_en: 'Auto-accept incoming orders without manual confirmation — reduces buyer wait and lifts conversion.',
76
+ },
77
+ auto_bid: {
78
+ label: '自动报价',
79
+ label_en: 'Auto bid',
80
+ icon: '🤖',
81
+ description: 'RFQ 求购单创建时,按预设规则(类目/地区/ETA/价格策略)自动向匹配的 RFQ 提交 bid,捕获急单流量。',
82
+ description_en: 'When an RFQ is posted, auto-submit a bid by your rules (category/region/ETA/strategy) — captures urgent demand.',
72
83
  },
73
84
  price_negotiation: {
74
85
  label: '价格协商',
86
+ label_en: 'Price negotiation',
75
87
  icon: '🤝',
76
88
  description: '允许买家 Agent 在指定范围内自动议价,无需人工参与价格谈判。',
89
+ description_en: 'Allow buyer agents to auto-negotiate within set bounds — no human required.',
77
90
  },
78
91
  quality_guarantee: {
79
92
  label: '质量承诺',
93
+ label_en: 'Quality guarantee',
80
94
  icon: '🛡️',
81
95
  description: '卖家额外质押 DCP 作为质量保证金,问题订单买家可获得额外赔偿。',
96
+ description_en: 'Seller stakes extra WAZ as quality bond — buyer gets additional compensation on disputes.',
82
97
  },
83
98
  instant_ship: {
84
99
  label: '极速发货',
100
+ label_en: 'Instant ship',
85
101
  icon: '🚀',
86
102
  description: '卖家承诺 24h 内发货,违约自动赔付。适合现货充足的卖家。',
103
+ description_en: 'Seller commits to shipping within 24h; auto-payout on violation.',
87
104
  },
88
105
  };
106
+ // 各 skill_type 的默认 config(QA 轮 16 P2:publish 不传 config → config_preview 空 {})
107
+ // publish 时若 config 缺省,套用这里的合理默认,agent 可后续 PATCH 调整
108
+ export const DEFAULT_SKILL_CONFIG = {
109
+ catalog_sync: { source: 'custom', auto_refresh_hours: 24 },
110
+ auto_accept: { enabled: true, max_order_value: null },
111
+ auto_bid: { enabled: true, categories: ['standard'], regions: [], max_eta_h: 24, bid_strategy: 'cheapest_undercut', undercut_pct: 0.05, max_price_cap: null, daily_cap: 20, cooldown_min: 60 },
112
+ price_negotiation: { enabled: true, min_accept_pct: 0.85, max_rounds: 3 },
113
+ quality_guarantee: { bond_amount: 0, compensation_pct: 0.5 },
114
+ instant_ship: { ship_within_hours: 24, penalty_pct: 0.1 },
115
+ };
89
116
  export function publishSkill(db, input) {
90
117
  const seller = db.prepare('SELECT id, role, name FROM users WHERE id = ?').get(input.sellerId);
91
118
  if (!seller)
@@ -97,7 +124,9 @@ export function publishSkill(db, input) {
97
124
  if (exists)
98
125
  throw new Error('你已发布过同名同类型的 Skill,请先修改现有 Skill');
99
126
  const id = generateId('skl');
100
- const config = JSON.stringify(input.config ?? {});
127
+ // QA 16 P2:config 缺省时套用 per-type 默认(避免 config_preview 空 {}
128
+ const hasConfig = input.config && Object.keys(input.config).length > 0;
129
+ const config = JSON.stringify(hasConfig ? input.config : (DEFAULT_SKILL_CONFIG[input.skillType] ?? {}));
101
130
  db.prepare(`
102
131
  INSERT INTO skills (id, seller_id, name, description, category, skill_type, config, price_per_use)
103
132
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)