@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,138 @@
1
+ export function registerAuthRegisterRoutes(app, deps) {
2
+ // VALID_REGIONS + INVITE_ROTATION_HANDLES 通过 deps.X 在 handler 内延迟读
3
+ // (server.ts 用 getter 注入;destructure at register-time would trigger TDZ 因为它们在下方 const)
4
+ const { db, errorRes, INTERNAL_AUDITOR_ID, isAllowedSponsor, resolveUserRef, generateId, generateSecureKey, generatePermanentCode, deriveHandle, clientIpHash, clientUaHash, pickPreferredSide, joinPowerLeg, inviteRotationLookup, recordSession, broadcastSystemEvent } = deps;
5
+ app.post('/api/register', (req, res) => {
6
+ const { name, role, sponsor_id, region, placement_inviter_id, placement_side } = req.body;
7
+ const validRoles = ['buyer', 'seller'];
8
+ if (!name?.trim())
9
+ return void errorRes(res, 400, 'NAME_REQUIRED', '请填写名称');
10
+ if (!validRoles.includes(role))
11
+ return void errorRes(res, 400, 'ROLE_NOT_PUBLIC_REGISTERABLE', '角色无效(仅允许 buyer/seller — 受信角色须经内部审批)');
12
+ const trimmed = name.trim();
13
+ if (trimmed.length < 2 || trimmed.length > 40)
14
+ return void errorRes(res, 400, 'NAME_LENGTH', '名称长度需在 2–40 个字符之间');
15
+ const dup = db.prepare("SELECT 1 FROM users WHERE name = ? AND id NOT IN ('sys_protocol', ?) LIMIT 1").get(trimmed, INTERNAL_AUDITOR_ID);
16
+ if (dup)
17
+ return void errorRes(res, 409, 'NAME_TAKEN', '该名称已被占用,请换一个');
18
+ const ROLE_WHITELIST = [];
19
+ const requireRef = db.prepare("SELECT value FROM system_state WHERE key='require_ref_to_register'").get()?.value === '1';
20
+ // 2026-05-30 合规复审:取消 china 豁免——D1b 引入时保留 china 通道是为获客,
21
+ // 但合规上正好反向(china 是 MLM 风险最高地区,豁免反而把最高风险地区做成最容易进)。
22
+ // 全球统一需邀请。第三方尽调报告风险 1 + 问题 1 的回应。
23
+ if (requireRef && !sponsor_id && !ROLE_WHITELIST.includes(role)) {
24
+ return void errorRes(res, 403, 'INVITE_REQUIRED', '注册需要邀请码。请联系已有用户获取邀请链接。', { hint: 'require_ref_enabled' });
25
+ }
26
+ let sponsorId = null;
27
+ let sponsorPath = null;
28
+ let sponsorSkipped = null;
29
+ let sponsorRawRef = (sponsor_id && typeof sponsor_id === 'string') ? sponsor_id.trim() : '';
30
+ let suffixSide = null;
31
+ const sufM = sponsorRawRef.match(/^(.+?)-([lLrR])$/);
32
+ if (sufM) {
33
+ sponsorRawRef = sufM[1];
34
+ suffixSide = sufM[2].toLowerCase() === 'l' ? 'left' : 'right';
35
+ }
36
+ if (sponsorRawRef) {
37
+ const resolvedSponsorId = resolveUserRef(sponsorRawRef);
38
+ if (!resolvedSponsorId || resolvedSponsorId === 'sys_protocol' || resolvedSponsorId === INTERNAL_AUDITOR_ID) {
39
+ return void errorRes(res, 400, 'INVALID_SPONSOR_REF', `邀请码无效:${sponsorRawRef.slice(0, 24)}(请检查或留空跳过)`);
40
+ }
41
+ const sponsor = db.prepare("SELECT id, sponsor_path FROM users WHERE id = ?")
42
+ .get(resolvedSponsorId);
43
+ if (!sponsor) {
44
+ return void errorRes(res, 400, 'INVALID_SPONSOR_REF', `邀请码对应用户不存在:${sponsorRawRef.slice(0, 24)}`);
45
+ }
46
+ sponsorId = sponsor.id;
47
+ sponsorPath = sponsor.sponsor_path ? `${sponsor.sponsor_path}>${sponsor.id}` : sponsor.id;
48
+ if (!isAllowedSponsor(sponsor.id))
49
+ sponsorSkipped = 'sponsor_pending_verification';
50
+ }
51
+ if (!sponsorId && !ROLE_WHITELIST.includes(role)) {
52
+ sponsorId = 'sys_protocol';
53
+ sponsorPath = 'sys_protocol';
54
+ }
55
+ const userRegion = (typeof region === 'string' && region.trim()) ? region.trim() : '';
56
+ if (!deps.VALID_REGIONS.has(userRegion)) {
57
+ return void errorRes(res, 400, 'REGION_REQUIRED', '请选择国家 / 地区(影响分润分账配置)');
58
+ }
59
+ const id = generateId('usr');
60
+ const apiKey = generateSecureKey('key');
61
+ const permaCode = generatePermanentCode();
62
+ const userHandle = deriveHandle(trimmed);
63
+ const ipHash = clientIpHash(req);
64
+ const uaHash = clientUaHash(req);
65
+ // Phase 3a:注册限频 — 同 IP 每小时最多 5 个新账号(挡批量刷号)
66
+ const recentReg = db.prepare(`SELECT COUNT(*) AS n FROM registration_audit_log WHERE ip_hash = ? AND created_at > datetime('now','-1 hour')`).get(ipHash).n;
67
+ if (recentReg >= 5) {
68
+ return void errorRes(res, 429, 'REGISTER_RATE_LIMITED', '注册过于频繁 — 同一网络每小时最多 5 个新账号,请稍后再试');
69
+ }
70
+ const registerTx = db.transaction(() => {
71
+ db.prepare(`INSERT INTO users (id, name, role, roles, api_key, sponsor_id, sponsor_path, region, permanent_code, handle)
72
+ VALUES (?,?,?,?,?,?,?,?,?,?)`)
73
+ .run(id, trimmed, role, JSON.stringify([role]), apiKey, sponsorId, sponsorPath, userRegion, permaCode, userHandle);
74
+ db.prepare('INSERT INTO wallets (user_id, balance) VALUES (?,1000)').run(id);
75
+ db.prepare(`INSERT INTO registration_audit_log (user_id, ip_hash, ua_hash, sponsor_id) VALUES (?,?,?,?)`)
76
+ .run(id, ipHash, uaHash, sponsorId);
77
+ let effectiveInviter = placement_inviter_id ? resolveUserRef(String(placement_inviter_id).replace(/-[lLrR]$/, '')) : null;
78
+ let effectiveSide = (placement_side === 'left' || placement_side === 'right') ? placement_side : suffixSide;
79
+ if (!effectiveInviter && sponsorRawRef)
80
+ effectiveInviter = resolveUserRef(sponsorRawRef);
81
+ if (effectiveInviter && !effectiveSide) {
82
+ try {
83
+ effectiveSide = pickPreferredSide(effectiveInviter);
84
+ }
85
+ catch {
86
+ effectiveSide = 'left';
87
+ }
88
+ }
89
+ let placement = null;
90
+ if (effectiveInviter && effectiveSide) {
91
+ const inviter = db.prepare("SELECT id FROM users WHERE id = ? AND id NOT IN ('sys_protocol', ?) LIMIT 1")
92
+ .get(effectiveInviter, INTERNAL_AUDITOR_ID);
93
+ if (inviter) {
94
+ try {
95
+ placement = joinPowerLeg(inviter.id, effectiveSide, id);
96
+ }
97
+ catch { }
98
+ }
99
+ }
100
+ const rotationEnabled = db.prepare("SELECT value FROM system_state WHERE key='invite_rotation_enabled'").get()?.value === '1';
101
+ if (rotationEnabled && sponsorId) {
102
+ for (let i = 0; i < deps.INVITE_ROTATION_HANDLES.length; i++) {
103
+ const u = inviteRotationLookup(i);
104
+ if (u && u.id === sponsorId) {
105
+ db.prepare("UPDATE invite_rotation_stats SET registered_count = registered_count + 1 WHERE slot = ?").run(i);
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ return { placement, effectiveInviter, effectiveSide };
111
+ });
112
+ let txResult;
113
+ try {
114
+ txResult = registerTx();
115
+ }
116
+ catch (e) {
117
+ console.error('[register-tx]', e.message);
118
+ return void res.status(500).json({ error: '注册写入失败,请重试' });
119
+ }
120
+ const { placement, effectiveInviter, effectiveSide } = txResult;
121
+ try {
122
+ recordSession(id, apiKey, req);
123
+ }
124
+ catch { }
125
+ try {
126
+ broadcastSystemEvent('register', '🎉', `新用户注册: ${trimmed} (${role})`, id);
127
+ }
128
+ catch { }
129
+ res.json({
130
+ success: true, api_key: apiKey, user_id: id, name: trimmed, role, roles: [role],
131
+ sponsor_id: sponsorId, region: userRegion,
132
+ permanent_code: permaCode,
133
+ handle: userHandle,
134
+ placement: placement ? { inviter_id: effectiveInviter, side: effectiveSide, depth: placement.depth } : null,
135
+ ...(sponsorSkipped ? { sponsor_skipped: sponsorSkipped } : {}),
136
+ });
137
+ });
138
+ }
@@ -0,0 +1,62 @@
1
+ export function registerAuthSessionsRoutes(app, deps) {
2
+ const { db, auth, verifyPassword, recordSession, generateSecureKey } = deps;
3
+ app.get('/api/auth/sessions', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ const currentKey = req.headers.authorization?.replace('Bearer ', '') ?? '';
8
+ const rows = db.prepare(`
9
+ SELECT id, ip, user_agent, fingerprint_hash, created_at, last_seen_at, revoked_at, api_key
10
+ FROM user_sessions WHERE user_id = ? AND revoked_at IS NULL
11
+ ORDER BY last_seen_at DESC LIMIT 30
12
+ `).all(user.id);
13
+ res.json({
14
+ sessions: rows.map(r => ({
15
+ id: r.id,
16
+ ip: r.ip,
17
+ user_agent: r.user_agent,
18
+ fingerprint_hash: r.fingerprint_hash?.slice(0, 8),
19
+ created_at: r.created_at,
20
+ last_seen_at: r.last_seen_at,
21
+ is_current: r.api_key === currentKey,
22
+ })),
23
+ });
24
+ });
25
+ // 远程吊销某个会话(不影响当前 session)
26
+ app.post('/api/auth/sessions/:id/revoke', (req, res) => {
27
+ const user = auth(req, res);
28
+ if (!user)
29
+ return;
30
+ const sid = req.params.id;
31
+ const row = db.prepare("SELECT user_id, api_key FROM user_sessions WHERE id = ?").get(sid);
32
+ if (!row || row.user_id !== user.id)
33
+ return void res.status(404).json({ error: '会话不存在' });
34
+ const currentKey = req.headers.authorization?.replace('Bearer ', '') ?? '';
35
+ if (row.api_key === currentKey)
36
+ return void res.json({ error: '不能吊销当前会话,请改用「全部登出」' });
37
+ db.prepare("UPDATE user_sessions SET revoked_at = datetime('now') WHERE id = ?").run(sid);
38
+ res.json({ ok: true });
39
+ });
40
+ // 一键全登出:rotate users.api_key + 吊销所有 session
41
+ // 要求密码二次验证(防 api_key 被盗后攻击者锁死真用户)
42
+ app.post('/api/auth/logout-all', (req, res) => {
43
+ const user = auth(req, res);
44
+ if (!user)
45
+ return;
46
+ const pwd = String(req.body?.password || '');
47
+ if (!user.password_hash)
48
+ return void res.json({ error: '该账户未设置登录密码,无法一键全登出。请先设置密码。' });
49
+ if (!verifyPassword(pwd, user.password_hash))
50
+ return void res.json({ error: '密码错误' });
51
+ const newKey = generateSecureKey('key');
52
+ db.prepare("UPDATE users SET api_key = ? WHERE id = ?").run(newKey, user.id);
53
+ db.prepare("UPDATE user_sessions SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL")
54
+ .run(user.id);
55
+ // 为新 key 建一个 session 行(让当前发起者继续可用)
56
+ try {
57
+ recordSession(user.id, newKey, req);
58
+ }
59
+ catch { }
60
+ res.json({ ok: true, new_api_key: newKey });
61
+ });
62
+ }
@@ -0,0 +1,60 @@
1
+ export function registerBlocklistRoutes(app, deps) {
2
+ const { db, auth } = deps;
3
+ app.post('/api/blocklist/:user_id', (req, res) => {
4
+ const me = auth(req, res);
5
+ if (!me)
6
+ return;
7
+ const target = req.params.user_id;
8
+ if (target === me.id)
9
+ return void res.json({ error: '不能拉黑自己' });
10
+ if (target === 'sys_protocol')
11
+ return void res.json({ error: '不能拉黑系统账户' });
12
+ const exists = db.prepare("SELECT 1 FROM users WHERE id = ?").get(target);
13
+ if (!exists)
14
+ return void res.json({ error: '用户不存在' });
15
+ const reason = (req.body?.reason || '').toString().slice(0, 200);
16
+ db.prepare("INSERT OR IGNORE INTO user_blocklist (blocker_id, blocked_id, reason) VALUES (?, ?, ?)")
17
+ .run(me.id, target, reason || null);
18
+ res.json({ ok: true });
19
+ });
20
+ app.delete('/api/blocklist/:user_id', (req, res) => {
21
+ const me = auth(req, res);
22
+ if (!me)
23
+ return;
24
+ db.prepare("DELETE FROM user_blocklist WHERE blocker_id = ? AND blocked_id = ?").run(me.id, req.params.user_id);
25
+ res.json({ ok: true });
26
+ });
27
+ // D-2: 列表
28
+ app.get('/api/blocklist', (req, res) => {
29
+ const me = auth(req, res);
30
+ if (!me)
31
+ return;
32
+ const rows = db.prepare(`
33
+ SELECT b.blocked_id, b.reason, b.created_at,
34
+ u.name, u.handle, u.role
35
+ FROM user_blocklist b
36
+ JOIN users u ON u.id = b.blocked_id
37
+ WHERE b.blocker_id = ?
38
+ ORDER BY b.created_at DESC LIMIT 200
39
+ `).all(me.id);
40
+ res.json({ items: rows });
41
+ });
42
+ app.get('/api/blocklist/me', (req, res) => {
43
+ const me = auth(req, res);
44
+ if (!me)
45
+ return;
46
+ const rows = db.prepare(`
47
+ SELECT b.blocked_id, b.reason, b.created_at, u.name as blocked_name, u.role as blocked_role
48
+ FROM user_blocklist b LEFT JOIN users u ON u.id = b.blocked_id
49
+ WHERE b.blocker_id = ? ORDER BY b.created_at DESC
50
+ `).all(me.id);
51
+ res.json({ blocked: rows });
52
+ });
53
+ app.get('/api/blocklist/:user_id/status', (req, res) => {
54
+ const me = auth(req, res);
55
+ if (!me)
56
+ return;
57
+ const row = db.prepare("SELECT 1 FROM user_blocklist WHERE blocker_id = ? AND blocked_id = ?").get(me.id, req.params.user_id);
58
+ res.json({ blocked: !!row });
59
+ });
60
+ }
@@ -0,0 +1,224 @@
1
+ export function registerBuyerFeedsRoutes(app, deps) {
2
+ const { db, auth, isTrustedRole, errorRes, getNearbyCellPrecision, getProtocolParam } = deps;
3
+ app.get('/api/recommendations/me', (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 limit = Math.min(30, Math.max(5, Number(req.query.limit) || 20));
10
+ const wishlistRows = db.prepare(`
11
+ SELECT w.product_id, p.category, p.seller_id FROM user_wishlist w
12
+ JOIN products p ON p.id = w.product_id
13
+ WHERE w.user_id = ? AND p.status = 'active' LIMIT 50
14
+ `).all(user.id);
15
+ const purchasedRows = db.prepare(`
16
+ SELECT DISTINCT product_id, seller_id FROM orders WHERE buyer_id = ? AND status = 'completed' LIMIT 200
17
+ `).all(user.id);
18
+ const followedRows = db.prepare(`SELECT followee_id FROM follows WHERE follower_id = ?`).all(user.id);
19
+ const wishCats = new Set(wishlistRows.map(r => r.category).filter(Boolean));
20
+ const knownProductIds = new Set([...wishlistRows.map(r => r.product_id), ...purchasedRows.map(r => r.product_id)]);
21
+ const knownSellerIds = new Set([...wishlistRows.map(r => r.seller_id), ...purchasedRows.map(r => r.seller_id)]);
22
+ const followedSellerIds = followedRows.map(r => r.followee_id);
23
+ const EXCL_LIMIT = 500;
24
+ const exclArgs = [...knownProductIds].slice(0, EXCL_LIMIT);
25
+ const exclSql = exclArgs.length > 0
26
+ ? `AND p.id NOT IN (${exclArgs.map(() => '?').join(',')})`
27
+ : '';
28
+ const baseCols = `p.id, p.title, p.price, p.stock, p.category, p.images, p.has_variants, p.seller_id,
29
+ (SELECT COUNT(1) FROM orders o WHERE o.product_id = p.id AND o.status = 'completed') as sales_count,
30
+ u.name as seller_name, u.handle as seller_handle`;
31
+ let followedProducts = [];
32
+ if (followedSellerIds.length > 0) {
33
+ const ph = followedSellerIds.map(() => '?').join(',');
34
+ followedProducts = db.prepare(`
35
+ SELECT ${baseCols}
36
+ FROM products p JOIN users u ON u.id = p.seller_id
37
+ WHERE p.seller_id IN (${ph}) AND p.status = 'active' AND p.stock > 0 ${exclSql}
38
+ ORDER BY p.created_at DESC LIMIT 10
39
+ `).all(...followedSellerIds, ...exclArgs);
40
+ }
41
+ let categoryProducts = [];
42
+ if (wishCats.size > 0) {
43
+ const ph = [...wishCats].map(() => '?').join(',');
44
+ categoryProducts = db.prepare(`
45
+ SELECT ${baseCols}
46
+ FROM products p JOIN users u ON u.id = p.seller_id
47
+ WHERE p.category IN (${ph}) AND p.status = 'active' AND p.stock > 0 ${exclSql}
48
+ ORDER BY sales_count DESC LIMIT 10
49
+ `).all(...wishCats, ...exclArgs);
50
+ }
51
+ let pastSellerProducts = [];
52
+ const pastSellers = [...knownSellerIds].filter(s => !followedSellerIds.includes(s));
53
+ if (pastSellers.length > 0) {
54
+ const ph = pastSellers.map(() => '?').join(',');
55
+ pastSellerProducts = db.prepare(`
56
+ SELECT ${baseCols}
57
+ FROM products p JOIN users u ON u.id = p.seller_id
58
+ WHERE p.seller_id IN (${ph}) AND p.status = 'active' AND p.stock > 0 ${exclSql}
59
+ ORDER BY p.created_at DESC LIMIT 10
60
+ `).all(...pastSellers, ...exclArgs);
61
+ }
62
+ const fallback = db.prepare(`
63
+ SELECT ${baseCols}
64
+ FROM products p JOIN users u ON u.id = p.seller_id
65
+ WHERE p.status = 'active' AND p.stock > 0 ${exclSql}
66
+ ORDER BY sales_count DESC, p.created_at DESC LIMIT 10
67
+ `).all(...exclArgs);
68
+ const seen = new Set();
69
+ const labeled = (bucket, arr) => arr.filter(it => {
70
+ const id = String(it.id);
71
+ if (seen.has(id))
72
+ return false;
73
+ seen.add(id);
74
+ return true;
75
+ }).map(it => ({ ...it, _bucket: bucket }));
76
+ const all = [
77
+ ...labeled('followed', followedProducts),
78
+ ...labeled('category', categoryProducts),
79
+ ...labeled('past_seller', pastSellerProducts),
80
+ ...labeled('trending', fallback),
81
+ ].slice(0, limit);
82
+ res.json({
83
+ items: all,
84
+ signals: {
85
+ wishlist_categories: [...wishCats],
86
+ followed_sellers: followedSellerIds.length,
87
+ past_purchases: purchasedRows.length,
88
+ },
89
+ });
90
+ });
91
+ app.get('/api/feed', (req, res) => {
92
+ const user = auth(req, res);
93
+ if (!user)
94
+ return;
95
+ const scope = String(req.query.scope || 'all');
96
+ const params = [];
97
+ if (scope === 'following') {
98
+ params.push(user.id, user.id, user.id);
99
+ }
100
+ const sql = `
101
+ SELECT * FROM (
102
+ SELECT 'purchase' as kind, o.id as ref_id, o.buyer_id as actor_id, ub.name as actor_name,
103
+ o.product_id, p.title as product_title, p.category, p.price, o.updated_at as ts,
104
+ NULL as extra
105
+ FROM orders o
106
+ JOIN products p ON p.id = o.product_id
107
+ JOIN users ub ON ub.id = o.buyer_id
108
+ WHERE o.status = 'completed'
109
+ AND COALESCE(ub.feed_visible, 1) = 1
110
+ ${scope === 'following' ? `AND o.buyer_id IN (SELECT followee_id FROM follows WHERE follower_id = ?)` : ''}
111
+
112
+ UNION ALL
113
+
114
+ SELECT 'join_binary' as kind, u.id as ref_id, u.id as actor_id, u.name as actor_name,
115
+ NULL as product_id, NULL as product_title, NULL as category, NULL as price,
116
+ u.created_at as ts,
117
+ json_object('placement_side', u.placement_side, 'placement_name', up.name) as extra
118
+ FROM users u
119
+ LEFT JOIN users up ON up.id = u.placement_id
120
+ WHERE u.placement_id IS NOT NULL
121
+ AND COALESCE(u.feed_visible, 1) = 1
122
+ ${scope === 'following' ? `AND u.id IN (SELECT followee_id FROM follows WHERE follower_id = ?)` : ''}
123
+
124
+ UNION ALL
125
+
126
+ SELECT 'commission' as kind, cr.id as ref_id, cr.beneficiary_id as actor_id, ub.name as actor_name,
127
+ o.product_id, p.title as product_title, p.category, p.price, cr.created_at as ts,
128
+ json_object('level', cr.level, 'amount', cr.amount) as extra
129
+ FROM commission_records cr
130
+ JOIN orders o ON o.id = cr.order_id
131
+ JOIN products p ON p.id = o.product_id
132
+ JOIN users ub ON ub.id = cr.beneficiary_id
133
+ WHERE cr.beneficiary_id != 'sys_protocol'
134
+ AND COALESCE(ub.feed_visible, 1) = 1
135
+ ${scope === 'following' ? `AND cr.beneficiary_id IN (SELECT followee_id FROM follows WHERE follower_id = ?)` : ''}
136
+ )
137
+ WHERE ts IS NOT NULL
138
+ ORDER BY ts DESC LIMIT 50
139
+ `;
140
+ const events = db.prepare(sql).all(...params);
141
+ res.json({ events, scope });
142
+ });
143
+ // 雷达扫描 MVP (2026-05-29):scope 范围档 + window 时间窗,k≥3 守护贯穿
144
+ // scope: cell(本格) / neighbors(周边 3×3) / region(同城) / global(全网)
145
+ // window: 24h / 7d / 30d
146
+ app.get('/api/nearby', (req, res) => {
147
+ const user = auth(req, res);
148
+ if (!user)
149
+ return;
150
+ const VALID_SCOPES = ['cell', 'neighbors', 'region', 'global'];
151
+ const scope = VALID_SCOPES.includes(String(req.query.scope)) ? String(req.query.scope) : 'neighbors';
152
+ const windowKey = ['24h', '7d', '30d'].includes(String(req.query.window)) ? String(req.query.window) : '7d';
153
+ const days = windowKey === '24h' ? 1 : windowKey === '30d' ? 30 : 7;
154
+ const { precision_deg, approx_km } = getNearbyCellPrecision();
155
+ const K = getProtocolParam('nearby_k_anonymity', 3);
156
+ const u = db.prepare("SELECT geo_lat, geo_lng, geo_updated_at, region FROM users WHERE id = ?").get(user.id);
157
+ const needsGeo = scope === 'cell' || scope === 'neighbors';
158
+ if (needsGeo && (u?.geo_lat == null || u?.geo_lng == null)) {
159
+ // 本格/周边需定位;同城/全网不需 → 前端可引导切到更大范围
160
+ return void res.json({ has_location: false, scope, window: windowKey, k_threshold: K });
161
+ }
162
+ // 按 scope 构造 WHERE + 标签
163
+ let where, args, scopeLabel;
164
+ let cell = null;
165
+ if (scope === 'cell') {
166
+ where = 'u.geo_lat = ? AND u.geo_lng = ?';
167
+ args = [u.geo_lat, u.geo_lng];
168
+ scopeLabel = `本格 ${approx_km}km`;
169
+ cell = { lat: u.geo_lat, lng: u.geo_lng, precision_deg, approx_km };
170
+ }
171
+ else if (scope === 'neighbors') {
172
+ const eps = precision_deg * 1.5;
173
+ where = 'u.geo_lat BETWEEN ? AND ? AND u.geo_lng BETWEEN ? AND ?';
174
+ args = [Number(u.geo_lat) - eps, Number(u.geo_lat) + eps, Number(u.geo_lng) - eps, Number(u.geo_lng) + eps];
175
+ scopeLabel = `周边 ~${Math.round(approx_km * 3)}km`;
176
+ cell = { lat: u.geo_lat, lng: u.geo_lng, precision_deg, approx_km };
177
+ }
178
+ else if (scope === 'region') {
179
+ where = 'u.region = ?';
180
+ args = [u.region || 'global'];
181
+ scopeLabel = `同城 · ${u.region || '区域'}`;
182
+ }
183
+ else { // global
184
+ where = '1=1';
185
+ args = [];
186
+ scopeLabel = '全网';
187
+ }
188
+ const dayClause = `o.updated_at > datetime('now', '-${days} day')`;
189
+ const totals = db.prepare(`
190
+ SELECT COUNT(DISTINCT o.buyer_id) as au, COUNT(*) as orders
191
+ FROM orders o JOIN users u ON u.id = o.buyer_id
192
+ WHERE ${where} AND o.status = 'completed' AND ${dayClause}
193
+ `).get(...args);
194
+ const sufficient = Number(totals.au) >= K;
195
+ const topProducts = sufficient ? db.prepare(`
196
+ SELECT p.id, p.title, p.price, p.category, p.images, COUNT(DISTINCT o.buyer_id) as buyers
197
+ FROM orders o JOIN users u ON u.id = o.buyer_id JOIN products p ON p.id = o.product_id
198
+ WHERE ${where} AND o.status = 'completed' AND ${dayClause}
199
+ GROUP BY p.id HAVING buyers >= ? ORDER BY buyers DESC LIMIT 10
200
+ `).all(...args, K) : [];
201
+ const topCategories = sufficient ? db.prepare(`
202
+ SELECT p.category, COUNT(*) as orders, COUNT(DISTINCT o.buyer_id) as buyers
203
+ FROM orders o JOIN users u ON u.id = o.buyer_id JOIN products p ON p.id = o.product_id
204
+ WHERE ${where} AND o.status = 'completed' AND ${dayClause} AND p.category IS NOT NULL
205
+ GROUP BY p.category HAVING buyers >= ? ORDER BY orders DESC LIMIT 6
206
+ `).all(...args, K) : [];
207
+ const staleDays = u?.geo_updated_at
208
+ ? Math.floor((Date.now() - new Date(u.geo_updated_at.replace(' ', 'T') + 'Z').getTime()) / 86400_000)
209
+ : null;
210
+ res.json({
211
+ has_location: true,
212
+ scope, scope_label: scopeLabel, window: windowKey, k_threshold: K,
213
+ cell,
214
+ location_stale_days: staleDays,
215
+ sufficient,
216
+ aggregate: {
217
+ active_users: sufficient ? Number(totals.au) : -1,
218
+ orders: sufficient ? Number(totals.orders) : -1,
219
+ },
220
+ top_products: topProducts,
221
+ top_categories: topCategories,
222
+ });
223
+ });
224
+ }
@@ -0,0 +1,155 @@
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
+ export function registerCartRoutes(app, deps) {
4
+ const { db, generateId, auth, isTrustedRole, errorRes, broadcastSystemEvent, checkStockAndMaybeDelist, addHours } = deps;
5
+ app.get('/api/cart', (req, res) => {
6
+ const user = auth(req, res);
7
+ if (!user)
8
+ return;
9
+ const items = db.prepare(`
10
+ SELECT c.product_id, c.qty, c.added_at,
11
+ p.title, p.price, p.category, p.commission_rate, p.stock, p.status as product_status,
12
+ u.name as seller_name
13
+ FROM cart_items c
14
+ JOIN products p ON p.id = c.product_id
15
+ JOIN users u ON u.id = p.seller_id
16
+ WHERE c.user_id = ?
17
+ ORDER BY c.added_at DESC
18
+ `).all(user.id);
19
+ res.json({ items });
20
+ });
21
+ app.post('/api/cart', (req, res) => {
22
+ const user = auth(req, res);
23
+ if (!user)
24
+ return;
25
+ const { product_id, qty } = req.body;
26
+ const q = Math.max(1, Math.min(99, Number(qty) || 1));
27
+ if (!product_id)
28
+ return void res.json({ error: 'product_id 必填' });
29
+ const product = db.prepare("SELECT id, status FROM products WHERE id = ?").get(product_id);
30
+ if (!product)
31
+ return void res.json({ error: '商品不存在' });
32
+ if (product.status !== 'active')
33
+ return void res.json({ error: '商品已下架' });
34
+ db.prepare(`
35
+ INSERT INTO cart_items (user_id, product_id, qty) VALUES (?, ?, ?)
36
+ ON CONFLICT(user_id, product_id) DO UPDATE SET qty = MIN(99, cart_items.qty + ?)
37
+ `).run(user.id, product_id, q, q);
38
+ res.json({ ok: true });
39
+ });
40
+ app.patch('/api/cart/:product_id', (req, res) => {
41
+ const user = auth(req, res);
42
+ if (!user)
43
+ return;
44
+ const q = Math.max(1, Math.min(99, Number(req.body.qty) || 1));
45
+ const r = db.prepare("UPDATE cart_items SET qty = ? WHERE user_id = ? AND product_id = ?").run(q, user.id, req.params.product_id);
46
+ if (r.changes === 0)
47
+ return void res.json({ error: '购物车中没有此商品' });
48
+ res.json({ ok: true, qty: q });
49
+ });
50
+ // C-1: 购物车批量下单(按 seller 自动分订单)
51
+ app.post('/api/cart/checkout', (req, res) => {
52
+ const user = auth(req, res);
53
+ if (!user)
54
+ return;
55
+ if (isTrustedRole(user))
56
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
57
+ if (user.role !== 'buyer')
58
+ return void res.status(403).json({ error: '仅买家可下单' });
59
+ const { shipping_address, notes } = req.body || {};
60
+ if (!shipping_address)
61
+ return void res.status(400).json({ error: '请填写收货地址' });
62
+ const items = db.prepare(`
63
+ SELECT c.product_id, c.qty, p.title, p.price, p.stock, p.seller_id, p.has_variants, p.status
64
+ FROM cart_items c JOIN products p ON p.id = c.product_id
65
+ WHERE c.user_id = ?
66
+ `).all(user.id);
67
+ if (items.length === 0)
68
+ return void res.status(400).json({ error: '购物车为空' });
69
+ const skipped = [];
70
+ const created = [];
71
+ let totalNeed = 0;
72
+ const ok = [];
73
+ for (const it of items) {
74
+ if (it.status !== 'active') {
75
+ skipped.push({ product_id: it.product_id, reason: '商品已下架' });
76
+ continue;
77
+ }
78
+ if (it.has_variants) {
79
+ skipped.push({ product_id: it.product_id, reason: '需在商品详情页选规格下单' });
80
+ continue;
81
+ }
82
+ if (it.stock < it.qty) {
83
+ skipped.push({ product_id: it.product_id, reason: `库存不足(${it.stock} < ${it.qty})` });
84
+ continue;
85
+ }
86
+ if (it.seller_id === user.id) {
87
+ skipped.push({ product_id: it.product_id, reason: '不可购买自己的商品' });
88
+ continue;
89
+ }
90
+ ok.push(it);
91
+ totalNeed += Number(it.price) * Number(it.qty);
92
+ }
93
+ if (ok.length === 0) {
94
+ return void res.status(400).json({ error: '购物车中无可下单商品', skipped });
95
+ }
96
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
97
+ if (!wallet)
98
+ return void res.status(500).json({ error: '钱包记录缺失' });
99
+ if (wallet.balance < totalNeed)
100
+ return void res.status(400).json({ error: `余额不足:需 ${totalNeed.toFixed(2)} WAZ,当前 ${wallet.balance.toFixed(2)}` });
101
+ try {
102
+ db.transaction(() => {
103
+ const now = new Date();
104
+ for (const it of ok) {
105
+ const total = Number(it.price) * Number(it.qty);
106
+ const orderId = generateId('ord');
107
+ db.prepare(`INSERT INTO orders (
108
+ id, product_id, buyer_id, seller_id, quantity, unit_price, total_amount, escrow_amount,
109
+ status, shipping_address, notes, pay_deadline, accept_deadline, ship_deadline,
110
+ pickup_deadline, delivery_deadline, confirm_deadline, source
111
+ ) VALUES (?,?,?,?,?,?,?,?,'created',?,?,?,?,?,?,?,?, 'cart_batch')`).run(orderId, it.product_id, user.id, it.seller_id, it.qty, it.price, total, total, shipping_address, notes || `[批量下单]`, addHours(now, 24), addHours(now, 48), addHours(now, 120), addHours(now, 168), addHours(now, 336), addHours(now, 408));
112
+ db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?').run(total, total, user.id);
113
+ // 扣库存(原子)
114
+ const upd = db.prepare('UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?').run(it.qty, it.product_id, it.qty);
115
+ if (upd.changes !== 1)
116
+ throw new Error(`STOCK_RACE:${it.product_id}`);
117
+ checkStockAndMaybeDelist(it.product_id);
118
+ transition(db, orderId, 'paid', user.id, [], '购物车批量支付');
119
+ notifyTransition(db, orderId, 'created', 'paid');
120
+ created.push({ order_id: orderId, product_id: it.product_id, total });
121
+ }
122
+ // 清空已下单的 cart items
123
+ const okIds = ok.map(i => i.product_id);
124
+ const ph = okIds.map(() => '?').join(',');
125
+ db.prepare(`DELETE FROM cart_items WHERE user_id = ? AND product_id IN (${ph})`).run(user.id, ...okIds);
126
+ })();
127
+ }
128
+ catch (e) {
129
+ const msg = e.message;
130
+ if (msg.startsWith('STOCK_RACE:')) {
131
+ return void res.status(409).json({ error: '库存已被抢光,请重试', error_code: 'STOCK_DEPLETED' });
132
+ }
133
+ console.error('[POST /cart/checkout]', msg);
134
+ return void res.status(500).json({ error: '下单失败,请重试' });
135
+ }
136
+ try {
137
+ broadcastSystemEvent('cart_checkout', '🧺', `购物车批量下单 ${created.length} 单 · 总 ${totalNeed.toFixed(2)} WAZ`, String(user.id));
138
+ }
139
+ catch { }
140
+ res.json({
141
+ success: true,
142
+ orders_created: created.length,
143
+ orders: created,
144
+ skipped,
145
+ total_paid: created.reduce((s, c) => s + c.total, 0),
146
+ });
147
+ });
148
+ app.delete('/api/cart/:product_id', (req, res) => {
149
+ const user = auth(req, res);
150
+ if (!user)
151
+ return;
152
+ db.prepare("DELETE FROM cart_items WHERE user_id = ? AND product_id = ?").run(user.id, req.params.product_id);
153
+ res.json({ ok: true });
154
+ });
155
+ }