@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,101 @@
1
+ export function registerAiRoutes(app, deps) {
2
+ const { db, auth, anthropic } = deps;
3
+ // G-2: AI 价格建议
4
+ app.post('/api/ai/price-suggestion', async (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ if (user.role !== 'seller')
9
+ return void res.status(403).json({ error: '仅卖家可用' });
10
+ const { title, category, description } = req.body || {};
11
+ if (!title)
12
+ return void res.status(400).json({ error: '请提供 title' });
13
+ // 类目历史价位
14
+ const stats = db.prepare(`
15
+ SELECT COUNT(*) as cnt, COALESCE(AVG(price), 0) as avg, COALESCE(MIN(price), 0) as min, COALESCE(MAX(price), 0) as max,
16
+ COALESCE((SELECT price FROM products WHERE status='active' AND category = ? ORDER BY price LIMIT 1 OFFSET CAST((SELECT COUNT(*) FROM products WHERE status='active' AND category = ?) / 2 AS INTEGER)), 0) as median
17
+ FROM products WHERE status = 'active' AND category = ?
18
+ `).get(category || '', category || '', category || '');
19
+ // 近 30 天成交均价(更可信)
20
+ const recentAvg = db.prepare(`
21
+ SELECT COALESCE(AVG(total_amount), 0) as avg FROM orders o
22
+ JOIN products p ON p.id = o.product_id
23
+ WHERE p.category = ? AND o.status = 'completed' AND o.created_at > datetime('now', '-30 days')
24
+ `).get(category || '').avg;
25
+ try {
26
+ const message = await anthropic.messages.create({
27
+ model: 'claude-haiku-4-5-20251001',
28
+ max_tokens: 400,
29
+ messages: [{
30
+ role: 'user',
31
+ content: `你是 WebAZ 定价顾问。给以下商品建议合理定价(WAZ ≈ CNY,1 USDC ≈ 1 WAZ):
32
+ 商品标题: ${String(title).slice(0, 100)}
33
+ 类目: ${category || '未填'}
34
+ 描述: ${String(description || '').slice(0, 300)}
35
+
36
+ 同类目市场数据(active 商品):
37
+ - 商品数: ${stats.cnt}
38
+ - 价位区间: ${stats.min} - ${stats.max} WAZ
39
+ - 均价: ${stats.avg.toFixed(0)} WAZ
40
+ - 中位价: ${stats.median} WAZ
41
+ - 近 30 天成交均价: ${recentAvg.toFixed(0)} WAZ
42
+
43
+ 只返回 JSON(无前后缀):
44
+ {
45
+ "suggested_price": 推荐价数字,
46
+ "low_price": 价格区间下限,
47
+ "high_price": 价格区间上限,
48
+ "reasoning": "1-2 句简短解释"
49
+ }`,
50
+ }],
51
+ });
52
+ const text = message.content[0]?.text || '';
53
+ const m = text.match(/\{[\s\S]*\}/);
54
+ if (!m)
55
+ return void res.status(500).json({ error: 'AI 返回格式错误' });
56
+ const parsed = JSON.parse(m[0]);
57
+ res.json({ ...parsed, market_data: stats, recent_avg: recentAvg });
58
+ }
59
+ catch (e) {
60
+ res.status(503).json({ error: 'AI 失败: ' + e.message });
61
+ }
62
+ });
63
+ // G-1: AI 文案生成(卖家发品辅助)
64
+ app.post('/api/ai/generate-description', async (req, res) => {
65
+ const user = auth(req, res);
66
+ if (!user)
67
+ return;
68
+ if (user.role !== 'seller')
69
+ return void res.status(403).json({ error: '仅卖家可用' });
70
+ const { title, category, keywords, language } = req.body || {};
71
+ if (!title)
72
+ return void res.status(400).json({ error: '请提供 title' });
73
+ const lang = language === 'en' ? 'English' : '中文';
74
+ try {
75
+ const message = await anthropic.messages.create({
76
+ model: 'claude-haiku-4-5-20251001',
77
+ max_tokens: 600,
78
+ messages: [{
79
+ role: 'user',
80
+ content: `你是 WebAZ 电商文案助手。根据以下信息生成商品描述(${lang}):
81
+ - 标题: ${String(title).slice(0, 100)}
82
+ - 类目: ${category || '未填'}
83
+ - 关键词: ${(keywords || []).slice(0, 10).join('、') || '无'}
84
+
85
+ 要求:
86
+ 1. 100-200 字
87
+ 2. 强调 1-2 个差异化卖点
88
+ 3. 无虚假宣传 / 无绝对化用语(最、第一)
89
+ 4. ${lang}
90
+ 5. 不加 emoji
91
+ 6. 直接输出文案正文,无多余前后缀`,
92
+ }],
93
+ });
94
+ const text = message.content[0]?.text || '';
95
+ res.json({ description: text.trim(), model: 'claude-haiku-4-5' });
96
+ }
97
+ catch (e) {
98
+ res.status(503).json({ error: 'AI 生成失败: ' + e.message });
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,272 @@
1
+ function median(arr) {
2
+ if (arr.length === 0)
3
+ return null;
4
+ const s = [...arr].sort((a, b) => a - b);
5
+ const mid = Math.floor(s.length / 2);
6
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
7
+ }
8
+ export function registerAnalyticsRoutes(app, deps) {
9
+ const { db, auth } = deps;
10
+ // 物流绩效卡 (Wave B-4)
11
+ app.get('/api/logistics/me/performance', (req, res) => {
12
+ const user = auth(req, res);
13
+ if (!user)
14
+ return;
15
+ if (user.role !== 'logistics') {
16
+ return void res.status(403).json({ error: '仅物流角色可访问' });
17
+ }
18
+ const windowDays = Math.max(7, Math.min(365, Number(req.query.window) || 30));
19
+ const orders = db.prepare(`
20
+ SELECT id, status, created_at, updated_at,
21
+ pickup_deadline, delivery_deadline
22
+ FROM orders
23
+ WHERE logistics_id = ? AND created_at > datetime('now', '-' || ? || ' days')
24
+ `).all(user.id, windowDays);
25
+ const orderIds = orders.map(o => o.id);
26
+ let history = [];
27
+ if (orderIds.length > 0) {
28
+ const placeholders = orderIds.map(() => '?').join(',');
29
+ history = db.prepare(`
30
+ SELECT order_id, from_status, to_status, created_at
31
+ FROM order_state_history
32
+ WHERE order_id IN (${placeholders})
33
+ ORDER BY created_at ASC
34
+ `).all(...orderIds);
35
+ }
36
+ const histByOrder = new Map();
37
+ for (const h of history) {
38
+ if (!histByOrder.has(h.order_id))
39
+ histByOrder.set(h.order_id, []);
40
+ histByOrder.get(h.order_id).push(h);
41
+ }
42
+ let pickupOnTime = 0, pickupOverdue = 0;
43
+ let deliveryOnTime = 0, deliveryOverdue = 0;
44
+ let totalDelivered = 0, totalInTransit = 0, totalCompleted = 0;
45
+ const pickupDurationsHr = [];
46
+ const transitDurationsHr = [];
47
+ for (const o of orders) {
48
+ const h = histByOrder.get(o.id) || [];
49
+ const shipped = h.find(x => x.to_status === 'shipped');
50
+ const pickedUp = h.find(x => x.to_status === 'picked_up');
51
+ const delivered = h.find(x => x.to_status === 'delivered');
52
+ if (o.status === 'completed')
53
+ totalCompleted++;
54
+ if (o.status === 'in_transit' || o.status === 'picked_up')
55
+ totalInTransit++;
56
+ if (delivered)
57
+ totalDelivered++;
58
+ if (shipped && pickedUp) {
59
+ const hrs = (new Date(pickedUp.created_at).getTime() - new Date(shipped.created_at).getTime()) / 3600000;
60
+ if (hrs >= 0)
61
+ pickupDurationsHr.push(hrs);
62
+ if (o.pickup_deadline) {
63
+ if (new Date(pickedUp.created_at) <= new Date(o.pickup_deadline))
64
+ pickupOnTime++;
65
+ else
66
+ pickupOverdue++;
67
+ }
68
+ }
69
+ if (pickedUp && delivered) {
70
+ const hrs = (new Date(delivered.created_at).getTime() - new Date(pickedUp.created_at).getTime()) / 3600000;
71
+ if (hrs >= 0)
72
+ transitDurationsHr.push(hrs);
73
+ if (o.delivery_deadline) {
74
+ if (new Date(delivered.created_at) <= new Date(o.delivery_deadline))
75
+ deliveryOnTime++;
76
+ else
77
+ deliveryOverdue++;
78
+ }
79
+ }
80
+ }
81
+ const disputes = db.prepare(`
82
+ SELECT COUNT(*) as n FROM disputes d
83
+ JOIN orders o ON o.id = d.order_id
84
+ WHERE o.logistics_id = ? AND d.created_at > datetime('now', '-' || ? || ' days')
85
+ `).get(user.id, windowDays).n;
86
+ // 败诉两路:auto-fault 判物流 + 仲裁裁定物流为被告且退款
87
+ const autoFaultLost = db.prepare(`
88
+ SELECT COUNT(*) as n FROM orders
89
+ WHERE logistics_id = ? AND status = 'fault_logistics'
90
+ AND updated_at > datetime('now', '-' || ? || ' days')
91
+ `).get(user.id, windowDays).n;
92
+ const arbitratedLost = db.prepare(`
93
+ SELECT COUNT(*) as n FROM disputes d
94
+ JOIN orders o ON o.id = d.order_id
95
+ WHERE o.logistics_id = ? AND d.defendant_id = ?
96
+ AND d.ruling_type IN ('refund_buyer','partial_refund')
97
+ AND d.created_at > datetime('now', '-' || ? || ' days')
98
+ `).get(user.id, user.id, windowDays).n;
99
+ const disputeLoss = autoFaultLost + arbitratedLost;
100
+ const pickupTotalEvaluated = pickupOnTime + pickupOverdue;
101
+ const deliveryTotalEvaluated = deliveryOnTime + deliveryOverdue;
102
+ res.json({
103
+ window_days: windowDays,
104
+ total_orders: orders.length,
105
+ in_progress: totalInTransit,
106
+ delivered: totalDelivered,
107
+ completed: totalCompleted,
108
+ pickup: {
109
+ on_time: pickupOnTime,
110
+ overdue: pickupOverdue,
111
+ on_time_rate: pickupTotalEvaluated > 0 ? pickupOnTime / pickupTotalEvaluated : null,
112
+ median_hours: median(pickupDurationsHr),
113
+ },
114
+ delivery: {
115
+ on_time: deliveryOnTime,
116
+ overdue: deliveryOverdue,
117
+ on_time_rate: deliveryTotalEvaluated > 0 ? deliveryOnTime / deliveryTotalEvaluated : null,
118
+ median_hours: median(transitDurationsHr),
119
+ },
120
+ disputes: {
121
+ total: disputes,
122
+ lost: disputeLoss,
123
+ loss_rate: disputes > 0 ? disputeLoss / disputes : null,
124
+ },
125
+ });
126
+ });
127
+ // 卖家销售分析 (Wave C-5)
128
+ app.get('/api/sellers/me/analytics', (req, res) => {
129
+ const user = auth(req, res);
130
+ if (!user)
131
+ return;
132
+ if (user.role !== 'seller')
133
+ return void res.status(403).json({ error: '仅卖家可访问' });
134
+ const windowDays = Math.max(7, Math.min(365, Number(req.query.window) || 30));
135
+ const ordersAgg = db.prepare(`
136
+ SELECT
137
+ COUNT(*) as total_orders,
138
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders,
139
+ SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_orders,
140
+ SUM(CASE WHEN status IN ('paid','accepted','shipped','picked_up','in_transit','delivered','confirmed') THEN 1 ELSE 0 END) as in_progress_orders,
141
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 0) as gmv,
142
+ COALESCE(AVG(CASE WHEN status = 'completed' THEN total_amount END), 0) as aov
143
+ FROM orders WHERE seller_id = ? AND created_at > datetime('now', '-' || ? || ' days')
144
+ `).get(user.id, windowDays);
145
+ const topProducts = db.prepare(`
146
+ SELECT p.id, p.title, p.price, COUNT(o.id) as sales,
147
+ COALESCE(SUM(o.total_amount), 0) as revenue
148
+ FROM products p
149
+ LEFT JOIN orders o ON o.product_id = p.id AND o.seller_id = p.seller_id
150
+ AND o.status = 'completed'
151
+ AND o.created_at > datetime('now', '-' || ? || ' days')
152
+ WHERE p.seller_id = ? AND p.status != 'deleted'
153
+ GROUP BY p.id
154
+ HAVING sales > 0
155
+ ORDER BY sales DESC LIMIT 10
156
+ `).all(windowDays, user.id);
157
+ const buyerStats = db.prepare(`
158
+ SELECT
159
+ COUNT(DISTINCT buyer_id) as unique_buyers,
160
+ COUNT(*) as orders_count
161
+ FROM orders WHERE seller_id = ? AND status = 'completed'
162
+ AND created_at > datetime('now', '-' || ? || ' days')
163
+ `).get(user.id, windowDays);
164
+ const repeatBuyers = db.prepare(`
165
+ SELECT COUNT(*) as n FROM (
166
+ SELECT buyer_id FROM orders WHERE seller_id = ? AND status = 'completed'
167
+ AND created_at > datetime('now', '-' || ? || ' days')
168
+ GROUP BY buyer_id HAVING COUNT(*) > 1
169
+ )
170
+ `).get(user.id, windowDays).n;
171
+ const wishlistAdds = db.prepare(`
172
+ SELECT COUNT(*) as n FROM user_wishlist w
173
+ JOIN products p ON p.id = w.product_id
174
+ WHERE p.seller_id = ? AND w.created_at > datetime('now', '-' || ? || ' days')
175
+ `).get(user.id, windowDays).n;
176
+ const dailyTrend = db.prepare(`
177
+ SELECT DATE(created_at) as date,
178
+ COUNT(*) as orders,
179
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 0) as gmv
180
+ FROM orders
181
+ WHERE seller_id = ? AND created_at > datetime('now', '-' || ? || ' days')
182
+ GROUP BY DATE(created_at)
183
+ ORDER BY date ASC
184
+ `).all(user.id, Math.min(windowDays, 30));
185
+ const ratingsAgg = db.prepare(`
186
+ SELECT COUNT(*) as cnt, COALESCE(AVG(stars), 0) as avg_stars
187
+ FROM order_ratings WHERE seller_id = ?
188
+ AND created_at > datetime('now', '-' || ? || ' days')
189
+ `).get(user.id, windowDays);
190
+ const refundsCount = db.prepare(`
191
+ SELECT COUNT(*) as n FROM return_requests
192
+ WHERE seller_id = ? AND status = 'refunded'
193
+ AND created_at > datetime('now', '-' || ? || ' days')
194
+ `).get(user.id, windowDays).n;
195
+ // S1: 平均备货时长(paid → shipped 中位 hours)
196
+ const handlingRow = db.prepare(`
197
+ SELECT COALESCE(AVG((julianday(h_ship.created_at) - julianday(h_paid.created_at)) * 24), 0) as avg_handling_hours,
198
+ COUNT(*) as sample_n
199
+ FROM orders o
200
+ JOIN order_state_history h_paid ON h_paid.order_id = o.id AND h_paid.to_status = 'paid'
201
+ JOIN order_state_history h_ship ON h_ship.order_id = o.id AND h_ship.to_status = 'shipped'
202
+ WHERE o.seller_id = ? AND o.created_at > datetime('now', '-' || ? || ' days')
203
+ `).get(user.id, windowDays);
204
+ const completedN = Number(ordersAgg.completed_orders) || 0;
205
+ const returnRate = completedN > 0 ? refundsCount / completedN : 0;
206
+ // S1: 上一窗口对比
207
+ const prevAgg = db.prepare(`
208
+ SELECT
209
+ COUNT(*) as total_orders,
210
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders,
211
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 0) as gmv
212
+ FROM orders WHERE seller_id = ?
213
+ AND created_at > datetime('now', '-' || ? || ' days')
214
+ AND created_at <= datetime('now', '-' || ? || ' days')
215
+ `).get(user.id, windowDays * 2, windowDays);
216
+ res.json({
217
+ window_days: windowDays,
218
+ orders: ordersAgg,
219
+ top_products: topProducts,
220
+ buyers: {
221
+ unique: buyerStats.unique_buyers,
222
+ repeat: repeatBuyers,
223
+ repeat_rate: buyerStats.unique_buyers > 0 ? repeatBuyers / buyerStats.unique_buyers : 0,
224
+ },
225
+ funnel: {
226
+ wishlist_adds: wishlistAdds,
227
+ orders: Number(ordersAgg.total_orders),
228
+ completed: Number(ordersAgg.completed_orders),
229
+ },
230
+ daily_trend: dailyTrend,
231
+ ratings: ratingsAgg,
232
+ refunds: refundsCount,
233
+ fulfillment: {
234
+ avg_handling_hours: Math.round(Number(handlingRow.avg_handling_hours) * 10) / 10,
235
+ sample_n: Number(handlingRow.sample_n),
236
+ },
237
+ quality: {
238
+ return_rate: Math.round(returnRate * 10000) / 10000,
239
+ refunds: refundsCount,
240
+ completed: completedN,
241
+ },
242
+ prev_window: {
243
+ total_orders: Number(prevAgg.total_orders),
244
+ completed_orders: Number(prevAgg.completed_orders),
245
+ gmv: Number(prevAgg.gmv),
246
+ },
247
+ });
248
+ });
249
+ // 卖家退货仪表盘
250
+ app.get('/api/sellers/me/return-stats', (req, res) => {
251
+ const user = auth(req, res);
252
+ if (!user)
253
+ return;
254
+ const totalReturns = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ?`).get(user.id).n;
255
+ const refunded = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ? AND status = 'refunded'`).get(user.id).n;
256
+ const rejected = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ? AND status = 'rejected'`).get(user.id).n;
257
+ const pending = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ? AND status = 'pending'`).get(user.id).n;
258
+ const totalOrders = db.prepare(`SELECT COUNT(*) as n FROM orders WHERE seller_id = ? AND status IN ('delivered','completed','refunded')`).get(user.id).n;
259
+ const reasonBreakdown = db.prepare(`
260
+ SELECT reason, COUNT(*) as cnt FROM return_requests
261
+ WHERE seller_id = ? GROUP BY reason ORDER BY cnt DESC
262
+ `).all(user.id);
263
+ const returnRate = totalOrders > 0 ? (refunded / totalOrders) : 0;
264
+ res.json({
265
+ total_returns: totalReturns,
266
+ refunded, rejected, pending,
267
+ total_orders: totalOrders,
268
+ return_rate: returnRate,
269
+ reason_breakdown: reasonBreakdown,
270
+ });
271
+ });
272
+ }
@@ -0,0 +1,169 @@
1
+ import { generateAnchor, lookupAnchor, retireAnchor, userReferralVolume, computeTierLetter, userAnchorQuotaStats, TIER_THRESHOLDS, ANCHOR_HANDLE_MAX_FOR_USE, } from '../../layer2-business/L2-anchor-registry/anchor-registry.js';
2
+ export function registerAnchorsRoutes(app, deps) {
3
+ const { db, auth, rateLimitOk } = deps;
4
+ // POST /api/anchor/generate
5
+ app.post('/api/anchor/generate', (req, res) => {
6
+ const user = auth(req, res);
7
+ if (!user)
8
+ return;
9
+ if (!rateLimitOk(req.ip || 'anon', 10, 60_000))
10
+ return void res.status(429).json({ error: '生成过于频繁' });
11
+ const { middle, target_kind, target_id } = req.body || {};
12
+ if (!middle || !target_kind || !target_id)
13
+ return void res.status(400).json({ error: 'middle / target_kind / target_id 必填' });
14
+ if (!['user', 'product', 'shareable', 'dispute_case'].includes(target_kind)) {
15
+ return void res.status(400).json({ error: 'target_kind 仅允许 user / product / shareable / dispute_case' });
16
+ }
17
+ const r = generateAnchor(db, {
18
+ ownerId: user.id,
19
+ middle: String(middle),
20
+ targetKind: target_kind,
21
+ targetId: String(target_id),
22
+ });
23
+ if (!r.ok)
24
+ return void res.status(400).json({ error: r.reason });
25
+ res.json({ ok: true, anchor: r.anchor, tier_letter: r.tier_letter });
26
+ });
27
+ // GET /api/anchor/:code/lookup — 公开(无需 auth)
28
+ app.get('/api/anchor/:code/lookup', (req, res) => {
29
+ if (!rateLimitOk(req.ip || 'anon', 60, 60_000))
30
+ return void res.status(429).json({ error: 'too_many_lookups' });
31
+ const r = lookupAnchor(db, String(req.params.code || ''));
32
+ if (!r.found)
33
+ return void res.status(404).json({ found: false });
34
+ if (r.status === 'retired') {
35
+ return void res.status(410).json({ found: true, status: 'retired', retired_at: r.retired_at, owner_id: r.owner_id, tier_letter: r.tier_letter, hint: 'archived' });
36
+ }
37
+ if (r.status === 'reclaimable') {
38
+ return void res.status(404).json({ found: false, hint: 'reclaimable' });
39
+ }
40
+ // 2026-05-24 富化响应:附 owner 详情 + 商品推荐指数
41
+ const owner = db.prepare(`
42
+ SELECT u.name, u.handle, u.region, u.created_at, u.bio,
43
+ (SELECT COUNT(*) FROM follows WHERE followee_id = u.id) as follower_count,
44
+ (SELECT COALESCE(SUM(s.like_count), 0) FROM shareables s WHERE s.owner_id = u.id AND s.status = 'active') as total_likes_received
45
+ FROM users u WHERE u.id = ? AND u.id != 'sys_protocol'
46
+ `).get(r.owner_id);
47
+ let product = null;
48
+ if (r.target_kind === 'product') {
49
+ product = db.prepare(`
50
+ SELECT p.id, p.title, p.price, p.category, p.images, p.completion_count, p.total_likes,
51
+ (SELECT COUNT(DISTINCT buyer_id) FROM order_ratings rt WHERE rt.product_id = p.id AND rt.stars >= 4) as recommend_count,
52
+ (SELECT ROUND(AVG(stars), 2) FROM order_ratings rt WHERE rt.product_id = p.id) as avg_rating,
53
+ (SELECT COUNT(*) FROM order_ratings rt WHERE rt.product_id = p.id) as rating_count,
54
+ u.handle as seller_handle, u.name as seller_name
55
+ FROM products p LEFT JOIN users u ON u.id = p.seller_id
56
+ WHERE p.id = ? AND p.status = 'active'
57
+ `).get(r.target_id);
58
+ }
59
+ else if (r.target_kind === 'shareable') {
60
+ const sh = db.prepare(`SELECT related_product_id FROM shareables WHERE id = ?`).get(r.target_id);
61
+ if (sh?.related_product_id) {
62
+ product = db.prepare(`
63
+ SELECT p.id, p.title, p.price, p.category, p.images, p.completion_count, p.total_likes,
64
+ (SELECT COUNT(DISTINCT buyer_id) FROM order_ratings rt WHERE rt.product_id = p.id AND rt.stars >= 4) as recommend_count,
65
+ (SELECT ROUND(AVG(stars), 2) FROM order_ratings rt WHERE rt.product_id = p.id) as avg_rating,
66
+ (SELECT COUNT(*) FROM order_ratings rt WHERE rt.product_id = p.id) as rating_count,
67
+ u.handle as seller_handle, u.name as seller_name
68
+ FROM products p LEFT JOIN users u ON u.id = p.seller_id
69
+ WHERE p.id = ? AND p.status = 'active'
70
+ `).get(sh.related_product_id);
71
+ }
72
+ }
73
+ res.json({
74
+ found: true, status: 'active',
75
+ target_kind: r.target_kind,
76
+ target_id: r.target_id,
77
+ owner_id: r.owner_id,
78
+ tier_letter: r.tier_letter,
79
+ owner,
80
+ product,
81
+ });
82
+ });
83
+ // POST /api/anchor/:code/touch — 写 attribution(first-touch + 30d)
84
+ app.post('/api/anchor/:code/touch', (req, res) => {
85
+ const user = auth(req, res);
86
+ if (!user)
87
+ return;
88
+ const r = lookupAnchor(db, String(req.params.code || ''));
89
+ if (!r.found || r.status !== 'active')
90
+ return void res.status(404).json({ error: 'anchor_not_active' });
91
+ if (r.owner_id === user.id)
92
+ return void res.json({ ok: true, skipped: 'self_anchor' });
93
+ let attributedProducts = 0;
94
+ const expiresAt = new Date(Date.now() + 30 * 86400_000).toISOString().slice(0, 19).replace('T', ' ');
95
+ if (r.target_kind === 'product') {
96
+ const existing = db.prepare(`SELECT 1 FROM product_share_attribution WHERE product_id = ? AND recipient_id = ?`).get(r.target_id, user.id);
97
+ if (!existing) {
98
+ db.prepare(`INSERT INTO product_share_attribution (product_id, recipient_id, sharer_id, shareable_id, expires_at) VALUES (?,?,?,NULL,?)`)
99
+ .run(r.target_id, user.id, r.owner_id, expiresAt);
100
+ attributedProducts = 1;
101
+ }
102
+ }
103
+ else if (r.target_kind === 'shareable') {
104
+ const s = db.prepare(`SELECT id, related_product_id FROM shareables WHERE id = ?`).get(r.target_id);
105
+ if (s?.related_product_id) {
106
+ const existing = db.prepare(`SELECT 1 FROM product_share_attribution WHERE product_id = ? AND recipient_id = ?`).get(s.related_product_id, user.id);
107
+ if (!existing) {
108
+ db.prepare(`INSERT INTO product_share_attribution (product_id, recipient_id, sharer_id, shareable_id, expires_at) VALUES (?,?,?,?,?)`)
109
+ .run(s.related_product_id, user.id, r.owner_id, s.id, expiresAt);
110
+ attributedProducts = 1;
111
+ }
112
+ }
113
+ }
114
+ else if (r.target_kind === 'user') {
115
+ // 限 LIMIT 50 防 DoS
116
+ const ownerProducts = db.prepare(`SELECT id FROM products WHERE seller_id = ? AND status = 'active' ORDER BY last_sold_at DESC NULLS LAST LIMIT 50`).all(r.owner_id);
117
+ db.transaction(() => {
118
+ for (const p of ownerProducts) {
119
+ const existing = db.prepare(`SELECT 1 FROM product_share_attribution WHERE product_id = ? AND recipient_id = ?`).get(p.id, user.id);
120
+ if (existing)
121
+ continue;
122
+ db.prepare(`INSERT INTO product_share_attribution (product_id, recipient_id, sharer_id, shareable_id, expires_at) VALUES (?,?,?,NULL,?)`)
123
+ .run(p.id, user.id, r.owner_id, expiresAt);
124
+ attributedProducts++;
125
+ }
126
+ })();
127
+ }
128
+ // dispute_case:无商业 attribution
129
+ res.json({
130
+ ok: true,
131
+ target_kind: r.target_kind,
132
+ target_id: r.target_id,
133
+ owner_id: r.owner_id,
134
+ tier_letter: r.tier_letter,
135
+ attributed_products: attributedProducts,
136
+ });
137
+ });
138
+ app.post('/api/anchor/:code/retire', (req, res) => {
139
+ const user = auth(req, res);
140
+ if (!user)
141
+ return;
142
+ const r = retireAnchor(db, user.id, String(req.params.code || ''));
143
+ if (!r.ok) {
144
+ const status = r.reason === 'not_found' ? 404 : r.reason === 'not_owner' ? 403 : 400;
145
+ return void res.status(status).json({ error: r.reason });
146
+ }
147
+ res.json({ ok: true });
148
+ });
149
+ app.get('/api/anchor/me', (req, res) => {
150
+ const user = auth(req, res);
151
+ if (!user)
152
+ return;
153
+ const rows = db.prepare(`
154
+ SELECT anchor, prefix, middle, tier_letter, target_kind, target_id, status, retired_at, hits, last_hit_at, created_at
155
+ FROM anchor_registry WHERE owner_id = ? ORDER BY created_at DESC LIMIT 100
156
+ `).all(user.id);
157
+ const vol = userReferralVolume(db, user.id);
158
+ const tier = computeTierLetter(vol);
159
+ const quota = userAnchorQuotaStats(db, user.id);
160
+ res.json({
161
+ items: rows,
162
+ current_tier: tier,
163
+ referral_volume: vol,
164
+ handle_max_for_anchor: ANCHOR_HANDLE_MAX_FOR_USE,
165
+ tier_thresholds: TIER_THRESHOLDS,
166
+ quota,
167
+ });
168
+ });
169
+ }
@@ -0,0 +1,110 @@
1
+ export function registerAnnouncementsRoutes(app, deps) {
2
+ const { db, generateId, auth, safeRoles, requireProtocolAdmin, isRootAdmin, getAdminScope, logAdminAction } = deps;
3
+ app.post('/api/admin/announcements', (req, res) => {
4
+ const admin = requireProtocolAdmin(req, res);
5
+ if (!admin)
6
+ return;
7
+ const { title, body, target_roles, target_regions, severity, starts_at, expires_at } = req.body || {};
8
+ if (!title?.trim() || title.length > 100)
9
+ return void res.status(400).json({ error: 'title 1-100 字' });
10
+ if (!body?.trim() || body.length > 2000)
11
+ return void res.status(400).json({ error: 'body 1-2000 字' });
12
+ if (severity && !['info', 'warning', 'critical'].includes(severity))
13
+ return void res.status(400).json({ error: 'severity 须为 info / warning / critical' });
14
+ const rolesJson = Array.isArray(target_roles) && target_roles.length > 0 ? JSON.stringify(target_roles) : null;
15
+ const regionsJson = Array.isArray(target_regions) && target_regions.length > 0 ? JSON.stringify(target_regions) : null;
16
+ // 区域 admin 只能发本区域
17
+ if (!isRootAdmin(admin)) {
18
+ const scope = getAdminScope(admin);
19
+ if (regionsJson) {
20
+ const parsedRegions = JSON.parse(regionsJson);
21
+ if (!parsedRegions.every(r => r === scope))
22
+ return void res.status(403).json({ error: `区域 admin 仅可发本区域 (${scope}) 公告` });
23
+ }
24
+ else {
25
+ return void res.status(400).json({ error: `区域 admin 必须指定 target_regions: ['${scope}']` });
26
+ }
27
+ }
28
+ const id = generateId('ann');
29
+ db.prepare(`INSERT INTO announcements (id, author_id, title, body, target_roles, target_regions, severity, starts_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?)`)
30
+ .run(id, admin.id, title.trim(), body.trim(), rolesJson, regionsJson, severity || 'info', starts_at || null, expires_at || null);
31
+ logAdminAction(admin.id, 'create_announcement', 'announcement', id, { title, severity: severity || 'info' });
32
+ res.json({ success: true, id });
33
+ });
34
+ app.patch('/api/admin/announcements/:id', (req, res) => {
35
+ const admin = requireProtocolAdmin(req, res);
36
+ if (!admin)
37
+ return;
38
+ const ann = db.prepare('SELECT id, author_id FROM announcements WHERE id = ?').get(req.params.id);
39
+ if (!ann)
40
+ return void res.status(404).json({ error: '公告不存在' });
41
+ if (!isRootAdmin(admin) && ann.author_id !== admin.id)
42
+ return void res.status(403).json({ error: '仅可编辑自己发的公告(root 可全管)' });
43
+ const { is_active, expires_at } = req.body || {};
44
+ const sets = [];
45
+ const args = [];
46
+ if (is_active !== undefined) {
47
+ sets.push('is_active = ?');
48
+ args.push(is_active ? 1 : 0);
49
+ }
50
+ if (expires_at !== undefined) {
51
+ sets.push('expires_at = ?');
52
+ args.push(expires_at);
53
+ }
54
+ if (sets.length === 0)
55
+ return void res.status(400).json({ error: '无可更新字段' });
56
+ args.push(req.params.id);
57
+ db.prepare(`UPDATE announcements SET ${sets.join(', ')} WHERE id = ?`).run(...args);
58
+ res.json({ success: true });
59
+ });
60
+ // 列出对当前用户可见的活跃公告(按角色 + 区域过滤)
61
+ app.get('/api/announcements/active', (req, res) => {
62
+ const user = auth(req, res);
63
+ if (!user)
64
+ return;
65
+ const userRoles = safeRoles(user);
66
+ const userRegion = user.region || 'global';
67
+ const rows = db.prepare(`
68
+ SELECT a.id, a.title, a.body, a.severity, a.created_at, a.target_roles, a.target_regions,
69
+ (SELECT 1 FROM announcement_reads WHERE user_id = ? AND announcement_id = a.id) as is_read
70
+ FROM announcements a
71
+ WHERE a.is_active = 1
72
+ AND (a.starts_at IS NULL OR a.starts_at <= datetime('now'))
73
+ AND (a.expires_at IS NULL OR a.expires_at >= datetime('now'))
74
+ ORDER BY a.created_at DESC LIMIT 50
75
+ `).all(user.id);
76
+ // JS 端 filter 角色 / 区域(避免 JSON LIKE 在 SQLite 中麻烦)
77
+ const filtered = rows.filter(a => {
78
+ if (a.target_roles) {
79
+ try {
80
+ const tr = JSON.parse(a.target_roles);
81
+ const matches = tr.some(r => userRoles.includes(r) || user.role === r);
82
+ if (!matches)
83
+ return false;
84
+ }
85
+ catch { }
86
+ }
87
+ if (a.target_regions) {
88
+ try {
89
+ const tg = JSON.parse(a.target_regions);
90
+ if (!tg.includes(userRegion))
91
+ return false;
92
+ }
93
+ catch { }
94
+ }
95
+ return true;
96
+ }).map(a => ({ ...a, target_roles: undefined, target_regions: undefined, is_read: !!a.is_read }));
97
+ res.json({ items: filtered });
98
+ });
99
+ app.post('/api/announcements/:id/read', (req, res) => {
100
+ const user = auth(req, res);
101
+ if (!user)
102
+ return;
103
+ try {
104
+ db.prepare(`INSERT OR IGNORE INTO announcement_reads (user_id, announcement_id) VALUES (?,?)`)
105
+ .run(user.id, req.params.id);
106
+ }
107
+ catch { }
108
+ res.json({ success: true });
109
+ });
110
+ }