@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.
- package/LICENSE +48 -0
- package/README.md +156 -20
- package/dist/layer0-foundation/L0-1-database/schema.js +5 -4
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +228 -7
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +156 -0
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +53 -12
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +14 -1
- package/dist/layer1-agent/L1-1-mcp-server/auth.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/server.js +3543 -852
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +324 -0
- package/dist/layer1-agent/L1-2-identity/agent-passport.js +100 -0
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +72 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +287 -0
- package/dist/layer2-business/L2-anchor-registry/anchor-registry.js +396 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +133 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +6 -6
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +246 -0
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +95 -1
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +31 -2
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +358 -0
- package/dist/pwa/public/app.js +31230 -2345
- package/dist/pwa/public/i18n.js +5282 -111
- package/dist/pwa/public/icon.svg +11 -0
- package/dist/pwa/public/index.html +4 -1
- package/dist/pwa/public/manifest.json +39 -4
- package/dist/pwa/public/openapi.json +5946 -0
- package/dist/pwa/public/style.css +278 -5
- package/dist/pwa/public/sw.js +41 -2
- package/dist/pwa/public/vendor/jsQR.js +10102 -0
- package/dist/pwa/public/webaz-logo.png +0 -0
- package/dist/pwa/routes/account-deletion.js +53 -0
- package/dist/pwa/routes/addresses.js +105 -0
- package/dist/pwa/routes/admin-admins.js +151 -0
- package/dist/pwa/routes/admin-analytics.js +253 -0
- package/dist/pwa/routes/admin-atomic.js +21 -0
- package/dist/pwa/routes/admin-catalog.js +64 -0
- package/dist/pwa/routes/admin-editor-picks.js +45 -0
- package/dist/pwa/routes/admin-events.js +60 -0
- package/dist/pwa/routes/admin-health.js +66 -0
- package/dist/pwa/routes/admin-moderation.js +120 -0
- package/dist/pwa/routes/admin-ops.js +179 -0
- package/dist/pwa/routes/admin-protocol-params.js +79 -0
- package/dist/pwa/routes/admin-reports.js +154 -0
- package/dist/pwa/routes/admin-tokenomics.js +113 -0
- package/dist/pwa/routes/admin-users-lifecycle.js +237 -0
- package/dist/pwa/routes/admin-users-query.js +390 -0
- package/dist/pwa/routes/admin-verifier-flow.js +126 -0
- package/dist/pwa/routes/admin-verifier-whitelist.js +111 -0
- package/dist/pwa/routes/admin-wallet-ops.js +66 -0
- package/dist/pwa/routes/agent-buy.js +215 -0
- package/dist/pwa/routes/agent-governance.js +341 -0
- package/dist/pwa/routes/agent-reputation.js +34 -0
- package/dist/pwa/routes/ai.js +101 -0
- package/dist/pwa/routes/analytics.js +272 -0
- package/dist/pwa/routes/anchors.js +169 -0
- package/dist/pwa/routes/announcements.js +110 -0
- package/dist/pwa/routes/arbitrator.js +117 -0
- package/dist/pwa/routes/auction.js +436 -0
- package/dist/pwa/routes/auth-login.js +40 -0
- package/dist/pwa/routes/auth-read.js +66 -0
- package/dist/pwa/routes/auth-register.js +138 -0
- package/dist/pwa/routes/auth-sessions.js +62 -0
- package/dist/pwa/routes/blocklist.js +60 -0
- package/dist/pwa/routes/buyer-feeds.js +224 -0
- package/dist/pwa/routes/cart.js +155 -0
- package/dist/pwa/routes/charity.js +816 -0
- package/dist/pwa/routes/chat.js +318 -0
- package/dist/pwa/routes/checkin-tasks.js +122 -0
- package/dist/pwa/routes/checkout-helpers.js +85 -0
- package/dist/pwa/routes/claim-initiators.js +88 -0
- package/dist/pwa/routes/claim-verify.js +615 -0
- package/dist/pwa/routes/claim-voting.js +114 -0
- package/dist/pwa/routes/claim-withdrawals.js +20 -0
- package/dist/pwa/routes/coupons.js +165 -0
- package/dist/pwa/routes/dashboards.js +99 -0
- package/dist/pwa/routes/dispute-cases.js +267 -0
- package/dist/pwa/routes/disputes-read.js +358 -0
- package/dist/pwa/routes/disputes-write.js +475 -0
- package/dist/pwa/routes/evidence.js +86 -0
- package/dist/pwa/routes/external-anchors.js +107 -0
- package/dist/pwa/routes/feedback.js +270 -0
- package/dist/pwa/routes/flash-sales.js +130 -0
- package/dist/pwa/routes/follows.js +103 -0
- package/dist/pwa/routes/group-buys.js +208 -0
- package/dist/pwa/routes/growth.js +199 -0
- package/dist/pwa/routes/import-product.js +153 -0
- package/dist/pwa/routes/kyc.js +40 -0
- package/dist/pwa/routes/leaderboard.js +149 -0
- package/dist/pwa/routes/listings.js +281 -0
- package/dist/pwa/routes/logistics.js +35 -0
- package/dist/pwa/routes/manifests.js +126 -0
- package/dist/pwa/routes/me-data.js +101 -0
- package/dist/pwa/routes/notifications.js +48 -0
- package/dist/pwa/routes/offers.js +96 -0
- package/dist/pwa/routes/orders-action.js +285 -0
- package/dist/pwa/routes/orders-create.js +339 -0
- package/dist/pwa/routes/orders-read.js +180 -0
- package/dist/pwa/routes/p2p-products.js +178 -0
- package/dist/pwa/routes/payments-governance.js +311 -0
- package/dist/pwa/routes/peers.js +34 -0
- package/dist/pwa/routes/pin-receipts.js +39 -0
- package/dist/pwa/routes/products-aliases.js +119 -0
- package/dist/pwa/routes/products-claims.js +60 -0
- package/dist/pwa/routes/products-create.js +206 -0
- package/dist/pwa/routes/products-crud.js +73 -0
- package/dist/pwa/routes/products-links.js +129 -0
- package/dist/pwa/routes/products-list.js +424 -0
- package/dist/pwa/routes/products-meta.js +155 -0
- package/dist/pwa/routes/products-update.js +125 -0
- package/dist/pwa/routes/profile-credentials.js +105 -0
- package/dist/pwa/routes/profile-identity.js +174 -0
- package/dist/pwa/routes/profile-location.js +35 -0
- package/dist/pwa/routes/profile-placement.js +70 -0
- package/dist/pwa/routes/profile-prefs.js +93 -0
- package/dist/pwa/routes/promoter.js +208 -0
- package/dist/pwa/routes/public-utils.js +170 -0
- package/dist/pwa/routes/push.js +54 -0
- package/dist/pwa/routes/ratings.js +220 -0
- package/dist/pwa/routes/recover-key.js +100 -0
- package/dist/pwa/routes/referral.js +58 -0
- package/dist/pwa/routes/reputation.js +34 -0
- package/dist/pwa/routes/returns.js +493 -0
- package/dist/pwa/routes/reviews.js +81 -0
- package/dist/pwa/routes/rfqs.js +443 -0
- package/dist/pwa/routes/search.js +172 -0
- package/dist/pwa/routes/secondhand.js +278 -0
- package/dist/pwa/routes/seller-quota.js +225 -0
- package/dist/pwa/routes/share-redirects.js +164 -0
- package/dist/pwa/routes/shareables-interactions.js +212 -0
- package/dist/pwa/routes/shareables.js +470 -0
- package/dist/pwa/routes/shops.js +98 -0
- package/dist/pwa/routes/signaling.js +43 -0
- package/dist/pwa/routes/skill-market.js +173 -0
- package/dist/pwa/routes/skills.js +174 -0
- package/dist/pwa/routes/snf.js +126 -0
- package/dist/pwa/routes/tags.js +47 -0
- package/dist/pwa/routes/trial.js +333 -0
- package/dist/pwa/routes/trusted-kpi.js +87 -0
- package/dist/pwa/routes/url-claim.js +113 -0
- package/dist/pwa/routes/users-public.js +317 -0
- package/dist/pwa/routes/variants.js +156 -0
- package/dist/pwa/routes/verifier-user.js +107 -0
- package/dist/pwa/routes/verify-tasks.js +120 -0
- package/dist/pwa/routes/waitlist.js +65 -0
- package/dist/pwa/routes/wallet-read.js +218 -0
- package/dist/pwa/routes/wallet-write.js +273 -0
- package/dist/pwa/routes/webauthn.js +188 -0
- package/dist/pwa/routes/webhooks.js +162 -0
- package/dist/pwa/routes/welcome.js +226 -0
- package/dist/pwa/routes/wishlist-qa.js +135 -0
- package/dist/pwa/security/ssrf.js +110 -0
- package/dist/pwa/server.js +9247 -2097
- 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
|
+
}
|