@seasonkoh/webaz 0.1.8 → 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 +3543 -852
  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 +31230 -2345
  22. package/dist/pwa/public/i18n.js +5282 -111
  23. package/dist/pwa/public/icon.svg +11 -0
  24. package/dist/pwa/public/index.html +4 -1
  25. package/dist/pwa/public/manifest.json +39 -4
  26. package/dist/pwa/public/openapi.json +5946 -0
  27. package/dist/pwa/public/style.css +278 -5
  28. package/dist/pwa/public/sw.js +41 -2
  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 +9247 -2097
  153. package/package.json +8 -3
@@ -0,0 +1,270 @@
1
+ const VALID_FEEDBACK_CAT = new Set(['bug', 'abuse', 'feature', 'account', 'other']);
2
+ const VALID_FEEDBACK_SEV = new Set(['low', 'medium', 'high']);
3
+ export function registerFeedbackRoutes(app, deps) {
4
+ const { db, generateId, auth, broadcastSystemEvent, detectFraud, anthropic } = deps;
5
+ app.post('/api/feedback', (req, res) => {
6
+ const user = auth(req, res);
7
+ if (!user)
8
+ return;
9
+ const { category, severity, subject, body } = req.body || {};
10
+ if (!VALID_FEEDBACK_CAT.has(String(category)))
11
+ return void res.status(400).json({ error: '无效类别' });
12
+ const sev = VALID_FEEDBACK_SEV.has(String(severity)) ? String(severity) : 'medium';
13
+ const sub = String(subject || '').trim();
14
+ const bod = String(body || '').trim();
15
+ if (sub.length < 4 || sub.length > 80)
16
+ return void res.status(400).json({ error: '标题 4-80 字' });
17
+ if (bod.length < 10 || bod.length > 2000)
18
+ return void res.status(400).json({ error: '正文 10-2000 字' });
19
+ const recent = db.prepare(`SELECT COUNT(*) as n FROM feedback_tickets WHERE user_id = ? AND created_at > datetime('now', '-1 hour')`).get(user.id).n;
20
+ if (recent >= 5)
21
+ return void res.status(429).json({ error: '提交过于频繁,请稍后再试' });
22
+ const id = generateId('fbk');
23
+ db.prepare(`INSERT INTO feedback_tickets (id, user_id, category, severity, subject, body) VALUES (?,?,?,?,?,?)`)
24
+ .run(id, user.id, String(category), sev, sub, bod);
25
+ try {
26
+ broadcastSystemEvent('feedback', '💬', `反馈工单 ${id} · ${category}/${sev}`, id);
27
+ }
28
+ catch { }
29
+ // G-4: 异步 AI 草拟回复(不阻塞)
30
+ ;
31
+ (async () => {
32
+ try {
33
+ const message = await anthropic.messages.create({
34
+ model: 'claude-haiku-4-5-20251001',
35
+ max_tokens: 400,
36
+ messages: [{
37
+ role: 'user',
38
+ content: `你是 WebAZ 客服 AI。用户提交了工单,你先草拟一份回复(admin 会审核后再发出)。客气专业,2-4 句,无前后缀:
39
+ 类别: ${category}
40
+ 紧急: ${sev}
41
+ 标题: ${sub}
42
+ 正文: ${bod}`,
43
+ }],
44
+ });
45
+ const text = message.content[0]?.text || '';
46
+ db.prepare(`UPDATE feedback_tickets SET ai_suggested_reply = ?, ai_generated_at = datetime('now') WHERE id = ?`)
47
+ .run(text.trim(), id);
48
+ }
49
+ catch (e) {
50
+ console.error('[ai feedback draft]', e.message);
51
+ }
52
+ })();
53
+ res.json({ success: true, id });
54
+ });
55
+ app.get('/api/feedback/mine', (req, res) => {
56
+ const user = auth(req, res);
57
+ if (!user)
58
+ return;
59
+ const rows = db.prepare(`SELECT id, category, severity, subject, body, status, admin_reply, replied_at, user_seen_reply_at, created_at, updated_at
60
+ FROM feedback_tickets WHERE user_id = ? ORDER BY created_at DESC LIMIT 100`).all(user.id);
61
+ // 派生 has_unread_reply
62
+ let unreadReplyCount = 0;
63
+ for (const r of rows) {
64
+ const hasReply = !!r.admin_reply;
65
+ const repliedAt = r.replied_at;
66
+ const seenAt = r.user_seen_reply_at;
67
+ const isUnread = hasReply && (!seenAt || (repliedAt && repliedAt > seenAt));
68
+ r.has_unread_reply = isUnread ? 1 : 0;
69
+ if (isUnread)
70
+ unreadReplyCount++;
71
+ }
72
+ res.json({ items: rows, unread_reply_count: unreadReplyCount });
73
+ });
74
+ app.post('/api/feedback/seen', (req, res) => {
75
+ const user = auth(req, res);
76
+ if (!user)
77
+ return;
78
+ db.prepare(`UPDATE feedback_tickets SET user_seen_reply_at = datetime('now')
79
+ WHERE user_id = ? AND admin_reply IS NOT NULL AND (user_seen_reply_at IS NULL OR replied_at > user_seen_reply_at)`).run(user.id);
80
+ res.json({ success: true });
81
+ });
82
+ // admin 列出工单
83
+ app.get('/api/admin/feedback', (req, res) => {
84
+ const user = auth(req, res);
85
+ if (!user)
86
+ return;
87
+ if (user.role !== 'admin')
88
+ return void res.status(403).json({ error: '仅 admin 可访问' });
89
+ const status = req.query.status ? String(req.query.status) : null;
90
+ const category = req.query.category ? String(req.query.category) : null;
91
+ const where = [];
92
+ const params = [];
93
+ if (status) {
94
+ where.push('f.status = ?');
95
+ params.push(status);
96
+ }
97
+ if (category) {
98
+ where.push('f.category = ?');
99
+ params.push(category);
100
+ }
101
+ const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
102
+ const rows = db.prepare(`
103
+ SELECT f.*, u.name as user_name, u.handle as user_handle, u.role as user_role
104
+ FROM feedback_tickets f
105
+ JOIN users u ON u.id = f.user_id
106
+ ${whereClause}
107
+ ORDER BY
108
+ CASE f.status WHEN 'open' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'resolved' THEN 3 ELSE 4 END,
109
+ CASE f.severity WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
110
+ f.created_at DESC
111
+ LIMIT 200
112
+ `).all(...params);
113
+ res.json({ items: rows });
114
+ });
115
+ // admin 回复 + 切状态
116
+ app.post('/api/admin/feedback/:id/reply', (req, res) => {
117
+ const user = auth(req, res);
118
+ if (!user)
119
+ return;
120
+ if (user.role !== 'admin')
121
+ return void res.status(403).json({ error: '仅 admin 可回复' });
122
+ const ticket = db.prepare('SELECT user_id, status FROM feedback_tickets WHERE id = ?').get(req.params.id);
123
+ if (!ticket)
124
+ return void res.status(404).json({ error: '工单不存在' });
125
+ const { reply, status } = req.body || {};
126
+ const replyStr = reply ? String(reply).slice(0, 2000).trim() : null;
127
+ if (!replyStr)
128
+ return void res.status(400).json({ error: '回复内容必填' });
129
+ const newStatus = status && ['open', 'in_progress', 'resolved', 'closed'].includes(String(status)) ? String(status) : 'resolved';
130
+ // 跨窗反诈一致性
131
+ const adminReasons = detectFraud(replyStr);
132
+ db.transaction(() => {
133
+ db.prepare(`INSERT INTO feedback_messages (id, ticket_id, sender_id, sender_role, body, flagged, flag_reasons) VALUES (?,?,?,?,?,?,?)`)
134
+ .run(generateId('fmsg'), req.params.id, user.id, 'admin', replyStr, adminReasons.length ? 1 : 0, adminReasons.length ? JSON.stringify(adminReasons) : null);
135
+ db.prepare(`UPDATE feedback_tickets SET admin_reply = ?, replied_by = ?, replied_at = datetime('now'), status = ?, updated_at = datetime('now') WHERE id = ?`)
136
+ .run(replyStr, user.id, newStatus, req.params.id);
137
+ })();
138
+ try {
139
+ const actions = JSON.stringify([{ kind: 'navigate', label: '查看工单', href: `#ticket/${req.params.id}`, style: 'primary' }]);
140
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`)
141
+ .run(generateId('ntf'), ticket.user_id, 'ticket_reply', `💬 客服回复了你的工单`, replyStr.slice(0, 100), null, actions);
142
+ }
143
+ catch (e) {
144
+ console.warn('[notif ticket_reply]', e.message);
145
+ }
146
+ res.json({ success: true });
147
+ });
148
+ // ─── W7 ticket-thread ────────────────────────────────────
149
+ // 工单详情 + timeline
150
+ app.get('/api/feedback/:id', (req, res) => {
151
+ const user = auth(req, res);
152
+ if (!user)
153
+ return;
154
+ const t = db.prepare(`
155
+ SELECT f.*, u.name as user_name, u.handle as user_handle, u.role as user_role
156
+ FROM feedback_tickets f JOIN users u ON u.id = f.user_id WHERE f.id = ?
157
+ `).get(req.params.id);
158
+ if (!t)
159
+ return void res.status(404).json({ error: '工单不存在' });
160
+ const isOwner = t.user_id === user.id;
161
+ const isAdmin = user.role === 'admin';
162
+ if (!isOwner && !isAdmin)
163
+ return void res.status(403).json({ error: '无权查看' });
164
+ const messages = db.prepare(`
165
+ SELECT m.*, u.name as sender_name, u.handle as sender_handle
166
+ FROM feedback_messages m LEFT JOIN users u ON u.id = m.sender_id
167
+ WHERE m.ticket_id = ? ORDER BY m.created_at ASC
168
+ `).all(t.id);
169
+ const events = [];
170
+ events.push({
171
+ id: `create-${t.id}`,
172
+ type: 'created',
173
+ ts: String(t.created_at || ''),
174
+ actor_id: String(t.user_id),
175
+ actor_role: 'user',
176
+ body: String(t.body || ''),
177
+ meta: { subject: t.subject, category: t.category, severity: t.severity },
178
+ });
179
+ for (const m of messages) {
180
+ let fr = [];
181
+ try {
182
+ fr = m.flag_reasons ? JSON.parse(String(m.flag_reasons)) : [];
183
+ }
184
+ catch { }
185
+ events.push({
186
+ id: `msg-${m.id}`,
187
+ type: 'message',
188
+ ts: String(m.created_at || ''),
189
+ actor_id: m.sender_id ? String(m.sender_id) : null,
190
+ actor_role: (m.sender_role || 'user'),
191
+ body: String(m.body || ''),
192
+ flagged: Number(m.flagged || 0),
193
+ flag_reasons: fr,
194
+ });
195
+ }
196
+ if ((t.status === 'resolved' || t.status === 'closed') && t.replied_at) {
197
+ events.push({
198
+ id: `done-${t.id}`,
199
+ type: t.status === 'closed' ? 'closed' : 'resolved',
200
+ ts: String(t.replied_at),
201
+ actor_id: t.replied_by ? String(t.replied_by) : null,
202
+ actor_role: 'admin',
203
+ body: '',
204
+ });
205
+ }
206
+ events.sort((a, b) => a.ts.localeCompare(b.ts));
207
+ if (isOwner) {
208
+ try {
209
+ db.prepare(`UPDATE feedback_tickets SET user_seen_reply_at = datetime('now') WHERE id = ? AND admin_reply IS NOT NULL`).run(t.id);
210
+ }
211
+ catch { }
212
+ }
213
+ if (isAdmin) {
214
+ try {
215
+ db.prepare(`UPDATE feedback_tickets SET admin_seen_at = datetime('now') WHERE id = ?`).run(t.id);
216
+ }
217
+ catch { }
218
+ }
219
+ res.json({ item: t, timeline: events, is_admin: isAdmin });
220
+ });
221
+ // 工单内追加消息(user 或 admin)
222
+ app.post('/api/feedback/:id/messages', (req, res) => {
223
+ const user = auth(req, res);
224
+ if (!user)
225
+ return;
226
+ const t = db.prepare(`SELECT id, user_id, status FROM feedback_tickets WHERE id = ?`).get(req.params.id);
227
+ if (!t)
228
+ return void res.status(404).json({ error: '工单不存在' });
229
+ const isOwner = t.user_id === user.id;
230
+ const isAdmin = user.role === 'admin';
231
+ if (!isOwner && !isAdmin)
232
+ return void res.status(403).json({ error: '无权操作' });
233
+ if (t.status === 'closed')
234
+ return void res.status(400).json({ error: '工单已关闭' });
235
+ const body = String(req.body?.body || '').trim();
236
+ if (body.length < 1 || body.length > 2000)
237
+ return void res.status(400).json({ error: '消息长度 1-2000 字' });
238
+ const reasons = detectFraud(body);
239
+ const mid = generateId('fmsg');
240
+ db.transaction(() => {
241
+ db.prepare(`INSERT INTO feedback_messages (id, ticket_id, sender_id, sender_role, body, flagged, flag_reasons) VALUES (?,?,?,?,?,?,?)`)
242
+ .run(mid, t.id, user.id, isAdmin ? 'admin' : 'user', body, reasons.length ? 1 : 0, reasons.length ? JSON.stringify(reasons) : null);
243
+ // user 追问 → 状态重新打开
244
+ if (isOwner && t.status === 'resolved') {
245
+ db.prepare(`UPDATE feedback_tickets SET status = 'in_progress', updated_at = datetime('now') WHERE id = ?`).run(t.id);
246
+ }
247
+ else {
248
+ db.prepare(`UPDATE feedback_tickets SET updated_at = datetime('now') WHERE id = ?`).run(t.id);
249
+ }
250
+ })();
251
+ try {
252
+ const tktAction = JSON.stringify([{ kind: 'navigate', label: '查看工单', href: `#ticket/${t.id}`, style: 'primary' }]);
253
+ if (isOwner) {
254
+ const admins = db.prepare(`SELECT id FROM users WHERE role = 'admin'`).all();
255
+ for (const a of admins) {
256
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`)
257
+ .run(generateId('ntf'), a.id, 'ticket_followup', '💬 用户追问了工单', body.slice(0, 100), null, tktAction);
258
+ }
259
+ }
260
+ else {
261
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`)
262
+ .run(generateId('ntf'), t.user_id, 'ticket_reply', '💬 客服回复了你的工单', body.slice(0, 100), null, tktAction);
263
+ }
264
+ }
265
+ catch (e) {
266
+ console.warn('[notif ticket_msg]', e.message);
267
+ }
268
+ res.json({ success: true, id: mid });
269
+ });
270
+ }
@@ -0,0 +1,130 @@
1
+ /** 拿商品(含 variant)当前生效的 flash sale;多重叠时取价最低。orders 下单也会用。 */
2
+ export function getActiveFlashSale(db, productId, variantId) {
3
+ const variantClause = variantId
4
+ ? `AND (variant_id IS NULL OR variant_id = ?)`
5
+ : `AND variant_id IS NULL`;
6
+ const sql = `
7
+ SELECT id, sale_price, ends_at, max_qty, sold_count
8
+ FROM flash_sales
9
+ WHERE product_id = ? AND is_active = 1
10
+ AND starts_at <= datetime('now') AND ends_at > datetime('now')
11
+ AND (max_qty = 0 OR sold_count < max_qty)
12
+ ${variantClause}
13
+ ORDER BY sale_price ASC LIMIT 1
14
+ `;
15
+ const args = [productId];
16
+ if (variantId)
17
+ args.push(variantId);
18
+ return db.prepare(sql).get(...args);
19
+ }
20
+ export function registerFlashSalesRoutes(app, deps) {
21
+ const { db, generateId, auth, broadcastSystemEvent } = deps;
22
+ app.post('/api/products/:product_id/flash-sale', (req, res) => {
23
+ const user = auth(req, res);
24
+ if (!user)
25
+ return;
26
+ const product = db.prepare('SELECT id, seller_id, price, has_variants FROM products WHERE id = ? AND status = \'active\'').get(req.params.product_id);
27
+ if (!product)
28
+ return void res.status(404).json({ error: '商品不存在或已下架' });
29
+ if (product.seller_id !== user.id)
30
+ return void res.status(403).json({ error: '仅商品卖家可创建限时促销' });
31
+ const { variant_id, sale_price, starts_at, ends_at, max_qty } = req.body || {};
32
+ const salePrice = Number(sale_price);
33
+ if (!Number.isFinite(salePrice) || salePrice <= 0 || salePrice >= Number(product.price)) {
34
+ return void res.status(400).json({ error: 'sale_price 必须 > 0 且 < 商品原价' });
35
+ }
36
+ if (!starts_at || !ends_at)
37
+ return void res.status(400).json({ error: 'starts_at / ends_at 必填(ISO 8601)' });
38
+ const startsT = new Date(starts_at).getTime();
39
+ const endsT = new Date(ends_at).getTime();
40
+ const now = Date.now();
41
+ if (!Number.isFinite(startsT) || !Number.isFinite(endsT))
42
+ return void res.status(400).json({ error: '时间格式无效' });
43
+ if (endsT <= startsT)
44
+ return void res.status(400).json({ error: '结束时间必须晚于开始时间' });
45
+ if (endsT - startsT > 30 * 86400 * 1000)
46
+ return void res.status(400).json({ error: '单次促销最多 30 天' });
47
+ if (endsT < now)
48
+ return void res.status(400).json({ error: '结束时间已过' });
49
+ let variantId = null;
50
+ if (variant_id) {
51
+ if (!product.has_variants)
52
+ return void res.status(400).json({ error: '该商品无规格,不可绑定 variant' });
53
+ const v = db.prepare('SELECT id FROM product_variants WHERE id = ? AND product_id = ? AND is_active = 1').get(variant_id, product.id);
54
+ if (!v)
55
+ return void res.status(400).json({ error: 'variant 不存在' });
56
+ variantId = String(variant_id);
57
+ }
58
+ else if (Number(product.has_variants) === 1) {
59
+ return void res.status(400).json({ error: '该商品有规格,请指定 variant_id' });
60
+ }
61
+ const maxQty = Number.isFinite(Number(max_qty)) ? Math.max(0, Number(max_qty)) : 0;
62
+ // 防重叠:同 product+variant 不能有进行中的促销
63
+ const conflict = db.prepare(`
64
+ SELECT id FROM flash_sales WHERE product_id = ? AND ${variantId ? 'variant_id = ?' : 'variant_id IS NULL'}
65
+ AND is_active = 1 AND ends_at > datetime('now') LIMIT 1
66
+ `).get(...(variantId ? [product.id, variantId] : [product.id]));
67
+ if (conflict)
68
+ return void res.status(409).json({ error: '已有进行中的促销,请先结束', existing_id: conflict.id });
69
+ const id = generateId('fls');
70
+ db.prepare(`INSERT INTO flash_sales (id, seller_id, product_id, variant_id, sale_price, original_price, max_qty, starts_at, ends_at) VALUES (?,?,?,?,?,?,?,?,?)`)
71
+ .run(id, user.id, product.id, variantId, salePrice, Number(product.price), maxQty, new Date(startsT).toISOString(), new Date(endsT).toISOString());
72
+ try {
73
+ broadcastSystemEvent('flash_sale', '⚡', `限时促销创建 ${product.id} · ${salePrice}/${product.price} WAZ`, product.id);
74
+ }
75
+ catch { }
76
+ res.json({ success: true, id });
77
+ });
78
+ // 公开:商品当前生效的 flash sale
79
+ app.get('/api/products/:product_id/flash-sale', (req, res) => {
80
+ const variantId = req.query.variant_id ? String(req.query.variant_id) : null;
81
+ const sale = getActiveFlashSale(db, req.params.product_id, variantId);
82
+ res.json({ sale });
83
+ });
84
+ // seller 自己的 flash sales(全部状态)
85
+ app.get('/api/sellers/me/flash-sales', (req, res) => {
86
+ const user = auth(req, res);
87
+ if (!user)
88
+ return;
89
+ const rows = db.prepare(`
90
+ SELECT f.*, p.title as product_title
91
+ FROM flash_sales f
92
+ JOIN products p ON p.id = f.product_id
93
+ WHERE f.seller_id = ?
94
+ ORDER BY f.ends_at DESC LIMIT 100
95
+ `).all(user.id);
96
+ res.json({ items: rows });
97
+ });
98
+ // 取消(仅 seller 自己,且未开始)
99
+ app.delete('/api/flash-sales/:id', (req, res) => {
100
+ const user = auth(req, res);
101
+ if (!user)
102
+ return;
103
+ const f = db.prepare('SELECT seller_id, starts_at FROM flash_sales WHERE id = ?').get(req.params.id);
104
+ if (!f)
105
+ return void res.status(404).json({ error: 'flash sale 不存在' });
106
+ if (f.seller_id !== user.id)
107
+ return void res.status(403).json({ error: '仅卖家可操作' });
108
+ if (new Date(f.starts_at).getTime() <= Date.now()) {
109
+ return void res.status(400).json({ error: '已开始的促销不可取消,请设置 is_active=0 提前结束' });
110
+ }
111
+ db.prepare('DELETE FROM flash_sales WHERE id = ?').run(req.params.id);
112
+ res.json({ success: true });
113
+ });
114
+ // buyer 视角:当前全平台正在进行的 flash sales(首屏 discovery)
115
+ app.get('/api/flash-sales/live', (req, res) => {
116
+ const rows = db.prepare(`
117
+ SELECT f.id, f.product_id, f.variant_id, f.sale_price, f.original_price, f.ends_at, f.max_qty, f.sold_count,
118
+ p.title, p.images, p.category,
119
+ u.handle as seller_handle, u.name as seller_name
120
+ FROM flash_sales f
121
+ JOIN products p ON p.id = f.product_id AND p.status = 'active'
122
+ JOIN users u ON u.id = f.seller_id
123
+ WHERE f.is_active = 1
124
+ AND f.starts_at <= datetime('now') AND f.ends_at > datetime('now')
125
+ AND (f.max_qty = 0 OR f.sold_count < f.max_qty)
126
+ ORDER BY f.ends_at ASC LIMIT 100
127
+ `).all();
128
+ res.json({ items: rows });
129
+ });
130
+ }
@@ -0,0 +1,103 @@
1
+ export function registerFollowsRoutes(app, deps) {
2
+ const { db, auth, generateId } = deps;
3
+ app.get('/api/follows/:user_id/status', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ const r = db.prepare("SELECT 1 FROM follows WHERE follower_id=? AND followee_id=?").get(user.id, req.params.user_id);
8
+ const followers = db.prepare("SELECT COUNT(*) as n FROM follows WHERE followee_id=?").get(req.params.user_id).n;
9
+ const following = db.prepare("SELECT COUNT(*) as n FROM follows WHERE follower_id=?").get(req.params.user_id).n;
10
+ res.json({ following: !!r, followers, following_count: following });
11
+ });
12
+ app.post('/api/follows/:user_id', (req, res) => {
13
+ const user = auth(req, res);
14
+ if (!user)
15
+ return;
16
+ if (user.id === req.params.user_id)
17
+ return void res.json({ error: '不能关注自己' });
18
+ const target = db.prepare("SELECT id FROM users WHERE id=?").get(req.params.user_id);
19
+ if (!target)
20
+ return void res.json({ error: '用户不存在' });
21
+ const result = db.prepare("INSERT OR IGNORE INTO follows (follower_id, followee_id) VALUES (?, ?)").run(user.id, req.params.user_id);
22
+ // 2026-05-24 新关注 → 通知被关注者(仅首次关注时;重复点击不重发)
23
+ if (result.changes > 0) {
24
+ try {
25
+ const followerName = db.prepare("SELECT name, handle FROM users WHERE id = ?").get(user.id);
26
+ const display = followerName?.handle ? '@' + followerName.handle : followerName?.name || 'someone';
27
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`)
28
+ .run(generateId('ntf'), req.params.user_id, 'social', `🤝 新关注`, `${display} 关注了你`, null);
29
+ }
30
+ catch (e) {
31
+ console.error('[follow notif]', e);
32
+ }
33
+ }
34
+ res.json({ ok: true, following: true });
35
+ });
36
+ app.delete('/api/follows/:user_id', (req, res) => {
37
+ const user = auth(req, res);
38
+ if (!user)
39
+ return;
40
+ db.prepare("DELETE FROM follows WHERE follower_id=? AND followee_id=?").run(user.id, req.params.user_id);
41
+ res.json({ ok: true, following: false });
42
+ });
43
+ app.get('/api/follows/me', (req, res) => {
44
+ const user = auth(req, res);
45
+ if (!user)
46
+ return;
47
+ const followers = db.prepare(`
48
+ SELECT u.id, u.name, u.role, f.created_at
49
+ FROM follows f JOIN users u ON u.id = f.follower_id
50
+ WHERE f.followee_id = ? ORDER BY f.created_at DESC LIMIT 100
51
+ `).all(user.id);
52
+ const following = db.prepare(`
53
+ SELECT u.id, u.name, u.role, f.created_at
54
+ FROM follows f JOIN users u ON u.id = f.followee_id
55
+ WHERE f.follower_id = ? ORDER BY f.created_at DESC LIMIT 100
56
+ `).all(user.id);
57
+ res.json({ followers, following });
58
+ });
59
+ // Wave D-1: 关注卖家动态 feed — new_product + restock 合并 + 去重
60
+ app.get('/api/follows/feed', (req, res) => {
61
+ const user = auth(req, res);
62
+ if (!user)
63
+ return;
64
+ const limit = Math.min(100, Math.max(10, Number(req.query.limit) || 50));
65
+ const followees = db.prepare(`SELECT followee_id FROM follows WHERE follower_id = ?`).all(user.id);
66
+ if (followees.length === 0)
67
+ return void res.json({ items: [] });
68
+ const ids = followees.map(f => f.followee_id);
69
+ const placeholders = ids.map(() => '?').join(',');
70
+ // 新品(近 30 天 active)
71
+ const newProducts = db.prepare(`
72
+ SELECT 'new_product' as type, p.created_at as ts, p.id as product_id, p.title, p.price, p.stock, p.category, p.images,
73
+ u.id as seller_id, u.name as seller_name, u.handle as seller_handle
74
+ FROM products p JOIN users u ON u.id = p.seller_id
75
+ WHERE p.seller_id IN (${placeholders}) AND p.status = 'active'
76
+ AND p.created_at > datetime('now', '-30 days')
77
+ ORDER BY p.created_at DESC LIMIT 100
78
+ `).all(...ids);
79
+ // 重新上架 / 补货(近 7 天 updated_at > created_at + 1 天,stock > 0)
80
+ const restocks = db.prepare(`
81
+ SELECT 'restock' as type, p.updated_at as ts, p.id as product_id, p.title, p.price, p.stock, p.category, p.images,
82
+ u.id as seller_id, u.name as seller_name, u.handle as seller_handle
83
+ FROM products p JOIN users u ON u.id = p.seller_id
84
+ WHERE p.seller_id IN (${placeholders}) AND p.status = 'active'
85
+ AND p.stock > 0
86
+ AND p.updated_at > datetime('now', '-7 days')
87
+ AND p.updated_at > datetime(p.created_at, '+1 days')
88
+ ORDER BY p.updated_at DESC LIMIT 30
89
+ `).all(...ids);
90
+ // 合并 + 去重(同 product 同时 new + restock → 优先 new)
91
+ const seen = new Set();
92
+ const merged = [];
93
+ for (const item of [...newProducts, ...restocks]) {
94
+ const prodKey = String(item.product_id);
95
+ if (seen.has(prodKey))
96
+ continue;
97
+ seen.add(prodKey);
98
+ merged.push(item);
99
+ }
100
+ merged.sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
101
+ res.json({ items: merged.slice(0, limit) });
102
+ });
103
+ }