@seasonkoh/webaz 0.1.26 → 0.1.27

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 (81) hide show
  1. package/LICENSE +2 -2
  2. package/NOTICE +24 -3
  3. package/README.md +74 -330
  4. package/README.zh-CN.md +419 -0
  5. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +21 -0
  6. package/dist/layer0-foundation/L0-5-manifest/manifest.js +8 -3
  7. package/dist/layer1-agent/L1-1-mcp-server/auth.js +13 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/server.js +36 -28
  9. package/dist/layer2-business/L2-9-contribution/admin-coordination-ingestion-engine.js +181 -0
  10. package/dist/layer2-business/L2-9-contribution/admin-coordination-resolver.js +114 -0
  11. package/dist/layer2-business/L2-9-contribution/admin-coordination-store.js +251 -0
  12. package/dist/layer2-business/L2-9-contribution/admin-operator-claim-workflow.js +390 -0
  13. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +24 -0
  14. package/dist/layer2-business/L2-9-contribution/build-task-participation.js +6 -2
  15. package/dist/layer2-business/L2-9-contribution/build-task-quota.js +337 -0
  16. package/dist/layer2-business/L2-9-contribution/build-task-read.js +25 -2
  17. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +57 -7
  18. package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +1 -1
  19. package/dist/layer2-business/L2-9-contribution/contribution-facts-read.js +66 -0
  20. package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +187 -18
  21. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +29 -4
  22. package/dist/ledger.js +1 -1
  23. package/dist/pwa/admin-audit.js +38 -0
  24. package/dist/pwa/anti-abuse-thresholds.js +135 -0
  25. package/dist/pwa/cf-origin-guard.js +33 -0
  26. package/dist/pwa/contract-fingerprint.js +1 -0
  27. package/dist/pwa/data/onboarding-cases.js +2 -2
  28. package/dist/pwa/data/onboarding-quiz.js +1 -1
  29. package/dist/pwa/economic-participation.js +2 -2
  30. package/dist/pwa/integration-contract.js +46 -4
  31. package/dist/pwa/internal/pv-settlement.js +12 -0
  32. package/dist/pwa/internal/wallet-signer.js +26 -0
  33. package/dist/pwa/public/app.js +679 -679
  34. package/dist/pwa/public/i18n.js +15 -28
  35. package/dist/pwa/public/index.html +1 -1
  36. package/dist/pwa/public/openapi.json +4760 -2769
  37. package/dist/pwa/pv-kill-switch.js +31 -0
  38. package/dist/pwa/routes/admin-admins.js +48 -1
  39. package/dist/pwa/routes/admin-analytics.js +1 -10
  40. package/dist/pwa/routes/admin-atomic.js +4 -17
  41. package/dist/pwa/routes/admin-operator-claims.js +280 -0
  42. package/dist/pwa/routes/admin-reports.js +4 -26
  43. package/dist/pwa/routes/admin-tokenomics.js +2 -76
  44. package/dist/pwa/routes/admin-users-lifecycle.js +1 -14
  45. package/dist/pwa/routes/admin-users-query.js +23 -1
  46. package/dist/pwa/routes/admin-wallet-ops.js +1 -1
  47. package/dist/pwa/routes/auth-read.js +1 -5
  48. package/dist/pwa/routes/auth-register.js +3 -13
  49. package/dist/pwa/routes/build-task-quota.js +113 -0
  50. package/dist/pwa/routes/claim-verify.js +15 -11
  51. package/dist/pwa/routes/contribution-facts.js +18 -0
  52. package/dist/pwa/routes/dispute-cases.js +5 -4
  53. package/dist/pwa/routes/growth.js +3 -3
  54. package/dist/pwa/routes/orders-action.js +27 -10
  55. package/dist/pwa/routes/orders-create.js +1 -1
  56. package/dist/pwa/routes/products-meta.js +19 -6
  57. package/dist/pwa/routes/profile-placement.js +1 -1
  58. package/dist/pwa/routes/promoter.js +10 -29
  59. package/dist/pwa/routes/public-build-tasks.js +5 -1
  60. package/dist/pwa/routes/public-utils.js +9 -12
  61. package/dist/pwa/routes/referral.js +5 -26
  62. package/dist/pwa/routes/rewards-apply.js +3 -2
  63. package/dist/pwa/routes/share-redirects.js +1 -1
  64. package/dist/pwa/routes/shareables-interactions.js +2 -1
  65. package/dist/pwa/routes/task-proposals.js +85 -9
  66. package/dist/pwa/routes/users-public.js +1 -4
  67. package/dist/pwa/routes/wallet-read.js +2 -14
  68. package/dist/pwa/routes/webauthn.js +1 -1
  69. package/dist/pwa/server.js +156 -469
  70. package/dist/settlement-math.js +3 -3
  71. package/dist/version.js +6 -4
  72. package/package.json +33 -7
  73. package/dist/index.js +0 -182
  74. package/dist/pwa/public/docs/ECONOMIC-MODEL.md +0 -287
  75. package/dist/pwa/public/docs/INTEGRATOR.md +0 -67
  76. package/dist/pwa/public/docs/META-RULES-FULL.md +0 -543
  77. package/dist/test-dispute.js +0 -153
  78. package/dist/test-manifest.js +0 -61
  79. package/dist/test-mcp-tools.js +0 -135
  80. package/dist/test-reputation.js +0 -116
  81. package/dist/test-skill-market.js +0 -101
@@ -89,12 +89,35 @@ export function registerAdminUsersQueryRoutes(app, deps) {
89
89
  return void res.status(400).json({ error: 'action 必须 suspend/unsuspend' });
90
90
  const reasonStr = action === 'suspend' ? (reason ? String(reason).slice(0, 200) : 'admin 批量暂停') : null;
91
91
  const results = [];
92
+ // Per-uid authorization boundary (res-free, so one bad uid never aborts the whole batch). Mirrors
93
+ // adminCanOperateOn but stricter for admin targets: an admin target is ROOT-only regardless of scope.
94
+ const actingRoot = isRootAdmin(admin);
95
+ const actingScope = admin.admin_scope || 'global';
96
+ const canOperate = (t) => {
97
+ if (t.admin_type)
98
+ return actingRoot ? { ok: true } : { ok: false, reason: '仅 root 可操作 admin 账号' };
99
+ if (actingRoot || actingScope === 'global')
100
+ return { ok: true };
101
+ if (t.region && t.region !== actingScope)
102
+ return { ok: false, reason: `跨区用户(${t.region})仅本区/全局 admin 可操作` };
103
+ return { ok: true };
104
+ };
92
105
  for (const uid of user_ids) {
93
106
  try {
94
107
  if (uid === 'sys_protocol' || uid === admin.id) {
95
108
  results.push({ user_id: uid, status: 'skipped', reason: '保留账户或自己' });
96
109
  continue;
97
110
  }
111
+ const target = await dbOne('SELECT admin_type, region FROM users WHERE id = ?', [uid]);
112
+ if (!target) {
113
+ results.push({ user_id: uid, status: 'skipped', reason: '用户不存在' });
114
+ continue;
115
+ }
116
+ const gate = canOperate(target);
117
+ if (!gate.ok) {
118
+ results.push({ user_id: uid, status: 'skipped', reason: gate.reason });
119
+ continue;
120
+ }
98
121
  if (action === 'suspend') {
99
122
  await dbRun(`INSERT INTO user_moderation (user_id, suspended, reason, suspended_by, suspended_at)
100
123
  VALUES (?, 1, ?, ?, datetime('now'))
@@ -386,7 +409,6 @@ export function registerAdminUsersQueryRoutes(app, deps) {
386
409
  reputation: user.reputation,
387
410
  failed_attempts: user.failed_attempts ?? 0,
388
411
  locked_until: user.locked_until,
389
- mgmt_bonus_eligible: !!user.mgmt_bonus_eligible,
390
412
  l1_share_override: Number(user.l1_share_override ?? 0),
391
413
  can_l1_share: isAllowedSponsor(user.id),
392
414
  },
@@ -58,7 +58,7 @@ export function registerAdminWalletOpsRoutes(app, deps) {
58
58
  res.json(list);
59
59
  });
60
60
  app.post('/api/admin/withdrawals/:id/approve', async (req, res) => {
61
- // 双轨过渡鉴权:优先认登录的 protocol-admin(Bearer)→ 记其真实 admin id;
61
+ // 双重受理过渡鉴权:优先认登录的 protocol-admin(Bearer)→ 记其真实 admin id;
62
62
  // 否则回落到共享 ADMIN_KEY(adminAuth,既有运维路径,行为不变)→ actor 记中性标记 'admin_key'。
63
63
  // 仅认 protocol 权限的 admin;非 protocol 的 Bearer 不放行(soft 解析返回 null),不扩大访问面,只精确归属。
64
64
  let actorId = 'admin_key';
@@ -1,7 +1,7 @@
1
1
  import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
2
  export function registerAuthReadRoutes(app, deps) {
3
3
  // db 已全量走 RFC-016 异步 seam(dbOne),不再直接用 deps.db
4
- const { auth, safeRoles, getRegionMaxLevels, userMlmGate, getUserLevel } = deps;
4
+ const { auth, safeRoles, getRegionMaxLevels, userMlmGate } = deps;
5
5
  app.get('/api/me', async (req, res) => {
6
6
  const user = auth(req, res);
7
7
  if (!user)
@@ -34,7 +34,6 @@ export function registerAuthReadRoutes(app, deps) {
34
34
  const wallet = await dbOne('SELECT balance, staked, escrowed, earned FROM wallets WHERE user_id = ?', [user.id]);
35
35
  const roles = safeRoles(user);
36
36
  const pv = await dbOne("SELECT total_left_pv, total_right_pv FROM users WHERE id = ?", [user.id]);
37
- const pendingScore = (await dbOne("SELECT COALESCE(SUM(score),0) as s FROM binary_score_records WHERE user_id = ? AND settled_at IS NULL", [user.id])).s;
38
37
  res.json({
39
38
  id: user.id, name: user.name, role: user.role, roles, api_key: user.api_key, wallet: wallet || null,
40
39
  permanent_code: user.permanent_code ?? null,
@@ -69,11 +68,8 @@ export function registerAuthReadRoutes(app, deps) {
69
68
  return null;
70
69
  }
71
70
  })(),
72
- pending_score: Number(pendingScore),
73
71
  total_left_pv: Number(pv?.total_left_pv ?? 0),
74
72
  total_right_pv: Number(pv?.total_right_pv ?? 0),
75
- lifetime_score: Number(user.lifetime_score ?? 0),
76
- user_level: getUserLevel(Number(user.lifetime_score ?? 0)),
77
73
  });
78
74
  });
79
75
  }
@@ -1,8 +1,8 @@
1
1
  import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
2
  export function registerAuthRegisterRoutes(app, deps) {
3
- // VALID_REGIONS + INVITE_ROTATION_HANDLES 通过 deps.X 在 handler 内延迟读
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, issueCode, findActiveCode, canDeliverCodes, emailDeliveryNotConfigured, recordSession, broadcastSystemEvent } = deps;
3
+ // VALID_REGIONS 通过 deps.X 在 handler 内延迟读
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, issueCode, findActiveCode, canDeliverCodes, emailDeliveryNotConfigured, recordSession, broadcastSystemEvent } = deps;
6
6
  // CODE_TTL_MIN / MAX_CODE_ATTEMPTS 通过 deps.X 在 handler 内延迟读(它们在 server.ts 是后置 const,
7
7
  // register-time destructure 会触发 TDZ)。
8
8
  const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
@@ -205,16 +205,6 @@ export function registerAuthRegisterRoutes(app, deps) {
205
205
  }
206
206
  }
207
207
  }
208
- const rotationEnabled = db.prepare("SELECT value FROM system_state WHERE key='invite_rotation_enabled'").get()?.value === '1';
209
- if (rotationEnabled && sponsorId) {
210
- for (let i = 0; i < deps.INVITE_ROTATION_HANDLES.length; i++) {
211
- const u = inviteRotationLookup(i);
212
- if (u && u.id === sponsorId) {
213
- db.prepare("UPDATE invite_rotation_stats SET registered_count = registered_count + 1 WHERE slot = ?").run(i);
214
- break;
215
- }
216
- }
217
- }
218
208
  return { placement, effectiveInviter, effectiveSide };
219
209
  });
220
210
  let txResult;
@@ -0,0 +1,113 @@
1
+ import { createQuotaRequest, listMyQuotaRequests, listQuotaRequests, getQuotaRequest, approveQuotaRequest, rejectQuotaRequest, revokeQuotaRequest, requesterUsage24h, remainingQuota, isQuotaError, } from '../../layer2-business/L2-9-contribution/build-task-quota.js';
2
+ // map a store error_code to an HTTP status
3
+ function httpFor(code) {
4
+ if (code === 'NOT_FOUND')
5
+ return 404;
6
+ if (code === 'ALREADY_PENDING' || code === 'BAD_STATE')
7
+ return 409;
8
+ if (code === 'SELF_DECISION')
9
+ return 403;
10
+ return 400;
11
+ }
12
+ // parse the stored linked_refs JSON + surface a derived remaining count for approved grants
13
+ function shapeRequest(r) {
14
+ let linked = [];
15
+ try {
16
+ linked = JSON.parse(String(r.linked_refs ?? '[]'));
17
+ }
18
+ catch {
19
+ linked = [];
20
+ }
21
+ const granted = r.granted_count == null ? null : Number(r.granted_count);
22
+ const consumed = Number(r.consumed_count ?? 0);
23
+ return { ...r, linked_refs: linked, remaining: granted == null ? null : Math.max(0, granted - consumed) };
24
+ }
25
+ export function registerBuildTaskQuotaRoutes(app, deps) {
26
+ const { db, errorRes, auth, requireRootAdmin } = deps;
27
+ // ── requester surface ─────────────────────────────────────────────────────
28
+ // submit a quota-increase request
29
+ app.post('/api/me/quota-requests', (req, res) => {
30
+ const user = auth(req, res);
31
+ if (!user)
32
+ return;
33
+ const b = (req.body ?? {});
34
+ const r = createQuotaRequest(db, {
35
+ requesterId: String(user.id),
36
+ requestedExtraCount: Number(b.requested_extra_count),
37
+ reason: String(b.reason ?? ''),
38
+ linkedRefs: b.linked_refs,
39
+ urgency: b.urgency,
40
+ requestedDurationHours: b.requested_duration_hours == null ? null : Number(b.requested_duration_hours),
41
+ quotaType: b.quota_type,
42
+ });
43
+ if (isQuotaError(r))
44
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
45
+ res.json({ request: r });
46
+ });
47
+ // list my own requests + current remaining temporary quota
48
+ app.get('/api/me/quota-requests', (req, res) => {
49
+ const user = auth(req, res);
50
+ if (!user)
51
+ return;
52
+ const requests = listMyQuotaRequests(db, String(user.id)).map(shapeRequest);
53
+ res.json({ requests, remaining_quota: remainingQuota(db, String(user.id)) });
54
+ });
55
+ // ── ROOT admin review surface ─────────────────────────────────────────────
56
+ // list quota requests (optional ?status=)
57
+ app.get('/api/admin/quota-requests', (req, res) => {
58
+ const admin = requireRootAdmin(req, res);
59
+ if (!admin)
60
+ return;
61
+ const status = typeof req.query.status === 'string' ? req.query.status : undefined;
62
+ const requests = listQuotaRequests(db, { status }).map(shapeRequest);
63
+ res.json({ requests });
64
+ });
65
+ // detail of one request + the requester's live 24h create usage (reviewer context)
66
+ app.get('/api/admin/quota-requests/:id', (req, res) => {
67
+ const admin = requireRootAdmin(req, res);
68
+ if (!admin)
69
+ return;
70
+ const r = getQuotaRequest(db, String(req.params.id));
71
+ if (!r)
72
+ return void errorRes(res, 404, 'NOT_FOUND', 'quota request not found');
73
+ res.json({ request: shapeRequest(r), requester_usage_24h: requesterUsage24h(db, String(r.requester_user_id)) });
74
+ });
75
+ // approve → time-boxed counted grant (self-approval rejected in the store)
76
+ app.post('/api/admin/quota-requests/:id/approve', (req, res) => {
77
+ const admin = requireRootAdmin(req, res);
78
+ if (!admin)
79
+ return;
80
+ const b = (req.body ?? {});
81
+ const r = approveQuotaRequest(db, String(req.params.id), String(admin.id), {
82
+ grantedCount: b.extra_count == null ? undefined : Number(b.extra_count),
83
+ durationHours: b.duration_hours == null ? undefined : Number(b.duration_hours),
84
+ expiresAt: typeof b.expires_at === 'string' ? b.expires_at : undefined,
85
+ decisionNote: typeof b.approval_note === 'string' ? b.approval_note : undefined,
86
+ });
87
+ if (isQuotaError(r))
88
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
89
+ res.json({ approved: r });
90
+ });
91
+ // reject (self-rejection also blocked by the store's SELF_DECISION guard)
92
+ app.post('/api/admin/quota-requests/:id/reject', (req, res) => {
93
+ const admin = requireRootAdmin(req, res);
94
+ if (!admin)
95
+ return;
96
+ const b = (req.body ?? {});
97
+ const r = rejectQuotaRequest(db, String(req.params.id), String(admin.id), { decisionNote: typeof b.rejection_note === 'string' ? b.rejection_note : undefined });
98
+ if (isQuotaError(r))
99
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
100
+ res.json({ rejected: r });
101
+ });
102
+ // revoke an already-approved grant (root)
103
+ app.post('/api/admin/quota-requests/:id/revoke', (req, res) => {
104
+ const admin = requireRootAdmin(req, res);
105
+ if (!admin)
106
+ return;
107
+ const b = (req.body ?? {});
108
+ const r = revokeQuotaRequest(db, String(req.params.id), String(admin.id), { decisionNote: typeof b.revocation_note === 'string' ? b.revocation_note : undefined });
109
+ if (isQuotaError(r))
110
+ return void errorRes(res, httpFor(r.error_code), r.error_code, r.error);
111
+ res.json({ revoked: r });
112
+ });
113
+ }
@@ -1,4 +1,6 @@
1
1
  import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
2
+ // #420 P1-3 — verifier outlier 阈值改由 governance-adjustable protocol_params 驱动
3
+ import { readAntiAbuseThresholds, verifierOutlierBand } from '../anti-abuse-thresholds.js';
2
4
  // RFC-016 Phase 1 — 仅端点纯校验读/列表/公开查询/读回 + 单语句标记/字段写 + 写后通知 → async seam。
3
5
  // 全部保持同步(Phase 3 再用 pg tx/行锁):
4
6
  // - 模块级 helper(settleClaimTask 三路径结算 / distributePool / checkAndApplyOutlierStrike /
@@ -23,11 +25,10 @@ const CLAIM_VALID_VOTES = new Set(['pass', 'fail', 'no_fault', 'abstain']);
23
25
  // V3:abstain 不计入 3-vote 共识、不参与 majority、不触发 outlier
24
26
  const CLAIM_SELLER_FINE_RATE = 0.10; // pass 时扣 product.stake_amount × 10%
25
27
  const CLAIM_NO_FAULT_SUBSIDY = 1; // no_fault 路径协议池补贴每个 verifier 1 WAZ
26
- // 跨域共用(server.ts checkVerifierOutlier 6 套 vote table 聚合也用同一阈值)
27
- export const CLAIM_SUSPEND_THRESHOLD = 3; // 180d 内 ≥3 次 outlier → 30d 冻结
28
- export const CLAIM_REVOKE_THRESHOLD = 5; // 180d ≥5 次 outlier → 永封
29
- export const CLAIM_SUSPEND_DAYS = 30;
30
- export const CLAIM_OUTLIER_WINDOW_DAYS = 180;
28
+ // #420 P1-3:verifier outlier 阈值(暂停/撤销/窗口/暂停时长)已抽到 governance-adjustable
29
+ // protocol_params,单一真相源在 ../anti-abuse-thresholds.ts(DEFAULT_ANTI_ABUSE_THRESHOLDS:
30
+ // outlierSuspendCount=3 / outlierRevokeCount=5 / outlierSuspendDays=30 / outlierWindowDays=180)。
31
+ // checkAndApplyOutlierStrike + server.ts checkVerifierOutlier 通过 readAntiAbuseThresholds(db) 读取。
31
32
  // ─── helpers (module-level, db 通过参数传) ───────────────────
32
33
  // 2026-05-22 V2:通知所有资格内 verifier 有新 claim 任务
33
34
  export function notifyEligibleVerifiers(db, generateId, args) {
@@ -99,28 +100,31 @@ export function activeClaimTaskCountForVerifier(db, userId) {
99
100
  }
100
101
  // M7.3b:单个 outlier 处罚检查
101
102
  function checkAndApplyOutlierStrike(db, generateId, userId) {
103
+ // #420 P1-3:窗口/暂停/撤销阈值由 protocol_params 驱动(默认 = 原 180d/≥5/≥3/30d)
104
+ const t = readAntiAbuseThresholds(db);
102
105
  const cnt = db.prepare(`
103
106
  SELECT COUNT(*) as n FROM claim_verification_votes cvv
104
107
  JOIN claim_verification_tasks cvt ON cvt.id = cvv.task_id
105
108
  WHERE cvv.verifier_id = ?
106
109
  AND cvv.was_majority = 0
107
110
  AND cvt.resolved_at IS NOT NULL
108
- AND cvt.resolved_at >= datetime('now', '-${CLAIM_OUTLIER_WINDOW_DAYS} days')
111
+ AND cvt.resolved_at >= datetime('now', '-${t.outlierWindowDays} days')
109
112
  `).get(userId).n;
110
113
  const existing = db.prepare(`SELECT type, outlier_count FROM claim_verifier_suspensions
111
114
  WHERE user_id = ? AND (type = 'revoked' OR until_at > datetime('now'))
112
115
  ORDER BY created_at DESC LIMIT 1`).get(userId);
113
116
  if (existing?.type === 'revoked')
114
117
  return { strikes_180d: cnt };
115
- if (cnt >= CLAIM_REVOKE_THRESHOLD && (!existing || existing.outlier_count < CLAIM_REVOKE_THRESHOLD)) {
118
+ const band = verifierOutlierBand(cnt, t);
119
+ if (band === 'revoke' && (!existing || existing.outlier_count < t.outlierRevokeCount)) {
116
120
  db.prepare(`INSERT INTO claim_verifier_suspensions (id, user_id, type, reason, outlier_count)
117
- VALUES (?,?, 'revoked', ?, ?)`).run(generateId('cvs'), userId, `180d 内累计 ${cnt} 次 outlier`, cnt);
121
+ VALUES (?,?, 'revoked', ?, ?)`).run(generateId('cvs'), userId, `${t.outlierWindowDays}d 内累计 ${cnt} 次 outlier`, cnt);
118
122
  return { strikes_180d: cnt, suspension: { type: 'revoked', until_at: null } };
119
123
  }
120
- if (cnt >= CLAIM_SUSPEND_THRESHOLD && !existing) {
121
- const until = new Date(Date.now() + CLAIM_SUSPEND_DAYS * 86400_000).toISOString();
124
+ if (band === 'suspend' && !existing) {
125
+ const until = new Date(Date.now() + t.outlierSuspendDays * 86400_000).toISOString();
122
126
  db.prepare(`INSERT INTO claim_verifier_suspensions (id, user_id, type, until_at, reason, outlier_count)
123
- VALUES (?,?, 'suspended', ?, ?, ?)`).run(generateId('cvs'), userId, until, `180d 内累计 ${cnt} 次 outlier`, cnt);
127
+ VALUES (?,?, 'suspended', ?, ?, ?)`).run(generateId('cvs'), userId, until, `${t.outlierWindowDays}d 内累计 ${cnt} 次 outlier`, cnt);
124
128
  return { strikes_180d: cnt, suspension: { type: 'suspended', until_at: until } };
125
129
  }
126
130
  return { strikes_180d: cnt };
@@ -0,0 +1,18 @@
1
+ import { getMyContributionFacts } from '../../layer2-business/L2-9-contribution/contribution-facts-read.js';
2
+ import { withUncommittedValueBoundary } from '../../layer2-business/L2-9-contribution/contribution-display-envelope.js';
3
+ export function registerContributionFactsRoutes(app, deps) {
4
+ const { db, auth, errorRes } = deps;
5
+ // ── READ-ONLY: the caller's OWN attributable contribution facts (GitHub + admin coordination) ──
6
+ app.get('/api/contribution-facts/me', (req, res) => {
7
+ const user = auth(req, res);
8
+ if (!user)
9
+ return;
10
+ try {
11
+ const surface = getMyContributionFacts(db, user.id);
12
+ res.json(withUncommittedValueBoundary(surface));
13
+ }
14
+ catch {
15
+ return void errorRes(res, 500, 'INTERNAL', '内部错误'); // never leak a stack / query
16
+ }
17
+ });
18
+ }
@@ -1,4 +1,5 @@
1
1
  import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
2
3
  export function registerDisputeCasesRoutes(app, deps) {
3
4
  const { db, auth, getUser, generateId, piiSanitize, detectFraud, commentBlocklistHit, llmModerateComment } = deps;
4
5
  // 公共发言门槛 — 防新号/小号刷评论/投票
@@ -7,7 +8,7 @@ export function registerDisputeCasesRoutes(app, deps) {
7
8
  const lifetime = Number(user.lifetime_score || 0);
8
9
  if (lifetime >= 5)
9
10
  return { ok: true };
10
- const completed = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE (buyer_id = ? OR seller_id = ?) AND status = 'completed'`, [user.id, user.id])).n;
11
+ const completed = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE (buyer_id = ? OR seller_id = ?) AND ${genuineSalePredicate('orders')}`, [user.id, user.id])).n; // 真实成交,排除退款/违约
11
12
  if (completed >= 1)
12
13
  return { ok: true };
13
14
  const created = user.created_at ? new Date(String(user.created_at).replace(' ', 'T') + 'Z').getTime() : 0;
@@ -93,9 +94,9 @@ export function registerDisputeCasesRoutes(app, deps) {
93
94
  };
94
95
  // 评论 + 自动身份标签
95
96
  const rawComments = await dbAll(`
96
- SELECT dc.*, u.handle, u.name, u.role, u.lifetime_score,
97
+ SELECT dc.*, u.handle, u.name, u.role,
97
98
  (SELECT COUNT(*) FROM orders o
98
- WHERE o.buyer_id = dc.commenter_id AND o.product_id = ? AND o.status = 'completed') as bought_count,
99
+ WHERE o.buyer_id = dc.commenter_id AND o.product_id = ? AND ${genuineSalePredicate('o')}) as bought_count,
99
100
  (SELECT COUNT(*) FROM products p
100
101
  WHERE p.seller_id = dc.commenter_id AND p.category = (SELECT category FROM products WHERE id = ?) AND p.status = 'active') as same_cat_seller_count
101
102
  FROM dispute_comments dc
@@ -118,7 +119,7 @@ export function registerDisputeCasesRoutes(app, deps) {
118
119
  // W5: 取所有子回复,按 parent_comment_id 分组挂在 comments 下
119
120
  const commentIds = rawComments.map(r => r.id);
120
121
  const rawReplies = commentIds.length > 0 ? await dbAll(`
121
- SELECT r.*, u.handle, u.name, u.role, u.lifetime_score
122
+ SELECT r.*, u.handle, u.name, u.role
122
123
  FROM dispute_comment_replies r LEFT JOIN users u ON u.id = r.replier_id
123
124
  WHERE r.parent_comment_id IN (${commentIds.map(() => '?').join(',')})
124
125
  ORDER BY r.created_at ASC
@@ -49,7 +49,7 @@ const GROWTH_TASK_CATALOG = [
49
49
  { id: 'tier1_match', chapter: 3,
50
50
  title_zh: '持续贡献阶段 1', title_en: 'Contribution stage 1',
51
51
  desc_zh: '推荐网络贡献达到第一阶段标准', desc_en: 'Referral-network contribution reaches stage-1 threshold',
52
- evaluate: c => c.weak_leg_pv >= 30000 },
52
+ evaluate: c => c.min_leg_pv >= 30000 },
53
53
  // 第 4 关:分享达人
54
54
  { id: 'monthly_100', chapter: 4,
55
55
  title_zh: '月度推荐收益 100 WAZ', title_en: 'Monthly referral income 100 WAZ',
@@ -83,7 +83,7 @@ async function buildGrowthTaskCtx(_db, userId) {
83
83
  const sCount = (await dbOne("SELECT COUNT(*) AS n FROM shareables WHERE owner_id = ? AND status = 'active'", [userId])).n;
84
84
  const mCount = (await dbOne("SELECT COUNT(*) AS n FROM manifest_registry WHERE owner_id = ? AND status = 'active'", [userId])).n;
85
85
  const pv = await dbOne("SELECT total_left_pv, total_right_pv FROM users WHERE id = ?", [userId]);
86
- const weakLeg = Math.min(Number(pv?.total_left_pv || 0), Number(pv?.total_right_pv || 0));
86
+ const minLeg = Math.min(Number(pv?.total_left_pv || 0), Number(pv?.total_right_pv || 0));
87
87
  const comm30 = (await dbOne(`SELECT COALESCE(SUM(amount),0) AS s FROM commission_records WHERE beneficiary_id = ? AND created_at >= datetime('now','-30 days')`, [userId])).s;
88
88
  const waz30 = (await dbOne(`SELECT COALESCE(SUM(waz_amount),0) AS s FROM binary_score_records WHERE user_id = ? AND settled_at >= datetime('now','-30 days')`, [userId])).s;
89
89
  return {
@@ -97,7 +97,7 @@ async function buildGrowthTaskCtx(_db, userId) {
97
97
  earnings_grand: grand,
98
98
  shareables_count: sCount,
99
99
  manifests_count: mCount,
100
- weak_leg_pv: weakLeg,
100
+ min_leg_pv: minLeg,
101
101
  last_30_total: comm30 + waz30,
102
102
  };
103
103
  }
@@ -320,12 +320,27 @@ export function registerOrdersActionRoutes(app, deps) {
320
320
  commissionDistributed += Number(r.amount);
321
321
  }
322
322
  const commissionRedirected = round2(commissionPool - commissionDistributed);
323
- // QA 14.b P2:redirected_total chain_gap(→charity) vs region_cap(→global_fund)
324
- // 之前单一数字让 agent 无法分辨钱去哪(global region L2/L3 global_fund,不是 charity
325
- const charityRow = (await dbOne("SELECT COALESCE(SUM(amount),0) AS s FROM charity_fund_txns WHERE related_order_id = ?", [req.params.id]));
326
- const redirectedToCharity = round2(Number(charityRow.s));
327
- const fundDepRow = (await dbOne("SELECT COALESCE(SUM(amount_l3),0) AS s FROM fund_deposits WHERE order_id = ?", [req.params.id]));
328
- const redirectedToGlobalFund = round2(Number(fundDepRow.s));
323
+ // 2026-06-04 三科目解耦后:未发出的 commission 不再进 charity_fund / global_fund
324
+ // region_cap / chain_gap / orphan_sponsor / opt_out_deactivated commission_reserve(按 kind
325
+ // opt-out 未激活(never_activated / auto_downgrade) → pending_commission_escrow(30 天内 recipient opt-in 可恢复)
326
+ // 此处只读汇总本单去向,让 agent 看清 redirected_total 实际落点(settleOrder 已完成,无写、无原子性要求)。
327
+ const crRows = await dbAll("SELECT kind, COALESCE(SUM(amount),0) AS s FROM commission_reserve_txns WHERE related_order_id = ? GROUP BY kind", [req.params.id]);
328
+ const reserveByKind = { region_cap: 0, chain_gap: 0, orphan_sponsor: 0, opt_out_deactivated: 0, escrow_expired: 0 };
329
+ for (const r of crRows) {
330
+ if (r.kind === 'redirect_region_cap')
331
+ reserveByKind.region_cap = round2(Number(r.s));
332
+ else if (r.kind === 'redirect_chain_gap')
333
+ reserveByKind.chain_gap = round2(Number(r.s));
334
+ else if (r.kind === 'redirect_orphan_sponsor')
335
+ reserveByKind.orphan_sponsor = round2(Number(r.s));
336
+ else if (r.kind === 'redirect_opt_out_deactivated')
337
+ reserveByKind.opt_out_deactivated = round2(Number(r.s));
338
+ else if (r.kind === 'redirect_escrow_expired')
339
+ reserveByKind.escrow_expired = round2(Number(r.s));
340
+ }
341
+ const redirectedToCommissionReserve = round2(reserveByKind.region_cap + reserveByKind.chain_gap + reserveByKind.orphan_sponsor + reserveByKind.opt_out_deactivated + reserveByKind.escrow_expired);
342
+ const escrowRow = (await dbOne("SELECT COALESCE(SUM(amount),0) AS s FROM pending_commission_escrow WHERE order_id = ? AND status = 'pending'", [req.params.id]));
343
+ const heldInOptOutEscrow = round2(Number(escrowRow.s));
329
344
  // QA 轮 9.5 P2:payouts 表只 MCP legacy 写,PWA settleOrder 直更 wallet.balance 不写 payouts
330
345
  // 改用公式推算 sellerAmount(跟 PWA settleOrder 内部计算一致),更可靠
331
346
  const fundBase1pct = round2(total * 0.01);
@@ -336,7 +351,7 @@ export function registerOrdersActionRoutes(app, deps) {
336
351
  order_amount: total,
337
352
  distribution: {
338
353
  seller_net: { amount: sellerAmountComputed, to: ord.seller_id, note: '不含可能的首销 stake 锁定(settleOrder 内 stake_locked_at 首次锁,从 sellerAmount 划出)' },
339
- protocol_fund_2pct: { amount: protocolFee, split: { management_bonus_pool: round2(protocolFee / 2), sys_protocol_ops: round2(protocolFee / 2) } },
354
+ protocol_fund_2pct: { amount: protocolFee, split: { protocol_reserve_pool: round2(protocolFee / 2), sys_protocol_ops: round2(protocolFee / 2) } },
340
355
  logistics_fee: { amount: logisticsActual, rate: isInPerson ? 'N/A in_person' : (ord.logistics_id ? '5%' : 'N/A self-fulfill') },
341
356
  commission_pool: { total: commissionPool, rate: `${(commissionRate * 100).toFixed(1)}%` },
342
357
  commission_distribution_7_2_1: {
@@ -345,9 +360,11 @@ export function registerOrdersActionRoutes(app, deps) {
345
360
  l3: commByLevel[3],
346
361
  distributed_total: round2(commissionDistributed),
347
362
  redirected_total: commissionRedirected,
348
- redirected_to_charity: redirectedToCharity, // chain_gap (无 L / sponsor 无效)
349
- redirected_to_global_fund: redirectedToGlobalFund, // region cap (level > region max_levels)
350
- redirect_note: 'chain_gap (无 L) charity_fund; region cap (level > max_levels) → global_fund',
363
+ redirected_to_commission_reserve: redirectedToCommissionReserve,
364
+ reserve_by_kind: reserveByKind, // region_cap / chain_gap / orphan_sponsor / opt_out_deactivated / escrow_expired
365
+ held_in_opt_out_escrow: heldInOptOutEscrow, // never_activated / auto_downgrade recipient opt-in 可恢复
366
+ redirect_accounted_ok: Math.abs(commissionRedirected - round2(redirectedToCommissionReserve + heldInOptOutEscrow)) < 0.01,
367
+ redirect_note: '未发出佣金 → commission_reserve(region_cap / chain_gap / orphan_sponsor / opt_out_deactivated);opt-out 未激活(never_activated / auto_downgrade)暂存 pending_commission_escrow(30 天内 opt-in 可恢复),逾期未恢复则转入 commission_reserve(escrow_expired)。2026-06-04 起不再进 charity_fund / global_fund。',
351
368
  },
352
369
  fund_base_1pct: fundBase1pct,
353
370
  },
@@ -371,7 +371,7 @@ export function registerOrdersCreateRoutes(app, deps) {
371
371
  }
372
372
  transition(db, orderId, 'paid', user.id, [], '模拟支付完成');
373
373
  notifyTransition(db, orderId, 'created', 'paid');
374
- // 里程碑 3-C:双轨同支检测(监测+审计;不阻断)
374
+ // 里程碑 3-C:放置同支检测(监测+审计;不阻断)
375
375
  try {
376
376
  auditSponsorChainCross(orderId, user.id, String(product.seller_uid), buyer.sponsor_path);
377
377
  }
@@ -1,4 +1,17 @@
1
1
  import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
3
+ /**
4
+ * 真实收货完成的订单数(分享资格判据)。
5
+ * 关键:status='completed' 是状态机的通用终态 — 不只「无争议自然完成」(confirmed→completed),
6
+ * 还包括 fault_seller / fault_logistics / fault_buyer / declined_nofault / disputed → completed
7
+ * 这些退款 / 违约 / 争议处置终态。单看 status='completed' 会把「被退款的失败交易」当成有效成交,
8
+ * 错误授予分享(进而分享分润)资格。
9
+ * 真实收货 = 该订单曾进入过 confirmed(买家确认收货,或送达后 72h 自动确认)— 仅 happy path 经过,
10
+ * 所有 fault/争议/退款终态都不经过 confirmed,据此排除。
11
+ */
12
+ async function genuineReceiptCount(buyerId, productId) {
13
+ return (await dbOne(`SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND product_id = ? AND ${genuineSalePredicate('orders')}`, [buyerId, productId])).n;
14
+ }
2
15
  export function registerProductsMetaRoutes(app, deps) {
3
16
  const { db, auth, generateId, rateLimitOk, flagNewAccountShareable, refreshProductSharerCount } = deps;
4
17
  void db; // RFC-016: 本文件已全量走异步 seam;db 仍在 deps 由调用方注入,此处不直接使用
@@ -117,16 +130,16 @@ export function registerProductsMetaRoutes(app, deps) {
117
130
  return void res.status(404).json({ error: 'not_found' });
118
131
  res.json(row);
119
132
  });
120
- // 分享许可:是否对该商品有 completed 订单
133
+ // 分享许可:是否真实收货完成该商品(经过 confirmed,排除退款/违约/争议终态)
121
134
  app.get('/api/products/:id/can-share', async (req, res) => {
122
135
  const user = auth(req, res);
123
136
  if (!user)
124
137
  return;
125
- const completed = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND product_id = ? AND status = 'completed'", [user.id, req.params.id])).n;
138
+ const completed = await genuineReceiptCount(user.id, req.params.id);
126
139
  res.json({
127
140
  can_share: completed > 0,
128
141
  completed_orders: completed,
129
- reason: completed > 0 ? 'verified_buyer_of_product' : 'need_completed_order_of_this_product',
142
+ reason: completed > 0 ? 'genuine_receipt_of_product' : 'need_genuine_receipt_of_this_product',
130
143
  });
131
144
  });
132
145
  // 获取或创建商品 shareable(被 sharePromoLink 用,走 /s/<id> 短链)
@@ -144,7 +157,7 @@ export function registerProductsMetaRoutes(app, deps) {
144
157
  };
145
158
  const minOrders = await getParam('rewards_opt_in.min_completed_orders', 1);
146
159
  const requirePasskey = await getParam('rewards_opt_in.require_passkey', 1);
147
- const totalCompleted = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [user.id])).n;
160
+ const totalCompleted = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [user.id])).n; // 真实成交,排除退款/违约
148
161
  const passkeyCount = (await dbOne("SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?", [user.id])).n;
149
162
  const missing = [];
150
163
  if (totalCompleted < minOrders)
@@ -165,9 +178,9 @@ export function registerProductsMetaRoutes(app, deps) {
165
178
  ],
166
179
  });
167
180
  }
168
- const completed = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND product_id = ? AND status = 'completed'", [user.id, productId])).n;
181
+ const completed = await genuineReceiptCount(user.id, productId);
169
182
  if (completed === 0)
170
- return void res.json({ error: '需先完成该商品的购买才能分享', completed_orders: 0 });
183
+ return void res.json({ error: '需先真实收货完成该商品的购买才能分享(退款 / 违约 / 争议订单不算)', completed_orders: 0 });
171
184
  // 优先复用现有 active shareable
172
185
  const existing = await dbOne(`SELECT id, owner_code FROM shareables WHERE owner_id = ? AND related_product_id = ? AND status = 'active' LIMIT 1`, [user.id, productId]);
173
186
  if (existing) {
@@ -35,7 +35,7 @@ export function registerProfilePlacementRoutes(app, deps) {
35
35
  return void res.json({ error: '不能挂靠到自己' });
36
36
  const u = await dbOne("SELECT placement_id, left_child_id, right_child_id FROM users WHERE id = ?", [user.id]);
37
37
  if (u?.placement_id)
38
- return void res.json({ error: '你已在双轨树中(永久第一触点,不可改)' });
38
+ return void res.json({ error: '你已在放置树中(永久第一触点,不可改)' });
39
39
  if (u?.left_child_id || u?.right_child_id)
40
40
  return void res.json({ error: '你已有下线,不可补绑(防破坏树结构)' });
41
41
  const inviter = await dbOne("SELECT id, placement_path FROM users WHERE id = ? AND id NOT IN ('sys_protocol', ?)", [resolvedInviterId, internalAuditorId]);
@@ -1,6 +1,7 @@
1
1
  import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
+ import { genuineSalePredicate } from '../../layer0-foundation/L0-2-state-machine/genuine-sale.js'; // 真实成交单一真相源
2
3
  export function registerPromoterRoutes(app, deps) {
3
- const { db, auth, isAllowedSponsor } = deps;
4
+ const { db, auth, isAllowedSponsor, participationRecordingActive } = deps;
4
5
  void db; // RFC-016: 本文件已全量走异步 seam;db 仍在 deps 由调用方注入,此处不直接使用
5
6
  app.get('/api/promoter/dashboard', async (req, res) => {
6
7
  const user = auth(req, res);
@@ -41,21 +42,9 @@ export function registerPromoterRoutes(app, deps) {
41
42
  const leftChildName = myUser?.left_child_id ? (await dbOne("SELECT name FROM users WHERE id = ?", [myUser.left_child_id]))?.name : null;
42
43
  const rightChildName = myUser?.right_child_id ? (await dbOne("SELECT name FROM users WHERE id = ?", [myUser.right_child_id]))?.name : null;
43
44
  const myPlacementName = myUser?.placement_id ? (await dbOne("SELECT name FROM users WHERE id = ?", [myUser.placement_id]))?.name : null;
44
- const scoreAgg = (await dbOne(`
45
- SELECT
46
- COALESCE(SUM(CASE WHEN settled_at IS NULL THEN score ELSE 0 END),0) as pending_score,
47
- COALESCE(SUM(CASE WHEN settled_at IS NOT NULL THEN waz_amount ELSE 0 END),0) as settled_waz,
48
- COUNT(*) as total_hits
49
- FROM binary_score_records WHERE user_id = ?
50
- `, [userId]));
51
- const recentBinary = await dbAll(`
52
- SELECT id, tier, score, settled_at, waz_amount, created_at
53
- FROM binary_score_records WHERE user_id = ?
54
- ORDER BY created_at DESC LIMIT 10
55
- `, [userId]);
56
- const tiers = await dbAll("SELECT tier, pv_threshold, score_per_hit FROM binary_tier_config WHERE active=1 ORDER BY tier ASC");
45
+ // matching-rewards reads (score / recent matches / tier config) removed — engine excised (#401).
57
46
  const canL1Share = isAllowedSponsor(userId);
58
- const completedOrders = (await dbOne("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
47
+ const completedOrders = (await dbOne(`SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')}`, [userId])).n; // 真实成交,排除退款/违约
59
48
  const overrideRow = await dbOne("SELECT l1_share_override FROM users WHERE id = ?", [userId]);
60
49
  const shareableProducts = await dbAll(`
61
50
  SELECT p.id, p.title, p.price, p.category, p.commission_rate,
@@ -64,7 +53,7 @@ export function registerPromoterRoutes(app, deps) {
64
53
  JOIN orders o2 ON o2.id = cr.order_id
65
54
  WHERE cr.beneficiary_id = ? AND o2.product_id = p.id), 0) as my_earned
66
55
  FROM products p
67
- WHERE p.id IN (SELECT DISTINCT product_id FROM orders WHERE buyer_id = ? AND status = 'completed')
56
+ WHERE p.id IN (SELECT DISTINCT product_id FROM orders WHERE buyer_id = ? AND ${genuineSalePredicate('orders')})
68
57
  AND p.commission_rate IS NOT NULL AND p.commission_rate > 0
69
58
  AND p.status = 'active'
70
59
  ORDER BY my_earned DESC, total_sales DESC LIMIT 20
@@ -77,21 +66,15 @@ export function registerPromoterRoutes(app, deps) {
77
66
  SELECT COALESCE(SUM(amount),0) as total FROM commission_records
78
67
  WHERE beneficiary_id = ? AND created_at >= datetime('now','-60 days')
79
68
  AND created_at < datetime('now','-30 days')
80
- `, [userId])).total;
81
- const wazLast30 = (await dbOne(`
82
- SELECT COALESCE(SUM(waz_amount),0) as total FROM binary_score_records
83
- WHERE user_id = ? AND settled_at >= datetime('now','-30 days')
84
69
  `, [userId])).total;
85
70
  const projection = {
86
71
  last_30_commission: earnedLast30,
87
72
  prev_30_commission: earnedPrev30,
88
- last_30_atomic_waz: wazLast30,
89
73
  growth_rate: earnedPrev30 > 0 ? earnedLast30 / earnedPrev30 - 1 : null,
90
- next_30_estimate: earnedLast30 + wazLast30,
74
+ next_30_estimate: earnedLast30,
91
75
  };
92
76
  const insights = [];
93
- // pre-public de-MLM:移除弱腿 / pairing / PV-tier 经营建议 —— PV 对碰为 pre-launch、未对用户启用,
94
- // 不在用户面 surface 营销主推弱腿 / pairing 公式 / PV-tier 进度等玩法。位置 / PV 仅为参与记录,非收益路径。
77
+ // 匹配奖励引擎已切除(#401):不展示任何奖励经营建议;位置 / PV 仅为参与记录,非收益路径。
95
78
  const lastInvite = (await dbOne(`SELECT MAX(created_at) as t FROM users WHERE sponsor_id = ?`, [userId]));
96
79
  if (lastInvite.t) {
97
80
  const days = Math.floor((Date.now() - new Date(lastInvite.t).getTime()) / 86400_000);
@@ -167,17 +150,15 @@ export function registerPromoterRoutes(app, deps) {
167
150
  shareable_products: shareableProducts,
168
151
  projection,
169
152
  insights,
153
+ gates: { participation_recording_active: participationRecordingActive() },
154
+ // Neutral participation record only — placement position + per-leg PV. No rewards (matching engine excised #401).
155
+ // (response keys `atomic` / `binary_tree` kept as the placement structure's stable shape — frontend reads them)
170
156
  atomic: {
171
- left_invite_url: codeForLink ? `${host}/i/${codeForLink}-L` : null,
172
- right_invite_url: codeForLink ? `${host}/i/${codeForLink}-R` : null,
173
157
  total_left_pv: Number(myUser?.total_left_pv ?? 0),
174
158
  total_right_pv: Number(myUser?.total_right_pv ?? 0),
175
159
  left_child: myUser?.left_child_id ? { id: myUser.left_child_id, name: leftChildName } : null,
176
160
  right_child: myUser?.right_child_id ? { id: myUser.right_child_id, name: rightChildName } : null,
177
161
  my_placement: myUser?.placement_id ? { id: myUser.placement_id, name: myPlacementName, side: myUser.placement_side } : null,
178
- score: scoreAgg,
179
- recent_binary: recentBinary,
180
- tier_config: tiers,
181
162
  binary_tree: binaryTree,
182
163
  },
183
164
  });
@@ -1,4 +1,5 @@
1
1
  import { listBuildTasksWithAgentMetadata, getBuildTaskWithAgentMetadata, validateTaskFilters, withContributionReadEnvelope } from '../../layer2-business/L2-9-contribution/build-task-read.js';
2
+ import { caseIdForTask } from '../../layer2-business/L2-9-contribution/task-proposal-draft.js';
2
3
  export function registerPublicBuildTasksRoutes(app, deps) {
3
4
  const { db, errorRes } = deps;
4
5
  app.get('/api/public/build-tasks', (req, res) => {
@@ -14,6 +15,9 @@ export function registerPublicBuildTasksRoutes(app, deps) {
14
15
  const task = getBuildTaskWithAgentMetadata(db, String(req.params.id), 'public');
15
16
  if (!task)
16
17
  return void errorRes(res, 404, 'NOT_FOUND', '任务不存在');
17
- res.json(withContributionReadEnvelope({ task }));
18
+ // case_id threads proposal → task → PR (= source proposal id if converted from a proposal, else the task id),
19
+ // so the proposer, the contributor, and the PR all quote one id. (Helper lives in the store — keeps this
20
+ // route off the RFC-016 raw-db seam.)
21
+ res.json(withContributionReadEnvelope({ task: { ...task, case_id: caseIdForTask(db, String(req.params.id)) } }));
18
22
  });
19
23
  }