@seasonkoh/webaz 0.1.25 → 0.1.26
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/README.md +3 -1
- package/dist/layer1-agent/L1-1-mcp-server/server.js +129 -150
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +9 -0
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +1 -1
- package/dist/layer2-business/L2-9-contribution/identity-claim-discovery.js +55 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-ai-store.js +99 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +191 -0
- package/dist/pwa/admin-bearer-auth.js +21 -0
- package/dist/pwa/email-delivery.js +127 -0
- package/dist/pwa/public/app.js +940 -245
- package/dist/pwa/public/i18n.js +269 -40
- package/dist/pwa/public/openapi.json +4 -4
- package/dist/pwa/public/whitepaper/en/index.html +153 -0
- package/dist/pwa/public/whitepaper/zh-CN/index.html +153 -0
- package/dist/pwa/routes/admin-atomic.js +10 -4
- package/dist/pwa/routes/admin-moderation.js +25 -1
- package/dist/pwa/routes/admin-ops.js +13 -2
- package/dist/pwa/routes/admin-users-query.js +12 -1
- package/dist/pwa/routes/admin-wallet-ops.js +26 -3
- package/dist/pwa/routes/auction.js +4 -2
- package/dist/pwa/routes/auth-read.js +10 -1
- package/dist/pwa/routes/auth-register.js +82 -12
- package/dist/pwa/routes/contribution-identity.js +17 -0
- package/dist/pwa/routes/growth.js +1 -1
- package/dist/pwa/routes/orders-action.js +19 -13
- package/dist/pwa/routes/profile-credentials.js +7 -4
- package/dist/pwa/routes/profile-placement.js +7 -8
- package/dist/pwa/routes/promoter.js +3 -17
- package/dist/pwa/routes/ratings.js +64 -4
- package/dist/pwa/routes/recover-key.js +58 -19
- package/dist/pwa/routes/referral.js +4 -24
- package/dist/pwa/routes/share-redirects.js +4 -3
- package/dist/pwa/routes/shop-referral.js +6 -5
- package/dist/pwa/routes/shops.js +5 -2
- package/dist/pwa/routes/task-proposals.js +76 -0
- package/dist/pwa/routes/trial.js +4 -2
- package/dist/pwa/routes/users-public.js +2 -12
- package/dist/pwa/routes/wallet-read.js +1 -1
- package/dist/pwa/server.js +67 -9
- package/package.json +31 -3
|
@@ -1,10 +1,53 @@
|
|
|
1
|
-
import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
1
|
+
import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
2
|
export function registerAuthRegisterRoutes(app, deps) {
|
|
3
3
|
// VALID_REGIONS + INVITE_ROTATION_HANDLES 通过 deps.X 在 handler 内延迟读
|
|
4
4
|
// (server.ts 用 getter 注入;destructure at register-time would trigger TDZ 因为它们在下方 const)
|
|
5
|
-
const { db, errorRes, INTERNAL_AUDITOR_ID, isAllowedSponsor, resolveInviteCodeRef, generateId, generateSecureKey, generatePermanentCode, deriveHandle, clientIpHash, clientUaHash, pickPreferredSide, joinPowerLeg, inviteRotationLookup, recordSession, broadcastSystemEvent } = deps;
|
|
5
|
+
const { db, errorRes, INTERNAL_AUDITOR_ID, isAllowedSponsor, resolveInviteCodeRef, generateId, generateSecureKey, generatePermanentCode, deriveHandle, clientIpHash, clientUaHash, pickPreferredSide, joinPowerLeg, inviteRotationLookup, issueCode, findActiveCode, canDeliverCodes, emailDeliveryNotConfigured, recordSession, broadcastSystemEvent } = deps;
|
|
6
|
+
// CODE_TTL_MIN / MAX_CODE_ATTEMPTS 通过 deps.X 在 handler 内延迟读(它们在 server.ts 是后置 const,
|
|
7
|
+
// register-time destructure 会触发 TDZ)。
|
|
8
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
9
|
+
const normEmail = (raw) => String(raw || '').trim().toLowerCase();
|
|
10
|
+
// IP 级发码限流(5/min)— 防爆破列举邮箱 / 刷验证码
|
|
11
|
+
const regCodeHits = new Map();
|
|
12
|
+
// 注册邮箱验证:发码到邮箱(purpose='register',无账号故 user_id='')。
|
|
13
|
+
// 注册场景需明确告知"邮箱已占用"(无法防枚举,标准取舍),但限流 + captcha 兜底。
|
|
14
|
+
app.post('/api/register/send-code', async (req, res) => {
|
|
15
|
+
const email = normEmail(req.body?.email);
|
|
16
|
+
if (!email || !EMAIL_RE.test(email))
|
|
17
|
+
return void errorRes(res, 400, 'EMAIL_INVALID', '请填写有效邮箱');
|
|
18
|
+
if (!canDeliverCodes()) {
|
|
19
|
+
const u = emailDeliveryNotConfigured();
|
|
20
|
+
return void res.status(u.status).json({ error: u.error, error_code: u.error_code });
|
|
21
|
+
}
|
|
22
|
+
const ip = req.ip || '';
|
|
23
|
+
if (ip) {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const rec = regCodeHits.get(ip);
|
|
26
|
+
if (rec && now - rec.firstAt < 60_000) {
|
|
27
|
+
rec.count++;
|
|
28
|
+
if (rec.count > 5)
|
|
29
|
+
return void errorRes(res, 429, 'CODE_RATE_LIMITED', '发送过于频繁,请 1 分钟后再试');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
regCodeHits.set(ip, { count: 1, firstAt: now });
|
|
33
|
+
}
|
|
34
|
+
if (regCodeHits.size > 1000) {
|
|
35
|
+
for (const [k, v] of regCodeHits)
|
|
36
|
+
if (now - v.firstAt > 60_000)
|
|
37
|
+
regCodeHits.delete(k);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const dup = await dbOne("SELECT 1 FROM users WHERE lower(email) = ? AND email_verified = 1 AND id NOT IN ('sys_protocol', ?) LIMIT 1", [email, INTERNAL_AUDITOR_ID]);
|
|
41
|
+
if (dup)
|
|
42
|
+
return void errorRes(res, 409, 'EMAIL_TAKEN', '该邮箱已注册,请直接登录或用 #recover 找回');
|
|
43
|
+
const issued = await issueCode('', 'email', email, 'register');
|
|
44
|
+
if (!issued.ok) {
|
|
45
|
+
return void res.status(issued.status || 503).json({ error: issued.error || '验证码发送失败,请稍后再试', error_code: issued.error_code || 'EMAIL_DELIVERY_FAILED' });
|
|
46
|
+
}
|
|
47
|
+
res.json({ success: true, notice: '验证码已发送至邮箱,请查收(含垃圾箱)', expires_in_min: deps.CODE_TTL_MIN });
|
|
48
|
+
});
|
|
6
49
|
app.post('/api/register', async (req, res) => {
|
|
7
|
-
const { name, role, sponsor_id, region, placement_inviter_id,
|
|
50
|
+
const { name, role, sponsor_id, region, placement_inviter_id, turnstile_token } = req.body;
|
|
8
51
|
const validRoles = ['buyer', 'seller'];
|
|
9
52
|
if (!name?.trim())
|
|
10
53
|
return void errorRes(res, 400, 'NAME_REQUIRED', '请填写名称');
|
|
@@ -43,6 +86,32 @@ export function registerAuthRegisterRoutes(app, deps) {
|
|
|
43
86
|
const dup = await dbOne("SELECT 1 FROM users WHERE name = ? AND id NOT IN ('sys_protocol', ?) LIMIT 1", [trimmed, INTERNAL_AUDITOR_ID]);
|
|
44
87
|
if (dup)
|
|
45
88
|
return void errorRes(res, 409, 'NAME_TAKEN', '该名称已被占用,请换一个');
|
|
89
|
+
// ── 邮箱验证优先注册 ────────────────────────────────────────
|
|
90
|
+
// PWA 人类路径强制:必须先 /register/send-code 拿验证码,提交时带 email + code,校验通过才建号 + email_verified=1。
|
|
91
|
+
// 这样每个新 PWA 账号天生有 verified 邮箱 → #recover 永远可用。
|
|
92
|
+
// agent/MCP 注册走 handleRegister 直插库(自托管 key,不需邮件找回),不经此端点,不受影响。
|
|
93
|
+
const email = normEmail(req.body?.email);
|
|
94
|
+
const code = String(req.body?.code || '').trim();
|
|
95
|
+
if (!email || !code)
|
|
96
|
+
return void errorRes(res, 400, 'EMAIL_VERIFICATION_REQUIRED', '注册需先验证邮箱:请填写邮箱并输入收到的验证码');
|
|
97
|
+
if (!EMAIL_RE.test(email))
|
|
98
|
+
return void errorRes(res, 400, 'EMAIL_INVALID', '邮箱格式不正确');
|
|
99
|
+
const emailDup = await dbOne("SELECT 1 FROM users WHERE lower(email) = ? AND email_verified = 1 AND id NOT IN ('sys_protocol', ?) LIMIT 1", [email, INTERNAL_AUDITOR_ID]);
|
|
100
|
+
if (emailDup)
|
|
101
|
+
return void errorRes(res, 409, 'EMAIL_TAKEN', '该邮箱已注册,请直接登录或用 #recover 找回');
|
|
102
|
+
// 校验注册验证码(按 email 查,purpose='register')。错码计数,超限作废,均【不建号】。
|
|
103
|
+
const codeRow = findActiveCode('email', email, 'register');
|
|
104
|
+
if (!codeRow)
|
|
105
|
+
return void errorRes(res, 400, 'CODE_EXPIRED', '验证码已过期或未发送,请重新获取');
|
|
106
|
+
if (String(codeRow.code) !== code) {
|
|
107
|
+
const attempts = Number(codeRow.attempts || 0) + 1;
|
|
108
|
+
if (attempts >= deps.MAX_CODE_ATTEMPTS) {
|
|
109
|
+
await dbRun("UPDATE verification_codes SET attempts = ?, used_at = datetime('now') WHERE id = ?", [attempts, codeRow.id]);
|
|
110
|
+
return void errorRes(res, 400, 'CODE_TOO_MANY', '错误次数过多,验证码已作废,请重新获取');
|
|
111
|
+
}
|
|
112
|
+
await dbRun("UPDATE verification_codes SET attempts = ? WHERE id = ?", [attempts, codeRow.id]);
|
|
113
|
+
return void errorRes(res, 400, 'CODE_INVALID', `验证码错误(剩余 ${deps.MAX_CODE_ATTEMPTS - attempts} 次)`);
|
|
114
|
+
}
|
|
46
115
|
const ROLE_WHITELIST = [];
|
|
47
116
|
const requireRef = (await dbOne("SELECT value FROM system_state WHERE key='require_ref_to_register'", []))?.value === '1';
|
|
48
117
|
// 2026-05-30 合规复审:取消 china 豁免——D1b 引入时保留 china 通道是为获客,
|
|
@@ -57,15 +126,14 @@ export function registerAuthRegisterRoutes(app, deps) {
|
|
|
57
126
|
// invite codes ONLY: 6-7 char permanent_code with optional -L/-R side. usr_xxx / @handle / handle are
|
|
58
127
|
// no longer accepted as a registration sponsor (anti-ambiguity; narrows the public invite surface).
|
|
59
128
|
const sponsorRawRef = (sponsor_id && typeof sponsor_id === 'string') ? sponsor_id.trim() : '';
|
|
60
|
-
let suffixSide = null;
|
|
61
129
|
let resolvedSponsorId = null;
|
|
62
130
|
if (sponsorRawRef) {
|
|
63
131
|
const ref = resolveInviteCodeRef(sponsorRawRef);
|
|
64
132
|
if (!ref) {
|
|
65
|
-
return void errorRes(res, 400, 'INVALID_SPONSOR_REF', `邀请码无效:${sponsorRawRef.slice(0, 24)}(仅接受 6-7
|
|
133
|
+
return void errorRes(res, 400, 'INVALID_SPONSOR_REF', `邀请码无效:${sponsorRawRef.slice(0, 24)}(仅接受 6-7 位永久码;请检查或留空跳过)`);
|
|
66
134
|
}
|
|
67
135
|
resolvedSponsorId = ref.userId;
|
|
68
|
-
|
|
136
|
+
// pre-public: 去左右码 — 邀请码自带的 -L/-R 侧别一律忽略,放置永远自动(见下)
|
|
69
137
|
const sponsor = await dbOne("SELECT id, sponsor_path FROM users WHERE id = ?", [ref.userId]);
|
|
70
138
|
if (!sponsor) {
|
|
71
139
|
return void errorRes(res, 400, 'INVALID_SPONSOR_REF', `邀请码对应用户不存在:${sponsorRawRef.slice(0, 24)}`);
|
|
@@ -95,21 +163,22 @@ export function registerAuthRegisterRoutes(app, deps) {
|
|
|
95
163
|
return void errorRes(res, 429, 'REGISTER_RATE_LIMITED', '注册过于频繁 — 同一网络每小时最多 5 个新账号,请稍后再试');
|
|
96
164
|
}
|
|
97
165
|
const registerTx = db.transaction(() => {
|
|
98
|
-
db.prepare(`INSERT INTO users (id, name, role, roles, api_key, sponsor_id, sponsor_path, region, permanent_code, handle)
|
|
99
|
-
VALUES (
|
|
100
|
-
.run(id, trimmed, role, JSON.stringify([role]), apiKey, sponsorId, sponsorPath, userRegion, permaCode, userHandle);
|
|
166
|
+
db.prepare(`INSERT INTO users (id, name, role, roles, api_key, sponsor_id, sponsor_path, region, permanent_code, handle, email, email_verified)
|
|
167
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,1)`)
|
|
168
|
+
.run(id, trimmed, role, JSON.stringify([role]), apiKey, sponsorId, sponsorPath, userRegion, permaCode, userHandle, email);
|
|
169
|
+
// 消费注册验证码(单次性)— 与建号同一事务,失败则整体回滚,不留"码已用但没建号"
|
|
170
|
+
db.prepare("UPDATE verification_codes SET used_at = datetime('now') WHERE id = ?").run(codeRow.id);
|
|
101
171
|
db.prepare('INSERT INTO wallets (user_id, balance) VALUES (?,1000)').run(id);
|
|
102
172
|
db.prepare(`INSERT INTO registration_audit_log (user_id, ip_hash, ua_hash, sponsor_id) VALUES (?,?,?,?)`)
|
|
103
173
|
.run(id, ipHash, uaHash, sponsorId);
|
|
104
174
|
// placement inviter is invite-code-only too (the sponsor code, or an explicit placement_inviter_id code)
|
|
105
175
|
let effectiveInviter = resolvedSponsorId;
|
|
106
|
-
|
|
176
|
+
// pre-public 去左右码:不再接受用户/邀请码指定的左右侧,放置侧别永远由系统自动决定(pickPreferredSide)
|
|
177
|
+
let effectiveSide = null;
|
|
107
178
|
if (placement_inviter_id) {
|
|
108
179
|
const p = resolveInviteCodeRef(String(placement_inviter_id));
|
|
109
180
|
if (p) {
|
|
110
181
|
effectiveInviter = p.userId;
|
|
111
|
-
if (!effectiveSide && p.side)
|
|
112
|
-
effectiveSide = p.side;
|
|
113
182
|
}
|
|
114
183
|
}
|
|
115
184
|
if (effectiveInviter && !effectiveSide) {
|
|
@@ -173,6 +242,7 @@ export function registerAuthRegisterRoutes(app, deps) {
|
|
|
173
242
|
sponsor_id: sponsorId, region: userRegion,
|
|
174
243
|
permanent_code: permaCode,
|
|
175
244
|
handle: userHandle,
|
|
245
|
+
email, email_verified: true,
|
|
176
246
|
placement: placement ? { inviter_id: effectiveInviter, side: effectiveSide, depth: placement.depth } : null,
|
|
177
247
|
...(sponsorSkipped ? { sponsor_skipped: sponsorSkipped } : {}),
|
|
178
248
|
});
|
|
@@ -3,6 +3,7 @@ import { issueGithubIdentityClaimChallenge, getIssuedChallengeForVerification, }
|
|
|
3
3
|
import { verifyGithubGistProof } from '../../layer2-business/L2-9-contribution/identity-claim-proof-verifier.js';
|
|
4
4
|
import { claimGithubIdentity } from '../../layer2-business/L2-9-contribution/identity-claim-engine.js';
|
|
5
5
|
import { getMyGithubIdentitySurface } from '../../layer2-business/L2-9-contribution/identity-claim-read.js';
|
|
6
|
+
import { listClaimableGithubIdentityFacts } from '../../layer2-business/L2-9-contribution/identity-claim-discovery.js';
|
|
6
7
|
import { withUncommittedValueBoundary } from '../../layer2-business/L2-9-contribution/contribution-display-envelope.js';
|
|
7
8
|
// ── strict request bodies (unknown/sensitive keys → rejected; nothing trusts a caller field) ──
|
|
8
9
|
const ChallengeBody = z.strictObject({
|
|
@@ -144,4 +145,20 @@ export function registerContributionIdentityRoutes(app, deps) {
|
|
|
144
145
|
return void errorRes(res, 500, 'INTERNAL', '内部错误'); // never leak a stack / query
|
|
145
146
|
}
|
|
146
147
|
});
|
|
148
|
+
// F10 — claimable-fact discovery (read-only). Same posture as /github/me: auth required, the account
|
|
149
|
+
// context is ALWAYS the session user (the request query/body are never read — an ?account_id= is ignored), the
|
|
150
|
+
// engine issues SELECT only (no challenge, no binding write, no accountable_ref change), the response
|
|
151
|
+
// carries the uncommitted-value boundary, and errors never leak SQL/stack.
|
|
152
|
+
app.get('/api/contribution-identity/github/claimable', async (req, res) => {
|
|
153
|
+
const user = auth(req, res);
|
|
154
|
+
if (!user)
|
|
155
|
+
return;
|
|
156
|
+
try {
|
|
157
|
+
const surface = await listClaimableGithubIdentityFacts(user.id);
|
|
158
|
+
res.json(withUncommittedValueBoundary(surface));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return void errorRes(res, 500, 'INTERNAL', '内部错误'); // never leak a stack / query
|
|
162
|
+
}
|
|
163
|
+
});
|
|
147
164
|
}
|
|
@@ -3,7 +3,7 @@ const GROWTH_TASK_CATALOG = [
|
|
|
3
3
|
// 第 1 关:新手起步
|
|
4
4
|
{ id: 'first_purchase', chapter: 1,
|
|
5
5
|
title_zh: '完成首笔购买', title_en: 'Complete first purchase',
|
|
6
|
-
desc_zh: '
|
|
6
|
+
desc_zh: '完成后可使用分享功能(分享仅作归因 / 参与记录)', desc_en: 'Unlocks the share feature (attribution / participation record only)',
|
|
7
7
|
cta: { label_zh: '去发现', label_en: 'Browse', href: '#buy' },
|
|
8
8
|
evaluate: c => c.completed_orders >= 1 },
|
|
9
9
|
{ id: 'default_address', chapter: 1,
|
|
@@ -21,12 +21,14 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
21
21
|
return void res.status(400).json({ error: 'order_ids 必填' });
|
|
22
22
|
if (order_ids.length > 100)
|
|
23
23
|
return void res.status(400).json({ error: '单次最多 100 单' });
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// 自发货(self-fulfill,Phase 1 默认):不传 logistics_company_id → logistics_id 留空,卖家自负后续流转。
|
|
25
|
+
// 只有传了物流公司时才校验其存在。
|
|
26
26
|
// RFC-016: 纯校验读 → 异步 seam(物流公司是否存在);循环内的逐单 read+write 仍同步(Phase 3 随订单事务迁)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
if (logistics_company_id) {
|
|
28
|
+
const lc = await dbOne("SELECT id FROM users WHERE id = ? AND role = 'logistics'", [logistics_company_id]);
|
|
29
|
+
if (!lc)
|
|
30
|
+
return void res.status(400).json({ error: '物流公司不存在' });
|
|
31
|
+
}
|
|
30
32
|
const results = [];
|
|
31
33
|
const trackingMap = (tracking_numbers && typeof tracking_numbers === 'object') ? tracking_numbers : {};
|
|
32
34
|
for (const oid of order_ids) {
|
|
@@ -44,18 +46,22 @@ export function registerOrdersActionRoutes(app, deps) {
|
|
|
44
46
|
results.push({ order_id: oid, status: 'skipped', reason: `状态非 accepted (当前 ${o.status})` });
|
|
45
47
|
continue;
|
|
46
48
|
}
|
|
47
|
-
|
|
49
|
+
// 仅当指定了物流公司时才绑定;自发货保持 logistics_id 为空(seller self-fulfill)
|
|
50
|
+
if (logistics_company_id && !o.logistics_id) {
|
|
48
51
|
db.prepare("UPDATE orders SET logistics_id = ? WHERE id = ?").run(logistics_company_id, oid);
|
|
49
52
|
}
|
|
50
53
|
const tn = trackingMap[oid] ? String(trackingMap[oid]).slice(0, 50) : null;
|
|
54
|
+
// accepted→shipped 状态机要求 evidence(requiresEvidence)。【始终】写一条文字 evidence,
|
|
55
|
+
// 否则无单号(尤其自发货默认)会被状态机拒绝 → shipped:0 卡在 accepted。单号可之后补。
|
|
56
|
+
const evDesc = logistics_company_id
|
|
57
|
+
? (tn ? `批量发货 · 快递单号:${tn} · 物流方 ${logistics_company_id}` : `批量发货,已交付物流公司 ${logistics_company_id},快递单号待物流揽收后回传`)
|
|
58
|
+
: (tn ? `卖家自己发货(批量)· 快递单号:${tn}` : `卖家自己发货(批量·自提自送)—— 由卖家负责揽收/运输/送达,单号可之后补`);
|
|
51
59
|
const evIds = [];
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
const result = transition(db, oid, 'shipped', user.id, evIds, tn ? `批量发货 · ${tn}` : '批量发货');
|
|
60
|
+
const eid = generateId('evt');
|
|
61
|
+
db.prepare(`INSERT INTO evidence (id, order_id, uploader_id, type, description, file_hash)
|
|
62
|
+
VALUES (?,?,?,'description',?,?)`).run(eid, oid, user.id, evDesc, `hash_${Date.now()}`);
|
|
63
|
+
evIds.push(eid);
|
|
64
|
+
const result = transition(db, oid, 'shipped', user.id, evIds, evDesc);
|
|
59
65
|
if (!result.success) {
|
|
60
66
|
results.push({ order_id: oid, status: 'skipped', reason: result.error || '状态机拒绝' });
|
|
61
67
|
continue;
|
|
@@ -2,7 +2,7 @@ import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // R
|
|
|
2
2
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3
3
|
export function registerProfileCredentialsRoutes(app, deps) {
|
|
4
4
|
// db 已全量走 RFC-016 异步 seam(dbOne/dbRun),不再直接用 deps.db
|
|
5
|
-
const { auth, verifyPassword, hashPassword, issueCode, findActiveCode,
|
|
5
|
+
const { auth, verifyPassword, hashPassword, issueCode, findActiveCode, MAX_CODE_ATTEMPTS } = deps;
|
|
6
6
|
// 设置 / 修改密码
|
|
7
7
|
app.post('/api/profile/set-password', async (req, res) => {
|
|
8
8
|
const user = auth(req, res);
|
|
@@ -65,12 +65,15 @@ export function registerProfileCredentialsRoutes(app, deps) {
|
|
|
65
65
|
const dup = await dbOne("SELECT 1 FROM users WHERE email = ? AND id != ? LIMIT 1", [target, user.id]);
|
|
66
66
|
if (dup)
|
|
67
67
|
return void res.json({ error: '该邮箱已被其他账户绑定' });
|
|
68
|
-
const
|
|
68
|
+
const issued = await issueCode(user.id, 'email', target, 'bind_email');
|
|
69
|
+
if (!issued.ok) {
|
|
70
|
+
return void res.status(issued.status).json({ error: issued.error, error_code: issued.error_code });
|
|
71
|
+
}
|
|
69
72
|
res.json({
|
|
70
73
|
success: true,
|
|
71
74
|
target_hint: target.replace(/^(.).*(@.*)$/, '$1***$2'),
|
|
72
|
-
expires_at,
|
|
73
|
-
...(
|
|
75
|
+
expires_at: issued.expires_at,
|
|
76
|
+
...(issued.provider === 'dev_console' ? { dev_code: issued.code } : {}),
|
|
74
77
|
});
|
|
75
78
|
});
|
|
76
79
|
// 绑定邮箱 — 步骤 2:确认验证码
|
|
@@ -22,14 +22,14 @@ export function registerProfilePlacementRoutes(app, deps) {
|
|
|
22
22
|
const user = auth(req, res);
|
|
23
23
|
if (!user)
|
|
24
24
|
return;
|
|
25
|
-
const { inviter_id
|
|
25
|
+
const { inviter_id } = req.body;
|
|
26
26
|
if (!inviter_id || typeof inviter_id !== 'string')
|
|
27
27
|
return void res.json({ error: '请提供 inviter_id' });
|
|
28
|
-
// invite-code ONLY: 6-7 位 permanent_code
|
|
29
|
-
//
|
|
28
|
+
// invite-code ONLY: 6-7 位 permanent_code。usr_xxx / @handle / 裸 handle 不再接受 —
|
|
29
|
+
// 这是积分树关系绑定入口,必须与收窄后的邀请面一致。
|
|
30
30
|
const ref = resolveInviteCodeRef(inviter_id);
|
|
31
31
|
if (!ref)
|
|
32
|
-
return void res.json({ error: 'inviter 邀请码无效(仅 6-7
|
|
32
|
+
return void res.json({ error: 'inviter 邀请码无效(仅 6-7 位永久码)' });
|
|
33
33
|
const resolvedInviterId = ref.userId;
|
|
34
34
|
if (resolvedInviterId === user.id)
|
|
35
35
|
return void res.json({ error: '不能挂靠到自己' });
|
|
@@ -45,9 +45,8 @@ export function registerProfilePlacementRoutes(app, deps) {
|
|
|
45
45
|
if ((inviter.placement_path || '').split('>').includes(user.id)) {
|
|
46
46
|
return void res.json({ error: '检测到环路(你已是 inviter 的上线)' });
|
|
47
47
|
}
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
const chosenSide = effSide || pickPreferredSide(inviter.id);
|
|
48
|
+
// pre-public 去左右码:忽略用户/邀请码指定的左右侧,放置侧别永远由系统自动决定
|
|
49
|
+
const chosenSide = pickPreferredSide(inviter.id);
|
|
51
50
|
try {
|
|
52
51
|
const placed = joinPowerLeg(inviter.id, chosenSide, user.id);
|
|
53
52
|
res.json({ success: true, inviter_id: inviter.id, side: chosenSide, depth: placed.depth });
|
|
@@ -67,6 +66,6 @@ export function registerProfilePlacementRoutes(app, deps) {
|
|
|
67
66
|
// legacy left/right 已不再支持长期强偏,无声折算为 team_count(agent 兼容期保护)
|
|
68
67
|
const stored = (pref === 'left' || pref === 'right') ? 'team_count' : pref;
|
|
69
68
|
await dbRun("UPDATE users SET placement_pref = ?, updated_at = datetime('now') WHERE id = ?", [stored, user.id]);
|
|
70
|
-
res.json({ success: true, placement_pref: stored, coerced: stored !== pref ? `${pref} → ${stored}
|
|
69
|
+
res.json({ success: true, placement_pref: stored, coerced: stored !== pref ? `${pref} → ${stored}(已统一为长期默认偏好)` : undefined });
|
|
71
70
|
});
|
|
72
71
|
}
|
|
@@ -90,17 +90,8 @@ export function registerPromoterRoutes(app, deps) {
|
|
|
90
90
|
next_30_estimate: earnedLast30 + wazLast30,
|
|
91
91
|
};
|
|
92
92
|
const insights = [];
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (leftPv > 0 || rightPv > 0) {
|
|
96
|
-
const max = Math.max(leftPv, rightPv), min = Math.min(leftPv, rightPv);
|
|
97
|
-
const ratio = max > 0 ? min / max : 0;
|
|
98
|
-
const weak = leftPv < rightPv ? '左区' : '右区';
|
|
99
|
-
if (ratio < 0.5)
|
|
100
|
-
insights.push({ type: 'weak_leg', level: 'warn', text: `${weak} PV 仅为强腿的 ${(ratio * 100).toFixed(0)}% — 建议主推${weak},对碰 = min(L,R) × tier_score` });
|
|
101
|
-
else
|
|
102
|
-
insights.push({ type: 'balanced', level: 'success', text: `双腿均衡度 ${(ratio * 100).toFixed(0)}% — 对碰节奏健康` });
|
|
103
|
-
}
|
|
93
|
+
// pre-public de-MLM:移除弱腿 / pairing / PV-tier 经营建议 —— PV 对碰为 pre-launch、未对用户启用,
|
|
94
|
+
// 不在用户面 surface 营销主推弱腿 / pairing 公式 / PV-tier 进度等玩法。位置 / PV 仅为参与记录,非收益路径。
|
|
104
95
|
const lastInvite = (await dbOne(`SELECT MAX(created_at) as t FROM users WHERE sponsor_id = ?`, [userId]));
|
|
105
96
|
if (lastInvite.t) {
|
|
106
97
|
const days = Math.floor((Date.now() - new Date(lastInvite.t).getTime()) / 86400_000);
|
|
@@ -112,13 +103,8 @@ export function registerPromoterRoutes(app, deps) {
|
|
|
112
103
|
else if (l1 === 0) {
|
|
113
104
|
insights.push({ type: 'no_team', level: 'info', text: `还没有直推 — 先分享你买过且好评的商品给好友` });
|
|
114
105
|
}
|
|
115
|
-
const pair = Math.min(leftPv, rightPv);
|
|
116
|
-
const nextTier = tiers.find(t => t.pv_threshold > pair);
|
|
117
|
-
if (nextTier && pair > 0 && pair / nextTier.pv_threshold > 0.7) {
|
|
118
|
-
insights.push({ type: 'near_tier', level: 'success', text: `距离 tier ${nextTier.tier} 仅差 ${(nextTier.pv_threshold - pair).toLocaleString()} PV (+${nextTier.score_per_hit} Score / 次)` });
|
|
119
|
-
}
|
|
120
106
|
if (!canL1Share && completedOrders === 0) {
|
|
121
|
-
insights.push({ type: '
|
|
107
|
+
insights.push({ type: 'share_hint', level: 'info', text: `完成首笔购买后可使用分享功能;分享记录仅作归因 / 参与记录,不构成收益承诺。` });
|
|
122
108
|
}
|
|
123
109
|
if (shareableProducts.length > 0 && grand === 0) {
|
|
124
110
|
insights.push({ type: 'first_share', level: 'info', text: `你有 ${shareableProducts.length} 个可分享商品但暂无成交 — 试着把链接发给身边的人` });
|
|
@@ -144,11 +144,17 @@ export function registerRatingsRoutes(app, deps) {
|
|
|
144
144
|
const user = auth(req, res);
|
|
145
145
|
if (!user)
|
|
146
146
|
return;
|
|
147
|
-
const r = await dbOne('SELECT seller_id, reply FROM order_ratings WHERE order_id = ?', [req.params.order_id]);
|
|
147
|
+
const r = await dbOne('SELECT seller_id, reply, hidden_until FROM order_ratings WHERE order_id = ?', [req.params.order_id]);
|
|
148
148
|
if (!r)
|
|
149
149
|
return void res.status(404).json({ error: '该订单暂无评价' });
|
|
150
150
|
if (r.seller_id !== user.id)
|
|
151
151
|
return void res.status(403).json({ error: '仅卖家可回复' });
|
|
152
|
+
// 双盲铁律:未揭晓前不能回复(回复=已读到评价)。揭晓条件 = 自己也评过买家 OR 盲评期已过。
|
|
153
|
+
const sellerAlsoRated = !!(await dbOne(`SELECT order_id FROM buyer_ratings WHERE order_id = ?`, [req.params.order_id]));
|
|
154
|
+
const blindExpired = !!r.hidden_until && new Date(r.hidden_until) < new Date();
|
|
155
|
+
if (!sellerAlsoRated && !blindExpired) {
|
|
156
|
+
return void res.status(403).json({ error: '双盲期未结束:请先评价买家,或等盲评期满后再回应', error_code: 'RATING_STILL_BLIND' });
|
|
157
|
+
}
|
|
152
158
|
if (r.reply)
|
|
153
159
|
return void res.status(400).json({ error: '已回复过,每条评价仅可回复一次' });
|
|
154
160
|
const reply = req.body?.reply ? String(req.body.reply).slice(0, 500) : null;
|
|
@@ -201,9 +207,63 @@ export function registerRatingsRoutes(app, deps) {
|
|
|
201
207
|
`, [req.params.product_id]);
|
|
202
208
|
res.json({ items: rows, agg });
|
|
203
209
|
});
|
|
204
|
-
//
|
|
210
|
+
// 卖家:自己店铺收到的全部评价(含 order_id 便于逐条回复 + 回复/追问状态)。
|
|
211
|
+
// 与公开聚合 endpoint 分开:authed + 只返回本人的评价 + 暴露 order_id(仅给卖家本人)。
|
|
212
|
+
// 纯只读,不改任何评价 / 资金逻辑;回复仍走既有 POST /orders/:order_id/rating/reply。
|
|
213
|
+
// ⚠️ 必须注册在 /api/sellers/:seller_id/ratings 【之前】,否则 'me' 会被 :seller_id 参数路由抢匹配。
|
|
214
|
+
app.get('/api/sellers/me/ratings', async (req, res) => {
|
|
215
|
+
const user = auth(req, res);
|
|
216
|
+
if (!user)
|
|
217
|
+
return;
|
|
218
|
+
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
|
|
219
|
+
// 双盲铁律:卖家看 buyer→seller 评价,必须【自己也评过买家】(buyer_ratings 存在) 或【盲评期已过】(hidden_until 到期)。
|
|
220
|
+
// 否则只返回遮蔽行(不含 stars/comment/reply),与 GET /orders/:id/rating 的揭晓条件一致 —— 防卖家看了买家评价再反向报复。
|
|
221
|
+
const rows = await dbAll(`
|
|
222
|
+
SELECT r.order_id, r.stars, r.comment, r.reply, r.replied_at, r.buyer_followup, r.buyer_followup_at, r.created_at, r.product_id, r.hidden_until,
|
|
223
|
+
p.title as product_title,
|
|
224
|
+
u.name as buyer_name, u.handle as buyer_handle,
|
|
225
|
+
(SELECT 1 FROM buyer_ratings br WHERE br.order_id = r.order_id) AS seller_also_rated
|
|
226
|
+
FROM order_ratings r
|
|
227
|
+
JOIN products p ON p.id = r.product_id
|
|
228
|
+
JOIN users u ON u.id = r.buyer_id
|
|
229
|
+
WHERE r.seller_id = ?
|
|
230
|
+
ORDER BY r.created_at DESC LIMIT ?
|
|
231
|
+
`, [user.id, limit]);
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
let unreplied = 0;
|
|
234
|
+
const items = rows.map(r => {
|
|
235
|
+
const blindExpired = !!r.hidden_until && new Date(r.hidden_until).getTime() < now;
|
|
236
|
+
const revealed = !!r.seller_also_rated || blindExpired;
|
|
237
|
+
if (!revealed) {
|
|
238
|
+
// 遮蔽:只回最小信息(有评价 + 商品 + 解除条件),绝不泄露分数/评论/回复
|
|
239
|
+
return { order_id: r.order_id, product_title: r.product_title, created_at: r.created_at, hidden_until: r.hidden_until, masked: true, reveal_reason: 'blind_until_both_or_expire' };
|
|
240
|
+
}
|
|
241
|
+
if (!r.reply)
|
|
242
|
+
unreplied++;
|
|
243
|
+
return {
|
|
244
|
+
order_id: r.order_id, stars: r.stars, comment: r.comment, reply: r.reply, replied_at: r.replied_at,
|
|
245
|
+
buyer_followup: r.buyer_followup, buyer_followup_at: r.buyer_followup_at, created_at: r.created_at,
|
|
246
|
+
product_id: r.product_id, product_title: r.product_title, buyer_name: r.buyer_name, buyer_handle: r.buyer_handle,
|
|
247
|
+
masked: false,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
// 聚合双盲铁律:cnt / avg_stars 必须【只算已揭晓评价】,否则盲评期内卖家能从均分反推买家未揭晓评分。
|
|
251
|
+
// 与公开面同 blindOpen 条件;另回 masked_count(只告知"有多少条遮蔽中",不含分数)。
|
|
252
|
+
const blindOpen = `(EXISTS (SELECT 1 FROM buyer_ratings br WHERE br.order_id = r.order_id) OR r.hidden_until IS NULL OR datetime(r.hidden_until) <= datetime('now'))`;
|
|
253
|
+
const agg = await dbOne(`
|
|
254
|
+
SELECT
|
|
255
|
+
SUM(CASE WHEN ${blindOpen} THEN 1 ELSE 0 END) as cnt,
|
|
256
|
+
COALESCE(AVG(CASE WHEN ${blindOpen} THEN stars END), 0) as avg_stars,
|
|
257
|
+
SUM(CASE WHEN ${blindOpen} THEN 0 ELSE 1 END) as masked_count
|
|
258
|
+
FROM order_ratings r WHERE r.seller_id = ?`, [user.id]);
|
|
259
|
+
res.json({ items, agg: { ...(agg || {}), unreplied } });
|
|
260
|
+
});
|
|
261
|
+
// 公开:卖家评价聚合(卖家主页)。注册在 /me 之后(见上面注释)。
|
|
205
262
|
app.get('/api/sellers/:seller_id/ratings', async (req, res) => {
|
|
206
263
|
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
|
|
264
|
+
// 双盲铁律(公开面):只展示已揭晓的评价 —— 与 GET /products/:id/ratings 同条件。
|
|
265
|
+
// 揭晓 = 双方都评过(buyer_ratings 存在) OR 无盲评窗口(hidden_until 空) OR 盲评期已过。
|
|
266
|
+
const blindOpen = `(EXISTS (SELECT 1 FROM buyer_ratings br WHERE br.order_id = r.order_id) OR r.hidden_until IS NULL OR datetime(r.hidden_until) <= datetime('now'))`;
|
|
207
267
|
const rows = await dbAll(`
|
|
208
268
|
SELECT r.stars, r.comment, r.reply, r.replied_at, r.buyer_followup, r.buyer_followup_at, r.created_at, r.product_id,
|
|
209
269
|
p.title as product_title,
|
|
@@ -211,10 +271,10 @@ export function registerRatingsRoutes(app, deps) {
|
|
|
211
271
|
FROM order_ratings r
|
|
212
272
|
JOIN products p ON p.id = r.product_id
|
|
213
273
|
JOIN users u ON u.id = r.buyer_id
|
|
214
|
-
WHERE r.seller_id = ?
|
|
274
|
+
WHERE r.seller_id = ? AND ${blindOpen}
|
|
215
275
|
ORDER BY r.created_at DESC LIMIT ?
|
|
216
276
|
`, [req.params.seller_id, limit]);
|
|
217
|
-
const agg = await dbOne(`SELECT COUNT(*) as cnt, COALESCE(AVG(stars), 0) as avg_stars FROM order_ratings WHERE seller_id =
|
|
277
|
+
const agg = await dbOne(`SELECT COUNT(*) as cnt, COALESCE(AVG(stars), 0) as avg_stars FROM order_ratings r WHERE r.seller_id = ? AND ${blindOpen}`, [req.params.seller_id]);
|
|
218
278
|
res.json({ items: rows, agg });
|
|
219
279
|
});
|
|
220
280
|
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
2
|
export function registerRecoverKeyRoutes(app, deps) {
|
|
3
3
|
// db 已走 RFC-016 异步 seam(dbOne/dbAll/dbRun),不再直接用 deps.db
|
|
4
|
-
const { internalAuditorId, issueCode, findActiveCode, CODE_TTL_MIN, MAX_CODE_ATTEMPTS } = deps;
|
|
4
|
+
const { internalAuditorId, issueCode, findActiveCode, canDeliverCodes, emailDeliveryNotConfigured, hashPassword, CODE_TTL_MIN, MAX_CODE_ATTEMPTS } = deps;
|
|
5
|
+
// 账号标识解析 —— 与 /api/login 一致:@handle / handle(小写)优先,name 兜底。
|
|
6
|
+
// 找回三步全部复用它,否则用 handle 登录的用户(如 @holden)在找回页按 name 永远查不到、邮件不发。
|
|
7
|
+
const accountRef = (raw) => {
|
|
8
|
+
const display = String(raw || '').trim();
|
|
9
|
+
return { display, handleRef: display.replace(/^@/, '').toLowerCase() };
|
|
10
|
+
};
|
|
11
|
+
// (handle = ? OR name = ?) 子句 + 参数,排除 sys/auditor。
|
|
12
|
+
const ACCOUNT_MATCH = "(lower(coalesce(handle, '')) = ? OR name = ?) AND id NOT IN ('sys_protocol', ?)";
|
|
5
13
|
// IP 级速率(5/min)— 防爆破列举账户
|
|
6
14
|
const recoverKeyHits = new Map();
|
|
7
15
|
app.post('/api/recover-key', async (req, res) => {
|
|
@@ -25,10 +33,11 @@ export function registerRecoverKeyRoutes(app, deps) {
|
|
|
25
33
|
}
|
|
26
34
|
const { name } = req.body;
|
|
27
35
|
if (!name?.trim())
|
|
28
|
-
return void res.json({ error: '
|
|
29
|
-
const
|
|
36
|
+
return void res.json({ error: '请填写注册时使用的名称或 @用户名' });
|
|
37
|
+
const { display, handleRef } = accountRef(name);
|
|
38
|
+
const rows = await dbAll(`SELECT name, role, api_key, email, phone, created_at FROM users WHERE ${ACCOUNT_MATCH}`, [handleRef, display, internalAuditorId]);
|
|
30
39
|
if (rows.length === 0)
|
|
31
|
-
return void res.json({ error: '
|
|
40
|
+
return void res.json({ error: '未找到该名称 / @用户名的账号' });
|
|
32
41
|
const mask = (s) => s && s.length > 8 ? `${s.slice(0, 4)}…${s.slice(-4)}` : s;
|
|
33
42
|
const maskEmail = (e) => {
|
|
34
43
|
if (!e)
|
|
@@ -60,32 +69,53 @@ export function registerRecoverKeyRoutes(app, deps) {
|
|
|
60
69
|
const { name, email } = req.body;
|
|
61
70
|
if (!name?.trim() || !email?.trim())
|
|
62
71
|
return void res.json({ error: '请填写名称和邮箱' });
|
|
72
|
+
if (!canDeliverCodes()) {
|
|
73
|
+
const unavailable = emailDeliveryNotConfigured();
|
|
74
|
+
return void res.status(unavailable.status).json({ error: unavailable.error, error_code: unavailable.error_code });
|
|
75
|
+
}
|
|
63
76
|
const target = email.trim().toLowerCase();
|
|
64
|
-
const
|
|
65
|
-
SELECT id, name, email FROM users
|
|
66
|
-
WHERE name = ? AND email = ? AND email_verified = 1
|
|
67
|
-
AND id NOT IN ('sys_protocol', ?) LIMIT 1
|
|
68
|
-
`, [name.trim(), target, internalAuditorId]);
|
|
69
|
-
if (user)
|
|
70
|
-
issueCode(user.id, 'email', target, 'recover_key');
|
|
71
|
-
res.json({
|
|
77
|
+
const genericResponse = {
|
|
72
78
|
success: true,
|
|
73
79
|
notice: '若该名称与邮箱组合存在,验证码已发送至该邮箱',
|
|
74
80
|
expires_in_min: CODE_TTL_MIN,
|
|
75
|
-
}
|
|
81
|
+
};
|
|
82
|
+
const { display, handleRef } = accountRef(name);
|
|
83
|
+
const user = await dbOne(`
|
|
84
|
+
SELECT id, name, email FROM users
|
|
85
|
+
WHERE ${ACCOUNT_MATCH} AND email = ? AND email_verified = 1 LIMIT 1
|
|
86
|
+
`, [handleRef, display, internalAuditorId, target]);
|
|
87
|
+
if (user) {
|
|
88
|
+
const issued = await issueCode(user.id, 'email', target, 'recover_key');
|
|
89
|
+
if (!issued.ok) {
|
|
90
|
+
console.warn(`[recover-key] verification email delivery failed: ${issued.error_code}`);
|
|
91
|
+
return void res.json(genericResponse);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
res.json(genericResponse);
|
|
76
95
|
});
|
|
77
|
-
// 步骤 2:提交验证码 → 返回完整 api_key
|
|
96
|
+
// 步骤 2:提交验证码 → 返回完整 api_key,并可选同时重置登录密码(code + new_password)。
|
|
97
|
+
// 安全等价:本端点本就返回完整 api_key(最高凭证),允许同时重置密码不扩大权限面 —— 验证码已是同等门槛。
|
|
78
98
|
app.post('/api/recover-key/confirm', async (req, res) => {
|
|
79
|
-
const { name, email, code } = req.body;
|
|
99
|
+
const { name, email, code, new_password } = req.body;
|
|
80
100
|
if (!name?.trim() || !email?.trim() || !code?.trim())
|
|
81
101
|
return void res.json({ error: '请填写完整信息' });
|
|
102
|
+
// 可选新密码:格式与 change-password 一致(≥8,≤200)。先校验格式,失败【不消费验证码】,可重试。
|
|
103
|
+
const wantsPasswordReset = new_password !== undefined && new_password !== null && String(new_password) !== '';
|
|
104
|
+
if (wantsPasswordReset) {
|
|
105
|
+
if (String(new_password).length < 8)
|
|
106
|
+
return void res.json({ error: '新密码至少 8 字符' });
|
|
107
|
+
if (String(new_password).length > 200)
|
|
108
|
+
return void res.json({ error: '密码过长(>200 字符)' });
|
|
109
|
+
}
|
|
82
110
|
const target = email.trim().toLowerCase();
|
|
83
111
|
const row = findActiveCode('email', target, 'recover_key');
|
|
84
112
|
if (!row)
|
|
85
113
|
return void res.json({ error: '验证码已过期或未发送,请重新开始' });
|
|
86
|
-
const user = await dbOne(`SELECT id, name, api_key FROM users WHERE id = ?`, [row.user_id]);
|
|
87
|
-
|
|
88
|
-
|
|
114
|
+
const user = await dbOne(`SELECT id, name, handle, api_key FROM users WHERE id = ?`, [row.user_id]);
|
|
115
|
+
const { display, handleRef } = accountRef(name);
|
|
116
|
+
const refMatches = !!user && (user.name === display || String(user.handle || '').toLowerCase() === handleRef);
|
|
117
|
+
if (!user || !refMatches)
|
|
118
|
+
return void res.json({ error: '名称 / @用户名与验证码不匹配' });
|
|
89
119
|
if (String(row.code) !== code.trim()) {
|
|
90
120
|
const attempts = row.attempts + 1;
|
|
91
121
|
if (attempts >= MAX_CODE_ATTEMPTS) {
|
|
@@ -96,6 +126,15 @@ export function registerRecoverKeyRoutes(app, deps) {
|
|
|
96
126
|
return void res.json({ error: `验证码错误(剩余 ${MAX_CODE_ATTEMPTS - attempts} 次)` });
|
|
97
127
|
}
|
|
98
128
|
await dbRun("UPDATE verification_codes SET used_at = datetime('now') WHERE id = ?", [row.id]);
|
|
99
|
-
|
|
129
|
+
// optional password reset — same credential gate as returning the api_key, so no extra power.
|
|
130
|
+
let passwordReset = false;
|
|
131
|
+
if (wantsPasswordReset) {
|
|
132
|
+
// mirror /profile/set-password: also clear lock state, else a user who forgot + got locked out by
|
|
133
|
+
// failed attempts stays locked and "new password is correct but can't log in" (auth-login rejects
|
|
134
|
+
// locked users before verifying the password).
|
|
135
|
+
await dbRun("UPDATE users SET password_hash = ?, failed_attempts = 0, locked_until = NULL, updated_at = datetime('now') WHERE id = ?", [hashPassword(String(new_password)), user.id]);
|
|
136
|
+
passwordReset = true;
|
|
137
|
+
}
|
|
138
|
+
res.json({ success: true, api_key: user.api_key, name: user.name, ...(passwordReset ? { password_reset: true } : {}) });
|
|
100
139
|
});
|
|
101
140
|
}
|
|
@@ -60,14 +60,13 @@ export function registerReferralRoutes(app, deps) {
|
|
|
60
60
|
});
|
|
61
61
|
// RFC-003 #1122: 生成商品分享链接(把 MCP webaz_share_link 的本地计算搬到服务端,
|
|
62
62
|
// 让 MCP NETWORK 模式可代理)。RFC-002 §3.5 valuation-layer gate:需 rewards opt-in。
|
|
63
|
-
//
|
|
63
|
+
// pre-public 去左右码:不再接受/返回 side,放置侧别由注册时系统自动决定。
|
|
64
64
|
app.get('/api/share-link', async (req, res) => {
|
|
65
65
|
const user = auth(req, res);
|
|
66
66
|
if (!user)
|
|
67
67
|
return;
|
|
68
68
|
const userId = user.id;
|
|
69
69
|
const productId = String(req.query.product_id || '');
|
|
70
|
-
const sideArg = String(req.query.side || 'auto');
|
|
71
70
|
if (!productId)
|
|
72
71
|
return void res.status(400).json({ error: 'product_id required', error_code: 'PRODUCT_ID_REQUIRED' });
|
|
73
72
|
const optIn = (await dbOne("SELECT rewards_opted_in FROM users WHERE id = ?", [userId]))?.rewards_opted_in ?? 0;
|
|
@@ -101,25 +100,7 @@ export function registerReferralRoutes(app, deps) {
|
|
|
101
100
|
const product = await dbOne("SELECT id, title, price, commission_rate FROM products WHERE id = ? AND status='active'", [productId]);
|
|
102
101
|
if (!product)
|
|
103
102
|
return void res.status(404).json({ error: '商品不存在或已下架', error_code: 'PRODUCT_NOT_FOUND' });
|
|
104
|
-
|
|
105
|
-
if (sideArg === 'left' || sideArg === 'right') {
|
|
106
|
-
side = sideArg;
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
const u = await dbOne("SELECT placement_pref, total_left_pv, total_right_pv, left_count, right_count FROM users WHERE id = ?", [userId]);
|
|
110
|
-
const pref = u?.placement_pref || 'team_count';
|
|
111
|
-
if (pref === 'pv_count') {
|
|
112
|
-
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' ');
|
|
113
|
-
const w = (await dbOne(`SELECT COALESCE(SUM(consumed_left_pv),0) AS l, COALESCE(SUM(consumed_right_pv),0) AS r
|
|
114
|
-
FROM binary_score_records WHERE user_id = ? AND created_at >= ?`, [userId, since]));
|
|
115
|
-
const leftPv = Number(u?.total_left_pv ?? 0) + Number(w.l);
|
|
116
|
-
const rightPv = Number(u?.total_right_pv ?? 0) + Number(w.r);
|
|
117
|
-
side = leftPv <= rightPv ? 'left' : 'right';
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
side = (Number(u?.left_count ?? 0) <= Number(u?.right_count ?? 0)) ? 'left' : 'right';
|
|
121
|
-
}
|
|
122
|
-
}
|
|
103
|
+
// pre-public 去左右码:分享链接不再计算/携带 side(放置侧别由注册时系统自动决定)
|
|
123
104
|
const completed = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
|
|
124
105
|
const override = (await dbOne("SELECT l1_share_override FROM users WHERE id = ?", [userId]))?.l1_share_override ?? 0;
|
|
125
106
|
const canL1 = override === 1 || (override === 0 && completed > 0);
|
|
@@ -128,13 +109,12 @@ export function registerReferralRoutes(app, deps) {
|
|
|
128
109
|
const refCode = (await dbOne("SELECT permanent_code FROM users WHERE id = ?", [userId]))?.permanent_code || null;
|
|
129
110
|
if (!refCode)
|
|
130
111
|
return void res.status(409).json({ error: '邀请码暂不可用,请刷新或联系支持', error_code: 'PERMANENT_CODE_MISSING' });
|
|
131
|
-
const link = `/?ref=${refCode}
|
|
112
|
+
const link = `/?ref=${refCode}#order-product/${productId}`;
|
|
132
113
|
res.json({
|
|
133
114
|
product: { id: product.id, title: product.title, price: product.price, commission_rate: rate },
|
|
134
115
|
share_link: link,
|
|
135
116
|
full_url_hint: 'Prepend webaz.xyz (production) to get the absolute URL',
|
|
136
|
-
|
|
137
|
-
binary_explanation: `New user via this link → placed in your ${side === 'left' ? '🔵 left' : '🟢 right'} subtree (tail anchor)`,
|
|
117
|
+
placement_note: 'New user via this link → placement is recorded automatically by the system (no left/right choice).',
|
|
138
118
|
commission_eligibility: canL1
|
|
139
119
|
? `You will earn 3-tier commission: L1=${(rate * 0.70 * 100).toFixed(1)}% L2=${(rate * 0.20 * 100).toFixed(1)}% L3=${(rate * 0.10 * 100).toFixed(1)}% of sale price`
|
|
140
120
|
: 'You are NOT verified yet (need 1 completed purchase). 3-tier commission will be skipped, but points-matching still builds.',
|