@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,318 @@
1
+ const VALID_CHAT_KINDS = new Set(['order', 'rfq', 'listing_qa']);
2
+ // 反诈正则(命中即 flag,仍发出)
3
+ const FRAUD_PATTERNS = [
4
+ { name: 'phone_cn', rx: /(?<!\d)1[3-9]\d{9}(?!\d)/ }, // 中国手机号 11 位
5
+ { name: 'wechat', rx: /(微信|vx|wechat|weixin|v[xX]\s*[::])/i },
6
+ { name: 'alipay', rx: /(支付宝|alipay)/i },
7
+ { name: 'qq', rx: /\bqq\s*[::]\s*\d{5,11}\b/i },
8
+ { name: 'bank_card', rx: /(?<!\d)\d{16,19}(?!\d)/ },
9
+ { name: 'telegram', rx: /(@[A-Za-z0-9_]{5,32}|t\.me\/[A-Za-z0-9_]+|telegram)/i },
10
+ // 锚定 host 段:(?:localhost|webaz\.app|webaz\.io) 后必须紧跟 / 或 : 或末端,防 webaz.io.evil.com 绕过
11
+ { name: 'external_url', rx: /https?:\/\/(?!(?:localhost|webaz\.app|webaz\.io)(?:[/:]|$))[^\s]+/i },
12
+ ];
13
+ // exported because server.ts 其它端点(如 listing Q&A / 评论审核)也用同一套反诈 regex
14
+ export function detectFraud(text) {
15
+ const hits = [];
16
+ for (const p of FRAUD_PATTERNS)
17
+ if (p.rx.test(text))
18
+ hits.push(p.name);
19
+ return hits;
20
+ }
21
+ export function registerChatRoutes(app, deps) {
22
+ const { db, auth, generateId, rateLimitOk } = deps;
23
+ // 上下文 + 对方校验:只允许有真实商业关系的两方建会话
24
+ function resolveChatParticipants(kind, contextId, requesterId, recipientId) {
25
+ if (kind === 'order') {
26
+ const o = db.prepare('SELECT buyer_id, seller_id FROM orders WHERE id = ?').get(contextId);
27
+ if (!o)
28
+ return { user_a: '', user_b: '', allowed: false, reason: '订单不存在' };
29
+ if (requesterId !== o.buyer_id && requesterId !== o.seller_id)
30
+ return { user_a: '', user_b: '', allowed: false, reason: '仅订单买卖双方可聊' };
31
+ return { user_a: o.buyer_id, user_b: o.seller_id, allowed: true };
32
+ }
33
+ if (kind === 'rfq') {
34
+ const r = db.prepare('SELECT buyer_id, status FROM rfqs WHERE id = ?').get(contextId);
35
+ if (!r)
36
+ return { user_a: '', user_b: '', allowed: false, reason: 'RFQ 不存在' };
37
+ if (requesterId === r.buyer_id) {
38
+ // buyer 主动找某个 bidder
39
+ if (!recipientId)
40
+ return { user_a: '', user_b: '', allowed: false, reason: '需指定 recipient' };
41
+ const hasBid = db.prepare('SELECT 1 FROM bids WHERE rfq_id = ? AND seller_id = ?').get(contextId, recipientId);
42
+ if (!hasBid)
43
+ return { user_a: '', user_b: '', allowed: false, reason: '对方未对此 RFQ 报价' };
44
+ return { user_a: r.buyer_id, user_b: recipientId, allowed: true };
45
+ }
46
+ // seller 找 buyer:必须自己已 bid
47
+ const myBid = db.prepare('SELECT 1 FROM bids WHERE rfq_id = ? AND seller_id = ?').get(contextId, requesterId);
48
+ if (!myBid)
49
+ return { user_a: '', user_b: '', allowed: false, reason: '需先报价才能联系买家' };
50
+ return { user_a: r.buyer_id, user_b: requesterId, allowed: true };
51
+ }
52
+ if (kind === 'listing_qa') {
53
+ const l = db.prepare('SELECT created_by FROM listings WHERE id = ?').get(contextId);
54
+ if (!l)
55
+ return { user_a: '', user_b: '', allowed: false, reason: 'listing 不存在' };
56
+ // 仅非创建者可主动发起(避免 listing 创建者主动 spam)
57
+ if (requesterId === l.created_by) {
58
+ // 创建者只能回复已存在的线程;不能新建
59
+ if (!recipientId)
60
+ return { user_a: '', user_b: '', allowed: false, reason: '请等买家先发起咨询' };
61
+ const [a, b] = l.created_by < recipientId ? [l.created_by, recipientId] : [recipientId, l.created_by];
62
+ const exists = db.prepare("SELECT 1 FROM conversations WHERE kind = 'listing_qa' AND context_id = ? AND user_a = ? AND user_b = ?").get(contextId, a, b);
63
+ if (!exists)
64
+ return { user_a: '', user_b: '', allowed: false, reason: '请等买家先发起咨询' };
65
+ return { user_a: l.created_by, user_b: recipientId, allowed: true };
66
+ }
67
+ return { user_a: l.created_by, user_b: requesterId, allowed: true };
68
+ }
69
+ return { user_a: '', user_b: '', allowed: false, reason: 'kind 无效' };
70
+ }
71
+ function findOrCreateConv(kind, contextId, userA, userB) {
72
+ // 规范化:user_a 字典序较小
73
+ const [a, b] = userA < userB ? [userA, userB] : [userB, userA];
74
+ const selectStmt = db.prepare('SELECT id FROM conversations WHERE kind = ? AND context_id = ? AND user_a = ? AND user_b = ?');
75
+ const existing = selectStmt.get(kind, contextId, a, b);
76
+ if (existing)
77
+ return existing.id;
78
+ // 并发场景下 UNIQUE 可能触发;用 INSERT OR IGNORE + 再次 SELECT 兜底
79
+ const id = generateId('cv');
80
+ db.prepare(`INSERT OR IGNORE INTO conversations (id, kind, context_id, user_a, user_b) VALUES (?,?,?,?,?)`)
81
+ .run(id, kind, contextId, a, b);
82
+ const final = selectStmt.get(kind, contextId, a, b);
83
+ return final.id;
84
+ }
85
+ // 开会话(idempotent — 已存在则返回 id)
86
+ app.post('/api/conversations/start', (req, res) => {
87
+ const user = auth(req, res);
88
+ if (!user)
89
+ return;
90
+ const { kind, context_id, recipient_id } = req.body;
91
+ if (!VALID_CHAT_KINDS.has(String(kind)))
92
+ return void res.json({ error: 'kind 无效' });
93
+ if (!context_id)
94
+ return void res.json({ error: 'context_id 必填' });
95
+ const r = resolveChatParticipants(String(kind), String(context_id), user.id, recipient_id ? String(recipient_id) : null);
96
+ if (!r.allowed)
97
+ return void res.json({ error: r.reason || '无权开启会话' });
98
+ const id = findOrCreateConv(String(kind), String(context_id), r.user_a, r.user_b);
99
+ res.json({ id, kind, context_id });
100
+ });
101
+ // 我的会话列表
102
+ app.get('/api/conversations', (req, res) => {
103
+ const user = auth(req, res);
104
+ if (!user)
105
+ return;
106
+ const rows = db.prepare(`
107
+ SELECT c.*,
108
+ CASE WHEN c.user_a = ? THEN c.unread_a ELSE c.unread_b END as my_unread,
109
+ CASE WHEN c.user_a = ? THEN c.user_b ELSE c.user_a END as other_id,
110
+ (SELECT handle FROM users WHERE id = CASE WHEN c.user_a = ? THEN c.user_b ELSE c.user_a END) as other_handle,
111
+ (SELECT name FROM users WHERE id = CASE WHEN c.user_a = ? THEN c.user_b ELSE c.user_a END) as other_name
112
+ FROM conversations c
113
+ WHERE (c.user_a = ? OR c.user_b = ?) AND c.status NOT IN ('blocked','archived')
114
+ ORDER BY COALESCE(c.last_message_at, c.created_at) DESC
115
+ LIMIT 100
116
+ `).all(user.id, user.id, user.id, user.id, user.id, user.id);
117
+ res.json({ items: rows });
118
+ });
119
+ // 会话详情 + 消息分页
120
+ app.get('/api/conversations/:id', (req, res) => {
121
+ const user = auth(req, res);
122
+ if (!user)
123
+ return;
124
+ const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
125
+ if (!conv)
126
+ return void res.status(404).json({ error: '会话不存在' });
127
+ if (conv.user_a !== user.id && conv.user_b !== user.id)
128
+ return void res.status(403).json({ error: '无权访问' });
129
+ const before = req.query.before ? String(req.query.before) : null;
130
+ const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
131
+ const args = [req.params.id];
132
+ let whereExtra = '';
133
+ if (before) {
134
+ whereExtra = ' AND created_at < ?';
135
+ args.push(before);
136
+ }
137
+ args.push(limit);
138
+ const messages = db.prepare(`
139
+ SELECT id, sender_id, body, attachments, flagged, flag_reasons, read_at, kind, meta, created_at
140
+ FROM messages
141
+ WHERE conversation_id = ?${whereExtra}
142
+ ORDER BY created_at DESC
143
+ LIMIT ?
144
+ `).all(...args);
145
+ messages.reverse(); // 返回时间正序,便于前端 append
146
+ // QA 轮 11 P1:read 端 flag_reasons 是 JSON string,send 端是 array → agent 双向 parse 易错
147
+ // 统一为 array (send 端格式)
148
+ for (const m of messages) {
149
+ if (typeof m.flag_reasons === 'string') {
150
+ try {
151
+ m.flag_reasons = JSON.parse(m.flag_reasons);
152
+ }
153
+ catch {
154
+ m.flag_reasons = [];
155
+ }
156
+ }
157
+ else if (m.flag_reasons == null) {
158
+ m.flag_reasons = [];
159
+ }
160
+ if (typeof m.attachments === 'string') {
161
+ try {
162
+ m.attachments = JSON.parse(m.attachments);
163
+ }
164
+ catch {
165
+ m.attachments = [];
166
+ }
167
+ }
168
+ else if (m.attachments == null) {
169
+ m.attachments = [];
170
+ }
171
+ }
172
+ const otherId = conv.user_a === user.id ? conv.user_b : conv.user_a;
173
+ const other = db.prepare('SELECT id, handle, name, region FROM users WHERE id = ?').get(otherId);
174
+ res.json({ conv, messages, other });
175
+ });
176
+ // 发消息
177
+ app.post('/api/conversations/:id/messages', (req, res) => {
178
+ const user = auth(req, res);
179
+ if (!user)
180
+ return;
181
+ // P1: 频率限制 — 同用户每分钟 ≤ 60 条(≈ 1/s 持续 + 短时突发)
182
+ if (!rateLimitOk(`chat_msg:${user.id}`, 60, 60_000))
183
+ return void res.status(429).json({ error: '发送过于频繁,请稍等' });
184
+ const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
185
+ if (!conv)
186
+ return void res.status(404).json({ error: '会话不存在' });
187
+ if (conv.status === 'blocked')
188
+ return void res.json({ error: '该会话已被屏蔽' });
189
+ if (conv.user_a !== user.id && conv.user_b !== user.id)
190
+ return void res.status(403).json({ error: '无权发送' });
191
+ const body = String(req.body.body || '').trim();
192
+ const attachmentsRaw = req.body.attachments;
193
+ const attachments = Array.isArray(attachmentsRaw)
194
+ ? attachmentsRaw.filter((a) => typeof a === 'string' && a.length < 200_000).slice(0, 4)
195
+ : [];
196
+ // W1: 结构化 kind + meta
197
+ const kind = String(req.body.kind || 'text');
198
+ if (!['text', 'offer', 'tracking'].includes(kind))
199
+ return void res.status(400).json({ error: '无效 kind' });
200
+ const metaIn = req.body.meta;
201
+ let metaJson = null;
202
+ let structuredPreview = null;
203
+ if (kind === 'offer') {
204
+ const amount = Number(metaIn?.amount);
205
+ const productId = metaIn?.product_id ? String(metaIn.product_id) : null;
206
+ const note = metaIn?.note ? String(metaIn.note).slice(0, 200) : '';
207
+ if (!(amount > 0) || amount > 1_000_000)
208
+ return void res.status(400).json({ error: '报价金额需在 0-1000000 之间' });
209
+ metaJson = JSON.stringify({ amount, product_id: productId, note });
210
+ structuredPreview = `💰 ${amount} WAZ${note ? ' · ' + note.slice(0, 30) : ''}`;
211
+ }
212
+ else if (kind === 'tracking') {
213
+ const carrier = metaIn?.carrier ? String(metaIn.carrier).slice(0, 40) : '';
214
+ const trackingNo = metaIn?.tracking_no ? String(metaIn.tracking_no).trim().slice(0, 60) : '';
215
+ if (!trackingNo)
216
+ return void res.status(400).json({ error: '单号必填' });
217
+ metaJson = JSON.stringify({ carrier, tracking_no: trackingNo });
218
+ structuredPreview = `🚚 ${carrier ? carrier + ' ' : ''}${trackingNo}`;
219
+ }
220
+ if (kind === 'text' && !body && attachments.length === 0)
221
+ return void res.json({ error: '内容不能为空' });
222
+ if (body.length > 2000)
223
+ return void res.json({ error: '消息最长 2000 字' });
224
+ const reasons = kind === 'text' ? detectFraud(body) : [];
225
+ const id = generateId('msg');
226
+ const isFromA = conv.user_a === user.id;
227
+ const preview = structuredPreview || (body ? body.slice(0, 60) : '[📷 ' + attachments.length + ']');
228
+ db.transaction(() => {
229
+ db.prepare(`INSERT INTO messages (id, conversation_id, sender_id, body, attachments, flagged, flag_reasons, kind, meta)
230
+ VALUES (?,?,?,?,?,?,?,?,?)`)
231
+ .run(id, req.params.id, user.id, body, attachments.length ? JSON.stringify(attachments) : null, reasons.length ? 1 : 0, reasons.length ? JSON.stringify(reasons) : null, kind, metaJson);
232
+ db.prepare(`UPDATE conversations SET
233
+ last_message_at = datetime('now'),
234
+ last_preview = ?,
235
+ ${isFromA ? 'unread_b = unread_b + 1' : 'unread_a = unread_a + 1'}
236
+ WHERE id = ?`).run(preview, req.params.id);
237
+ })();
238
+ // 通知接收方
239
+ try {
240
+ const recipient = isFromA ? conv.user_b : conv.user_a;
241
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
242
+ VALUES (?,?,'chat_new',?,?,datetime('now'))`)
243
+ .run(generateId('ntf'), recipient, `💬 新消息`, preview);
244
+ }
245
+ catch (e) {
246
+ console.error('[chat notify]', e);
247
+ }
248
+ res.json({ id, flagged: reasons.length > 0, flag_reasons: reasons });
249
+ });
250
+ // 标记已读
251
+ app.post('/api/conversations/:id/read', (req, res) => {
252
+ const user = auth(req, res);
253
+ if (!user)
254
+ return;
255
+ const conv = db.prepare('SELECT user_a, user_b FROM conversations WHERE id = ?').get(req.params.id);
256
+ if (!conv)
257
+ return void res.status(404).json({ error: '会话不存在' });
258
+ if (conv.user_a !== user.id && conv.user_b !== user.id)
259
+ return void res.status(403).json({ error: '无权访问' });
260
+ const col = conv.user_a === user.id ? 'unread_a' : 'unread_b';
261
+ db.transaction(() => {
262
+ db.prepare(`UPDATE conversations SET ${col} = 0 WHERE id = ?`).run(req.params.id);
263
+ db.prepare(`UPDATE messages SET read_at = datetime('now')
264
+ WHERE conversation_id = ? AND sender_id != ? AND read_at IS NULL`).run(req.params.id, user.id);
265
+ })();
266
+ res.json({ success: true });
267
+ });
268
+ // 归档(仅自己侧)
269
+ app.post('/api/conversations/:id/archive', (req, res) => {
270
+ const user = auth(req, res);
271
+ if (!user)
272
+ return;
273
+ const conv = db.prepare('SELECT user_a, user_b, status FROM conversations WHERE id = ?').get(req.params.id);
274
+ if (!conv)
275
+ return void res.status(404).json({ error: '会话不存在' });
276
+ if (conv.user_a !== user.id && conv.user_b !== user.id)
277
+ return void res.status(403).json({ error: '无权操作' });
278
+ db.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?").run(req.params.id);
279
+ res.json({ success: true });
280
+ });
281
+ // 拉黑(双向屏蔽)
282
+ app.post('/api/conversations/:id/block', (req, res) => {
283
+ const user = auth(req, res);
284
+ if (!user)
285
+ return;
286
+ const conv = db.prepare('SELECT user_a, user_b FROM conversations WHERE id = ?').get(req.params.id);
287
+ if (!conv)
288
+ return void res.status(404).json({ error: '会话不存在' });
289
+ if (conv.user_a !== user.id && conv.user_b !== user.id)
290
+ return void res.status(403).json({ error: '无权操作' });
291
+ db.prepare("UPDATE conversations SET status = 'blocked' WHERE id = ?").run(req.params.id);
292
+ res.json({ success: true });
293
+ });
294
+ // 举报(人工审核)
295
+ app.post('/api/conversations/:id/report', (req, res) => {
296
+ const user = auth(req, res);
297
+ if (!user)
298
+ return;
299
+ const conv = db.prepare('SELECT user_a, user_b FROM conversations WHERE id = ?').get(req.params.id);
300
+ if (!conv)
301
+ return void res.status(404).json({ error: '会话不存在' });
302
+ if (conv.user_a !== user.id && conv.user_b !== user.id)
303
+ return void res.status(403).json({ error: '无权操作' });
304
+ const body = req.body;
305
+ const reason = String(body.reason || '').trim();
306
+ if (!reason)
307
+ return void res.json({ error: '原因必填' });
308
+ // P1: 同 (reporter, conversation) 24h 内最多 3 次
309
+ const recentRpt = db.prepare(`SELECT COUNT(1) as n FROM chat_reports WHERE conversation_id = ? AND reporter_id = ? AND created_at > datetime('now','-1 day')`).get(req.params.id, user.id).n;
310
+ if (recentRpt >= 3)
311
+ return void res.status(429).json({ error: '24 小时内对同一会话最多举报 3 次' });
312
+ const reportedId = conv.user_a === user.id ? conv.user_b : conv.user_a;
313
+ db.prepare(`INSERT INTO chat_reports (id, conversation_id, message_id, reporter_id, reported_id, reason, note)
314
+ VALUES (?,?,?,?,?,?,?)`)
315
+ .run(generateId('rpt'), req.params.id, body.message_id ? String(body.message_id) : null, user.id, reportedId, reason, body.note ? String(body.note).slice(0, 500) : null);
316
+ res.json({ success: true });
317
+ });
318
+ }
@@ -0,0 +1,122 @@
1
+ export function registerCheckinTasksRoutes(app, deps) {
2
+ const { db, auth, isTrustedRole, errorRes, generateId, getProtocolParam, resolveCheckinDate, TASK_DEFS, computeTaskProgress, disbursePlatformReward, broadcastSystemEvent } = deps;
3
+ app.get('/api/checkin/status', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ if (isTrustedRole(user))
8
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无此功能');
9
+ const today = resolveCheckinDate(req.query.local_date ? String(req.query.local_date) : undefined);
10
+ const todayCheckin = db.prepare('SELECT reward, streak FROM daily_checkins WHERE user_id = ? AND checkin_date = ?').get(user.id, today);
11
+ // streak: 连续签到 — 检查昨日
12
+ const yesterday = new Date(new Date(today + 'T00:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
13
+ const yesterdayCheckin = db.prepare('SELECT streak FROM daily_checkins WHERE user_id = ? AND checkin_date = ?').get(user.id, yesterday);
14
+ const currentStreak = todayCheckin?.streak || (yesterdayCheckin ? yesterdayCheckin.streak + 1 : 1);
15
+ // F-2: 里程碑参数 admin 可调
16
+ const bonus7 = getProtocolParam('streak_bonus_7', 5);
17
+ const bonus30 = getProtocolParam('streak_bonus_30', 20);
18
+ const bonus100 = getProtocolParam('streak_bonus_100', 50);
19
+ const milestoneBonus = (s) => s % 100 === 0 ? bonus100 : s % 30 === 0 ? bonus30 : s % 7 === 0 ? bonus7 : 0;
20
+ const baseReward = getProtocolParam('checkin_base_reward', 0.5);
21
+ const nextReward = baseReward + milestoneBonus(currentStreak);
22
+ // 任务列表
23
+ const progress = computeTaskProgress(String(user.id));
24
+ const claimed = new Map();
25
+ for (const row of db.prepare('SELECT task_key, claimed_at FROM task_completions WHERE user_id = ?').all(user.id)) {
26
+ if (row.claimed_at)
27
+ claimed.set(row.task_key, row.claimed_at);
28
+ }
29
+ const tasks = Object.entries(TASK_DEFS).map(([key, def]) => ({
30
+ key, label: def.label, reward: def.reward,
31
+ progress: progress[key].progress,
32
+ goal: progress[key].goal,
33
+ eligible: progress[key].eligible,
34
+ claimed_at: claimed.get(key) || null,
35
+ }));
36
+ res.json({
37
+ today,
38
+ today_checked_in: !!todayCheckin,
39
+ today_reward: todayCheckin?.reward || nextReward,
40
+ current_streak: currentStreak,
41
+ next_reward: nextReward,
42
+ base_reward: baseReward,
43
+ tasks,
44
+ });
45
+ });
46
+ app.post('/api/checkin', (req, res) => {
47
+ const user = auth(req, res);
48
+ if (!user)
49
+ return;
50
+ if (isTrustedRole(user))
51
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无此功能');
52
+ const today = resolveCheckinDate(req.body?.local_date ? String(req.body.local_date) : undefined);
53
+ const existing = db.prepare('SELECT reward FROM daily_checkins WHERE user_id = ? AND checkin_date = ?').get(user.id, today);
54
+ if (existing)
55
+ return void res.status(400).json({ error: '今日已签到', error_code: 'ALREADY_CHECKED_IN' });
56
+ const yesterday = new Date(new Date(today + 'T00:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
57
+ const yesterdayCheckin = db.prepare('SELECT streak FROM daily_checkins WHERE user_id = ? AND checkin_date = ?').get(user.id, yesterday);
58
+ const streak = yesterdayCheckin ? yesterdayCheckin.streak + 1 : 1;
59
+ // F-2: admin 可调里程碑
60
+ const bonus7 = getProtocolParam('streak_bonus_7', 5);
61
+ const bonus30 = getProtocolParam('streak_bonus_30', 20);
62
+ const bonus100 = getProtocolParam('streak_bonus_100', 50);
63
+ const baseReward = getProtocolParam('checkin_base_reward', 0.5);
64
+ const milestoneBonus = streak % 100 === 0 ? bonus100 : streak % 30 === 0 ? bonus30 : streak % 7 === 0 ? bonus7 : 0;
65
+ const reward = baseReward + milestoneBonus;
66
+ db.transaction(() => {
67
+ db.prepare(`INSERT INTO daily_checkins (user_id, checkin_date, reward, streak) VALUES (?,?,?,?)`).run(user.id, today, reward, streak);
68
+ // P1-3: 走平台拨付(从 sys_protocol 扣 + 给 user 加 + 写日志)
69
+ disbursePlatformReward(String(user.id), reward, milestoneBonus > 0 ? `milestone_${streak}` : 'daily_checkin', String(streak));
70
+ // 2026-05-24 写通知:让消息中心通知 sub-tab 能看到
71
+ const title = milestoneBonus > 0 ? `🎉 连续签到 ${streak} 天里程碑!` : `✅ 签到成功`;
72
+ const body = `+${reward} WAZ${milestoneBonus > 0 ? ` (含里程碑奖 ${milestoneBonus})` : ''} · streak ${streak} 天`;
73
+ try {
74
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`)
75
+ .run(generateId('ntf'), user.id, 'reward', title, body, null);
76
+ }
77
+ catch (e) {
78
+ console.error('[checkin notif]', e);
79
+ }
80
+ })();
81
+ try {
82
+ broadcastSystemEvent('checkin', '📅', `签到 streak=${streak} · +${reward} WAZ`, String(user.id));
83
+ }
84
+ catch { }
85
+ res.json({ success: true, reward, streak, milestone_bonus: milestoneBonus });
86
+ });
87
+ app.post('/api/tasks/:key/claim', (req, res) => {
88
+ const user = auth(req, res);
89
+ if (!user)
90
+ return;
91
+ if (isTrustedRole(user))
92
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无此功能');
93
+ const taskKey = String(req.params.key);
94
+ const def = TASK_DEFS[taskKey];
95
+ if (!def)
96
+ return void res.status(400).json({ error: '任务不存在' });
97
+ const progress = computeTaskProgress(String(user.id));
98
+ if (!progress[taskKey].eligible)
99
+ return void res.status(400).json({ error: '任务未完成', progress: progress[taskKey] });
100
+ const existing = db.prepare('SELECT claimed_at FROM task_completions WHERE user_id = ? AND task_key = ?').get(user.id, taskKey);
101
+ if (existing?.claimed_at)
102
+ return void res.status(400).json({ error: '任务奖励已领取' });
103
+ db.transaction(() => {
104
+ db.prepare(`INSERT OR REPLACE INTO task_completions (user_id, task_key, completed_at, claimed_at, reward) VALUES (?,?,datetime('now'),datetime('now'),?)`).run(user.id, taskKey, def.reward);
105
+ // P1-3: 走平台拨付助手
106
+ disbursePlatformReward(String(user.id), def.reward, `task_${taskKey}`, null);
107
+ // 2026-05-24 写通知
108
+ try {
109
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`)
110
+ .run(generateId('ntf'), user.id, 'reward', `🎁 任务完成 — ${def.label || taskKey}`, `+${def.reward} WAZ`, null);
111
+ }
112
+ catch (e) {
113
+ console.error('[task notif]', e);
114
+ }
115
+ })();
116
+ try {
117
+ broadcastSystemEvent('task_claim', '🎁', `任务领取 ${taskKey} · +${def.reward} WAZ`, String(user.id));
118
+ }
119
+ catch { }
120
+ res.json({ success: true, reward: def.reward });
121
+ });
122
+ }
@@ -0,0 +1,85 @@
1
+ export function registerCheckoutHelpersRoutes(app, deps) {
2
+ const { db, auth, generateId, formatProductForAgent } = deps;
3
+ app.get('/api/checkout/tax-preview', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ const productId = String(req.query.product_id || '');
8
+ const qtyN = Math.max(1, Math.floor(Number(req.query.quantity) || 1));
9
+ if (!productId)
10
+ return void res.status(400).json({ error: 'product_id required' });
11
+ const product = db.prepare(`SELECT p.id, p.price, p.title, u.region as seller_region
12
+ FROM products p JOIN users u ON u.id = p.seller_id
13
+ WHERE p.id = ?`).get(productId);
14
+ if (!product)
15
+ return void res.status(404).json({ error: '商品不存在' });
16
+ const buyerRegion = user.region || 'global';
17
+ const sellerRegion = product.seller_region || 'global';
18
+ const isCrossBorder = buyerRegion !== sellerRegion;
19
+ if (!isCrossBorder) {
20
+ return void res.json({
21
+ is_cross_border: false, buyer_region: buyerRegion, seller_region: sellerRegion,
22
+ estimated_duty_waz: 0, duty_pct: 0, threshold_waz: 0, below_threshold: true,
23
+ disclaimer: '同地区订单,无跨境关税',
24
+ });
25
+ }
26
+ const cfg = db.prepare(`SELECT est_import_duty_pct, est_import_threshold_waz
27
+ FROM region_config WHERE region = ?`).get(buyerRegion);
28
+ const pct = Number(cfg?.est_import_duty_pct || 0);
29
+ const threshold = Number(cfg?.est_import_threshold_waz || 0);
30
+ const orderTotal = Number(product.price) * qtyN;
31
+ const belowThreshold = orderTotal < threshold;
32
+ const dutyWaz = belowThreshold ? 0 : Math.round(orderTotal * pct * 100) / 100;
33
+ res.json({
34
+ is_cross_border: true,
35
+ buyer_region: buyerRegion, seller_region: sellerRegion,
36
+ order_total_waz: orderTotal,
37
+ duty_pct: pct, threshold_waz: threshold,
38
+ below_threshold: belowThreshold,
39
+ estimated_duty_waz: dutyWaz,
40
+ total_with_duty: Math.round((orderTotal + dutyWaz) * 100) / 100,
41
+ disclaimer: pct > 0
42
+ ? `跨境订单可能产生约 ${(pct * 100).toFixed(1)}% 关税/进口税(具体由海关认定 · 协议不代收)`
43
+ : '跨境订单 — 协议无该地区税费数据,请咨询当地海关',
44
+ });
45
+ });
46
+ app.post('/api/verify-price', (req, res) => {
47
+ const user = auth(req, res);
48
+ if (!user)
49
+ return;
50
+ const { product_id, quantity = 1 } = req.body;
51
+ if (!product_id)
52
+ return void res.json({ error: '请提供 product_id' });
53
+ const product = db.prepare(`
54
+ SELECT p.*, u.name as seller_name,
55
+ COALESCE(rs.level, 'new') as rep_level
56
+ FROM products p
57
+ JOIN users u ON p.seller_id = u.id
58
+ LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
59
+ WHERE p.id = ? AND p.status = 'active'
60
+ `).get(product_id);
61
+ if (!product)
62
+ return void res.json({ error: '商品不存在或已下架' });
63
+ const qty = Number(quantity);
64
+ if (product.stock < qty) {
65
+ return void res.json({ error: `库存不足:当前库存 ${product.stock},请求数量 ${qty}` });
66
+ }
67
+ const now = new Date();
68
+ const expiresAt = new Date(now.getTime() + 10 * 60_000);
69
+ const token = generateId('pst');
70
+ db.prepare(`
71
+ INSERT INTO price_sessions (token, product_id, user_id, price, quantity, created_at, expires_at)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?)
73
+ `).run(token, product_id, user.id, product.price, qty, now.toISOString(), expiresAt.toISOString());
74
+ res.json({
75
+ session_token: token,
76
+ verified_price: product.price,
77
+ quantity: qty,
78
+ total: product.price * qty,
79
+ product: formatProductForAgent(product),
80
+ expires_at: expiresAt.toISOString(),
81
+ expires_in_seconds: 600,
82
+ note: '此价格在10分钟内有效。下单时传入 session_token 可保证此价格不变。',
83
+ });
84
+ });
85
+ }
@@ -0,0 +1,88 @@
1
+ export function registerClaimInitiatorsRoutes(app, deps) {
2
+ const { db, auth, isTrustedRole, errorRes, generateId } = deps;
3
+ const wire = (cfg) => {
4
+ const { entityPath, entityTable, entityIdCol, entityPartyCol, taskTable, taskPartyCol, voteTable, targets, stake, deadlineHours, idPrefix, allowedStatuses, notFoundMsg, ownClaimMsg, statusErrMsg, dupErrMsg, taskAlias: a } = cfg;
5
+ app.post(`/api/${entityPath}/:id/claim`, (req, res) => {
6
+ const user = auth(req, res);
7
+ if (!user)
8
+ return;
9
+ if (isTrustedRole(user)) {
10
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_CLAIM', '受信角色不可发起声明');
11
+ }
12
+ const entity = db.prepare(`SELECT id, ${entityPartyCol}, status FROM ${entityTable} WHERE id = ?`)
13
+ .get(req.params.id);
14
+ if (!entity)
15
+ return void res.status(404).json({ error: notFoundMsg });
16
+ const partyId = entity[entityPartyCol];
17
+ if (partyId === user.id)
18
+ return void errorRes(res, 403, 'CANNOT_CLAIM_OWN', ownClaimMsg);
19
+ if (!allowedStatuses.includes(entity.status))
20
+ return void res.status(400).json({ error: statusErrMsg });
21
+ const target = String(req.body?.claim_target || '').trim();
22
+ if (!targets.has(target))
23
+ return void res.status(400).json({ error: `claim_target 须为 ${[...targets].join(' / ')}` });
24
+ const text = String(req.body?.claim_text || '').trim();
25
+ if (text.length < 6 || text.length > 500)
26
+ return void res.status(400).json({ error: 'claim_text 长度需 6-500 字' });
27
+ const evidence = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
28
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
29
+ if (!wallet || wallet.balance < stake)
30
+ return void res.status(400).json({ error: `余额不足:发起需锁 ${stake} WAZ` });
31
+ const dup = db.prepare(`SELECT id FROM ${taskTable} WHERE ${entityIdCol} = ? AND claimant_id = ? AND claim_target = ? AND status = 'open'`)
32
+ .get(req.params.id, user.id, target);
33
+ if (dup)
34
+ return void res.status(409).json({ error: dupErrMsg });
35
+ const id = generateId(idPrefix);
36
+ const deadline = new Date(Date.now() + deadlineHours * 3600_000).toISOString();
37
+ db.prepare(`INSERT INTO ${taskTable} (id, ${entityIdCol}, ${taskPartyCol}, claimant_id, claim_target, claim_text, evidence_uri, stake_claimant, deadline_at, status) VALUES (?,?,?,?,?,?,?,?,?,'open')`)
38
+ .run(id, req.params.id, partyId, user.id, target, text, evidence, stake, deadline);
39
+ db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?')
40
+ .run(stake, stake, user.id);
41
+ res.json({ success: true, claim_id: id, deadline_at: deadline, stake_locked: stake });
42
+ });
43
+ app.get(`/api/${entityPath}/:id/claims`, (req, res) => {
44
+ const sql = `
45
+ SELECT ${a}.id, ${a}.claim_target, ${a}.claim_text, ${a}.evidence_uri, ${a}.status, ${a}.ruling, ${a}.deadline_at, ${a}.resolved_at, ${a}.created_at,
46
+ u.name as claimant_name,
47
+ (SELECT COUNT(*) FROM ${voteTable} WHERE claim_id = ${a}.id) as votes_count
48
+ FROM ${taskTable} ${a} JOIN users u ON u.id = ${a}.claimant_id
49
+ WHERE ${a}.${entityIdCol} = ? ORDER BY ${a}.created_at DESC LIMIT 50
50
+ `;
51
+ const rows = db.prepare(sql).all(req.params.id);
52
+ res.json({ claims: rows, votes_needed: 3 });
53
+ });
54
+ };
55
+ wire({
56
+ entityPath: 'secondhand', entityTable: 'secondhand_items', entityIdCol: 'sh_item_id',
57
+ entityPartyCol: 'seller_id', taskTable: 'secondhand_claim_tasks', taskPartyCol: 'seller_id',
58
+ voteTable: 'secondhand_claim_votes', voteCountedFromVotesTable: 'secondhand_claim_votes',
59
+ targets: new Set(['condition', 'images', 'description', 'title', 'price', 'other']),
60
+ stake: 5, deadlineHours: 72, idPrefix: 'sct',
61
+ allowedStatuses: ['available'],
62
+ notFoundMsg: '二手物品不存在', ownClaimMsg: '不可对自己挂售的物品发起声明',
63
+ statusErrMsg: '仅在售物品可发起声明', dupErrMsg: '你已对此物品同一项发起过 open 声明',
64
+ taskAlias: 'sct',
65
+ });
66
+ wire({
67
+ entityPath: 'auctions', entityTable: 'auctions', entityIdCol: 'auction_id',
68
+ entityPartyCol: 'seller_id', taskTable: 'auction_claim_tasks', taskPartyCol: 'seller_id',
69
+ voteTable: 'auction_claim_votes', voteCountedFromVotesTable: 'auction_claim_votes',
70
+ targets: new Set(['unreasonable_reserve', 'shill_bidding', 'collusion', 'fake_listing', 'other']),
71
+ stake: 5, deadlineHours: 72, idPrefix: 'act',
72
+ allowedStatuses: ['open'],
73
+ notFoundMsg: '拍卖不存在', ownClaimMsg: '不可对自己发起的拍卖发起声明',
74
+ statusErrMsg: '仅进行中的拍卖可发起声明', dupErrMsg: '你已对此拍卖同一项发起过 open 声明',
75
+ taskAlias: 'act',
76
+ });
77
+ wire({
78
+ entityPath: 'wishes', entityTable: 'wishes', entityIdCol: 'wish_id',
79
+ entityPartyCol: 'user_id', taskTable: 'wish_claim_tasks', taskPartyCol: 'wisher_id',
80
+ voteTable: 'wish_claim_votes', voteCountedFromVotesTable: 'wish_claim_votes',
81
+ targets: new Set(['fake_identity', 'fake_story', 'already_fulfilled', 'duplicate', 'inappropriate', 'other']),
82
+ stake: 5, deadlineHours: 72, idPrefix: 'wct',
83
+ allowedStatuses: ['open', 'claimed'],
84
+ notFoundMsg: '许愿不存在', ownClaimMsg: '不可对自己发起的许愿发起声明',
85
+ statusErrMsg: '仅 open / claimed 状态的许愿可发起声明', dupErrMsg: '你已对此许愿同一项发起过 open 声明',
86
+ taskAlias: 'wct',
87
+ });
88
+ }