@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,278 @@
1
+ import { transition } from '../../layer0-foundation/L0-2-state-machine/engine.js';
2
+ import { notifyTransition } from '../../layer2-business/L2-6-notifications/notification-engine.js';
3
+ const SH_CATEGORIES = new Set(['phone', 'computer', 'appliance', 'furniture', 'clothing', 'book', 'toy', 'sports', 'other']);
4
+ const SH_CONDITIONS = new Set(['brand_new', 'like_new', 'lightly_used', 'well_used', 'heavily_used']);
5
+ const SH_FULFILLMENT = new Set(['shipping', 'in_person', 'both']);
6
+ const SH_STATUS_USER_SET = new Set(['available', 'reserved', 'closed']); // 'sold' 由系统在 settleOrder 设
7
+ function addHours(date, hours) {
8
+ return new Date(date.getTime() + hours * 3_600_000).toISOString();
9
+ }
10
+ export function registerSecondhandRoutes(app, deps) {
11
+ const { db, generateId, auth, errorRes } = deps;
12
+ // 1. 发布
13
+ app.post('/api/secondhand', (req, res) => {
14
+ const user = auth(req, res);
15
+ if (!user)
16
+ return;
17
+ const { title, description, category, condition_grade, price, negotiable, images, region, fulfillment } = req.body || {};
18
+ const t = String(title || '').trim();
19
+ if (t.length < 2 || t.length > 60)
20
+ return void res.status(400).json({ error: '标题需 2-60 字' });
21
+ if (!SH_CATEGORIES.has(category))
22
+ return void res.status(400).json({ error: '类目无效' });
23
+ if (!SH_CONDITIONS.has(condition_grade))
24
+ return void res.status(400).json({ error: '成色无效' });
25
+ const p = Number(price);
26
+ if (!Number.isFinite(p) || p <= 0 || p > 100000)
27
+ return void res.status(400).json({ error: '价格须 0-100000 WAZ' });
28
+ const desc = description ? String(description).trim().slice(0, 1000) : null;
29
+ const imgs = Array.isArray(images) ? images.filter((x) => typeof x === 'string' && x.length < 800_000).slice(0, 9) : [];
30
+ if (imgs.length === 0)
31
+ return void res.status(400).json({ error: '至少上传 1 张图片' });
32
+ const ff = SH_FULFILLMENT.has(fulfillment) ? fulfillment : 'both';
33
+ const reg = region ? String(region).trim().slice(0, 40) : null;
34
+ const id = generateId('shi');
35
+ db.prepare(`INSERT INTO secondhand_items (id, seller_id, title, description, category, condition_grade, price, negotiable, images, region, fulfillment)
36
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run(id, user.id, t, desc, category, condition_grade, p, negotiable ? 1 : 0, JSON.stringify(imgs), reg, ff);
37
+ res.json({ success: true, id });
38
+ });
39
+ // 2. 列表(市场入口)
40
+ app.get('/api/secondhand', (req, res) => {
41
+ const category = String(req.query.category || '').trim();
42
+ const conditionList = String(req.query.condition || '').split(',').map(s => s.trim()).filter(s => SH_CONDITIONS.has(s));
43
+ const region = String(req.query.region || '').trim();
44
+ const minP = Number(req.query.min_price) || 0;
45
+ const maxP = Number(req.query.max_price) || Infinity;
46
+ const q = String(req.query.q || '').trim().toLowerCase();
47
+ const limit = Math.min(Math.max(Number(req.query.limit) || 60, 1), 100);
48
+ const sort = String(req.query.sort || 'newest');
49
+ const orderBy = sort === 'price_asc' ? 'si.price ASC' : sort === 'price_desc' ? 'si.price DESC' : sort === 'popular' ? 'si.view_count DESC, si.created_at DESC' : 'si.created_at DESC';
50
+ const where = [`status = 'available'`];
51
+ const args = [];
52
+ if (SH_CATEGORIES.has(category)) {
53
+ where.push('category = ?');
54
+ args.push(category);
55
+ }
56
+ if (conditionList.length > 0) {
57
+ where.push(`condition_grade IN (${conditionList.map(() => '?').join(',')})`);
58
+ args.push(...conditionList);
59
+ }
60
+ if (region) {
61
+ where.push('region = ?');
62
+ args.push(region);
63
+ }
64
+ if (minP > 0) {
65
+ where.push('price >= ?');
66
+ args.push(minP);
67
+ }
68
+ if (Number.isFinite(maxP)) {
69
+ where.push('price <= ?');
70
+ args.push(maxP);
71
+ }
72
+ if (q) {
73
+ where.push('LOWER(title) LIKE ?');
74
+ args.push('%' + q + '%');
75
+ }
76
+ // 排除自己(如登录)
77
+ const me = (req.headers.authorization || '').replace('Bearer ', '');
78
+ if (me) {
79
+ const u = db.prepare("SELECT id FROM users WHERE api_key = ?").get(me);
80
+ if (u) {
81
+ where.push('seller_id != ?');
82
+ args.push(u.id);
83
+ }
84
+ }
85
+ const rows = db.prepare(`SELECT si.id, si.seller_id, si.title, si.category, si.condition_grade, si.price, si.negotiable,
86
+ si.region, si.fulfillment, si.images, si.view_count, si.created_at,
87
+ u.name as seller_name, u.handle as seller_handle
88
+ FROM secondhand_items si JOIN users u ON u.id = si.seller_id
89
+ WHERE ${where.join(' AND ')}
90
+ ORDER BY ${orderBy} LIMIT ${limit}`).all(...args);
91
+ for (const r of rows) {
92
+ try {
93
+ const arr = JSON.parse(r.images);
94
+ r.cover = arr[0] || null;
95
+ }
96
+ catch {
97
+ r.cover = null;
98
+ }
99
+ delete r.images;
100
+ }
101
+ res.json({ items: rows });
102
+ });
103
+ // 3. 我的二手发布
104
+ app.get('/api/secondhand/mine', (req, res) => {
105
+ const user = auth(req, res);
106
+ if (!user)
107
+ return;
108
+ const rows = db.prepare(`SELECT id, title, category, condition_grade, price, negotiable, status,
109
+ region, fulfillment, images, view_count, created_at, sold_at, sold_order_id
110
+ FROM secondhand_items WHERE seller_id = ? ORDER BY created_at DESC LIMIT 100`).all(user.id);
111
+ for (const r of rows) {
112
+ try {
113
+ const arr = JSON.parse(r.images);
114
+ r.cover = arr[0] || null;
115
+ }
116
+ catch {
117
+ r.cover = null;
118
+ }
119
+ delete r.images;
120
+ }
121
+ const stats = db.prepare(`SELECT
122
+ SUM(CASE WHEN status='available' THEN 1 ELSE 0 END) as available_count,
123
+ SUM(CASE WHEN status='reserved' THEN 1 ELSE 0 END) as reserved_count,
124
+ SUM(CASE WHEN status='sold' THEN 1 ELSE 0 END) as sold_count,
125
+ SUM(CASE WHEN status='closed' THEN 1 ELSE 0 END) as closed_count,
126
+ COALESCE(SUM(CASE WHEN status='sold' THEN price ELSE 0 END), 0) as gross_sold_amount
127
+ FROM secondhand_items WHERE seller_id = ?`).get(user.id);
128
+ // 估算净收入(扣 1% 协议 + 1% 基金)
129
+ stats.estimated_earned = Math.round((stats.gross_sold_amount || 0) * 0.98 * 100) / 100;
130
+ res.json({ items: rows, stats });
131
+ });
132
+ // 4. 详情(view_count++)+ 同卖家其他在售
133
+ app.get('/api/secondhand/:id', (req, res) => {
134
+ const row = db.prepare(`SELECT si.*, u.name as seller_name, u.handle as seller_handle, u.permanent_code as seller_code
135
+ FROM secondhand_items si JOIN users u ON u.id = si.seller_id WHERE si.id = ?`).get(req.params.id);
136
+ if (!row)
137
+ return void res.status(404).json({ error: '物品不存在' });
138
+ db.prepare('UPDATE secondhand_items SET view_count = view_count + 1 WHERE id = ?').run(req.params.id);
139
+ try {
140
+ row.images = JSON.parse(row.images);
141
+ }
142
+ catch {
143
+ row.images = [];
144
+ }
145
+ const sellerOthers = db.prepare(`SELECT id, title, category, condition_grade, price, images, region
146
+ FROM secondhand_items WHERE seller_id = ? AND status='available' AND id != ?
147
+ ORDER BY created_at DESC LIMIT 6`).all(row.seller_id, req.params.id);
148
+ for (const o of sellerOthers) {
149
+ try {
150
+ const arr = JSON.parse(o.images);
151
+ o.cover = arr[0] || null;
152
+ }
153
+ catch {
154
+ o.cover = null;
155
+ }
156
+ delete o.images;
157
+ }
158
+ res.json({ item: row, seller_others: sellerOthers });
159
+ });
160
+ // 5. 编辑(仅 owner;可改 price / description / negotiable / status / fulfillment)
161
+ app.patch('/api/secondhand/:id', (req, res) => {
162
+ const user = auth(req, res);
163
+ if (!user)
164
+ return;
165
+ const item = db.prepare('SELECT * FROM secondhand_items WHERE id = ?').get(req.params.id);
166
+ if (!item)
167
+ return void res.status(404).json({ error: '物品不存在' });
168
+ if (item.seller_id !== user.id)
169
+ return void res.status(403).json({ error: '仅发布者可编辑' });
170
+ if (item.status === 'sold')
171
+ return void res.status(400).json({ error: '已售出,不可编辑' });
172
+ const sets = [];
173
+ const args = [];
174
+ const { price, description, negotiable, status, fulfillment } = req.body || {};
175
+ if (price !== undefined) {
176
+ const p = Number(price);
177
+ if (!Number.isFinite(p) || p <= 0 || p > 100000)
178
+ return void res.status(400).json({ error: '价格无效' });
179
+ sets.push('price = ?');
180
+ args.push(p);
181
+ }
182
+ if (description !== undefined) {
183
+ sets.push('description = ?');
184
+ args.push(String(description).slice(0, 1000));
185
+ }
186
+ if (negotiable !== undefined) {
187
+ sets.push('negotiable = ?');
188
+ args.push(negotiable ? 1 : 0);
189
+ }
190
+ if (status !== undefined) {
191
+ if (!SH_STATUS_USER_SET.has(status))
192
+ return void res.status(400).json({ error: '状态无效(仅 available / reserved / closed 可手动设置)' });
193
+ if (item.status === 'reserved' && status === 'available') {
194
+ return void res.status(400).json({ error: 'reserved 状态由系统管理,请等待买家完成支付' });
195
+ }
196
+ // Sprint 5: claim_loss_count >= 3 的 closed 物品不可自助 re-open
197
+ if (status === 'available' && (Number(item.claim_loss_count) || 0) >= 3) {
198
+ return void errorRes(res, 403, 'CLAIM_THRESHOLD_REACHED', `该物品累计 ${item.claim_loss_count} 次声明被验证不实,已达自动下架阈值。需 admin 干预方可重新上架。`);
199
+ }
200
+ sets.push('status = ?');
201
+ args.push(status);
202
+ }
203
+ if (fulfillment !== undefined) {
204
+ if (!SH_FULFILLMENT.has(fulfillment))
205
+ return void res.status(400).json({ error: '履约方式无效' });
206
+ sets.push('fulfillment = ?');
207
+ args.push(fulfillment);
208
+ }
209
+ if (sets.length === 0)
210
+ return void res.status(400).json({ error: '无字段更新' });
211
+ sets.push("updated_at = datetime('now')");
212
+ args.push(req.params.id);
213
+ db.prepare(`UPDATE secondhand_items SET ${sets.join(', ')} WHERE id = ?`).run(...args);
214
+ res.json({ success: true });
215
+ });
216
+ // 6. 下单(CAS 锁库存)
217
+ app.post('/api/secondhand/:id/order', (req, res) => {
218
+ const user = auth(req, res);
219
+ if (!user)
220
+ return;
221
+ const { shipping_address, notes, fulfillment_mode } = req.body || {};
222
+ const item = db.prepare('SELECT * FROM secondhand_items WHERE id = ?').get(req.params.id);
223
+ if (!item)
224
+ return void res.status(404).json({ error: '物品不存在' });
225
+ if (item.status !== 'available')
226
+ return void res.status(409).json({ error: `物品状态:${item.status},不可购买` });
227
+ if (item.seller_id === user.id)
228
+ return void res.status(403).json({ error: '不可购买自己的物品' });
229
+ const mode = String(fulfillment_mode || 'shipping');
230
+ if (mode !== 'shipping' && mode !== 'in_person')
231
+ return void res.status(400).json({ error: 'fulfillment_mode 必须为 shipping / in_person' });
232
+ if (item.fulfillment === 'shipping' && mode === 'in_person')
233
+ return void res.status(400).json({ error: '该物品仅支持快递' });
234
+ if (item.fulfillment === 'in_person' && mode === 'shipping')
235
+ return void res.status(400).json({ error: '该物品仅支持面交' });
236
+ if (mode === 'shipping' && !shipping_address)
237
+ return void res.status(400).json({ error: '快递必须填收货地址' });
238
+ const total = item.price;
239
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
240
+ if (wallet.balance < total)
241
+ return void res.status(400).json({ error: `余额不足:需 ${total} WAZ,当前 ${wallet.balance}` });
242
+ // CAS 抢占
243
+ const claim = db.prepare(`UPDATE secondhand_items SET status='reserved', updated_at=datetime('now')
244
+ WHERE id = ? AND status='available'`).run(req.params.id);
245
+ if (claim.changes !== 1)
246
+ return void res.status(409).json({ error: '物品已被预定,请刷新' });
247
+ try {
248
+ const now = new Date();
249
+ const orderId = generateId('ord');
250
+ const buyer = db.prepare("SELECT sponsor_id, sponsor_path, region FROM users WHERE id = ?").get(user.id);
251
+ const buyerRegionSnapshot = buyer?.region || 'global';
252
+ // orders.product_id 原本 FK products(id),二手指向 secondhand_items(id) — 临时关 FK
253
+ db.pragma('foreign_keys = OFF');
254
+ try {
255
+ db.prepare(`INSERT INTO orders (
256
+ id, product_id, buyer_id, seller_id, quantity, unit_price, total_amount, escrow_amount,
257
+ status, shipping_address, notes, pay_deadline, accept_deadline, ship_deadline,
258
+ pickup_deadline, delivery_deadline, confirm_deadline,
259
+ snapshot_commission_rate, buyer_region, source, fulfillment_mode
260
+ ) VALUES (?,?,?,?,1,?,?,?,'created',?,?,?,?,?,?,?,?,?,?, 'secondhand', ?)`).run(orderId, req.params.id, user.id, item.seller_id, total, total, total, mode === 'shipping' ? shipping_address : (shipping_address || '面交'), notes || null, addHours(now, 24), addHours(now, 48), addHours(now, 120), addHours(now, 168), addHours(now, 336), addHours(now, 408), 0, buyerRegionSnapshot, mode);
261
+ }
262
+ finally {
263
+ db.pragma('foreign_keys = ON');
264
+ }
265
+ db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?')
266
+ .run(total, total, user.id);
267
+ transition(db, orderId, 'paid', user.id, [], '二手下单 — escrow 已锁');
268
+ notifyTransition(db, orderId, 'created', 'paid');
269
+ res.json({ success: true, order_id: orderId, total_amount: total, fulfillment_mode: mode });
270
+ }
271
+ catch (e) {
272
+ // 失败回滚:释放 reserved
273
+ db.prepare(`UPDATE secondhand_items SET status='available', updated_at=datetime('now') WHERE id = ?`).run(req.params.id);
274
+ console.error('[secondhand order]', e);
275
+ res.status(500).json({ error: '下单失败:' + e.message });
276
+ }
277
+ });
278
+ }
@@ -0,0 +1,225 @@
1
+ export function registerSellerQuotaRoutes(app, deps) {
2
+ const { db, generateId, auth, requireUsersAdmin, safeRoles, checkSellerCanList, adminCanOperateOn, logAdminAction, QUOTA_TIERS } = deps;
3
+ // 配额状态
4
+ app.get('/api/seller/quota-status', (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ if (user.role !== 'seller' && !safeRoles(user).includes('seller')) {
9
+ return void res.json({ error: '仅卖家可查看配额' });
10
+ }
11
+ const status = checkSellerCanList(user);
12
+ const pending = db.prepare("SELECT id, requested_quota, applied_at FROM quota_increase_applications WHERE user_id = ? AND status = 'pending' ORDER BY applied_at DESC LIMIT 1").get(user.id);
13
+ const max = Number(user.max_products ?? 200);
14
+ const nextTierIdx = QUOTA_TIERS.indexOf(max);
15
+ const nextTier = nextTierIdx >= 0 && nextTierIdx < QUOTA_TIERS.length - 1 ? QUOTA_TIERS[nextTierIdx + 1] : null;
16
+ res.json({
17
+ max_products: max,
18
+ total_used: status.total ?? 0,
19
+ daily_limit: status.daily_limit,
20
+ daily_used: status.daily_used ?? 0,
21
+ new_user: !!status.new_user,
22
+ listing_paused: !!user.listing_paused,
23
+ listing_paused_reason: user.listing_paused_reason ?? null,
24
+ next_tier: nextTier,
25
+ pending_application: pending ?? null,
26
+ can_list: status.ok,
27
+ block_reason: status.reason ?? null,
28
+ });
29
+ });
30
+ // 数据中心(30d GMV / 7d 曲线 / Top 5 / 客户洞察 / 状态分布)
31
+ app.get('/api/seller/insights', (req, res) => {
32
+ const user = auth(req, res);
33
+ if (!user)
34
+ return;
35
+ const roles = safeRoles(user);
36
+ if (user.role !== 'seller' && !roles.includes('seller')) {
37
+ return void res.json({ error: '仅卖家可查看' });
38
+ }
39
+ const sellerId = user.id;
40
+ const now = Date.now();
41
+ // SQLite datetime('now') 返回 'YYYY-MM-DD HH:MM:SS' — 用同格式避免字典比较错位
42
+ const fmt = (d) => d.toISOString().replace('T', ' ').slice(0, 19);
43
+ const d30 = fmt(new Date(now - 30 * 86400000));
44
+ const d60 = fmt(new Date(now - 60 * 86400000));
45
+ const orders = db.prepare(`
46
+ SELECT o.id, o.product_id, o.buyer_id, o.status, o.total_amount, o.created_at,
47
+ COALESCE(p.title, '已下架商品') as product_title,
48
+ COALESCE(ub.name, '匿名') as buyer_name
49
+ FROM orders o
50
+ LEFT JOIN products p ON o.product_id = p.id
51
+ LEFT JOIN users ub ON o.buyer_id = ub.id
52
+ WHERE o.seller_id = ? AND o.created_at >= ?
53
+ ORDER BY o.created_at DESC
54
+ `).all(sellerId, d60);
55
+ const completedStatuses = new Set(['completed', 'confirmed']);
56
+ const disputedStatuses = new Set(['disputed', 'fault_seller', 'fault_buyer', 'fault_logistics', 'resolved_for_seller', 'refunded_partial', 'refunded_full', 'dispute_dismissed']);
57
+ const cancelledStatuses = new Set(['cancelled', 'expired']);
58
+ const inLast30 = orders.filter(o => o.created_at >= d30);
59
+ const inPrev30 = orders.filter(o => o.created_at < d30);
60
+ const gmv = (arr) => arr.filter(o => completedStatuses.has(o.status)).reduce((s, o) => s + Number(o.total_amount || 0), 0);
61
+ const curGmv = gmv(inLast30);
62
+ const prevGmv = gmv(inPrev30);
63
+ const curCount = inLast30.length;
64
+ const prevCount = inPrev30.length;
65
+ // 7 天日序列
66
+ const daily = [];
67
+ for (let i = 6; i >= 0; i--) {
68
+ const dt = new Date(now - i * 86400000).toISOString().slice(0, 10);
69
+ const day = inLast30.filter(o => (o.created_at || '').startsWith(dt));
70
+ daily.push({
71
+ date: dt,
72
+ orders: day.length,
73
+ gmv: day.filter(o => completedStatuses.has(o.status)).reduce((s, o) => s + Number(o.total_amount || 0), 0),
74
+ });
75
+ }
76
+ // Top 5 商品(30 天 GMV)
77
+ const productAgg = new Map();
78
+ for (const o of inLast30) {
79
+ if (!completedStatuses.has(o.status))
80
+ continue;
81
+ const cur = productAgg.get(o.product_id) || { product_id: o.product_id, title: o.product_title, gmv: 0, count: 0 };
82
+ cur.gmv += Number(o.total_amount || 0);
83
+ cur.count += 1;
84
+ productAgg.set(o.product_id, cur);
85
+ }
86
+ const topProducts = [...productAgg.values()].sort((a, b) => b.gmv - a.gmv).slice(0, 5);
87
+ // 客户洞察
88
+ const customerAgg = new Map();
89
+ for (const o of inLast30) {
90
+ if (!completedStatuses.has(o.status))
91
+ continue;
92
+ const cur = customerAgg.get(o.buyer_id) || { buyer_id: o.buyer_id, name: o.buyer_name || '匿名', gmv: 0, count: 0 };
93
+ cur.gmv += Number(o.total_amount || 0);
94
+ cur.count += 1;
95
+ customerAgg.set(o.buyer_id, cur);
96
+ }
97
+ const customers = [...customerAgg.values()];
98
+ const uniqueBuyers = customers.length;
99
+ const repeatBuyers = customers.filter(c => c.count >= 2).length;
100
+ const repeatRate = uniqueBuyers > 0 ? repeatBuyers / uniqueBuyers : 0;
101
+ const topCustomers = customers.sort((a, b) => b.gmv - a.gmv).slice(0, 3);
102
+ const statusBreakdown = {
103
+ completed: inLast30.filter(o => completedStatuses.has(o.status)).length,
104
+ disputed: inLast30.filter(o => disputedStatuses.has(o.status)).length,
105
+ cancelled: inLast30.filter(o => cancelledStatuses.has(o.status)).length,
106
+ in_progress: inLast30.filter(o => !completedStatuses.has(o.status) && !disputedStatuses.has(o.status) && !cancelledStatuses.has(o.status)).length,
107
+ };
108
+ const totalConcluded = statusBreakdown.completed + statusBreakdown.disputed + statusBreakdown.cancelled;
109
+ const completeRate = totalConcluded > 0 ? statusBreakdown.completed / totalConcluded : 0;
110
+ const disputeRate = totalConcluded > 0 ? statusBreakdown.disputed / totalConcluded : 0;
111
+ const completedOrders30 = inLast30.filter(o => completedStatuses.has(o.status));
112
+ const aov = completedOrders30.length > 0 ? curGmv / completedOrders30.length : 0;
113
+ res.json({
114
+ period_days: 30,
115
+ summary: {
116
+ gmv: curGmv,
117
+ order_count: curCount,
118
+ completed_count: completedOrders30.length,
119
+ aov,
120
+ unique_buyers: uniqueBuyers,
121
+ repeat_buyers: repeatBuyers,
122
+ repeat_rate: repeatRate,
123
+ complete_rate: completeRate,
124
+ dispute_rate: disputeRate,
125
+ },
126
+ vs_prev: {
127
+ gmv_delta: curGmv - prevGmv,
128
+ gmv_pct: prevGmv > 0 ? (curGmv - prevGmv) / prevGmv : (curGmv > 0 ? 1 : 0),
129
+ count_delta: curCount - prevCount,
130
+ count_pct: prevCount > 0 ? (curCount - prevCount) / prevCount : (curCount > 0 ? 1 : 0),
131
+ },
132
+ daily_7d: daily,
133
+ top_products: topProducts,
134
+ top_customers: topCustomers,
135
+ status_breakdown: statusBreakdown,
136
+ });
137
+ });
138
+ app.post('/api/seller/apply-quota-increase', (req, res) => {
139
+ const user = auth(req, res);
140
+ if (!user)
141
+ return;
142
+ if (user.role !== 'seller' && !safeRoles(user).includes('seller')) {
143
+ return void res.json({ error: '仅卖家可申请扩容' });
144
+ }
145
+ const { requested_quota, reason } = req.body;
146
+ const current = Number(user.max_products ?? 200);
147
+ const currentIdx = QUOTA_TIERS.indexOf(current);
148
+ if (currentIdx < 0 || currentIdx >= QUOTA_TIERS.length - 1) {
149
+ return void res.json({ error: '已是最高配额,无法继续扩容' });
150
+ }
151
+ const nextTier = QUOTA_TIERS[currentIdx + 1];
152
+ if (Number(requested_quota) !== nextTier) {
153
+ return void res.json({ error: `下一档配额应为 ${nextTier}` });
154
+ }
155
+ const existing = db.prepare("SELECT 1 FROM quota_increase_applications WHERE user_id = ? AND status = 'pending' LIMIT 1").get(user.id);
156
+ if (existing)
157
+ return void res.json({ error: '已有待审申请' });
158
+ db.prepare(`INSERT INTO quota_increase_applications (id, user_id, current_quota, requested_quota, reason) VALUES (?,?,?,?,?)`)
159
+ .run(generateId('qapp'), user.id, current, nextTier, (reason || '').toString().slice(0, 500));
160
+ res.json({ success: true, requested_quota: nextTier });
161
+ });
162
+ app.post('/api/seller/withdraw-quota-application', (req, res) => {
163
+ const user = auth(req, res);
164
+ if (!user)
165
+ return;
166
+ const pending = db.prepare("SELECT id FROM quota_increase_applications WHERE user_id = ? AND status = 'pending' LIMIT 1").get(user.id);
167
+ if (!pending)
168
+ return void res.json({ error: '没有待审申请' });
169
+ db.prepare("UPDATE quota_increase_applications SET status = 'withdrawn', reviewed_at = datetime('now') WHERE id = ?").run(pending.id);
170
+ res.json({ success: true });
171
+ });
172
+ // Admin
173
+ app.get('/api/admin/quota-applications', (req, res) => {
174
+ const admin = requireUsersAdmin(req, res);
175
+ if (!admin)
176
+ return;
177
+ const status = req.query.status || 'pending';
178
+ const rows = db.prepare(`
179
+ SELECT qa.*, u.name as user_name, u.email
180
+ FROM quota_increase_applications qa
181
+ LEFT JOIN users u ON u.id = qa.user_id
182
+ WHERE qa.status = ?
183
+ ORDER BY qa.applied_at DESC LIMIT 100
184
+ `).all(status);
185
+ res.json({ applications: rows });
186
+ });
187
+ app.post('/api/admin/quota-applications/:id/approve', (req, res) => {
188
+ const admin = requireUsersAdmin(req, res);
189
+ if (!admin)
190
+ return;
191
+ const { note } = req.body;
192
+ const appRow = db.prepare("SELECT id, user_id, requested_quota, status FROM quota_increase_applications WHERE id = ?")
193
+ .get(req.params.id);
194
+ if (!appRow)
195
+ return void res.json({ error: '申请不存在' });
196
+ if (!adminCanOperateOn(admin, appRow.user_id, res))
197
+ return;
198
+ if (appRow.status !== 'pending')
199
+ return void res.json({ error: '该申请不在待审状态' });
200
+ if (!QUOTA_TIERS.includes(appRow.requested_quota))
201
+ return void res.json({ error: '请求配额不合法' });
202
+ db.prepare("UPDATE quota_increase_applications SET status='approved', reviewed_at=datetime('now'), reviewed_by=?, decision_note=? WHERE id=?")
203
+ .run(admin.id, note || null, appRow.id);
204
+ db.prepare("UPDATE users SET max_products = ?, updated_at = datetime('now') WHERE id = ?").run(appRow.requested_quota, appRow.user_id);
205
+ logAdminAction(admin.id, 'approve_quota_increase', 'user', appRow.user_id, { quota: appRow.requested_quota, note });
206
+ res.json({ success: true });
207
+ });
208
+ app.post('/api/admin/quota-applications/:id/reject', (req, res) => {
209
+ const admin = requireUsersAdmin(req, res);
210
+ if (!admin)
211
+ return;
212
+ const { note } = req.body;
213
+ const appRow = db.prepare("SELECT id, user_id, status FROM quota_increase_applications WHERE id = ?").get(req.params.id);
214
+ if (!appRow)
215
+ return void res.json({ error: '申请不存在' });
216
+ if (!adminCanOperateOn(admin, appRow.user_id, res))
217
+ return;
218
+ if (appRow.status !== 'pending')
219
+ return void res.json({ error: '该申请不在待审状态' });
220
+ db.prepare("UPDATE quota_increase_applications SET status='rejected', reviewed_at=datetime('now'), reviewed_by=?, decision_note=? WHERE id=?")
221
+ .run(admin.id, note || null, appRow.id);
222
+ logAdminAction(admin.id, 'reject_quota_increase', 'user', appRow.user_id, { note });
223
+ res.json({ success: true });
224
+ });
225
+ }
@@ -0,0 +1,164 @@
1
+ import QRCode from 'qrcode';
2
+ import { createHash } from 'crypto';
3
+ export function registerShareRedirectsRoutes(app, deps) {
4
+ const { db, auth, clientIpHash, clientUaHash } = deps;
5
+ // 二维码生成(24h cache + ETag)
6
+ app.get('/api/qr', async (req, res) => {
7
+ const text = String(req.query.text || '').slice(0, 1024).trim();
8
+ if (!text)
9
+ return void res.status(400).send('text required');
10
+ const size = Math.min(Math.max(parseInt(String(req.query.size || '256'), 10) || 256, 64), 1024);
11
+ try {
12
+ const svg = await QRCode.toString(text, { type: 'svg', margin: 1, width: size, errorCorrectionLevel: 'M' });
13
+ const etag = '"' + createHash('sha1').update(text + ':' + size).digest('hex').slice(0, 16) + '"';
14
+ if (req.headers['if-none-match'] === etag)
15
+ return void res.status(304).end();
16
+ res.set('Content-Type', 'image/svg+xml');
17
+ res.set('Cache-Control', 'public, max-age=86400, immutable');
18
+ res.set('ETag', etag);
19
+ res.send(svg);
20
+ }
21
+ catch (e) {
22
+ res.status(500).send('qr generation failed: ' + e.message);
23
+ }
24
+ });
25
+ // 商品分享短链 /s/<shareable_id>
26
+ // 笔记 → 跳 PWA 内 #note/<id>;商品 → #order-product/<id>;外链 → 直跳外部 URL
27
+ app.get('/s/:id', (req, res) => {
28
+ const id = String(req.params.id || '').trim();
29
+ const row = db.prepare(`
30
+ SELECT id, owner_id, owner_code, type, external_url, related_product_id, related_anchor, status
31
+ FROM shareables WHERE id = ? AND status = 'active'
32
+ `).get(id);
33
+ // Phase C 笔记着陆页 — type=note 优先跳 PWA 内的 #note/<id>
34
+ if (row && row.type === 'note') {
35
+ const ownerRef = row.owner_code || row.owner_id || '';
36
+ const qs = new URLSearchParams();
37
+ if (ownerRef)
38
+ qs.set('ref', String(ownerRef));
39
+ qs.set('share_id', id);
40
+ try {
41
+ const ipHash = clientIpHash(req);
42
+ const uaHash = clientUaHash(req);
43
+ const dup = db.prepare(`SELECT 1 FROM shareable_click_log WHERE shareable_id = ? AND ip_hash = ? AND ua_hash = ? AND created_at > datetime('now', '-6 hours') LIMIT 1`).get(id, ipHash, uaHash);
44
+ db.prepare(`INSERT INTO shareable_click_log (shareable_id, ip_hash, ua_hash, ref_path) VALUES (?,?,?,?)`).run(id, ipHash, uaHash, req.originalUrl || null);
45
+ db.prepare(`UPDATE shareables SET click_count = COALESCE(click_count,0) + 1 WHERE id = ?`).run(id);
46
+ if (!dup)
47
+ db.prepare(`UPDATE shareables SET unique_click_count = COALESCE(unique_click_count,0) + 1 WHERE id = ?`).run(id);
48
+ }
49
+ catch (e) {
50
+ console.error('[note-click]', e);
51
+ }
52
+ return void res.redirect(302, `/?${qs.toString()}#note/${id}`);
53
+ }
54
+ if (!row)
55
+ return void res.status(404).send('Shareable not found or removed.');
56
+ // 点击量 + 反互助点击 unique 去重(6h 窗口)
57
+ try {
58
+ const ipHash = clientIpHash(req);
59
+ const uaHash = clientUaHash(req);
60
+ const dup = db.prepare(`
61
+ SELECT 1 FROM shareable_click_log
62
+ WHERE shareable_id = ? AND ip_hash = ? AND ua_hash = ?
63
+ AND created_at > datetime('now', '-6 hours') LIMIT 1
64
+ `).get(id, ipHash, uaHash);
65
+ db.prepare(`INSERT INTO shareable_click_log (shareable_id, ip_hash, ua_hash, ref_path) VALUES (?,?,?,?)`)
66
+ .run(id, ipHash, uaHash, req.originalUrl || null);
67
+ db.prepare(`UPDATE shareables SET click_count = COALESCE(click_count,0) + 1 WHERE id = ?`).run(id);
68
+ if (!dup) {
69
+ db.prepare(`UPDATE shareables SET unique_click_count = COALESCE(unique_click_count,0) + 1 WHERE id = ?`).run(id);
70
+ }
71
+ }
72
+ catch (e) {
73
+ console.error('[M3-click]', e);
74
+ }
75
+ const ownerRef = row.owner_code || row.owner_id || '';
76
+ const isProduct = !!row.related_product_id;
77
+ const baseParams = new URLSearchParams();
78
+ if (ownerRef)
79
+ baseParams.set('ref', String(ownerRef));
80
+ if (isProduct)
81
+ baseParams.set('share_id', id);
82
+ const qs = baseParams.toString() ? '?' + baseParams.toString() : '';
83
+ if (isProduct) {
84
+ return void res.redirect(302, `/${qs}#order-product/${row.related_product_id}`);
85
+ }
86
+ if (row.related_anchor) {
87
+ return void res.redirect(302, `/${qs}#u/${row.owner_id}`);
88
+ }
89
+ if (row.external_url) {
90
+ return void res.redirect(302, String(row.external_url));
91
+ }
92
+ res.redirect(302, `/${qs}#u/${row.owner_id}`);
93
+ });
94
+ // 商品分享归因落库(前端登录后首次进入带 share_id 时调用)
95
+ app.post('/api/product-share/touch', (req, res) => {
96
+ const user = auth(req, res);
97
+ if (!user)
98
+ return;
99
+ const { shareable_id } = req.body || {};
100
+ if (!shareable_id || typeof shareable_id !== 'string') {
101
+ return void res.json({ ok: false, error: 'invalid_shareable_id' });
102
+ }
103
+ const s = db.prepare(`
104
+ SELECT id, owner_id, related_product_id FROM shareables
105
+ WHERE id = ? AND status = 'active'
106
+ `).get(shareable_id);
107
+ if (!s || !s.related_product_id)
108
+ return void res.json({ ok: false, error: 'not_product_shareable' });
109
+ if (s.owner_id === user.id)
110
+ return void res.json({ ok: false, error: 'cannot_self_attribute' });
111
+ const expiresAt = new Date(Date.now() + 30 * 86400_000).toISOString().slice(0, 19).replace('T', ' ');
112
+ // first-touch:未过期 → 静默保留;过期 → 替换
113
+ const existing = db.prepare(`
114
+ SELECT sharer_id, expires_at FROM product_share_attribution
115
+ WHERE product_id = ? AND recipient_id = ?
116
+ `).get(s.related_product_id, user.id);
117
+ if (!existing) {
118
+ db.prepare(`
119
+ INSERT INTO product_share_attribution (product_id, recipient_id, sharer_id, shareable_id, expires_at)
120
+ VALUES (?, ?, ?, ?, ?)
121
+ `).run(s.related_product_id, user.id, s.owner_id, s.id, expiresAt);
122
+ return void res.json({ ok: true, attributed: true, sharer_id: s.owner_id, product_id: s.related_product_id });
123
+ }
124
+ const stillValid = new Date(existing.expires_at.replace(' ', 'T') + 'Z').getTime() > Date.now();
125
+ if (stillValid) {
126
+ return void res.json({ ok: true, attributed: false, existing_sharer_id: existing.sharer_id, locked: true });
127
+ }
128
+ // 已过期 → 刷新
129
+ db.prepare(`
130
+ UPDATE product_share_attribution
131
+ SET sharer_id = ?, shareable_id = ?, created_at = datetime('now'), expires_at = ?
132
+ WHERE product_id = ? AND recipient_id = ?
133
+ `).run(s.owner_id, s.id, expiresAt, s.related_product_id, user.id);
134
+ res.json({ ok: true, attributed: true, refreshed: true, sharer_id: s.owner_id });
135
+ });
136
+ // 邀请短链 /i/CODE
137
+ // 格式:/i/VKSF9P / /i/VKSF9P-L / /i/VKSF9P-R / /i/@handle / /i/@handle-L
138
+ app.get('/i/:code', (req, res) => {
139
+ let raw = String(req.params.code || '').trim();
140
+ let side = null;
141
+ const m = raw.match(/^(.+?)-([lLrR])$/);
142
+ if (m) {
143
+ raw = m[1];
144
+ side = m[2].toLowerCase() === 'l' ? 'left' : 'right';
145
+ }
146
+ // permanent_code 或 handle 规范化
147
+ let display = null;
148
+ if (/^[A-Z0-9]{6,7}$/.test(raw.toUpperCase()) && !raw.startsWith('@')) {
149
+ const r = db.prepare("SELECT permanent_code FROM users WHERE permanent_code = ? AND id != 'sys_protocol'").get(raw.toUpperCase());
150
+ if (r)
151
+ display = r.permanent_code;
152
+ }
153
+ if (!display) {
154
+ const h = raw.replace(/^@/, '').toLowerCase();
155
+ const r = db.prepare("SELECT handle FROM users WHERE handle = ? AND id != 'sys_protocol'").get(h);
156
+ if (r)
157
+ display = '@' + r.handle; // @ 前缀辨识 handle 形态
158
+ }
159
+ if (!display)
160
+ return void res.status(404).send('Invitation link not found. Code: ' + raw);
161
+ const target = `/?ref=${encodeURIComponent(display)}${side ? `&side=${side}` : ''}`;
162
+ res.redirect(302, target);
163
+ });
164
+ }