@seasonkoh/webaz 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3691 -714
- 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 +31947 -0
- package/dist/pwa/public/i18n.js +5751 -0
- package/dist/pwa/public/icon.svg +11 -0
- package/dist/pwa/public/index.html +21 -0
- package/dist/pwa/public/manifest.json +48 -0
- package/dist/pwa/public/openapi.json +5946 -0
- package/dist/pwa/public/style.css +535 -0
- package/dist/pwa/public/sw.js +63 -0
- 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 +9679 -698
- package/package.json +11 -4
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// ─── 评估 cron — 重算 reach_score 决定退款 / 兜底超时 ──────────
|
|
2
|
+
// reach_score = views(unique_click_count)*0.1 + shares(child notes)*1 + conversions(attributed orders)*10
|
|
3
|
+
// 达阈 → 从卖家钱包扣 refund_amount 退给买家;30 天兜底超时
|
|
4
|
+
export function evaluateTrialClaims(db, generateId) {
|
|
5
|
+
let evaluated = 0, refunded = 0, expired = 0;
|
|
6
|
+
const candidates = db.prepare(`
|
|
7
|
+
SELECT c.*,
|
|
8
|
+
-- 审计 P0-2:优先用 snapshot 字段(claim 时锁定),fallback 到 campaign 当前值
|
|
9
|
+
COALESCE(c.snap_reach_threshold, camp.reach_threshold) as eval_threshold,
|
|
10
|
+
COALESCE(c.snap_min_days_live, camp.min_days_live) as eval_min_days_live,
|
|
11
|
+
COALESCE(c.snap_min_chars, camp.min_chars) as eval_min_chars,
|
|
12
|
+
o.total_amount as order_amount,
|
|
13
|
+
-- 审计 P1-3:用 unique_click_count(dedup by IP+UA)而非 raw click_count
|
|
14
|
+
COALESCE(n.unique_click_count, n.click_count, 0) as views,
|
|
15
|
+
n.like_count, n.native_text, n.photo_hashes, n.status as note_status
|
|
16
|
+
FROM product_trial_claims c
|
|
17
|
+
JOIN product_trial_campaigns camp ON camp.id = c.campaign_id
|
|
18
|
+
JOIN orders o ON o.id = c.order_id
|
|
19
|
+
LEFT JOIN shareables n ON n.id = c.note_id
|
|
20
|
+
WHERE c.status = 'pending_threshold' AND c.note_id IS NOT NULL
|
|
21
|
+
`).all();
|
|
22
|
+
for (const r of candidates) {
|
|
23
|
+
evaluated++;
|
|
24
|
+
try {
|
|
25
|
+
// 笔记必须仍存在 + active + 满足最少字数 + 至少 1 张图
|
|
26
|
+
if (!r.note_status || r.note_status !== 'active')
|
|
27
|
+
continue;
|
|
28
|
+
const txtLen = String(r.native_text || '').length;
|
|
29
|
+
if (txtLen < Number(r.eval_min_chars || 50))
|
|
30
|
+
continue;
|
|
31
|
+
const photos = r.photo_hashes ? JSON.parse(String(r.photo_hashes)) : [];
|
|
32
|
+
if (!Array.isArray(photos) || photos.length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
// 检查 note 是否 live 够久
|
|
35
|
+
const linkedAt = new Date(String(r.note_linked_at)).getTime();
|
|
36
|
+
const liveDays = (Date.now() - linkedAt) / 86400_000;
|
|
37
|
+
if (liveDays < Number(r.eval_min_days_live || 7))
|
|
38
|
+
continue;
|
|
39
|
+
// 30 天兜底超时
|
|
40
|
+
if (liveDays > 30) {
|
|
41
|
+
db.prepare("UPDATE product_trial_claims SET status='expired', expired_at=datetime('now'), last_eval_at=datetime('now') WHERE id=?").run(r.id);
|
|
42
|
+
expired++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// 计算 metrics(views 已用 unique 去重)
|
|
46
|
+
const views = Number(r.views || 0);
|
|
47
|
+
const sharesRow = db.prepare("SELECT COUNT(*) as n FROM shareables WHERE parent_id = ? AND status='active'").get(r.note_id);
|
|
48
|
+
const shares = Number(sharesRow?.n || 0);
|
|
49
|
+
const convRow = db.prepare(`SELECT COUNT(DISTINCT o2.id) as n FROM orders o2
|
|
50
|
+
JOIN product_share_attribution psa ON psa.product_id = o2.product_id AND psa.recipient_id = o2.buyer_id
|
|
51
|
+
WHERE psa.shareable_id = ? AND o2.status IN ('confirmed','completed')
|
|
52
|
+
AND o2.buyer_id != ?
|
|
53
|
+
AND o2.created_at >= psa.created_at`).get(r.note_id, r.buyer_id);
|
|
54
|
+
const conversions = Number(convRow?.n || 0);
|
|
55
|
+
const reachScore = views * 0.1 + shares * 1 + conversions * 10;
|
|
56
|
+
const metricsJson = JSON.stringify({ views, shares, conversions });
|
|
57
|
+
if (reachScore >= Number(r.eval_threshold || 50)) {
|
|
58
|
+
// 达阈 → 退款。卖家钱包扣 refund_amount,买家钱包加(不变 escrow / commission)
|
|
59
|
+
const amount = Number(r.order_amount || 0);
|
|
60
|
+
if (amount <= 0) {
|
|
61
|
+
db.prepare("UPDATE product_trial_claims SET reach_score=?, metrics_json=?, last_eval_at=datetime('now') WHERE id=?").run(reachScore, metricsJson, r.id);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const seller = db.prepare("SELECT balance FROM wallets WHERE user_id = ?").get(r.seller_id);
|
|
65
|
+
if (!seller || Number(seller.balance) < amount) {
|
|
66
|
+
// 卖家余额不足 — 暂不退,下次再评(卖家可能补充余额)
|
|
67
|
+
db.prepare("UPDATE product_trial_claims SET reach_score=?, metrics_json=?, last_eval_at=datetime('now') WHERE id=?").run(reachScore, metricsJson, r.id);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const tx = db.transaction(() => {
|
|
71
|
+
db.prepare("UPDATE wallets SET balance = balance - ?, updated_at=datetime('now') WHERE user_id = ?").run(amount, r.seller_id);
|
|
72
|
+
db.prepare("INSERT INTO wallets (user_id, balance) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET balance = balance + ?, updated_at=datetime('now')").run(r.buyer_id, amount, amount);
|
|
73
|
+
db.prepare(`UPDATE product_trial_claims SET status='refunded', refund_amount=?, refunded_at=datetime('now'),
|
|
74
|
+
reach_score=?, metrics_json=?, last_eval_at=datetime('now') WHERE id=?`).run(amount, reachScore, metricsJson, r.id);
|
|
75
|
+
try {
|
|
76
|
+
// notifications schema 没有 data 列;用 actions(JSON 数组)存可点击跳转
|
|
77
|
+
db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, actions)
|
|
78
|
+
VALUES (?, ?, 'trial_refunded', ?, ?, ?)`).run(generateId('ntf'), r.buyer_id, '测评免单 · 退款已到账', `reach=${Math.round(reachScore)} 达阈 ${r.eval_threshold},已退 ${amount} WAZ`, JSON.stringify([{ label: '查看', hash: '#trials' }]));
|
|
79
|
+
db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, actions)
|
|
80
|
+
VALUES (?, ?, 'trial_refunded', ?, ?, ?)`).run(generateId('ntf'), r.seller_id, '测评免单 · 已为买家退款', `claim 完成 reach=${Math.round(reachScore)},退 ${amount} WAZ`, JSON.stringify([{ label: '查看活动', hash: '#seller-trials' }]));
|
|
81
|
+
}
|
|
82
|
+
catch { /* notifications 表可能用旧 schema, 忽略 */ }
|
|
83
|
+
});
|
|
84
|
+
tx();
|
|
85
|
+
refunded++;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
db.prepare("UPDATE product_trial_claims SET reach_score=?, metrics_json=?, last_eval_at=datetime('now') WHERE id=?").run(reachScore, metricsJson, r.id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
console.error('[cron trial-eval]', r.id, e);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { evaluated, refunded, expired };
|
|
96
|
+
}
|
|
97
|
+
export function registerTrialRoutes(app, deps) {
|
|
98
|
+
const { db, generateId, auth, clientIpHash, clientUaHash, requireProtocolAdmin } = deps;
|
|
99
|
+
// 卖家:开/更新活动
|
|
100
|
+
app.post('/api/products/:product_id/trial-campaign', (req, res) => {
|
|
101
|
+
const user = auth(req, res);
|
|
102
|
+
if (!user)
|
|
103
|
+
return;
|
|
104
|
+
const product = db.prepare('SELECT id, seller_id, status FROM products WHERE id = ?').get(req.params.product_id);
|
|
105
|
+
if (!product)
|
|
106
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
107
|
+
if (product.seller_id !== user.id)
|
|
108
|
+
return void res.status(403).json({ error: '仅商品卖家可开启测评' });
|
|
109
|
+
const body = req.body || {};
|
|
110
|
+
const quota = Math.floor(Number(body.quota_total) || 0);
|
|
111
|
+
const threshold = Math.floor(Number(body.reach_threshold) || 50);
|
|
112
|
+
const minChars = Math.floor(Number(body.min_chars) || 50);
|
|
113
|
+
const minDays = Math.floor(Number(body.min_days_live) || 7);
|
|
114
|
+
if (quota < 1 || quota > 200)
|
|
115
|
+
return void res.status(400).json({ error: 'quota_total 需在 1-200 之间' });
|
|
116
|
+
if (threshold < 10 || threshold > 10000)
|
|
117
|
+
return void res.status(400).json({ error: 'reach_threshold 需在 10-10000 之间' });
|
|
118
|
+
if (minChars < 20 || minChars > 5000)
|
|
119
|
+
return void res.status(400).json({ error: 'min_chars 需在 20-5000 之间' });
|
|
120
|
+
if (minDays < 1 || minDays > 90)
|
|
121
|
+
return void res.status(400).json({ error: 'min_days_live 需在 1-90 之间' });
|
|
122
|
+
// B3 修:1 product 1 row(UNIQUE)所以"关闭后再开"必须走 UPDATE 路径,不再 INSERT
|
|
123
|
+
// 查任意 status 的现存行;存在即 UPDATE,不存在才 INSERT
|
|
124
|
+
const existing = db.prepare("SELECT id, status, quota_claimed, reach_threshold, min_chars, min_days_live FROM product_trial_campaigns WHERE product_id = ?").get(product.id);
|
|
125
|
+
if (existing) {
|
|
126
|
+
if (quota < existing.quota_claimed)
|
|
127
|
+
return void res.status(400).json({ error: `quota_total 不可低于已申请数 ${existing.quota_claimed}` });
|
|
128
|
+
// 审计 P0-3:仅 active + 有 claim 时禁止上调阈值(关闭活动重开不限)
|
|
129
|
+
if (existing.status === 'active' && existing.quota_claimed > 0) {
|
|
130
|
+
if (threshold > existing.reach_threshold)
|
|
131
|
+
return void res.status(400).json({ error: `已有 ${existing.quota_claimed} 个申请,reach_threshold 不可上调(当前 ${existing.reach_threshold})` });
|
|
132
|
+
if (minChars > existing.min_chars)
|
|
133
|
+
return void res.status(400).json({ error: `已有申请,min_chars 不可上调(当前 ${existing.min_chars})` });
|
|
134
|
+
if (minDays > existing.min_days_live)
|
|
135
|
+
return void res.status(400).json({ error: `已有申请,min_days_live 不可上调(当前 ${existing.min_days_live})` });
|
|
136
|
+
}
|
|
137
|
+
// 重开:status='closed' → 重置为 active 且清 closed_at
|
|
138
|
+
db.prepare(`UPDATE product_trial_campaigns
|
|
139
|
+
SET quota_total=?, reach_threshold=?, min_chars=?, min_days_live=?,
|
|
140
|
+
status='active', closed_at=NULL
|
|
141
|
+
WHERE id=?`)
|
|
142
|
+
.run(quota, threshold, minChars, minDays, existing.id);
|
|
143
|
+
return void res.json({ ok: true, campaign_id: existing.id, updated: true, reopened: existing.status !== 'active' });
|
|
144
|
+
}
|
|
145
|
+
const id = generateId('ptc');
|
|
146
|
+
db.prepare(`INSERT INTO product_trial_campaigns (id, product_id, seller_id, quota_total, reach_threshold, min_chars, min_days_live)
|
|
147
|
+
VALUES (?,?,?,?,?,?,?)`).run(id, product.id, user.id, quota, threshold, minChars, minDays);
|
|
148
|
+
res.json({ ok: true, campaign_id: id, created: true });
|
|
149
|
+
});
|
|
150
|
+
// 卖家关闭活动(仍允许 pending claims 完成评估)
|
|
151
|
+
app.delete('/api/products/:product_id/trial-campaign', (req, res) => {
|
|
152
|
+
const user = auth(req, res);
|
|
153
|
+
if (!user)
|
|
154
|
+
return;
|
|
155
|
+
const camp = db.prepare("SELECT id, seller_id FROM product_trial_campaigns WHERE product_id = ? AND status = 'active'").get(req.params.product_id);
|
|
156
|
+
if (!camp)
|
|
157
|
+
return void res.status(404).json({ error: '无活跃活动' });
|
|
158
|
+
if (camp.seller_id !== user.id)
|
|
159
|
+
return void res.status(403).json({ error: '仅卖家可关闭' });
|
|
160
|
+
db.prepare("UPDATE product_trial_campaigns SET status='closed', closed_at=datetime('now') WHERE id=?").run(camp.id);
|
|
161
|
+
res.json({ ok: true, closed: true });
|
|
162
|
+
});
|
|
163
|
+
// 公开查询商品的活动状态(任何人)
|
|
164
|
+
app.get('/api/products/:product_id/trial-campaign', (req, res) => {
|
|
165
|
+
const camp = db.prepare(`SELECT id, quota_total, quota_claimed, reach_threshold, min_chars, min_days_live, status, created_at
|
|
166
|
+
FROM product_trial_campaigns WHERE product_id = ? AND status = 'active'`).get(req.params.product_id);
|
|
167
|
+
if (!camp)
|
|
168
|
+
return void res.json({ campaign: null });
|
|
169
|
+
res.json({ campaign: { ...camp, quota_remaining: Number(camp.quota_total) - Number(camp.quota_claimed) } });
|
|
170
|
+
});
|
|
171
|
+
// 买家申请名额(必须已 confirmed/completed 该商品订单)
|
|
172
|
+
// 审计防御:
|
|
173
|
+
// P0: 拒绝 buyer_id === seller_id(自买自评)
|
|
174
|
+
// P0: 快照 campaign 配置到 claim 行,cron 按快照评估(防卖家中途上调阈值)
|
|
175
|
+
// P1: 新账号 < 3 天禁申请;IP/UA 与卖家 session 重叠 → 标 account_link 审计 flag
|
|
176
|
+
app.post('/api/products/:product_id/trial-claim', (req, res) => {
|
|
177
|
+
const user = auth(req, res);
|
|
178
|
+
if (!user)
|
|
179
|
+
return;
|
|
180
|
+
const productId = req.params.product_id;
|
|
181
|
+
const camp = db.prepare("SELECT * FROM product_trial_campaigns WHERE product_id = ? AND status = 'active'").get(productId);
|
|
182
|
+
if (!camp)
|
|
183
|
+
return void res.status(404).json({ error: '该商品当前无测评活动' });
|
|
184
|
+
if (Number(camp.quota_claimed) >= Number(camp.quota_total))
|
|
185
|
+
return void res.status(409).json({ error: '名额已满' });
|
|
186
|
+
// 审计 P0-2:禁止卖家给自己商品申请测评(自买自评最直接的形式)
|
|
187
|
+
if (user.id === camp.seller_id)
|
|
188
|
+
return void res.status(403).json({ error: '卖家不能为自己的商品申请测评' });
|
|
189
|
+
// 审计 P1-1:新账号冷启动锁(注册 < 3 天)
|
|
190
|
+
const userCreatedAt = String(user.created_at || '');
|
|
191
|
+
if (userCreatedAt) {
|
|
192
|
+
const ageDays = (Date.now() - new Date(userCreatedAt).getTime()) / 86400_000;
|
|
193
|
+
if (ageDays < 3)
|
|
194
|
+
return void res.status(403).json({ error: `新账号需注册满 3 天才能申请测评(你已注册 ${ageDays.toFixed(1)} 天)` });
|
|
195
|
+
}
|
|
196
|
+
// 买家必须有该商品的 confirmed/completed 订单
|
|
197
|
+
const order = db.prepare(`SELECT id, total_amount FROM orders WHERE product_id = ? AND buyer_id = ? AND status IN ('confirmed','completed') ORDER BY created_at DESC LIMIT 1`).get(productId, user.id);
|
|
198
|
+
if (!order)
|
|
199
|
+
return void res.status(400).json({ error: '需先完成订单 (confirmed 或 completed) 才能申请测评' });
|
|
200
|
+
const dup = db.prepare("SELECT id FROM product_trial_claims WHERE buyer_id = ? AND product_id = ?").get(user.id, productId);
|
|
201
|
+
if (dup)
|
|
202
|
+
return void res.status(409).json({ error: '已申请过该商品测评', existing_id: dup.id });
|
|
203
|
+
// 审计 P1-7(2026-05-25):单 IP 1h 频次限制 — 防脚本批量小号撞名额
|
|
204
|
+
// #1016 fix: 实际列名是 claimed_at(datetime('now') default),不是 created_at
|
|
205
|
+
const buyerIp = clientIpHash(req);
|
|
206
|
+
const buyerUa = clientUaHash(req);
|
|
207
|
+
const recentIpClaims = db.prepare(`SELECT COUNT(*) as n FROM product_trial_claims
|
|
208
|
+
WHERE buyer_ip_hash = ? AND claimed_at > datetime('now', '-1 hour')`).get(buyerIp).n;
|
|
209
|
+
if (recentIpClaims >= 3) {
|
|
210
|
+
return void res.status(429).json({ error: '当前网络申请过于频繁,请稍后再试', error_code: 'TRIAL_IP_RATE_LIMITED' });
|
|
211
|
+
}
|
|
212
|
+
// 审计 P1-2:IP/UA 重叠检测 — 标记 flag,不阻断(阻断会误伤共享网络的真买家)
|
|
213
|
+
const linkRow = db.prepare(`SELECT 1 FROM user_sessions
|
|
214
|
+
WHERE user_id = ? AND (ip = ? OR fingerprint_hash = ?) LIMIT 1`).get(camp.seller_id, buyerIp, buyerUa);
|
|
215
|
+
const flags = [];
|
|
216
|
+
if (linkRow)
|
|
217
|
+
flags.push('account_link_ip_or_ua');
|
|
218
|
+
const auditFlags = flags.length ? JSON.stringify(flags) : null;
|
|
219
|
+
const id = generateId('pcl');
|
|
220
|
+
// R1 修:quota 超卖竞态 — 用条件性 UPDATE 抢名额,原子化
|
|
221
|
+
// 把 UPDATE 放在 tx 最前面,changes==0 即抛错让 tx 回滚(INSERT 也不会执行)
|
|
222
|
+
try {
|
|
223
|
+
const tx = db.transaction(() => {
|
|
224
|
+
const upd = db.prepare(`UPDATE product_trial_campaigns
|
|
225
|
+
SET quota_claimed = quota_claimed + 1
|
|
226
|
+
WHERE id = ? AND quota_claimed < quota_total AND status = 'active'`).run(camp.id);
|
|
227
|
+
if (upd.changes === 0)
|
|
228
|
+
throw new Error('QUOTA_FULL');
|
|
229
|
+
db.prepare(`INSERT INTO product_trial_claims (id, campaign_id, product_id, seller_id, buyer_id, order_id,
|
|
230
|
+
snap_reach_threshold, snap_min_chars, snap_min_days_live, buyer_ip_hash, buyer_ua_hash, audit_flags)
|
|
231
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(id, camp.id, productId, camp.seller_id, user.id, order.id, Number(camp.reach_threshold), Number(camp.min_chars), Number(camp.min_days_live), buyerIp, buyerUa, auditFlags);
|
|
232
|
+
});
|
|
233
|
+
tx();
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
if (e.message === 'QUOTA_FULL')
|
|
237
|
+
return void res.status(409).json({ error: '名额已满(并发申请抢占)' });
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
res.json({ ok: true, claim_id: id, refund_eligible_amount: order.total_amount, audit_flags: flags });
|
|
241
|
+
});
|
|
242
|
+
// 买家关联笔记
|
|
243
|
+
app.post('/api/trial-claims/:claim_id/link-note', (req, res) => {
|
|
244
|
+
const user = auth(req, res);
|
|
245
|
+
if (!user)
|
|
246
|
+
return;
|
|
247
|
+
const claim = db.prepare("SELECT * FROM product_trial_claims WHERE id = ?").get(req.params.claim_id);
|
|
248
|
+
if (!claim)
|
|
249
|
+
return void res.status(404).json({ error: '申请不存在' });
|
|
250
|
+
if (claim.buyer_id !== user.id)
|
|
251
|
+
return void res.status(403).json({ error: '仅本人可关联' });
|
|
252
|
+
if (claim.status !== 'pending_note')
|
|
253
|
+
return void res.status(400).json({ error: `当前状态 ${claim.status} 不可关联笔记` });
|
|
254
|
+
const { note_id } = req.body || {};
|
|
255
|
+
if (!note_id)
|
|
256
|
+
return void res.status(400).json({ error: '缺少 note_id' });
|
|
257
|
+
// 笔记必须存在 + 是 type=note + owner=买家 + related_product_id=该商品
|
|
258
|
+
const note = db.prepare(`SELECT id, owner_id, type, related_product_id, native_text, photo_hashes, status, created_at
|
|
259
|
+
FROM shareables WHERE id = ?`).get(note_id);
|
|
260
|
+
if (!note)
|
|
261
|
+
return void res.status(404).json({ error: '笔记不存在' });
|
|
262
|
+
if (note.owner_id !== user.id)
|
|
263
|
+
return void res.status(403).json({ error: '仅本人笔记可关联' });
|
|
264
|
+
if (note.type !== 'note')
|
|
265
|
+
return void res.status(400).json({ error: '仅 type=note 可关联' });
|
|
266
|
+
if (note.related_product_id !== claim.product_id)
|
|
267
|
+
return void res.status(400).json({ error: '笔记需绑定该商品(含 anchor)' });
|
|
268
|
+
if (note.status !== 'active')
|
|
269
|
+
return void res.status(400).json({ error: '笔记需为 active' });
|
|
270
|
+
const txtLen = String(note.native_text || '').length;
|
|
271
|
+
// 审计 P0-2:用 claim 的 snapshot(申请时锁定),非 campaign 当前值
|
|
272
|
+
const minChars = Number(claim.snap_min_chars || 50);
|
|
273
|
+
if (txtLen < minChars) {
|
|
274
|
+
return void res.status(400).json({ error: `笔记字数不足 ${minChars}(当前 ${txtLen})` });
|
|
275
|
+
}
|
|
276
|
+
const photoHashes = note.photo_hashes ? JSON.parse(String(note.photo_hashes)) : [];
|
|
277
|
+
if (!Array.isArray(photoHashes) || photoHashes.length === 0)
|
|
278
|
+
return void res.status(400).json({ error: '笔记需至少 1 张图' });
|
|
279
|
+
db.prepare(`UPDATE product_trial_claims SET note_id=?, note_linked_at=datetime('now'), status='pending_threshold' WHERE id=?`).run(note_id, claim.id);
|
|
280
|
+
res.json({ ok: true, status: 'pending_threshold' });
|
|
281
|
+
});
|
|
282
|
+
// 买家:我的测评列表
|
|
283
|
+
app.get('/api/me/trial-claims', (req, res) => {
|
|
284
|
+
const user = auth(req, res);
|
|
285
|
+
if (!user)
|
|
286
|
+
return;
|
|
287
|
+
const rows = db.prepare(`SELECT c.*, p.title as product_title, p.price as product_price,
|
|
288
|
+
camp.reach_threshold, camp.min_days_live
|
|
289
|
+
FROM product_trial_claims c
|
|
290
|
+
LEFT JOIN products p ON p.id = c.product_id
|
|
291
|
+
LEFT JOIN product_trial_campaigns camp ON camp.id = c.campaign_id
|
|
292
|
+
WHERE c.buyer_id = ?
|
|
293
|
+
ORDER BY c.claimed_at DESC LIMIT 100`).all(user.id);
|
|
294
|
+
res.json({ items: rows });
|
|
295
|
+
});
|
|
296
|
+
// 卖家:我的测评活动列表(含每个的 claims 计数)
|
|
297
|
+
app.get('/api/me/seller/trial-campaigns', (req, res) => {
|
|
298
|
+
const user = auth(req, res);
|
|
299
|
+
if (!user)
|
|
300
|
+
return;
|
|
301
|
+
const rows = db.prepare(`SELECT camp.*, p.title as product_title, p.price as product_price,
|
|
302
|
+
(SELECT COUNT(*) FROM product_trial_claims WHERE campaign_id = camp.id AND status='refunded') as refunded_count,
|
|
303
|
+
(SELECT COUNT(*) FROM product_trial_claims WHERE campaign_id = camp.id AND status='pending_threshold') as evaluating_count,
|
|
304
|
+
(SELECT COUNT(*) FROM product_trial_claims WHERE campaign_id = camp.id AND status='expired') as expired_count
|
|
305
|
+
FROM product_trial_campaigns camp
|
|
306
|
+
LEFT JOIN products p ON p.id = camp.product_id
|
|
307
|
+
WHERE camp.seller_id = ?
|
|
308
|
+
ORDER BY camp.created_at DESC LIMIT 100`).all(user.id);
|
|
309
|
+
res.json({ items: rows });
|
|
310
|
+
});
|
|
311
|
+
// 卖家:查看某活动的 claims 详情
|
|
312
|
+
app.get('/api/trial-campaigns/:campaign_id/claims', (req, res) => {
|
|
313
|
+
const user = auth(req, res);
|
|
314
|
+
if (!user)
|
|
315
|
+
return;
|
|
316
|
+
const camp = db.prepare("SELECT id, seller_id FROM product_trial_campaigns WHERE id = ?").get(req.params.campaign_id);
|
|
317
|
+
if (!camp)
|
|
318
|
+
return void res.status(404).json({ error: '活动不存在' });
|
|
319
|
+
if (camp.seller_id !== user.id)
|
|
320
|
+
return void res.status(403).json({ error: '仅卖家可查看' });
|
|
321
|
+
const rows = db.prepare(`SELECT c.*, u.handle as buyer_handle, u.created_at as buyer_created_at FROM product_trial_claims c
|
|
322
|
+
LEFT JOIN users u ON u.id = c.buyer_id
|
|
323
|
+
WHERE c.campaign_id = ? ORDER BY c.claimed_at DESC`).all(camp.id);
|
|
324
|
+
res.json({ items: rows });
|
|
325
|
+
});
|
|
326
|
+
// Admin 手动触发测评评估(测试 + 紧急 + 立即生效)
|
|
327
|
+
app.post('/api/admin/trial/run-eval', (req, res) => {
|
|
328
|
+
const admin = requireProtocolAdmin(req, res);
|
|
329
|
+
if (!admin)
|
|
330
|
+
return;
|
|
331
|
+
res.json(evaluateTrialClaims(db, generateId));
|
|
332
|
+
});
|
|
333
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export function registerTrustedKpiRoutes(app, deps) {
|
|
2
|
+
const { db, auth } = deps;
|
|
3
|
+
// Verifier KPI(白名单 tier / 配额 / 准确率 / 窗口奖励)
|
|
4
|
+
app.get('/api/verifier/me/kpi', (req, res) => {
|
|
5
|
+
const user = auth(req, res);
|
|
6
|
+
if (!user)
|
|
7
|
+
return;
|
|
8
|
+
const windowDays = Math.max(7, Math.min(365, Number(req.query.window) || 30));
|
|
9
|
+
const cumul = db.prepare('SELECT tasks_done, tasks_correct, tasks_wrong, verify_rights FROM verifier_stats WHERE user_id = ?').get(user.id);
|
|
10
|
+
const wl = db.prepare('SELECT tier, daily_quota, tasks_today, is_system FROM verifier_whitelist WHERE user_id = ?').get(user.id);
|
|
11
|
+
// 窗口内投票数(跨多个 votes 表聚合)
|
|
12
|
+
const windowVotes = db.prepare(`
|
|
13
|
+
SELECT
|
|
14
|
+
(SELECT COUNT(*) FROM claim_verification_votes WHERE verifier_id = ? AND voted_at > datetime('now', '-' || ? || ' days')) +
|
|
15
|
+
(SELECT COUNT(*) FROM product_claim_votes WHERE verifier_id = ? AND voted_at > datetime('now', '-' || ? || ' days')) +
|
|
16
|
+
(SELECT COUNT(*) FROM review_claim_votes WHERE verifier_id = ? AND voted_at > datetime('now', '-' || ? || ' days'))
|
|
17
|
+
as n
|
|
18
|
+
`).get(user.id, windowDays, user.id, windowDays, user.id, windowDays).n;
|
|
19
|
+
// 窗口内奖励 (reputation_events: claim_correct 等)
|
|
20
|
+
const earnedEvents = db.prepare(`
|
|
21
|
+
SELECT COALESCE(SUM(points), 0) as pts FROM reputation_events
|
|
22
|
+
WHERE user_id = ? AND event_type IN ('claim_correct', 'claim_upheld_against', 'claim_dismissed_false')
|
|
23
|
+
AND created_at > datetime('now', '-' || ? || ' days')
|
|
24
|
+
`).get(user.id, windowDays).pts;
|
|
25
|
+
const wal = db.prepare('SELECT earned FROM wallets WHERE user_id = ?').get(user.id);
|
|
26
|
+
const accuracy = cumul && cumul.tasks_done > 0 ? cumul.tasks_correct / cumul.tasks_done : null;
|
|
27
|
+
res.json({
|
|
28
|
+
window_days: windowDays,
|
|
29
|
+
is_external: wl ? wl.is_system === 0 : false,
|
|
30
|
+
tier: wl?.tier || null,
|
|
31
|
+
daily_quota: wl?.daily_quota || 0,
|
|
32
|
+
tasks_today: wl?.tasks_today || 0,
|
|
33
|
+
verify_rights: cumul?.verify_rights || 0,
|
|
34
|
+
cumulative: {
|
|
35
|
+
tasks_done: cumul?.tasks_done || 0,
|
|
36
|
+
tasks_correct: cumul?.tasks_correct || 0,
|
|
37
|
+
tasks_wrong: cumul?.tasks_wrong || 0,
|
|
38
|
+
accuracy,
|
|
39
|
+
},
|
|
40
|
+
window: {
|
|
41
|
+
votes: windowVotes,
|
|
42
|
+
rep_points: earnedEvents,
|
|
43
|
+
},
|
|
44
|
+
total_earned_waz: Number(wal?.earned || 0),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
// Arbitrator KPI(仲裁累计 + 裁决分布 + pending)
|
|
48
|
+
app.get('/api/arbitrator/me/kpi', (req, res) => {
|
|
49
|
+
const user = auth(req, res);
|
|
50
|
+
if (!user)
|
|
51
|
+
return;
|
|
52
|
+
const windowDays = Math.max(7, Math.min(365, Number(req.query.window) || 30));
|
|
53
|
+
const idLike = `%"${user.id}"%`;
|
|
54
|
+
const cumul = db.prepare(`
|
|
55
|
+
SELECT COUNT(*) as total,
|
|
56
|
+
SUM(CASE WHEN ruling_type = 'refund_buyer' THEN 1 ELSE 0 END) as refund_buyer_cnt,
|
|
57
|
+
SUM(CASE WHEN ruling_type = 'partial_refund' THEN 1 ELSE 0 END) as partial_cnt,
|
|
58
|
+
SUM(CASE WHEN ruling_type = 'release_seller' THEN 1 ELSE 0 END) as release_seller_cnt
|
|
59
|
+
FROM disputes WHERE assigned_arbitrators LIKE ? AND status IN ('resolved','dismissed')
|
|
60
|
+
`).get(idLike);
|
|
61
|
+
const windowTotal = db.prepare(`
|
|
62
|
+
SELECT COUNT(*) as n FROM disputes
|
|
63
|
+
WHERE assigned_arbitrators LIKE ? AND status IN ('resolved','dismissed')
|
|
64
|
+
AND resolved_at > datetime('now', '-' || ? || ' days')
|
|
65
|
+
`).get(idLike, windowDays).n;
|
|
66
|
+
const pending = db.prepare(`
|
|
67
|
+
SELECT COUNT(*) as n FROM disputes
|
|
68
|
+
WHERE assigned_arbitrators LIKE ? AND status NOT IN ('resolved','dismissed')
|
|
69
|
+
`).get(idLike).n;
|
|
70
|
+
const wl = db.prepare('SELECT is_system, stake_amount FROM arbitrator_whitelist WHERE user_id = ?').get(user.id);
|
|
71
|
+
const wal = db.prepare('SELECT earned FROM wallets WHERE user_id = ?').get(user.id);
|
|
72
|
+
res.json({
|
|
73
|
+
window_days: windowDays,
|
|
74
|
+
is_external: wl ? wl.is_system === 0 : false,
|
|
75
|
+
stake_amount: wl?.stake_amount || 0,
|
|
76
|
+
cumulative: {
|
|
77
|
+
total: cumul?.total || 0,
|
|
78
|
+
refund_buyer: cumul?.refund_buyer_cnt || 0,
|
|
79
|
+
partial_refund: cumul?.partial_cnt || 0,
|
|
80
|
+
release_seller: cumul?.release_seller_cnt || 0,
|
|
81
|
+
},
|
|
82
|
+
window_total: windowTotal,
|
|
83
|
+
pending,
|
|
84
|
+
total_earned_waz: Number(wal?.earned || 0),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export function registerUrlClaimRoutes(app, deps) {
|
|
2
|
+
const { db, auth, safeFetch, generateId, parsePlatformUrl, getStakeDiscount, makeCommitmentHash, makeDescriptionHash, makePriceHash } = deps;
|
|
3
|
+
app.post('/api/link-challenges/:id/verify', async (req, res) => {
|
|
4
|
+
const user = auth(req, res);
|
|
5
|
+
if (!user)
|
|
6
|
+
return;
|
|
7
|
+
const challenge = db.prepare(`SELECT * FROM link_challenges WHERE id = ? AND status = 'pending'`)
|
|
8
|
+
.get(req.params.id);
|
|
9
|
+
if (!challenge)
|
|
10
|
+
return void res.json({ error: '验证码不存在或已失效' });
|
|
11
|
+
if (challenge.product_id !== undefined) {
|
|
12
|
+
const prod = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(challenge.product_id);
|
|
13
|
+
if (!prod || prod.seller_id !== user.id)
|
|
14
|
+
return void res.status(403).json({ error: '无权限' });
|
|
15
|
+
}
|
|
16
|
+
if (new Date(challenge.expires_at) < new Date()) {
|
|
17
|
+
db.prepare(`UPDATE link_challenges SET status='expired' WHERE id = ?`).run(req.params.id);
|
|
18
|
+
return void res.json({ error: '验证码已过期(48小时有效),请重新添加链接' });
|
|
19
|
+
}
|
|
20
|
+
const fullCode = `WebAZ-${challenge.code}`;
|
|
21
|
+
const chUrl = String(challenge.url || '');
|
|
22
|
+
try {
|
|
23
|
+
const ctrl = new AbortController();
|
|
24
|
+
setTimeout(() => ctrl.abort(), 10000);
|
|
25
|
+
const resp = await safeFetch(chUrl, { signal: ctrl.signal, headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'zh-CN,zh' } });
|
|
26
|
+
const html = await resp.text();
|
|
27
|
+
if (!html.includes(fullCode)) {
|
|
28
|
+
return void res.json({ error: `页面中未找到验证码 "${fullCode}",请确认已保存到商品标题或描述中` });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
const msg = e.message;
|
|
33
|
+
if (msg.startsWith('ssrf_'))
|
|
34
|
+
return void res.json({ error: '链接指向私网/localhost 或经 redirect 触达内部地址,已拦截' });
|
|
35
|
+
return void res.json({ error: `无法访问页面:${msg}` });
|
|
36
|
+
}
|
|
37
|
+
db.prepare(`UPDATE product_external_links SET product_id = ?, verify_note = '通过挑战验证,从原商品转移', verified_at = datetime('now') WHERE url = ?`)
|
|
38
|
+
.run(challenge.product_id, challenge.url);
|
|
39
|
+
db.prepare(`UPDATE link_challenges SET status='verified', verified_at=datetime('now') WHERE id=?`).run(req.params.id);
|
|
40
|
+
res.json({ success: true, message: `验证成功!链接已转移到此商品。` });
|
|
41
|
+
});
|
|
42
|
+
app.post('/api/claim-url', (req, res) => {
|
|
43
|
+
const user = auth(req, res);
|
|
44
|
+
if (!user)
|
|
45
|
+
return;
|
|
46
|
+
if (user.role !== 'seller')
|
|
47
|
+
return void res.json({ error: '仅卖家可发起认领' });
|
|
48
|
+
const { url, title, description, price, stock = 1, category = '', specs, handling_hours = 24, return_days = 7, warranty_days = 0, external_title, } = req.body;
|
|
49
|
+
if (!url || !title || !description || !price) {
|
|
50
|
+
return void res.json({ error: '请填写链接、商品名、描述和价格' });
|
|
51
|
+
}
|
|
52
|
+
const claimExternalTitle = typeof external_title === 'string' && external_title.trim() ? external_title.trim() : null;
|
|
53
|
+
const otherClaim = db.prepare(`
|
|
54
|
+
SELECT p.id FROM product_external_links pel
|
|
55
|
+
JOIN products p ON pel.product_id = p.id
|
|
56
|
+
WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
|
|
57
|
+
`).get(url, user.id);
|
|
58
|
+
if (!otherClaim) {
|
|
59
|
+
return void res.json({ error: '该链接当前没有其他商家认领,请直接使用导入上架功能' });
|
|
60
|
+
}
|
|
61
|
+
const existingClaim = db.prepare(`
|
|
62
|
+
SELECT vt.id FROM verify_tasks vt
|
|
63
|
+
JOIN products p ON vt.product_id = p.id
|
|
64
|
+
WHERE vt.url = ? AND p.seller_id = ? AND vt.status IN ('code_issued','open')
|
|
65
|
+
`).get(url, user.id);
|
|
66
|
+
if (existingClaim) {
|
|
67
|
+
return void res.json({ error: '您已有针对此链接的进行中认领任务,请在商品编辑页查看并确认', task_id: existingClaim.id });
|
|
68
|
+
}
|
|
69
|
+
const VERIFIERS_NEEDED = 1;
|
|
70
|
+
const REWARD_EACH = 0.1;
|
|
71
|
+
const feeLocked = VERIFIERS_NEEDED * REWARD_EACH;
|
|
72
|
+
const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
73
|
+
const priceNum = Number(price);
|
|
74
|
+
const stakeDiscount = getStakeDiscount(db, user.id);
|
|
75
|
+
const stakeRate = Math.max(0.05, 0.15 - stakeDiscount);
|
|
76
|
+
const stakeAmount = Math.round(priceNum * stakeRate * 100) / 100;
|
|
77
|
+
if (wallet.balance < stakeAmount + feeLocked) {
|
|
78
|
+
return void res.json({ error: `余额不足:需要 ${stakeAmount} WAZ 质押 + ${feeLocked} WAZ 验证费,当前余额 ${wallet.balance} WAZ` });
|
|
79
|
+
}
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
const productId = generateId('prd');
|
|
82
|
+
const specsJson = specs ? (typeof specs === 'string' ? specs : JSON.stringify(specs)) : null;
|
|
83
|
+
const pFields = { ship_regions: '全国', handling_hours, estimated_days: null, return_days, return_condition: '', warranty_days };
|
|
84
|
+
db.prepare(`INSERT INTO products (
|
|
85
|
+
id, seller_id, title, description, price, stock, category, stake_amount,
|
|
86
|
+
specs, source_url, handling_hours, return_days, warranty_days,
|
|
87
|
+
commitment_hash, description_hash, price_hash, hashed_at, status
|
|
88
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'warehouse')`).run(productId, user.id, title, description, priceNum, Number(stock), category, stakeAmount, specsJson, url, Number(handling_hours), Number(return_days), Number(warranty_days), makeCommitmentHash(pFields), makeDescriptionHash({ title, description, specs: specsJson }), makePriceHash(priceNum, now), now);
|
|
89
|
+
db.prepare(`UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?`)
|
|
90
|
+
.run(stakeAmount, stakeAmount, user.id);
|
|
91
|
+
db.prepare(`UPDATE products SET stake_locked_at = datetime('now') WHERE id = ?`).run(productId);
|
|
92
|
+
const linkId = generateId('lnk');
|
|
93
|
+
const claimUrlMeta = parsePlatformUrl(url);
|
|
94
|
+
db.prepare(`INSERT INTO product_external_links
|
|
95
|
+
(id, product_id, url, source, verified, verify_note, platform, external_id, external_title)
|
|
96
|
+
VALUES (?,?,?,'claim',0,'认领验证进行中',?,?,?)`).run(linkId, productId, url, claimUrlMeta?.platform ?? null, claimUrlMeta?.external_id ?? null, claimExternalTitle);
|
|
97
|
+
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
98
|
+
const code = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
|
99
|
+
const taskId = generateId('vtk');
|
|
100
|
+
const expiresAt = new Date(Date.now() + 72 * 3600_000).toISOString();
|
|
101
|
+
db.prepare(`INSERT INTO verify_tasks (id, type, product_id, url, code, verifiers_needed, reward_per_verifier, fee_locked, status, expires_at)
|
|
102
|
+
VALUES (?,?,?,?,?,?,?,?,'code_issued',?)`).run(taskId, 'code_check', productId, url, code, VERIFIERS_NEEDED, REWARD_EACH, feeLocked, expiresAt);
|
|
103
|
+
db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ?`).run(feeLocked, user.id);
|
|
104
|
+
res.json({
|
|
105
|
+
success: true,
|
|
106
|
+
product_id: productId,
|
|
107
|
+
task_id: taskId,
|
|
108
|
+
code: `[${code}]`,
|
|
109
|
+
expires_at: expiresAt,
|
|
110
|
+
message: `商品已建立,认领任务已创建。请在原平台商品标题或描述中加入验证码 [${code}],完成后在商品编辑页点击「确认已添加」提交任务,审核通过后链接归属自动转移。`,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|