@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,125 @@
1
+ export function registerProductsUpdateRoutes(app, deps) {
2
+ const { db, auth, makeCommitmentHash, makeDescriptionHash, makePriceHash, notifyWaitlist, notifyWishlistPriceDrop, checkStockAndMaybeDelist } = deps;
3
+ app.put('/api/products/:id', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ const product = db.prepare('SELECT * FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
8
+ if (!product)
9
+ return void res.status(404).json({ error: '商品不存在或无权限' });
10
+ const { title, description, price, stock, specs, brand, model, handling_hours, ship_regions, estimated_days, fragile, return_days, return_condition, warranty_days, low_stock_threshold, auto_delist_on_zero, origin_claims, i18n_titles, i18n_descs, } = req.body;
11
+ const now = new Date().toISOString();
12
+ const specsJson = specs != null ? (typeof specs === 'object' ? JSON.stringify(specs) : specs) : product.specs;
13
+ const estJson = estimated_days != null ? (typeof estimated_days === 'object' ? JSON.stringify(estimated_days) : String(estimated_days)) : product.estimated_days;
14
+ const newTitle = title ?? product.title;
15
+ const newDesc = description ?? product.description;
16
+ const newPrice = price != null ? Number(price) : product.price;
17
+ const newHandling = handling_hours != null ? Number(handling_hours) : product.handling_hours;
18
+ const newShipRegions = ship_regions ?? product.ship_regions;
19
+ const newEstDays = estJson;
20
+ const newReturnDays = return_days != null ? Number(return_days) : product.return_days;
21
+ const newReturnCond = return_condition ?? product.return_condition;
22
+ const newWarranty = warranty_days != null ? Number(warranty_days) : product.warranty_days;
23
+ const newFragile = fragile != null ? (fragile ? 1 : 0) : product.fragile;
24
+ const pFields = { ship_regions: newShipRegions, handling_hours: newHandling, estimated_days: newEstDays, return_days: newReturnDays, return_condition: newReturnCond, warranty_days: newWarranty };
25
+ const newStock = stock != null ? Number(stock) : product.stock;
26
+ const oldStock = Number(product.stock || 0);
27
+ // 库存预警 / 自动下架配置(默认沿用旧值)
28
+ const newLowThreshold = low_stock_threshold != null
29
+ ? Math.max(0, Math.floor(Number(low_stock_threshold) || 0))
30
+ : (product.low_stock_threshold ?? 3);
31
+ const newAutoDelist = auto_delist_on_zero != null
32
+ ? (auto_delist_on_zero ? 1 : 0)
33
+ : (product.auto_delist_on_zero ?? 1);
34
+ // 库存回归到阈值之上 → 清零 alert 时间戳,让下次下穿能再发
35
+ const resetAlert = Number(newStock) > Number(newLowThreshold) ? 1 : 0;
36
+ // S4 商品溯源:origin_claims 必须是 object(可空),最大 4KB
37
+ let newOriginClaims = product.origin_claims;
38
+ if (origin_claims !== undefined) {
39
+ if (origin_claims === null || (typeof origin_claims === 'object' && Object.keys(origin_claims).length === 0)) {
40
+ newOriginClaims = null;
41
+ }
42
+ else if (typeof origin_claims === 'object') {
43
+ const json = JSON.stringify(origin_claims);
44
+ if (json.length > 4096)
45
+ return void res.status(400).json({ error: 'origin_claims 超 4KB' });
46
+ // certs sha256 格式校验
47
+ const certs = origin_claims.certs;
48
+ if (Array.isArray(certs)) {
49
+ for (const c of certs) {
50
+ if (c && typeof c === 'object' && c.sha256) {
51
+ const s = String(c.sha256);
52
+ if (!/^[0-9a-f]{64}$/i.test(s))
53
+ return void res.status(400).json({ error: '证书 sha256 必须是 64 位 hex' });
54
+ }
55
+ }
56
+ }
57
+ newOriginClaims = json;
58
+ }
59
+ }
60
+ // S3 i18n:zh 默认在 title/description;i18n_titles/descs 存 en/ja/ko 等
61
+ const SUPPORTED_LANGS = new Set(['en', 'ja', 'ko', 'fr', 'de', 'es', 'pt', 'ru', 'ar']);
62
+ const validateI18n = (raw) => {
63
+ if (raw === undefined)
64
+ return undefined; // 不更新
65
+ if (raw === null)
66
+ return null;
67
+ if (typeof raw !== 'object')
68
+ return undefined;
69
+ const obj = raw;
70
+ const out = {};
71
+ for (const [k, v] of Object.entries(obj)) {
72
+ if (!SUPPORTED_LANGS.has(k))
73
+ continue;
74
+ if (typeof v !== 'string' || v.length === 0)
75
+ continue;
76
+ out[k] = v.slice(0, 500);
77
+ }
78
+ return Object.keys(out).length > 0 ? JSON.stringify(out) : null;
79
+ };
80
+ const titlesResult = validateI18n(i18n_titles);
81
+ const descsResult = validateI18n(i18n_descs);
82
+ const newI18nTitles = titlesResult === undefined ? product.i18n_titles : titlesResult;
83
+ const newI18nDescs = descsResult === undefined ? product.i18n_descs : descsResult;
84
+ db.prepare(`UPDATE products SET
85
+ title=?, description=?, price=?, stock=?,
86
+ specs=?, brand=?, model=?, handling_hours=?, ship_regions=?,
87
+ estimated_days=?, fragile=?, return_days=?, return_condition=?, warranty_days=?,
88
+ low_stock_threshold=?, auto_delist_on_zero=?,
89
+ low_stock_alerted_at = CASE WHEN ?=1 THEN NULL ELSE low_stock_alerted_at END,
90
+ origin_claims=?,
91
+ i18n_titles=?, i18n_descs=?,
92
+ commitment_hash=?, description_hash=?, price_hash=?, hashed_at=?,
93
+ updated_at=datetime('now')
94
+ WHERE id=?`).run(newTitle, newDesc, newPrice, newStock, specsJson, brand ?? product.brand, model ?? product.model, newHandling, newShipRegions, newEstDays, newFragile, newReturnDays, newReturnCond, newWarranty, newLowThreshold, newAutoDelist, resetAlert, newOriginClaims, newI18nTitles, newI18nDescs, makeCommitmentHash(pFields), makeDescriptionHash({ title: newTitle, description: newDesc, specs: specsJson }), makePriceHash(newPrice, now), now, req.params.id);
95
+ // Wave B-2: stock 从 0 → 正数时通知 waitlist 用户
96
+ if (oldStock === 0 && Number(newStock) > 0) {
97
+ try {
98
+ notifyWaitlist(String(req.params.id), String(newTitle));
99
+ }
100
+ catch (e) {
101
+ console.error('[waitlist notify]', e);
102
+ }
103
+ }
104
+ // 卖家手动减库存到 ≤ 阈值时也触发检查
105
+ if (Number(newStock) < oldStock) {
106
+ try {
107
+ checkStockAndMaybeDelist(String(req.params.id));
108
+ }
109
+ catch (e) {
110
+ console.error('[stock-check]', e);
111
+ }
112
+ }
113
+ // 2026-05-24 价格下降 → 通知 wishlist 中开启 notify_price_drop 的用户
114
+ const oldPrice = Number(product.price);
115
+ if (newPrice < oldPrice) {
116
+ try {
117
+ notifyWishlistPriceDrop(String(req.params.id), String(newTitle), oldPrice, newPrice);
118
+ }
119
+ catch (e) {
120
+ console.error('[wishlist price-drop notify]', e);
121
+ }
122
+ }
123
+ res.json({ success: true });
124
+ });
125
+ }
@@ -0,0 +1,105 @@
1
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2
+ export function registerProfileCredentialsRoutes(app, deps) {
3
+ const { db, auth, verifyPassword, hashPassword, issueCode, findActiveCode, IS_DEV, MAX_CODE_ATTEMPTS } = deps;
4
+ // 设置 / 修改密码
5
+ app.post('/api/profile/set-password', (req, res) => {
6
+ const user = auth(req, res);
7
+ if (!user)
8
+ return;
9
+ const { old_password, new_password } = req.body;
10
+ if (!new_password || String(new_password).length < 8)
11
+ return void res.json({ error: '新密码至少 8 字符' });
12
+ if (String(new_password).length > 200)
13
+ return void res.json({ error: '密码过长(>200 字符)' });
14
+ if (user.password_hash) {
15
+ if (!old_password)
16
+ return void res.json({ error: '请提供原密码' });
17
+ if (!verifyPassword(String(old_password), user.password_hash)) {
18
+ return void res.json({ error: '原密码错误' });
19
+ }
20
+ }
21
+ const hash = hashPassword(String(new_password));
22
+ db.prepare("UPDATE users SET password_hash = ?, failed_attempts = 0, locked_until = NULL, updated_at = datetime('now') WHERE id = ?")
23
+ .run(hash, user.id);
24
+ res.json({ success: true });
25
+ });
26
+ // 验证密码(显示 API Key 前的二次确认)
27
+ app.post('/api/profile/verify-password', (req, res) => {
28
+ const user = auth(req, res);
29
+ if (!user)
30
+ return;
31
+ if (!user.password_hash)
32
+ return void res.json({ ok: false, no_password: true, error: '未设置密码' });
33
+ const { password } = req.body;
34
+ if (!password)
35
+ return void res.json({ ok: false, error: '请输入密码' });
36
+ if (!verifyPassword(String(password), user.password_hash)) {
37
+ return void res.json({ ok: false, error: '密码错误' });
38
+ }
39
+ res.json({ ok: true });
40
+ });
41
+ // 移除密码(恢复只用 API Key 模式)
42
+ app.post('/api/profile/remove-password', (req, res) => {
43
+ const user = auth(req, res);
44
+ if (!user)
45
+ return;
46
+ const { current_password } = req.body;
47
+ if (!user.password_hash)
48
+ return void res.json({ error: '当前未设置密码' });
49
+ if (!current_password || !verifyPassword(String(current_password), user.password_hash)) {
50
+ return void res.json({ error: '密码错误' });
51
+ }
52
+ db.prepare("UPDATE users SET password_hash = NULL, updated_at = datetime('now') WHERE id = ?")
53
+ .run(user.id);
54
+ res.json({ success: true });
55
+ });
56
+ // 绑定邮箱 — 步骤 1:发码
57
+ app.post('/api/profile/bind-email', (req, res) => {
58
+ const user = auth(req, res);
59
+ if (!user)
60
+ return;
61
+ const { email } = req.body;
62
+ if (!email?.trim() || !EMAIL_RE.test(email.trim()))
63
+ return void res.json({ error: '邮箱格式无效' });
64
+ const target = email.trim().toLowerCase();
65
+ const dup = db.prepare("SELECT 1 FROM users WHERE email = ? AND id != ? LIMIT 1").get(target, user.id);
66
+ if (dup)
67
+ return void res.json({ error: '该邮箱已被其他账户绑定' });
68
+ const { expires_at, code } = issueCode(user.id, 'email', target, 'bind_email');
69
+ res.json({
70
+ success: true,
71
+ target_hint: target.replace(/^(.).*(@.*)$/, '$1***$2'),
72
+ expires_at,
73
+ ...(IS_DEV ? { dev_code: code } : {}),
74
+ });
75
+ });
76
+ // 绑定邮箱 — 步骤 2:确认验证码
77
+ app.post('/api/profile/confirm-email', (req, res) => {
78
+ const user = auth(req, res);
79
+ if (!user)
80
+ return;
81
+ const { email, code } = req.body;
82
+ if (!email?.trim() || !code?.trim())
83
+ return void res.json({ error: '请填写邮箱和验证码' });
84
+ const target = email.trim().toLowerCase();
85
+ const row = findActiveCode('email', target, 'bind_email');
86
+ if (!row)
87
+ return void res.json({ error: '验证码已过期或未发送,请重新获取' });
88
+ if (row.user_id !== user.id)
89
+ return void res.json({ error: '此验证码不属于当前账号' });
90
+ if (String(row.code) !== code.trim()) {
91
+ const attempts = row.attempts + 1;
92
+ if (attempts >= MAX_CODE_ATTEMPTS) {
93
+ db.prepare("UPDATE verification_codes SET attempts = ?, used_at = datetime('now') WHERE id = ?")
94
+ .run(attempts, row.id);
95
+ return void res.json({ error: '错误次数过多,验证码已作废,请重新获取' });
96
+ }
97
+ db.prepare("UPDATE verification_codes SET attempts = ? WHERE id = ?").run(attempts, row.id);
98
+ return void res.json({ error: `验证码错误(剩余 ${MAX_CODE_ATTEMPTS - attempts} 次)` });
99
+ }
100
+ db.prepare("UPDATE verification_codes SET used_at = datetime('now') WHERE id = ?").run(row.id);
101
+ db.prepare("UPDATE users SET email = ?, email_verified = 1, updated_at = datetime('now') WHERE id = ?")
102
+ .run(target, user.id);
103
+ res.json({ success: true, email: target });
104
+ });
105
+ }
@@ -0,0 +1,174 @@
1
+ const ROLE_LOCKED_ROLES = ['admin', 'verifier'];
2
+ const VALID_REGIONS = new Set(['china', 'us', 'eu', 'india', 'singapore', 'global_north', 'global']);
3
+ const HANDLE_BASE_COOLDOWN_MONTHS = 12;
4
+ export function registerProfileIdentityRoutes(app, deps) {
5
+ const { db, generateId, auth, safeRoles } = deps;
6
+ app.post('/api/profile/add-role', (req, res) => {
7
+ const user = auth(req, res);
8
+ if (!user)
9
+ return;
10
+ const { role } = req.body;
11
+ const SELF_SERVE_ROLES = ['buyer', 'seller'];
12
+ const currentRoles = safeRoles(user);
13
+ if (currentRoles.some(r => ROLE_LOCKED_ROLES.includes(r))) {
14
+ return void res.json({
15
+ error: '受信角色(admin / verifier)不能自助添加其他角色',
16
+ hint: '权责分离原则:管理员 / 审核员需保持纯净身份,避免利益冲突。如需切换身份,请用其他账号注册。'
17
+ });
18
+ }
19
+ if (!SELF_SERVE_ROLES.includes(role)) {
20
+ return void res.json({
21
+ error: '该角色不能自助添加',
22
+ hint: role === 'verifier' ? '请到「申请审核员」页面提交申请'
23
+ : role === 'admin' ? '管理员角色仅可由现有管理员授予'
24
+ : '请联系管理员申请此角色(logistics / arbitrator)'
25
+ });
26
+ }
27
+ // P2: 事务内 re-read + write 防并发丢失更新
28
+ let finalRoles = [];
29
+ let alreadyHas = false;
30
+ try {
31
+ db.transaction(() => {
32
+ const fresh = db.prepare('SELECT roles FROM users WHERE id = ?').get(user.id);
33
+ const list = safeRoles({ roles: fresh?.roles });
34
+ if (list.includes(role)) {
35
+ alreadyHas = true;
36
+ finalRoles = list;
37
+ return;
38
+ }
39
+ list.push(role);
40
+ db.prepare("UPDATE users SET roles = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(list), user.id);
41
+ finalRoles = list;
42
+ })();
43
+ }
44
+ catch (e) {
45
+ return void res.status(500).json({ error: '更新角色失败', detail: e.message });
46
+ }
47
+ if (alreadyHas)
48
+ return void res.json({ error: '已拥有该角色' });
49
+ res.json({ success: true, roles: finalRoles });
50
+ });
51
+ app.post('/api/profile/switch-role', (req, res) => {
52
+ const user = auth(req, res);
53
+ if (!user)
54
+ return;
55
+ const { role } = req.body;
56
+ const roles = safeRoles(user);
57
+ if (!roles.includes(role))
58
+ return void res.json({ error: '你还没有该角色,请先添加' });
59
+ // 防御:已是 admin/verifier 的用户不能切到 buyer/seller(防遗留多角色账户绕过)
60
+ if (roles.some(r => ROLE_LOCKED_ROLES.includes(r)) && !ROLE_LOCKED_ROLES.includes(role)) {
61
+ return void res.json({
62
+ error: '受信角色不能切换为 buyer / seller',
63
+ hint: '权责分离原则;如需买卖请用其他账号。'
64
+ });
65
+ }
66
+ db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, user.id);
67
+ res.json({ success: true, role, roles });
68
+ });
69
+ app.post('/api/profile/region', (req, res) => {
70
+ const user = auth(req, res);
71
+ if (!user)
72
+ return;
73
+ const region = String(req.body?.region || '').trim();
74
+ if (!VALID_REGIONS.has(region)) {
75
+ return void res.json({ error: 'region 必须是 china / us / eu / india / singapore / global_north / global 之一' });
76
+ }
77
+ const fromRegion = user.region ?? null;
78
+ if (fromRegion === region) {
79
+ return void res.json({ success: true, region, unchanged: true });
80
+ }
81
+ // 30 天冷却(防规避 MLM 合规 + 历史 commission 已按当时 region 快照结算)
82
+ const lastChange = db.prepare(`SELECT created_at FROM region_change_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 1`).get(user.id);
83
+ if (lastChange) {
84
+ const sinceMs = Date.now() - new Date(lastChange.created_at + 'Z').getTime();
85
+ const COOLDOWN_MS = 30 * 24 * 3600 * 1000;
86
+ if (sinceMs < COOLDOWN_MS) {
87
+ const remainDays = Math.ceil((COOLDOWN_MS - sinceMs) / (24 * 3600_000));
88
+ return void res.status(429).json({
89
+ error: `region 切换 30 天仅 1 次,请 ${remainDays} 天后再试(防止规避 MLM 合规限制)`,
90
+ retry_after_days: remainDays,
91
+ });
92
+ }
93
+ }
94
+ db.prepare("UPDATE users SET region = ?, updated_at = datetime('now') WHERE id = ?").run(region, user.id);
95
+ const ip = req.ip || '';
96
+ db.prepare(`INSERT INTO region_change_log (id, user_id, from_region, to_region, ip) VALUES (?,?,?,?,?)`)
97
+ .run(generateId('rcl'), user.id, fromRegion, region, ip || null);
98
+ res.json({ success: true, region });
99
+ });
100
+ app.post('/api/profile/change-name', (req, res) => {
101
+ const user = auth(req, res);
102
+ if (!user)
103
+ return;
104
+ const { name } = req.body;
105
+ if (!name?.trim())
106
+ return void res.json({ error: '请填写新昵称' });
107
+ const trimmed = name.trim();
108
+ if (trimmed.length < 1 || trimmed.length > 40)
109
+ return void res.json({ error: '昵称长度需在 1–40 个字符之间' });
110
+ if (trimmed === user.name)
111
+ return void res.json({ error: '新昵称与当前相同' });
112
+ // 昵称可重复(唯一标识由 handle / permanent_code 承担)
113
+ db.prepare("UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?").run(trimmed, user.id);
114
+ res.json({ success: true, name: trimmed });
115
+ });
116
+ // 改 handle:累进式冷却 — 第 N 次改需距上次 N × 12 月
117
+ app.post('/api/profile/change-handle', (req, res) => {
118
+ const user = auth(req, res);
119
+ if (!user)
120
+ return;
121
+ const raw = String(req.body?.handle ?? '').trim().replace(/^@/, '').toLowerCase();
122
+ if (!raw)
123
+ return void res.json({ error: '请填写新用户名' });
124
+ if (!/^[a-z0-9._]+$/.test(raw))
125
+ return void res.json({ error: '只能用小写字母 / 数字 / . _' });
126
+ if (raw.length < 3 || raw.length > 20)
127
+ return void res.json({ error: '用户名长度需在 3–20 个字符之间' });
128
+ if (/^[._]|[._]$/.test(raw))
129
+ return void res.json({ error: '开头/结尾不能是 . 或 _' });
130
+ if (/^(usr|sys|admin|webaz|anonymous|null)/i.test(raw))
131
+ return void res.json({ error: '该前缀被系统保留' });
132
+ if (raw === user.handle)
133
+ return void res.json({ error: '新用户名与当前相同' });
134
+ // 累进冷却
135
+ let log = [];
136
+ try {
137
+ log = JSON.parse(user.handle_change_log || '[]');
138
+ }
139
+ catch { }
140
+ const N = log.length; // 已发生的改名次数
141
+ if (N > 0) {
142
+ const lastChange = log[log.length - 1];
143
+ const lastMs = new Date(lastChange.at.replace(' ', 'T') + (lastChange.at.includes('Z') ? '' : 'Z')).getTime();
144
+ const requiredMonths = N * HANDLE_BASE_COOLDOWN_MONTHS;
145
+ const requiredMs = requiredMonths * 30 * 86400_000;
146
+ if (Date.now() - lastMs < requiredMs) {
147
+ const remainMs = requiredMs - (Date.now() - lastMs);
148
+ const remainMonths = Math.ceil(remainMs / (30 * 86400_000));
149
+ return void res.json({
150
+ error: `第 ${N + 1} 次改名需距上次至少 ${requiredMonths} 个月,还差约 ${remainMonths} 个月`,
151
+ change_count: N,
152
+ required_months_for_next: requiredMonths,
153
+ remain_months: remainMonths,
154
+ });
155
+ }
156
+ }
157
+ // 唯一性
158
+ const dup = db.prepare("SELECT 1 FROM users WHERE handle = ? AND id != ?").get(raw, user.id);
159
+ if (dup)
160
+ return void res.json({ error: '该用户名已被占用' });
161
+ log.push({ at: new Date().toISOString().slice(0, 19).replace('T', ' '), from: String(user.handle || '') });
162
+ // 全量保留 — 累进式冷却需要完整历史
163
+ db.prepare(`UPDATE users SET handle = ?, handle_last_created_at = datetime('now'), handle_change_log = ?, updated_at = datetime('now') WHERE id = ?`)
164
+ .run(raw, JSON.stringify(log), user.id);
165
+ const nextRequiredMonths = (N + 1) * HANDLE_BASE_COOLDOWN_MONTHS;
166
+ res.json({
167
+ success: true,
168
+ handle: raw,
169
+ change_count: N + 1,
170
+ next_change_required_months: nextRequiredMonths,
171
+ hint: `下次改名需距本次至少 ${nextRequiredMonths} 个月`,
172
+ });
173
+ });
174
+ }
@@ -0,0 +1,35 @@
1
+ function quantizeCoord(value, precision_deg) {
2
+ // 例:precision=0.1 → factor=10;precision=0.05 → factor=20
3
+ const factor = 1 / precision_deg;
4
+ return Math.round(value * factor) / factor;
5
+ }
6
+ export function registerProfileLocationRoutes(app, deps) {
7
+ const { db, auth, getNearbyCellPrecision } = deps;
8
+ app.post('/api/profile/set-location', (req, res) => {
9
+ const user = auth(req, res);
10
+ if (!user)
11
+ return;
12
+ const rawLat = Number(req.body?.lat);
13
+ const rawLng = Number(req.body?.lng);
14
+ if (!Number.isFinite(rawLat) || !Number.isFinite(rawLng)) {
15
+ return void res.json({ error: 'lat/lng 必须为有效数字' });
16
+ }
17
+ if (rawLat < -90 || rawLat > 90 || rawLng < -180 || rawLng > 180) {
18
+ return void res.json({ error: 'lat/lng 超出地理范围' });
19
+ }
20
+ const { precision_deg, approx_km } = getNearbyCellPrecision();
21
+ // 服务端截断(防客户端传精确坐标)
22
+ const lat = quantizeCoord(rawLat, precision_deg);
23
+ const lng = quantizeCoord(rawLng, precision_deg);
24
+ db.prepare("UPDATE users SET geo_lat = ?, geo_lng = ?, geo_updated_at = datetime('now'), updated_at = datetime('now') WHERE id = ?")
25
+ .run(lat, lng, user.id);
26
+ res.json({ ok: true, lat, lng, precision_deg, approx_km });
27
+ });
28
+ app.post('/api/profile/clear-location', (req, res) => {
29
+ const user = auth(req, res);
30
+ if (!user)
31
+ return;
32
+ db.prepare("UPDATE users SET geo_lat = NULL, geo_lng = NULL, geo_updated_at = NULL WHERE id = ?").run(user.id);
33
+ res.json({ ok: true });
34
+ });
35
+ }
@@ -0,0 +1,70 @@
1
+ export function registerProfilePlacementRoutes(app, deps) {
2
+ const { db, auth, internalAuditorId, resolveUserRef, pickPreferredSide, joinPowerLeg } = deps;
3
+ app.get('/api/profile/placement-status', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ const u = db.prepare("SELECT placement_id, placement_side, left_child_id, right_child_id, placement_pref FROM users WHERE id = ?").get(user.id);
8
+ const hasPlacement = !!u?.placement_id;
9
+ const hasDownline = !!u?.left_child_id || !!u?.right_child_id;
10
+ res.json({
11
+ has_placement: hasPlacement,
12
+ has_downline: hasDownline,
13
+ can_bind: !hasPlacement && !hasDownline,
14
+ placement_pref: u?.placement_pref || 'team_count',
15
+ placement_id: u?.placement_id,
16
+ placement_side: u?.placement_side,
17
+ });
18
+ });
19
+ app.post('/api/profile/bind-placement', (req, res) => {
20
+ const user = auth(req, res);
21
+ if (!user)
22
+ return;
23
+ const { inviter_id, side } = req.body;
24
+ if (!inviter_id || typeof inviter_id !== 'string')
25
+ return void res.json({ error: '请提供 inviter_id' });
26
+ // 多形态识别:usr_xxx / VKSF9P / @handle
27
+ const resolvedInviterId = resolveUserRef(inviter_id);
28
+ if (!resolvedInviterId)
29
+ return void res.json({ error: 'inviter 不存在' });
30
+ if (resolvedInviterId === user.id)
31
+ return void res.json({ error: '不能挂靠到自己' });
32
+ const u = db.prepare("SELECT placement_id, left_child_id, right_child_id FROM users WHERE id = ?").get(user.id);
33
+ if (u?.placement_id)
34
+ return void res.json({ error: '你已在双轨树中(永久第一触点,不可改)' });
35
+ if (u?.left_child_id || u?.right_child_id)
36
+ return void res.json({ error: '你已有下线,不可补绑(防破坏树结构)' });
37
+ const inviter = db.prepare("SELECT id, placement_path FROM users WHERE id = ? AND id NOT IN ('sys_protocol', ?)")
38
+ .get(resolvedInviterId, internalAuditorId);
39
+ if (!inviter)
40
+ return void res.json({ error: 'inviter 不存在' });
41
+ // 环路检查:inviter 的 placement_path 不能含自己
42
+ if ((inviter.placement_path || '').split('>').includes(user.id)) {
43
+ return void res.json({ error: '检测到环路(你已是 inviter 的上线)' });
44
+ }
45
+ // 选边:用户提供 side / 否则用 inviter.placement_pref 自动选
46
+ const chosenSide = (side === 'left' || side === 'right')
47
+ ? side
48
+ : pickPreferredSide(inviter.id);
49
+ try {
50
+ const placed = joinPowerLeg(inviter.id, chosenSide, user.id);
51
+ res.json({ success: true, inviter_id: inviter.id, side: chosenSide, depth: placed.depth });
52
+ }
53
+ catch (e) {
54
+ res.json({ error: `挂靠失败: ${e.message}` });
55
+ }
56
+ });
57
+ app.post('/api/profile/placement-pref', (req, res) => {
58
+ const user = auth(req, res);
59
+ if (!user)
60
+ return;
61
+ const { pref } = req.body;
62
+ if (!['team_count', 'pv_count', 'left', 'right'].includes(pref)) {
63
+ return void res.json({ error: 'pref 必须是 team_count / pv_count' });
64
+ }
65
+ // legacy left/right 已不再支持长期强偏,无声折算为 team_count(agent 兼容期保护)
66
+ const stored = (pref === 'left' || pref === 'right') ? 'team_count' : pref;
67
+ db.prepare("UPDATE users SET placement_pref = ?, updated_at = datetime('now') WHERE id = ?").run(stored, user.id);
68
+ res.json({ success: true, placement_pref: stored, coerced: stored !== pref ? `${pref} → ${stored}(已统一为长期默认偏好;要单次强偏请使用左/右码)` : undefined });
69
+ });
70
+ }
@@ -0,0 +1,93 @@
1
+ export function registerProfilePrefsRoutes(app, deps) {
2
+ const { db, auth } = deps;
3
+ // 默认地址(结构化 + 兼容旧 text/region)
4
+ app.post('/api/profile/default-address', (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ const body = req.body || {};
9
+ // 兼容旧 API
10
+ if (body.text !== undefined || body.region !== undefined) {
11
+ const text = (body.text || '').toString().trim().slice(0, 200);
12
+ const region = (body.region || '').toString().trim().slice(0, 40);
13
+ db.prepare("UPDATE users SET default_address_text = ?, default_address_region = ?, updated_at = datetime('now') WHERE id = ?")
14
+ .run(text || null, region || null, user.id);
15
+ return void res.json({ ok: true, text: text || null, region: region || null });
16
+ }
17
+ // 结构化模式
18
+ const line1 = (body.line1 || '').toString().trim().slice(0, 100);
19
+ const line2 = (body.line2 || '').toString().trim().slice(0, 100);
20
+ const country = (body.country || '').toString().trim().slice(0, 40);
21
+ const state = (body.state || '').toString().trim().slice(0, 40);
22
+ const city = (body.city || '').toString().trim().slice(0, 40);
23
+ const recipient = (body.recipient_name || '').toString().trim().slice(0, 40);
24
+ const phone1 = (body.phone1 || '').toString().trim().slice(0, 30);
25
+ const phone2 = (body.phone2 || '').toString().trim().slice(0, 30);
26
+ const postal = (body.postal_code || '').toString().trim().slice(0, 20);
27
+ const missing = [];
28
+ if (!line1)
29
+ missing.push('地址行 1');
30
+ if (!country)
31
+ missing.push('国家/地区');
32
+ if (!state)
33
+ missing.push('省/州');
34
+ if (!city)
35
+ missing.push('城市');
36
+ if (!recipient)
37
+ missing.push('收件人姓名');
38
+ if (!phone1)
39
+ missing.push('主要联系方式');
40
+ if (missing.length > 0) {
41
+ return void res.json({ error: '以下必填项缺失:' + missing.join('、') });
42
+ }
43
+ const structured = { line1, line2, country, state, city, recipient_name: recipient, phone1, phone2, postal_code: postal };
44
+ const text = [recipient, `${country} ${state} ${city}`.trim(), line1, line2, postal, phone1].filter(Boolean).join(' / ').slice(0, 200);
45
+ db.prepare("UPDATE users SET default_address_text = ?, default_address_region = ?, default_address_json = ?, updated_at = datetime('now') WHERE id = ?")
46
+ .run(text || null, state || null, JSON.stringify(structured), user.id);
47
+ res.json({ ok: true, address: structured, derived_text: text, derived_region: state });
48
+ });
49
+ // 隐私开关(旧 API,向后兼容;新代码用 PATCH /api/profile)
50
+ app.patch('/api/profile/feed-visible', (req, res) => {
51
+ const user = auth(req, res);
52
+ if (!user)
53
+ return;
54
+ const v = req.body?.visible ? 1 : 0;
55
+ db.prepare("UPDATE users SET feed_visible = ?, updated_at = datetime('now') WHERE id = ?").run(v, user.id);
56
+ res.json({ ok: true, feed_visible: v });
57
+ });
58
+ // 通用 profile patch(search_anchor / bio / feed_visible)
59
+ app.patch('/api/profile', (req, res) => {
60
+ const user = auth(req, res);
61
+ if (!user)
62
+ return;
63
+ const updates = [];
64
+ const values = [];
65
+ if ('search_anchor' in (req.body || {})) {
66
+ const raw = (req.body.search_anchor || '').toString().trim();
67
+ if (raw.length > 40)
68
+ return void res.json({ error: 'search_anchor 不能超过 40 字符' });
69
+ if (raw && !/^[\w一-龥\-_\.]+$/.test(raw))
70
+ return void res.json({ error: 'search_anchor 仅允许字母/数字/汉字/-_.' });
71
+ updates.push('search_anchor = ?');
72
+ values.push(raw || null);
73
+ }
74
+ if ('bio' in (req.body || {})) {
75
+ const raw = (req.body.bio || '').toString().trim();
76
+ if (raw.length > 120)
77
+ return void res.json({ error: 'bio 不能超过 120 字符' });
78
+ updates.push('bio = ?');
79
+ values.push(raw || null);
80
+ }
81
+ if ('feed_visible' in (req.body || {})) {
82
+ updates.push('feed_visible = ?');
83
+ values.push(req.body.feed_visible ? 1 : 0);
84
+ }
85
+ if (updates.length === 0)
86
+ return void res.json({ error: '没有可更新的字段' });
87
+ updates.push(`updated_at = datetime('now')`);
88
+ values.push(user.id);
89
+ db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
90
+ const u = db.prepare("SELECT search_anchor, bio, feed_visible FROM users WHERE id = ?").get(user.id);
91
+ res.json({ ok: true, search_anchor: u.search_anchor, bio: u.bio, feed_visible: Number(u.feed_visible ?? 1) });
92
+ });
93
+ }