@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,493 @@
1
+ import { recordRepEvent } from '../../layer4-economics/L4-3-reputation/reputation-engine.js';
2
+ const VALID_RETURN_REASONS = new Set(['quality', 'wrong_item', 'damaged', 'no_longer_needed', 'other']);
3
+ const RETURN_REASON_DEFAULT_LABEL = {
4
+ quality: '质量问题',
5
+ wrong_item: '收到错款',
6
+ damaged: '运输破损',
7
+ no_longer_needed: '不再需要',
8
+ other: '其他',
9
+ };
10
+ export function registerReturnsRoutes(app, deps) {
11
+ const { db, generateId, auth, isTrustedRole, errorRes, broadcastSystemEvent, detectFraud } = deps;
12
+ // L3 Phase 2 抽出:accept(无 pickup) 和 received(有 pickup) 共享退款 + 库存 + 通知
13
+ function executeReturnRefund(rr, response) {
14
+ db.transaction(() => {
15
+ const refundAmt = Number(rr.refund_amount);
16
+ const sellerWal = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(rr.seller_id);
17
+ if (!sellerWal || Number(sellerWal.balance) < refundAmt) {
18
+ throw new Error('INSUFFICIENT_SELLER_BALANCE');
19
+ }
20
+ db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?').run(refundAmt, rr.seller_id);
21
+ db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(refundAmt, rr.buyer_id);
22
+ const ord = db.prepare('SELECT quantity, source, variant_id FROM orders WHERE id = ?').get(rr.order_id);
23
+ if (ord && ord.source !== 'secondhand') {
24
+ const qty = Math.max(1, Number(ord.quantity) || 1);
25
+ db.prepare('UPDATE products SET stock = stock + ?, updated_at = datetime(\'now\') WHERE id = ?').run(qty, rr.product_id);
26
+ if (ord.variant_id) {
27
+ db.prepare('UPDATE product_variants SET stock = stock + ?, updated_at = datetime(\'now\') WHERE id = ?').run(qty, ord.variant_id);
28
+ }
29
+ }
30
+ db.prepare(`UPDATE return_requests SET status = 'refunded', seller_response = COALESCE(?, seller_response), resolved_at = datetime('now') WHERE id = ?`)
31
+ .run(response, rr.id);
32
+ try {
33
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
34
+ .run(generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✓ 已退款] ${response || ''}`);
35
+ }
36
+ catch { }
37
+ try {
38
+ db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
39
+ .run(generateId('ntf'), rr.buyer_id, '✓ 退款已到账', `${refundAmt} WAZ 已退回至你的钱包`, rr.order_id);
40
+ }
41
+ catch { }
42
+ })();
43
+ try {
44
+ const SELLER_FAULT_REASONS = new Set(['quality', 'wrong_item', 'damaged']);
45
+ if (SELLER_FAULT_REASONS.has(String(rr.reason))) {
46
+ recordRepEvent(db, String(rr.seller_id), 'claim_upheld_against', `退货接受 (reason=${rr.reason}, return=${rr.id})`, String(rr.order_id));
47
+ }
48
+ }
49
+ catch (e) {
50
+ console.error('[return rep event]', e);
51
+ }
52
+ }
53
+ // buyer 发起退货
54
+ app.post('/api/orders/:order_id/return-request', (req, res) => {
55
+ const user = auth(req, res);
56
+ if (!user)
57
+ return;
58
+ if (isTrustedRole(user))
59
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
60
+ const order = db.prepare(`
61
+ SELECT o.id, o.buyer_id, o.seller_id, o.product_id, o.status, o.total_amount, o.created_at, o.updated_at,
62
+ p.return_days, p.title as product_title
63
+ FROM orders o JOIN products p ON p.id = o.product_id
64
+ WHERE o.id = ?
65
+ `).get(req.params.order_id);
66
+ if (!order)
67
+ return void res.status(404).json({ error: '订单不存在' });
68
+ if (order.buyer_id !== user.id)
69
+ return void res.status(403).json({ error: '仅买家可申请退货' });
70
+ // P0-1: 只允许 completed 退货 — escrow 已结算
71
+ if (order.status !== 'completed') {
72
+ return void res.status(400).json({ error: '仅订单完成后可申请退货(确认收货后)' });
73
+ }
74
+ const returnDays = Number(order.return_days || 0);
75
+ if (returnDays <= 0)
76
+ return void res.status(400).json({ error: '该商品不支持退货' });
77
+ const baseTime = order.updated_at || order.created_at;
78
+ const deadlineMs = new Date(baseTime).getTime() + returnDays * 86400 * 1000;
79
+ if (Date.now() > deadlineMs) {
80
+ return void res.status(400).json({ error: `已超过 ${returnDays} 天退货窗口` });
81
+ }
82
+ const existing = db.prepare(`
83
+ SELECT id, status FROM return_requests WHERE order_id = ? AND status IN ('pending', 'accepted') LIMIT 1
84
+ `).get(order.id);
85
+ if (existing)
86
+ return void res.status(400).json({ error: `已存在退货请求 (${existing.status})` });
87
+ const reason = String(req.body?.reason || '');
88
+ if (!VALID_RETURN_REASONS.has(reason))
89
+ return void res.status(400).json({ error: '无效的退货原因' });
90
+ const reasonText = req.body?.reason_text ? String(req.body.reason_text).slice(0, 500) : null;
91
+ const refundAmount = req.body?.refund_amount != null ? Number(req.body.refund_amount) : Number(order.total_amount);
92
+ if (refundAmount <= 0 || refundAmount > Number(order.total_amount)) {
93
+ return void res.status(400).json({ error: '退款金额必须在 0 ~ 订单金额之间' });
94
+ }
95
+ // L3+B3:上门取件请求
96
+ const pickupRequested = req.body?.pickup_requested ? 1 : 0;
97
+ const pickupAddress = pickupRequested && req.body?.pickup_address
98
+ ? String(req.body.pickup_address).slice(0, 300).trim()
99
+ : null;
100
+ if (pickupRequested && (!pickupAddress || pickupAddress.length < 4)) {
101
+ return void res.status(400).json({ error: '请求上门取件时必须提供取件地址(≥ 4 字)' });
102
+ }
103
+ const reqId = generateId('ret');
104
+ db.prepare(`
105
+ INSERT INTO return_requests (id, order_id, buyer_id, seller_id, product_id, reason, reason_text, refund_amount, status, pickup_requested, pickup_address)
106
+ VALUES (?,?,?,?,?,?,?,?,'pending',?,?)
107
+ `).run(reqId, order.id, order.buyer_id, order.seller_id, order.product_id, reason, reasonText, refundAmount, pickupRequested, pickupAddress);
108
+ try {
109
+ const actions = JSON.stringify([{ kind: 'navigate', label: '处理退货', href: `#order/${order.id}`, style: 'primary' }]);
110
+ const pickupNote = pickupRequested ? '(含上门取件请求)' : '';
111
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`)
112
+ .run(generateId('ntf'), order.seller_id, 'return_request', '⚠ 收到退货请求' + pickupNote, `订单 ${order.product_title} 申请退货 — 原因:${reason}${pickupRequested ? '\n📍 上门取件:' + pickupAddress : ''}`, order.id, actions);
113
+ }
114
+ catch (e) {
115
+ console.error('[return notify]', e);
116
+ }
117
+ try {
118
+ broadcastSystemEvent('return', '↩', `退货申请 ${reqId} · ${refundAmount} WAZ`, order.id);
119
+ }
120
+ catch { }
121
+ res.json({ success: true, id: reqId, pickup_requested: !!pickupRequested });
122
+ });
123
+ // P1-5: 订单级直查
124
+ app.get('/api/orders/:order_id/return-request', (req, res) => {
125
+ const user = auth(req, res);
126
+ if (!user)
127
+ return;
128
+ const order = db.prepare('SELECT buyer_id, seller_id FROM orders WHERE id = ?').get(req.params.order_id);
129
+ if (!order)
130
+ return void res.status(404).json({ error: '订单不存在' });
131
+ if (order.buyer_id !== user.id && order.seller_id !== user.id) {
132
+ return void res.status(403).json({ error: '无权查看' });
133
+ }
134
+ const row = db.prepare(`
135
+ SELECT id, order_id, product_id, reason, reason_text, refund_amount,
136
+ status, seller_response, escalated_dispute_id, created_at, resolved_at
137
+ FROM return_requests
138
+ WHERE order_id = ?
139
+ ORDER BY created_at DESC LIMIT 1
140
+ `).get(req.params.order_id);
141
+ res.json({ item: row || null });
142
+ });
143
+ app.get('/api/return-requests', (req, res) => {
144
+ const user = auth(req, res);
145
+ if (!user)
146
+ return;
147
+ const status = req.query.status ? String(req.query.status) : null;
148
+ const role = req.query.role === 'seller' ? 'seller' : 'buyer';
149
+ const field = role === 'seller' ? 'seller_id' : 'buyer_id';
150
+ const where = [`r.${field} = ?`];
151
+ const params = [user.id];
152
+ if (status) {
153
+ where.push('r.status = ?');
154
+ params.push(status);
155
+ }
156
+ const rows = db.prepare(`
157
+ SELECT r.id, r.order_id, r.product_id, r.reason, r.reason_text, r.refund_amount,
158
+ r.status, r.seller_response, r.escalated_dispute_id, r.created_at, r.resolved_at,
159
+ p.title as product_title, p.category,
160
+ o.total_amount as order_total,
161
+ ub.name as buyer_name, ub.handle as buyer_handle,
162
+ us.name as seller_name, us.handle as seller_handle
163
+ FROM return_requests r
164
+ JOIN products p ON p.id = r.product_id
165
+ JOIN orders o ON o.id = r.order_id
166
+ JOIN users ub ON ub.id = r.buyer_id
167
+ JOIN users us ON us.id = r.seller_id
168
+ WHERE ${where.join(' AND ')}
169
+ ORDER BY r.created_at DESC LIMIT 200
170
+ `).all(...params);
171
+ res.json({ items: rows });
172
+ });
173
+ app.post('/api/return-requests/:id/decide', (req, res) => {
174
+ const user = auth(req, res);
175
+ if (!user)
176
+ return;
177
+ const rr = db.prepare(`SELECT * FROM return_requests WHERE id = ?`).get(req.params.id);
178
+ if (!rr)
179
+ return void res.status(404).json({ error: '退货请求不存在' });
180
+ if (rr.seller_id !== user.id)
181
+ return void res.status(403).json({ error: '仅卖家可决策' });
182
+ if (rr.status !== 'pending')
183
+ return void res.status(400).json({ error: `当前状态 ${rr.status},不可决策` });
184
+ const decision = String(req.body?.decision || '');
185
+ if (!['accept', 'reject'].includes(decision))
186
+ return void res.status(400).json({ error: '无效决策' });
187
+ const response = req.body?.response ? String(req.body.response).slice(0, 500) : null;
188
+ if (decision === 'reject' && !response)
189
+ return void res.status(400).json({ error: '拒绝时必须填写说明' });
190
+ if (decision === 'accept') {
191
+ if (Number(rr.pickup_requested) === 1) {
192
+ db.prepare(`UPDATE return_requests SET status = 'accepted_pickup_pending', seller_response = ? WHERE id = ?`)
193
+ .run(response, rr.id);
194
+ try {
195
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
196
+ .run(generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✓ 同意 · 等待上门取件] ${response || ''}`);
197
+ }
198
+ catch { }
199
+ try {
200
+ db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
201
+ .run(generateId('ntf'), rr.buyer_id, '✓ 退货已接受 · 等待上门取件', `卖家将安排物流到 ${rr.pickup_address || '指定地址'} 上门取件`, rr.order_id);
202
+ }
203
+ catch { }
204
+ return void res.json({ success: true, status: 'accepted_pickup_pending' });
205
+ }
206
+ try {
207
+ executeReturnRefund(rr, response);
208
+ }
209
+ catch (e) {
210
+ const msg = e.message === 'INSUFFICIENT_SELLER_BALANCE' ? '卖家余额不足以退款' : '退款失败';
211
+ return void res.status(400).json({ error: msg });
212
+ }
213
+ return void res.json({ success: true, status: 'refunded' });
214
+ }
215
+ else {
216
+ db.prepare(`UPDATE return_requests SET status = 'rejected', seller_response = ?, resolved_at = datetime('now') WHERE id = ?`)
217
+ .run(response, rr.id);
218
+ try {
219
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
220
+ .run(generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✗ 拒绝退款] ${response}`);
221
+ }
222
+ catch { }
223
+ try {
224
+ db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
225
+ .run(generateId('ntf'), rr.buyer_id, '⚠ 退货请求被拒绝', `卖家说明:${response} — 如有异议可发起争议`, rr.order_id);
226
+ }
227
+ catch { }
228
+ return void res.json({ success: true, status: 'rejected' });
229
+ }
230
+ });
231
+ app.delete('/api/return-requests/:id', (req, res) => {
232
+ const user = auth(req, res);
233
+ if (!user)
234
+ return;
235
+ const rr = db.prepare(`SELECT id, buyer_id, status FROM return_requests WHERE id = ?`).get(req.params.id);
236
+ if (!rr)
237
+ return void res.status(404).json({ error: '不存在' });
238
+ if (rr.buyer_id !== user.id)
239
+ return void res.status(403).json({ error: '仅买家可取消' });
240
+ if (rr.status !== 'pending')
241
+ return void res.status(400).json({ error: `当前状态 ${rr.status},不可取消` });
242
+ db.prepare(`UPDATE return_requests SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ?`).run(rr.id);
243
+ res.json({ success: true });
244
+ });
245
+ // ─── W2 售后协商时间线 ───────────────────────────────
246
+ app.get('/api/return-requests/:id', (req, res) => {
247
+ const user = auth(req, res);
248
+ if (!user)
249
+ return;
250
+ const rr = db.prepare(`
251
+ SELECT r.*, p.title as product_title, p.category,
252
+ o.total_amount as order_total,
253
+ ub.name as buyer_name, ub.handle as buyer_handle,
254
+ us.name as seller_name, us.handle as seller_handle
255
+ FROM return_requests r
256
+ JOIN products p ON p.id = r.product_id
257
+ JOIN orders o ON o.id = r.order_id
258
+ JOIN users ub ON ub.id = r.buyer_id
259
+ JOIN users us ON us.id = r.seller_id
260
+ WHERE r.id = ?
261
+ `).get(req.params.id);
262
+ if (!rr)
263
+ return void res.status(404).json({ error: '不存在' });
264
+ if (rr.buyer_id !== user.id && rr.seller_id !== user.id) {
265
+ return void res.status(403).json({ error: '无权查看' });
266
+ }
267
+ const messages = db.prepare(`
268
+ SELECT m.*, u.name as sender_name, u.handle as sender_handle
269
+ FROM return_messages m LEFT JOIN users u ON u.id = m.sender_id
270
+ WHERE m.return_id = ? ORDER BY m.created_at ASC
271
+ `).all(rr.id);
272
+ const events = [];
273
+ events.push({
274
+ id: `create-${rr.id}`,
275
+ type: 'created',
276
+ ts: String(rr.created_at || ''),
277
+ actor_id: String(rr.buyer_id),
278
+ actor_role: 'buyer',
279
+ body: String(rr.reason_text || ''),
280
+ meta: { reason: rr.reason, refund_amount: rr.refund_amount },
281
+ });
282
+ for (const m of messages) {
283
+ let fr = [];
284
+ try {
285
+ fr = m.flag_reasons ? JSON.parse(String(m.flag_reasons)) : [];
286
+ }
287
+ catch { }
288
+ events.push({
289
+ id: `msg-${m.id}`,
290
+ type: 'message',
291
+ ts: String(m.created_at || ''),
292
+ actor_id: m.sender_id ? String(m.sender_id) : null,
293
+ actor_role: (m.sender_role || 'buyer'),
294
+ body: String(m.body || ''),
295
+ flagged: Number(m.flagged || 0),
296
+ flag_reasons: fr,
297
+ });
298
+ }
299
+ if (rr.resolved_at) {
300
+ const status = String(rr.status);
301
+ let type = null;
302
+ let role = 'system';
303
+ let actorId = null;
304
+ if (status === 'refunded') {
305
+ type = 'refunded';
306
+ role = 'seller';
307
+ actorId = String(rr.seller_id);
308
+ }
309
+ else if (status === 'cancelled') {
310
+ type = 'cancelled';
311
+ role = 'buyer';
312
+ actorId = String(rr.buyer_id);
313
+ }
314
+ else if (status === 'escalated') {
315
+ type = 'escalated';
316
+ role = 'buyer';
317
+ actorId = String(rr.buyer_id);
318
+ }
319
+ if (type) {
320
+ events.push({
321
+ id: `done-${rr.id}`,
322
+ type,
323
+ ts: String(rr.resolved_at),
324
+ actor_id: actorId,
325
+ actor_role: role,
326
+ body: '',
327
+ meta: type === 'escalated' ? { dispute_id: rr.escalated_dispute_id } : undefined,
328
+ });
329
+ }
330
+ }
331
+ events.sort((a, b) => a.ts.localeCompare(b.ts));
332
+ res.json({ item: rr, timeline: events });
333
+ });
334
+ // L3 Phase 2: 物流揽收
335
+ app.post('/api/return-requests/:id/picked-up', (req, res) => {
336
+ const user = auth(req, res);
337
+ if (!user)
338
+ return;
339
+ if (user.role !== 'logistics')
340
+ return void res.status(403).json({ error: '仅物流角色可确认揽收' });
341
+ const rr = db.prepare(`SELECT * FROM return_requests WHERE id = ?`).get(req.params.id);
342
+ if (!rr)
343
+ return void res.status(404).json({ error: '退货请求不存在' });
344
+ if (rr.status !== 'accepted_pickup_pending')
345
+ return void res.status(400).json({ error: `当前状态 ${rr.status},不可揽收` });
346
+ const evidence = String(req.body?.evidence || '').trim().slice(0, 500);
347
+ if (evidence.length < 4)
348
+ return void res.status(400).json({ error: '请提供揽收证据(快递单号 / GPS / 照片描述)≥ 4 字' });
349
+ db.prepare(`UPDATE return_requests SET status = 'picked_up' WHERE id = ?`).run(rr.id);
350
+ try {
351
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
352
+ .run(generateId('rmsg'), rr.id, user.id, 'logistics', `[📦 已揽收] ${evidence}`);
353
+ }
354
+ catch { }
355
+ try {
356
+ const actions = JSON.stringify([{ kind: 'navigate', label: '处理退货', href: `#returns`, style: 'primary' }]);
357
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`)
358
+ .run(generateId('ntf'), rr.seller_id, 'return_pickup', '📦 退货已揽收 · 等待你确认收到', `物流已揽收:${evidence.slice(0, 80)}`, rr.order_id, actions);
359
+ }
360
+ catch { }
361
+ res.json({ success: true, status: 'picked_up' });
362
+ });
363
+ // L3 Phase 2: 卖家确认收到 → refunded
364
+ app.post('/api/return-requests/:id/received', (req, res) => {
365
+ const user = auth(req, res);
366
+ if (!user)
367
+ return;
368
+ const rr = db.prepare(`SELECT * FROM return_requests WHERE id = ?`).get(req.params.id);
369
+ if (!rr)
370
+ return void res.status(404).json({ error: '退货请求不存在' });
371
+ if (rr.seller_id !== user.id)
372
+ return void res.status(403).json({ error: '仅卖家可确认收到' });
373
+ if (rr.status !== 'picked_up')
374
+ return void res.status(400).json({ error: `当前状态 ${rr.status},不可确认(应在 picked_up 状态)` });
375
+ const note = req.body?.note ? String(req.body.note).slice(0, 300) : null;
376
+ try {
377
+ executeReturnRefund(rr, note);
378
+ }
379
+ catch (e) {
380
+ const msg = e.message === 'INSUFFICIENT_SELLER_BALANCE' ? '卖家余额不足以退款' : '退款失败';
381
+ return void res.status(400).json({ error: msg });
382
+ }
383
+ res.json({ success: true, status: 'refunded' });
384
+ });
385
+ app.get('/api/logistics/return-pickups', (req, res) => {
386
+ const user = auth(req, res);
387
+ if (!user)
388
+ return;
389
+ if (user.role !== 'logistics')
390
+ return void res.status(403).json({ error: '仅物流角色' });
391
+ const rows = db.prepare(`
392
+ SELECT rr.id, rr.order_id, rr.product_id, rr.refund_amount, rr.pickup_address,
393
+ rr.reason, rr.created_at, p.title as product_title,
394
+ ub.handle as buyer_handle, us.name as seller_name
395
+ FROM return_requests rr
396
+ JOIN products p ON p.id = rr.product_id
397
+ JOIN users ub ON ub.id = rr.buyer_id
398
+ JOIN users us ON us.id = rr.seller_id
399
+ WHERE rr.status = 'accepted_pickup_pending' AND rr.pickup_requested = 1
400
+ ORDER BY rr.created_at ASC LIMIT 50
401
+ `).all();
402
+ res.json({ items: rows });
403
+ });
404
+ app.post('/api/return-requests/:id/messages', (req, res) => {
405
+ const user = auth(req, res);
406
+ if (!user)
407
+ return;
408
+ const rr = db.prepare(`SELECT id, buyer_id, seller_id, status FROM return_requests WHERE id = ?`)
409
+ .get(req.params.id);
410
+ if (!rr)
411
+ return void res.status(404).json({ error: '不存在' });
412
+ const isBuyer = rr.buyer_id === user.id;
413
+ const isSeller = rr.seller_id === user.id;
414
+ if (!isBuyer && !isSeller)
415
+ return void res.status(403).json({ error: '仅买卖双方可发消息' });
416
+ if (['refunded', 'cancelled', 'escalated'].includes(rr.status)) {
417
+ return void res.status(400).json({ error: `当前状态 ${rr.status},协商已结束` });
418
+ }
419
+ const body = String(req.body?.body || '').trim();
420
+ if (body.length < 1 || body.length > 1000)
421
+ return void res.status(400).json({ error: '消息长度 1-1000 字' });
422
+ const reasons = detectFraud(body);
423
+ const mid = generateId('rmsg');
424
+ db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body, flagged, flag_reasons) VALUES (?,?,?,?,?,?,?)`)
425
+ .run(mid, rr.id, user.id, isBuyer ? 'buyer' : 'seller', body, reasons.length ? 1 : 0, reasons.length ? JSON.stringify(reasons) : null);
426
+ try {
427
+ const otherId = isBuyer ? rr.seller_id : rr.buyer_id;
428
+ const orderId = rr.order_id;
429
+ const actions = JSON.stringify([{ kind: 'navigate', label: '查看协商', href: `#order/${orderId}`, style: 'primary' }]);
430
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`)
431
+ .run(generateId('ntf'), otherId, 'return_msg', '💬 退货协商新消息', body.slice(0, 80), orderId, actions);
432
+ }
433
+ catch (e) {
434
+ console.warn('[notif return_msg]', e.message);
435
+ }
436
+ res.json({ success: true, id: mid, flagged: reasons.length > 0, flag_reasons: reasons });
437
+ });
438
+ // buyer 升级到争议(仅 rejected 后或 pending ≥ 7 天)
439
+ app.post('/api/return-requests/:id/escalate', (req, res) => {
440
+ const user = auth(req, res);
441
+ if (!user)
442
+ return;
443
+ const rr = db.prepare(`SELECT * FROM return_requests WHERE id = ?`).get(req.params.id);
444
+ if (!rr)
445
+ return void res.status(404).json({ error: '不存在' });
446
+ if (rr.buyer_id !== user.id)
447
+ return void res.status(403).json({ error: '仅买家可升级' });
448
+ if (rr.status !== 'rejected' && rr.status !== 'pending') {
449
+ return void res.status(400).json({ error: `当前状态 ${rr.status},无法升级` });
450
+ }
451
+ if (rr.status === 'pending') {
452
+ const ageMs = Date.now() - new Date(String(rr.created_at)).getTime();
453
+ if (ageMs < 7 * 86400 * 1000) {
454
+ return void res.status(400).json({ error: '卖家有 7 天回应窗口,超期后可升级' });
455
+ }
456
+ }
457
+ if (rr.escalated_dispute_id)
458
+ return void res.status(400).json({ error: '已升级' });
459
+ const order = db.prepare('SELECT id, total_amount FROM orders WHERE id = ?').get(rr.order_id);
460
+ if (!order)
461
+ return void res.status(500).json({ error: '订单数据缺失' });
462
+ const reason = `退货协商失败:${RETURN_REASON_DEFAULT_LABEL[String(rr.reason)] || rr.reason}${rr.reason_text ? ' — ' + rr.reason_text : ''}`;
463
+ const disputeId = generateId('dsp');
464
+ const now = new Date();
465
+ const respondDeadline = new Date(now.getTime() + 48 * 3600 * 1000).toISOString();
466
+ const arbitrateDeadline = new Date(now.getTime() + 120 * 3600 * 1000).toISOString();
467
+ try {
468
+ db.transaction(() => {
469
+ db.prepare(`
470
+ INSERT INTO disputes (
471
+ id, order_id, initiator_id, defendant_id, reason, status,
472
+ defendant_evidence_ids, respond_deadline, arbitrate_deadline, assigned_arbitrators
473
+ ) VALUES (?, ?, ?, ?, ?, 'open', '[]', ?, ?, '[]')
474
+ `).run(disputeId, order.id, rr.buyer_id, rr.seller_id, reason, respondDeadline, arbitrateDeadline);
475
+ db.prepare(`UPDATE return_requests SET status = 'escalated', escalated_dispute_id = ?, resolved_at = datetime('now') WHERE id = ?`)
476
+ .run(disputeId, rr.id);
477
+ })();
478
+ }
479
+ catch (e) {
480
+ return void res.status(500).json({ error: '升级失败:' + e.message });
481
+ }
482
+ try {
483
+ db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
484
+ .run(generateId('ntf'), rr.seller_id, '⚖️ 退货已升级为争议', `争议 ${disputeId} 已创建,请在 48h 内提交反驳`, rr.order_id);
485
+ }
486
+ catch { }
487
+ try {
488
+ broadcastSystemEvent('dispute_open', '⚖', `退货升级 (订单 ${order.id})`, order.id);
489
+ }
490
+ catch { }
491
+ res.json({ success: true, dispute_id: disputeId });
492
+ });
493
+ }
@@ -0,0 +1,81 @@
1
+ export function registerReviewsRoutes(app, deps) {
2
+ const { db, auth, isTrustedRole, errorRes, generateId, REVIEW_CLAIM_TARGETS, REVIEW_CLAIM_STAKE, REVIEW_CLAIM_DEADLINE_HOURS, REVIEW_VERIFIERS_NEEDED } = deps;
3
+ app.get('/api/reviews/recent', (req, res) => {
4
+ const limit = Math.min(100, Math.max(10, Number(req.query.limit) || 50));
5
+ const items = db.prepare(`
6
+ SELECT s.id, s.external_url, s.external_platform, s.thumbnail_url, s.title, s.click_count, s.like_count,
7
+ s.created_at, s.related_product_id,
8
+ u.handle as owner_handle, u.name as owner_name,
9
+ p.title as product_title, p.price as product_price, p.images as product_images
10
+ FROM shareables s
11
+ JOIN users u ON u.id = s.owner_id
12
+ LEFT JOIN products p ON p.id = s.related_product_id AND p.status = 'active'
13
+ WHERE s.status = 'active'
14
+ ORDER BY s.created_at DESC LIMIT ?
15
+ `).all(limit);
16
+ res.json({ items });
17
+ });
18
+ app.post('/api/reviews/:type/:id/claim', (req, res) => {
19
+ const user = auth(req, res);
20
+ if (!user)
21
+ return;
22
+ if (isTrustedRole(user)) {
23
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_CLAIM', '受信角色不可发起声明');
24
+ }
25
+ const reviewType = req.params.type;
26
+ if (!['shareable', 'manifest'].includes(reviewType))
27
+ return void res.status(400).json({ error: 'review type must be shareable / manifest' });
28
+ let reviewerId = null;
29
+ let productId = null;
30
+ // #1017 fix: shareables / manifest_registry 实际列名是 related_product_id
31
+ if (reviewType === 'shareable') {
32
+ const row = db.prepare('SELECT owner_id, related_product_id FROM shareables WHERE id = ?').get(req.params.id);
33
+ if (!row)
34
+ return void res.status(404).json({ error: '评测不存在' });
35
+ reviewerId = row.owner_id;
36
+ productId = row.related_product_id;
37
+ }
38
+ else {
39
+ const row = db.prepare('SELECT owner_id, related_product_id FROM manifest_registry WHERE hash = ?').get(req.params.id);
40
+ if (!row)
41
+ return void res.status(404).json({ error: '原生评测不存在' });
42
+ reviewerId = row.owner_id;
43
+ productId = row.related_product_id;
44
+ }
45
+ if (reviewerId === user.id)
46
+ return void errorRes(res, 403, 'CANNOT_CLAIM_OWN', '不可对自己的评测发起声明');
47
+ const target = String(req.body?.claim_target || '').trim();
48
+ if (!REVIEW_CLAIM_TARGETS.has(target))
49
+ return void res.status(400).json({ error: `claim_target 须为 ${[...REVIEW_CLAIM_TARGETS].join(' / ')}` });
50
+ const text = String(req.body?.claim_text || '').trim();
51
+ if (text.length < 6 || text.length > 500)
52
+ return void res.status(400).json({ error: 'claim_text 长度需 6-500 字' });
53
+ const evidence = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
54
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
55
+ if (!wallet || wallet.balance < REVIEW_CLAIM_STAKE) {
56
+ return void res.status(400).json({ error: `余额不足:发起需锁 ${REVIEW_CLAIM_STAKE} WAZ` });
57
+ }
58
+ const dup = db.prepare(`SELECT id FROM review_claim_tasks WHERE review_type = ? AND review_id = ? AND claimant_id = ? AND claim_target = ? AND status = 'open'`)
59
+ .get(reviewType, req.params.id, user.id, target);
60
+ if (dup)
61
+ return void res.status(409).json({ error: '你已对此评测同一项发起过 open 声明' });
62
+ const id = generateId('rct');
63
+ const deadline = new Date(Date.now() + REVIEW_CLAIM_DEADLINE_HOURS * 3600_000).toISOString();
64
+ db.prepare(`INSERT INTO review_claim_tasks (id, review_type, review_id, product_id, reviewer_id, claimant_id, claim_target, claim_text, evidence_uri, stake_claimant, deadline_at, status) VALUES (?,?,?,?,?,?,?,?,?,?,?,'open')`)
65
+ .run(id, reviewType, req.params.id, productId, reviewerId, user.id, target, text, evidence, REVIEW_CLAIM_STAKE, deadline);
66
+ db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?')
67
+ .run(REVIEW_CLAIM_STAKE, REVIEW_CLAIM_STAKE, user.id);
68
+ res.json({ success: true, claim_id: id, deadline_at: deadline, stake_locked: REVIEW_CLAIM_STAKE });
69
+ });
70
+ app.get('/api/reviews/:type/:id/claims', (req, res) => {
71
+ const rows = db.prepare(`
72
+ SELECT rct.id, rct.claim_target, rct.claim_text, rct.evidence_uri, rct.status, rct.ruling, rct.deadline_at, rct.resolved_at, rct.created_at,
73
+ u.name as claimant_name,
74
+ (SELECT COUNT(*) FROM review_claim_votes WHERE claim_id = rct.id) as votes_count
75
+ FROM review_claim_tasks rct JOIN users u ON u.id = rct.claimant_id
76
+ WHERE rct.review_type = ? AND rct.review_id = ?
77
+ ORDER BY rct.created_at DESC LIMIT 50
78
+ `).all(req.params.type, req.params.id);
79
+ res.json({ claims: rows, votes_needed: REVIEW_VERIFIERS_NEEDED });
80
+ });
81
+ }