@seasonkoh/webaz 0.1.7 → 0.1.8

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.
@@ -13,6 +13,20 @@ import { initNotificationSchema, notifyTransition, getNotifications, getUnreadCo
13
13
  import { initSkillSchema, publishSkill, listSkills, getMySkills, subscribeSkill, unsubscribeSkill, getMySubscriptions, shouldAutoAccept, } from '../layer4-economics/L4-4-skill-market/skill-engine.js';
14
14
  import { initReputationSchema, recordOrderReputation, recordViolationReputation, recordDisputeReputation, getReputation, getStakeDiscount, } from '../layer4-economics/L4-3-reputation/reputation-engine.js';
15
15
  import { generateManifest } from '../layer0-foundation/L0-5-manifest/manifest.js';
16
+ import Anthropic from '@anthropic-ai/sdk';
17
+ import { privateKeyToAddress, privateKeyToAccount } from 'viem/accounts';
18
+ import { createPublicClient, createWalletClient, http, parseAbiItem, parseAbi, parseEther } from 'viem';
19
+ import { baseSepolia } from 'viem/chains';
20
+ import { createHmac, createHash } from 'node:crypto';
21
+ const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
22
+ // ─── 链上地址派生 ──────────────────────────────────────────────
23
+ const MASTER_SEED = process.env.WALLET_MASTER_SEED ?? 'webaz-dev-seed-changeme';
24
+ function derivePrivKey(seed) {
25
+ return `0x${createHmac('sha256', MASTER_SEED).update(seed).digest('hex')}`;
26
+ }
27
+ function deriveDepositAddress(userId) {
28
+ return privateKeyToAddress(derivePrivKey(userId));
29
+ }
16
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
31
  const db = initDatabase();
18
32
  initSystemUser(db);
@@ -21,6 +35,206 @@ initNotificationSchema(db);
21
35
  initSkillSchema(db);
22
36
  initReputationSchema(db);
23
37
  initEvidenceRequestSchema(db);
38
+ // ─── 验证员白名单表 ───────────────────────────────────────────────
39
+ db.exec(`
40
+ CREATE TABLE IF NOT EXISTS verifier_whitelist (
41
+ user_id TEXT PRIMARY KEY,
42
+ added_at TEXT DEFAULT (datetime('now')),
43
+ note TEXT
44
+ )
45
+ `);
46
+ // ─── MCP 工具调用埋点表(远程上报)─────────────────────────────────
47
+ db.exec(`
48
+ CREATE TABLE IF NOT EXISTS mcp_tool_calls (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ tool_name TEXT NOT NULL,
51
+ user_id_hash TEXT,
52
+ server_version TEXT,
53
+ outcome TEXT NOT NULL,
54
+ latency_ms INTEGER NOT NULL,
55
+ ts TEXT NOT NULL DEFAULT (datetime('now'))
56
+ )
57
+ `);
58
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_mcp_tc_ts ON mcp_tool_calls(ts)`);
59
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_mcp_tc_tool ON mcp_tool_calls(tool_name, ts)`);
60
+ // ─── 内部审核账号(固定 ID,密钥由 MASTER_SEED 派生,幂等)────────
61
+ const INTERNAL_AUDITOR_ID = 'usr_iaudit_001';
62
+ const INTERNAL_AUDITOR_KEY = 'key_iaudit_' + createHmac('sha256', MASTER_SEED).update('internal_auditor_v1').digest('hex').slice(0, 32);
63
+ (() => {
64
+ const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(INTERNAL_AUDITOR_ID);
65
+ if (!existing) {
66
+ const wid = 'wal_iaudit_001';
67
+ db.prepare('INSERT INTO users (id, name, role, roles, api_key) VALUES (?,?,?,?,?)')
68
+ .run(INTERNAL_AUDITOR_ID, '内部审核员', 'buyer', JSON.stringify(['buyer']), INTERNAL_AUDITOR_KEY);
69
+ db.prepare('INSERT OR IGNORE INTO wallets (id, user_id, balance) VALUES (?,?,0)').run(wid, INTERNAL_AUDITOR_ID);
70
+ console.log(`[WebAZ] 内部审核账号已创建,API Key: ${INTERNAL_AUDITOR_KEY}`);
71
+ }
72
+ db.prepare('INSERT OR IGNORE INTO verifier_whitelist (user_id, note) VALUES (?,?)').run(INTERNAL_AUDITOR_ID, '内部审核员');
73
+ })();
74
+ // ─── Schema 迁移(幂等)──────────────────────────────────────────
75
+ try {
76
+ db.exec('ALTER TABLE wallets ADD COLUMN deposit_address TEXT');
77
+ }
78
+ catch { }
79
+ const NEW_PRODUCT_COLS = [
80
+ 'ALTER TABLE products ADD COLUMN specs TEXT',
81
+ 'ALTER TABLE products ADD COLUMN brand TEXT',
82
+ 'ALTER TABLE products ADD COLUMN model TEXT',
83
+ 'ALTER TABLE products ADD COLUMN source_url TEXT',
84
+ 'ALTER TABLE products ADD COLUMN source_price REAL',
85
+ 'ALTER TABLE products ADD COLUMN source_price_at TEXT',
86
+ 'ALTER TABLE products ADD COLUMN weight_kg REAL',
87
+ 'ALTER TABLE products ADD COLUMN ship_regions TEXT DEFAULT "全国"',
88
+ 'ALTER TABLE products ADD COLUMN excluded_regions TEXT',
89
+ 'ALTER TABLE products ADD COLUMN handling_hours INTEGER DEFAULT 24',
90
+ 'ALTER TABLE products ADD COLUMN estimated_days TEXT',
91
+ 'ALTER TABLE products ADD COLUMN fragile INTEGER DEFAULT 0',
92
+ 'ALTER TABLE products ADD COLUMN return_days INTEGER DEFAULT 7',
93
+ 'ALTER TABLE products ADD COLUMN return_condition TEXT',
94
+ 'ALTER TABLE products ADD COLUMN warranty_days INTEGER DEFAULT 0',
95
+ 'ALTER TABLE products ADD COLUMN commitment_hash TEXT',
96
+ 'ALTER TABLE products ADD COLUMN description_hash TEXT',
97
+ 'ALTER TABLE products ADD COLUMN price_hash TEXT',
98
+ 'ALTER TABLE products ADD COLUMN hashed_at TEXT',
99
+ 'ALTER TABLE products ADD COLUMN updated_at TEXT',
100
+ ];
101
+ for (const sql of NEW_PRODUCT_COLS) {
102
+ try {
103
+ db.exec(sql);
104
+ }
105
+ catch { }
106
+ }
107
+ // ─── 商品信息 hash(防篡改)──────────────────────────────────────
108
+ function md5(data) { return createHash('md5').update(data).digest('hex'); }
109
+ function makeCommitmentHash(p) {
110
+ return md5(JSON.stringify({
111
+ ship_regions: p.ship_regions ?? '全国',
112
+ handling_hours: p.handling_hours ?? 24,
113
+ estimated_days: p.estimated_days ?? null,
114
+ return_days: p.return_days ?? 7,
115
+ return_condition: p.return_condition ?? '',
116
+ warranty_days: p.warranty_days ?? 0,
117
+ }));
118
+ }
119
+ function makeDescriptionHash(p) {
120
+ return md5(JSON.stringify({ title: p.title, description: p.description, specs: p.specs ?? null }));
121
+ }
122
+ function makePriceHash(price, ts) {
123
+ return md5(JSON.stringify({ price, created_at: ts }));
124
+ }
125
+ db.exec(`
126
+ CREATE TABLE IF NOT EXISTS withdrawal_requests (
127
+ id TEXT PRIMARY KEY,
128
+ user_id TEXT NOT NULL,
129
+ to_address TEXT NOT NULL,
130
+ amount REAL NOT NULL,
131
+ status TEXT DEFAULT 'pending',
132
+ created_at TEXT DEFAULT (datetime('now')),
133
+ processed_at TEXT,
134
+ tx_hash TEXT
135
+ )
136
+ `);
137
+ db.exec(`
138
+ CREATE TABLE IF NOT EXISTS deposit_txns (
139
+ tx_hash TEXT PRIMARY KEY,
140
+ user_id TEXT NOT NULL,
141
+ amount REAL NOT NULL,
142
+ block_number INTEGER,
143
+ swept INTEGER DEFAULT 0,
144
+ created_at TEXT DEFAULT (datetime('now'))
145
+ )
146
+ `);
147
+ try {
148
+ db.exec('ALTER TABLE deposit_txns ADD COLUMN swept INTEGER DEFAULT 0');
149
+ }
150
+ catch { }
151
+ db.exec(`
152
+ CREATE TABLE IF NOT EXISTS system_state (
153
+ key TEXT PRIMARY KEY,
154
+ value TEXT
155
+ )
156
+ `);
157
+ db.exec(`
158
+ CREATE TABLE IF NOT EXISTS price_sessions (
159
+ token TEXT PRIMARY KEY,
160
+ product_id TEXT NOT NULL,
161
+ user_id TEXT NOT NULL,
162
+ price REAL NOT NULL,
163
+ quantity INTEGER NOT NULL DEFAULT 1,
164
+ created_at TEXT NOT NULL,
165
+ expires_at TEXT NOT NULL,
166
+ used_at TEXT
167
+ )
168
+ `);
169
+ db.exec(`
170
+ CREATE TABLE IF NOT EXISTS product_external_links (
171
+ id TEXT PRIMARY KEY,
172
+ product_id TEXT NOT NULL,
173
+ url TEXT NOT NULL,
174
+ source TEXT DEFAULT 'manual',
175
+ verified INTEGER DEFAULT 0,
176
+ verify_note TEXT,
177
+ added_at TEXT DEFAULT (datetime('now')),
178
+ verified_at TEXT,
179
+ UNIQUE(product_id, url)
180
+ )
181
+ `);
182
+ try {
183
+ db.exec('ALTER TABLE product_external_links ADD COLUMN revoked INTEGER DEFAULT 0');
184
+ }
185
+ catch { }
186
+ // link_challenges 保留用于向后兼容,新流程用 verify_tasks
187
+ db.exec(`
188
+ CREATE TABLE IF NOT EXISTS link_challenges (
189
+ id TEXT PRIMARY KEY,
190
+ product_id TEXT NOT NULL,
191
+ url TEXT NOT NULL,
192
+ code TEXT NOT NULL,
193
+ status TEXT DEFAULT 'pending',
194
+ created_at TEXT DEFAULT (datetime('now')),
195
+ expires_at TEXT NOT NULL,
196
+ verified_at TEXT
197
+ )
198
+ `);
199
+ db.exec(`
200
+ CREATE TABLE IF NOT EXISTS verify_tasks (
201
+ id TEXT PRIMARY KEY,
202
+ type TEXT NOT NULL DEFAULT 'code_check',
203
+ product_id TEXT NOT NULL,
204
+ url TEXT NOT NULL,
205
+ code TEXT,
206
+ verifiers_needed INTEGER NOT NULL DEFAULT 3,
207
+ reward_per_verifier REAL NOT NULL DEFAULT 0.1,
208
+ fee_locked REAL NOT NULL DEFAULT 0,
209
+ status TEXT NOT NULL DEFAULT 'open',
210
+ result TEXT,
211
+ created_at TEXT DEFAULT (datetime('now')),
212
+ expires_at TEXT NOT NULL,
213
+ settled_at TEXT
214
+ )
215
+ `);
216
+ db.exec(`
217
+ CREATE TABLE IF NOT EXISTS verify_submissions (
218
+ id TEXT PRIMARY KEY,
219
+ task_id TEXT NOT NULL,
220
+ verifier_id TEXT NOT NULL,
221
+ submission TEXT,
222
+ verdict TEXT,
223
+ claimed_at TEXT DEFAULT (datetime('now')),
224
+ submitted_at TEXT,
225
+ UNIQUE(task_id, verifier_id)
226
+ )
227
+ `);
228
+ db.exec(`
229
+ CREATE TABLE IF NOT EXISTS verifier_stats (
230
+ user_id TEXT PRIMARY KEY,
231
+ verify_rights INTEGER NOT NULL DEFAULT 3,
232
+ tasks_done INTEGER NOT NULL DEFAULT 0,
233
+ tasks_correct INTEGER NOT NULL DEFAULT 0,
234
+ tasks_wrong INTEGER NOT NULL DEFAULT 0,
235
+ suspended_until TEXT
236
+ )
237
+ `);
24
238
  const app = express();
25
239
  app.use(express.json());
26
240
  // express.static は API ルートの後で登録する(順番が重要)
@@ -125,8 +339,81 @@ app.post('/api/recover-key', (req, res) => {
125
339
  res.json({ found: users.length, accounts: users });
126
340
  });
127
341
  // 搜索商品(声誉权重排序)
342
+ // 构建 agent_summary:一句话决策摘要
343
+ function buildAgentSummary(p) {
344
+ const parts = [];
345
+ if (p.brand)
346
+ parts.push(String(p.brand));
347
+ if (p.model)
348
+ parts.push(String(p.model));
349
+ const returnDays = p.return_days != null ? Number(p.return_days) : null;
350
+ if (returnDays != null && returnDays > 0)
351
+ parts.push(`${returnDays}天退货`);
352
+ else if (returnDays === 0)
353
+ parts.push('不支持退货');
354
+ const warranty = p.warranty_days != null ? Number(p.warranty_days) : null;
355
+ if (warranty && warranty > 0)
356
+ parts.push(`${warranty}天质保`);
357
+ const handling = p.handling_hours != null ? Number(p.handling_hours) : null;
358
+ if (handling != null)
359
+ parts.push(`${handling}h发货`);
360
+ const est = p.estimated_days;
361
+ if (est) {
362
+ const estParsed = typeof est === 'string' ? (() => { try {
363
+ return JSON.parse(est);
364
+ }
365
+ catch {
366
+ return est;
367
+ } })() : est;
368
+ if (typeof estParsed === 'object' && estParsed !== null) {
369
+ const vals = Object.values(estParsed).map(Number).filter(n => !isNaN(n));
370
+ if (vals.length)
371
+ parts.push(`全国${Math.min(...vals)}-${Math.max(...vals)}天`);
372
+ }
373
+ else if (typeof estParsed === 'number') {
374
+ parts.push(`全国约${estParsed}天`);
375
+ }
376
+ else {
377
+ parts.push(`时效:${String(estParsed)}`);
378
+ }
379
+ }
380
+ if (p.ship_regions && p.ship_regions !== '全国')
381
+ parts.push(`发货:${p.ship_regions}`);
382
+ if (p.fragile)
383
+ parts.push('易碎品');
384
+ return parts.join(',') || '暂无物流信息';
385
+ }
386
+ // 格式化商品行为 agent 友好结构
387
+ function formatProductForAgent(p) {
388
+ const specsRaw = p.specs;
389
+ let specs = null;
390
+ if (specsRaw) {
391
+ try {
392
+ specs = JSON.parse(specsRaw);
393
+ }
394
+ catch {
395
+ specs = null;
396
+ }
397
+ }
398
+ const estRaw = p.estimated_days;
399
+ let estimated_days = null;
400
+ if (estRaw) {
401
+ try {
402
+ estimated_days = JSON.parse(estRaw);
403
+ }
404
+ catch {
405
+ estimated_days = null;
406
+ }
407
+ }
408
+ return {
409
+ ...p,
410
+ specs,
411
+ estimated_days,
412
+ agent_summary: buildAgentSummary(p),
413
+ };
414
+ }
128
415
  app.get('/api/products', (req, res) => {
129
- const { q = '', category, max_price } = req.query;
416
+ const { q = '', category, max_price, min_return_days, max_handling_hours } = req.query;
130
417
  let sql = `SELECT p.*, u.name as seller_name,
131
418
  COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
132
419
  FROM products p
@@ -146,15 +433,50 @@ app.get('/api/products', (req, res) => {
146
433
  sql += ` AND p.price <= ?`;
147
434
  params.push(Number(max_price));
148
435
  }
436
+ if (min_return_days) {
437
+ sql += ` AND p.return_days >= ?`;
438
+ params.push(Number(min_return_days));
439
+ }
440
+ if (max_handling_hours) {
441
+ sql += ` AND p.handling_hours <= ?`;
442
+ params.push(Number(max_handling_hours));
443
+ }
149
444
  sql += ` ORDER BY rep_points DESC, p.created_at DESC LIMIT 30`;
150
- res.json(db.prepare(sql).all(...params));
445
+ const rows = db.prepare(sql).all(...params);
446
+ res.json(rows.map(formatProductForAgent));
447
+ });
448
+ // 单品详情(agent verify price 时使用)
449
+ app.get('/api/products/:id', (req, res) => {
450
+ // 卖家可查看自己的非上架商品(编辑页用),其他人只能看 active
451
+ const token = (req.headers.authorization || '').replace('Bearer ', '');
452
+ const selfUser = token ? db.prepare('SELECT id FROM users WHERE api_key = ?').get(token) : undefined;
453
+ const row = db.prepare(`
454
+ SELECT p.*, u.name as seller_name,
455
+ COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
456
+ FROM products p
457
+ JOIN users u ON p.seller_id = u.id
458
+ LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
459
+ WHERE p.id = ? AND (p.status = 'active' OR p.seller_id = ?)
460
+ `).get(req.params.id, selfUser?.id ?? '');
461
+ if (!row)
462
+ return void res.status(404).json({ error: 'not_found' });
463
+ res.json(formatProductForAgent(row));
151
464
  });
152
465
  // 卖家:我的商品
153
466
  app.get('/api/my-products', (req, res) => {
154
467
  const user = auth(req, res);
155
468
  if (!user)
156
469
  return;
157
- const products = db.prepare(`SELECT * FROM products WHERE seller_id = ? ORDER BY created_at DESC`).all(user.id);
470
+ const products = db.prepare(`
471
+ SELECT p.*,
472
+ CASE WHEN EXISTS (
473
+ SELECT 1 FROM verify_tasks WHERE product_id=p.id AND status IN ('code_issued','open')
474
+ ) THEN 1 ELSE 0 END as has_pending_task,
475
+ CASE WHEN EXISTS (SELECT 1 FROM product_external_links WHERE product_id=p.id AND revoked=1)
476
+ AND NOT EXISTS (SELECT 1 FROM product_external_links WHERE product_id=p.id AND verified=1 AND (revoked IS NULL OR revoked=0))
477
+ THEN 1 ELSE 0 END as all_links_revoked
478
+ FROM products p WHERE p.seller_id = ? ORDER BY p.created_at DESC
479
+ `).all(user.id);
158
480
  res.json(products);
159
481
  });
160
482
  // 卖家:上架商品
@@ -164,9 +486,20 @@ app.post('/api/products', (req, res) => {
164
486
  return;
165
487
  if (user.role !== 'seller')
166
488
  return void res.json({ error: '仅卖家可上架商品' });
167
- const { title, description, price, stock = 1, category = '' } = req.body;
489
+ const { title, description, price, stock = 1, category = '', specs, brand, model, source_url, source_price, weight_kg, ship_regions = '全国', handling_hours = 24, estimated_days, fragile = 0, return_days = 7, return_condition = '', warranty_days = 0, } = req.body;
168
490
  if (!title || !description || !price)
169
491
  return void res.json({ error: '请填写商品名、描述、价格' });
492
+ // ── 上架前检查:同一卖家不能重复关联相同外部链接 ──────────────
493
+ if (source_url) {
494
+ const sameSellerDupe = db.prepare(`
495
+ SELECT COUNT(*) as n FROM product_external_links pel
496
+ JOIN products p ON pel.product_id = p.id
497
+ WHERE pel.url = ? AND p.seller_id = ?
498
+ `).get(source_url, user.id);
499
+ if (sameSellerDupe.n > 0) {
500
+ return void res.json({ error: '您已上架过来自此链接的商品,不能重复关联相同外部链接' });
501
+ }
502
+ }
170
503
  const priceNum = Number(price);
171
504
  const stakeDiscount = getStakeDiscount(db, user.id);
172
505
  const stakeRate = Math.max(0.05, 0.15 - stakeDiscount);
@@ -175,12 +508,1222 @@ app.post('/api/products', (req, res) => {
175
508
  if (wallet.balance < stakeAmount) {
176
509
  return void res.json({ error: `余额不足:上架需质押 ${stakeAmount} WAZ,当前余额 ${wallet.balance} WAZ` });
177
510
  }
511
+ const now = new Date().toISOString();
178
512
  const id = generateId('prd');
179
- db.prepare(`INSERT INTO products (id, seller_id, title, description, price, stock, category, stake_amount)
180
- VALUES (?,?,?,?,?,?,?,?)`).run(id, user.id, title, description, priceNum, stock, category, stakeAmount);
513
+ const specsJson = specs ? (typeof specs === 'string' ? specs : JSON.stringify(specs)) : null;
514
+ const estJson = estimated_days ? (typeof estimated_days === 'string' ? estimated_days : JSON.stringify(estimated_days)) : null;
515
+ const pFields = { ship_regions, handling_hours, estimated_days: estJson, return_days, return_condition, warranty_days };
516
+ db.prepare(`INSERT INTO products (
517
+ id, seller_id, title, description, price, stock, category, stake_amount,
518
+ specs, brand, model, source_url, source_price, source_price_at,
519
+ weight_kg, ship_regions, handling_hours, estimated_days, fragile,
520
+ return_days, return_condition, warranty_days,
521
+ commitment_hash, description_hash, price_hash, hashed_at
522
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(id, user.id, title, description, priceNum, Number(stock), category, stakeAmount, specsJson, brand ?? null, model ?? null, source_url ?? null, source_price ? Number(source_price) : null, source_price ? now : null, weight_kg ? Number(weight_kg) : null, ship_regions, Number(handling_hours), estJson, fragile ? 1 : 0, Number(return_days), return_condition, Number(warranty_days), makeCommitmentHash(pFields), makeDescriptionHash({ title, description, specs: specsJson }), makePriceHash(priceNum, now), now);
181
523
  db.prepare(`UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?`)
182
524
  .run(stakeAmount, stakeAmount, user.id);
183
- res.json({ success: true, product_id: id, stake_locked: stakeAmount });
525
+ // ── 来源链接:冲突检测 ────────────────────────────────────────
526
+ let linkConflict = null;
527
+ if (source_url) {
528
+ // 另一家卖家已认领此链接(verified=1)
529
+ const otherClaim = db.prepare(`
530
+ SELECT pel.product_id FROM product_external_links pel
531
+ JOIN products p ON pel.product_id = p.id
532
+ WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
533
+ `).get(source_url, user.id);
534
+ if (otherClaim) {
535
+ // 插入为未验证状态
536
+ db.prepare(`INSERT OR IGNORE INTO product_external_links (id, product_id, url, source, verified, verify_note)
537
+ VALUES (?, ?, ?, 'import', 0, '链接冲突:等待众包验证确认归属')`).run(generateId('lnk'), id, source_url);
538
+ // 创建认领验证任务(扣锁定费)
539
+ const VERIFIERS_NEEDED = 1;
540
+ const REWARD_EACH = 0.1;
541
+ const feeLocked = VERIFIERS_NEEDED * REWARD_EACH;
542
+ // 重新读钱包(已扣质押)
543
+ const walletNow = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
544
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
545
+ const code = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
546
+ const taskId = generateId('vtk');
547
+ const expiresAt = new Date(Date.now() + 72 * 3600_000).toISOString();
548
+ const baseMsg = `此商品来源链接已被其他商家认领。请将验证码 [${code}] 放入该平台商品标题或描述,等待人工审核确认后归属自动转移。`;
549
+ if (walletNow.balance >= feeLocked) {
550
+ try {
551
+ db.prepare(`INSERT INTO verify_tasks (id, type, product_id, url, code, verifiers_needed, reward_per_verifier, fee_locked, status, expires_at)
552
+ VALUES (?,?,?,?,?,?,?,?,'code_issued',?)`).run(taskId, 'code_check', id, source_url, code, VERIFIERS_NEEDED, REWARD_EACH, feeLocked, expiresAt);
553
+ db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ?`).run(feeLocked, user.id);
554
+ linkConflict = { task_id: taskId, code: `[${code}]`, expires_at: expiresAt, message: baseMsg };
555
+ }
556
+ catch {
557
+ linkConflict = { message: `${baseMsg}(余额不足以锁定验证费 ${feeLocked} WAZ,请前往商品外部链接手动发起验证)` };
558
+ }
559
+ }
560
+ else {
561
+ linkConflict = { message: `${baseMsg}(当前余额不足以锁定验证费 ${feeLocked} WAZ,请充值后前往商品编辑页手动发起验证)` };
562
+ }
563
+ // 有冲突:商品进入仓库,等待验证结果后再上架
564
+ db.prepare(`UPDATE products SET status='warehouse', updated_at=datetime('now') WHERE id=?`).run(id);
565
+ }
566
+ else {
567
+ // 无冲突 — 直接标记 verified=1
568
+ db.prepare(`INSERT OR IGNORE INTO product_external_links (id, product_id, url, source, verified, verified_at)
569
+ VALUES (?, ?, ?, 'import', 1, datetime('now'))`).run(generateId('lnk'), id, source_url);
570
+ }
571
+ }
572
+ // 额外链接:同步冲突检查,无冲突直接关联 verified=1,已被他人认领则跳过并返回 blocked_links
573
+ const additionalLinks = req.body.additional_links;
574
+ const blockedLinks = [];
575
+ if (Array.isArray(additionalLinks) && additionalLinks.length > 0) {
576
+ for (const extraUrl of additionalLinks.slice(0, 5)) {
577
+ if (typeof extraUrl !== 'string' || !extraUrl.startsWith('http'))
578
+ continue;
579
+ const alreadyLinked = db.prepare('SELECT id FROM product_external_links WHERE product_id = ? AND url = ?').get(id, extraUrl);
580
+ if (alreadyLinked)
581
+ continue;
582
+ // 同卖家已在其他商品关联过此链接
583
+ const selfConflict = db.prepare(`
584
+ SELECT p.title FROM product_external_links pel
585
+ JOIN products p ON pel.product_id = p.id
586
+ WHERE pel.url = ? AND p.seller_id = ? AND p.id != ?
587
+ `).get(extraUrl, user.id, id);
588
+ if (selfConflict) {
589
+ blockedLinks.push({ url: extraUrl, message: `您已在商品「${selfConflict.title}」中关联了此链接` });
590
+ continue;
591
+ }
592
+ // 他人已认领(verified=1)
593
+ const otherConflict = db.prepare(`
594
+ SELECT p.title FROM product_external_links pel
595
+ JOIN products p ON pel.product_id = p.id
596
+ WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
597
+ `).get(extraUrl, user.id);
598
+ if (otherConflict) {
599
+ blockedLinks.push({ url: extraUrl, message: `此链接已被其他商家认领,上架后可在商品编辑页发起验证任务` });
600
+ continue;
601
+ }
602
+ // 无冲突 — 直接关联 verified=1
603
+ try {
604
+ db.prepare(`INSERT OR IGNORE INTO product_external_links (id, product_id, url, source, verified, verified_at)
605
+ VALUES (?, ?, ?, 'import_extra', 1, datetime('now'))`).run(generateId('lnk'), id, extraUrl);
606
+ }
607
+ catch { }
608
+ }
609
+ }
610
+ res.json({
611
+ success: true,
612
+ product_id: id,
613
+ stake_locked: stakeAmount,
614
+ ...(linkConflict ? { link_conflict: linkConflict } : {}),
615
+ ...(blockedLinks.length > 0 ? { blocked_links: blockedLinks } : {}),
616
+ });
617
+ });
618
+ // 链接认领状态查询(上架前检查,无需商品 ID)
619
+ app.get('/api/check-url', (req, res) => {
620
+ const user = auth(req, res);
621
+ if (!user)
622
+ return;
623
+ const url = req.query.url;
624
+ if (!url)
625
+ return void res.json({ error: '请提供 url 参数' });
626
+ // 同卖家已有此链接
627
+ const selfClaim = db.prepare(`
628
+ SELECT p.id as product_id, p.title FROM product_external_links pel
629
+ JOIN products p ON pel.product_id = p.id
630
+ WHERE pel.url = ? AND p.seller_id = ?
631
+ `).get(url, user.id);
632
+ if (selfClaim) {
633
+ return void res.json({ claimed: true, self: true, product_title: selfClaim.title, message: `您已在商品「${selfClaim.title}」中关联了此链接` });
634
+ }
635
+ // 他人已认领(verified=1)
636
+ const otherClaim = db.prepare(`
637
+ SELECT p.title as product_title FROM product_external_links pel
638
+ JOIN products p ON pel.product_id = p.id
639
+ WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
640
+ `).get(url, user.id);
641
+ if (otherClaim) {
642
+ return void res.json({ claimed: true, self: false, message: `此链接已被其他商家认领,不能直接添加,上架后请在商品编辑页发起认领验证任务` });
643
+ }
644
+ res.json({ claimed: false });
645
+ });
646
+ // 商品外部链接:查询
647
+ app.get('/api/products/:id/links', (req, res) => {
648
+ const user = auth(req, res);
649
+ if (!user)
650
+ return;
651
+ const product = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(req.params.id);
652
+ if (!product)
653
+ return void res.status(404).json({ error: '商品不存在' });
654
+ if (product.seller_id !== user.id)
655
+ return void res.status(403).json({ error: '无权限' });
656
+ const links = db.prepare(`SELECT id, url, source, verified, revoked, verify_note, added_at FROM product_external_links WHERE product_id = ? ORDER BY added_at ASC`).all(req.params.id);
657
+ res.json(links);
658
+ });
659
+ // 商品外部链接:手动添加
660
+ // 规则:新链接(无人认领)直接关联 verified=1;已被他人认领则发起众包验证任务
661
+ app.post('/api/products/:id/links', (req, res) => {
662
+ const user = auth(req, res);
663
+ if (!user)
664
+ return;
665
+ const product = db.prepare('SELECT * FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
666
+ if (!product)
667
+ return void res.status(404).json({ error: '商品不存在或无权限' });
668
+ const { url } = req.body;
669
+ if (!url || !url.startsWith('http'))
670
+ return void res.json({ error: '请提供有效链接' });
671
+ // 已关联此商品
672
+ const existing = db.prepare('SELECT id, verified, revoked FROM product_external_links WHERE product_id = ? AND url = ?')
673
+ .get(req.params.id, url);
674
+ if (existing) {
675
+ // 主权失效的旧记录:删除后允许重新发起认领申诉
676
+ if (existing.revoked) {
677
+ db.prepare('DELETE FROM product_external_links WHERE id = ?').run(existing.id);
678
+ }
679
+ else {
680
+ return void res.json({ error: '该链接已关联到此商品' });
681
+ }
682
+ }
683
+ // 同卖家的其他商品已关联此链接
684
+ const sameSellerOther = db.prepare(`
685
+ SELECT p.title FROM product_external_links pel
686
+ JOIN products p ON pel.product_id = p.id
687
+ WHERE pel.url = ? AND p.seller_id = ? AND pel.product_id != ?
688
+ `).get(url, user.id, req.params.id);
689
+ if (sameSellerOther) {
690
+ return void res.json({ error: `此链接已在您的商品「${sameSellerOther.title}」中关联,一个链接不能关联多个商品` });
691
+ }
692
+ // 检查是否已被其他卖家认领(verified=1)
693
+ const otherClaim = db.prepare(`
694
+ SELECT p.title as product_title FROM product_external_links pel
695
+ JOIN products p ON pel.product_id = p.id
696
+ WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
697
+ `).get(url, user.id);
698
+ if (!otherClaim) {
699
+ // ── 新链接,无冲突:直接关联 verified=1 ──────────────────────
700
+ const linkId = generateId('lnk');
701
+ db.prepare(`INSERT INTO product_external_links (id, product_id, url, source, verified, verified_at)
702
+ VALUES (?, ?, ?, 'manual', 1, datetime('now'))`).run(linkId, req.params.id, url);
703
+ return void res.json({ link_id: linkId, verified: 1, message: '链接已关联' });
704
+ }
705
+ // ── 已被他人认领:发起众包验证任务 ───────────────────────────
706
+ // 已有进行中的验证任务(本商品+此链接)则直接返回
707
+ const existingTask = db.prepare(`SELECT id, code, status, expires_at FROM verify_tasks WHERE product_id = ? AND url = ? AND status IN ('code_issued','open')`)
708
+ .get(req.params.id, url);
709
+ if (existingTask) {
710
+ const isPending = existingTask.status === 'code_issued';
711
+ return void res.json({
712
+ task_id: existingTask.id,
713
+ code: `[${existingTask.code}]`,
714
+ status: existingTask.status,
715
+ expires_at: existingTask.expires_at,
716
+ already_pending: true,
717
+ conflict: true,
718
+ instructions: isPending
719
+ ? `此链接已有认领任务,请将验证码 [${existingTask.code}] 放入原平台商品标题或描述,完成后回来点击「确认已添加」提交任务。`
720
+ : `此链接已有进行中的认领任务,等待验证者确认。`,
721
+ });
722
+ }
723
+ const VERIFIERS_NEEDED = 1;
724
+ const REWARD_EACH = 0.1;
725
+ const feeLocked = VERIFIERS_NEEDED * REWARD_EACH;
726
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
727
+ if (wallet.balance < feeLocked) {
728
+ return void res.json({ error: `余额不足:认领验证需锁定 ${feeLocked} WAZ,当前余额 ${wallet.balance} WAZ` });
729
+ }
730
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
731
+ const code = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
732
+ const linkId = generateId('lnk');
733
+ const taskId = generateId('vtk');
734
+ const expiresAt = new Date(Date.now() + 72 * 3600_000).toISOString();
735
+ db.prepare(`INSERT INTO product_external_links (id, product_id, url, source, verified, verify_note)
736
+ VALUES (?, ?, ?, 'manual', 0, '认领验证进行中')`).run(linkId, req.params.id, url);
737
+ db.prepare(`INSERT INTO verify_tasks (id, type, product_id, url, code, verifiers_needed, reward_per_verifier, fee_locked, status, expires_at)
738
+ VALUES (?,?,?,?,?,?,?,?,'code_issued',?)`).run(taskId, 'claim', req.params.id, url, code, VERIFIERS_NEEDED, REWARD_EACH, feeLocked, expiresAt);
739
+ db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ?`).run(feeLocked, user.id);
740
+ res.json({
741
+ link_id: linkId,
742
+ task_id: taskId,
743
+ verified: 0,
744
+ conflict: true,
745
+ code: `[${code}]`,
746
+ instructions: `此链接已被其他商家的商品「${otherClaim.product_title}」认领。请将验证码 [${code}] 放入该平台商品标题或描述,完成后在商品编辑页点击「确认已添加」提交验证任务,经审核确认后,链接归属将转移到您的商品。`,
747
+ expires_at: expiresAt,
748
+ });
749
+ });
750
+ // 商品外部链接:删除
751
+ app.delete('/api/products/:id/links/:linkId', (req, res) => {
752
+ const user = auth(req, res);
753
+ if (!user)
754
+ return;
755
+ const product = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(req.params.id);
756
+ if (!product || product.seller_id !== user.id)
757
+ return void res.status(403).json({ error: '无权限' });
758
+ db.prepare('DELETE FROM product_external_links WHERE id = ? AND product_id = ?').run(req.params.linkId, req.params.id);
759
+ res.json({ success: true });
760
+ });
761
+ // ─── 众包验证任务引擎 ─────────────────────────────────────────
762
+ function getVerifierStats(userId) {
763
+ let stats = db.prepare('SELECT * FROM verifier_stats WHERE user_id = ?').get(userId);
764
+ if (!stats) {
765
+ db.prepare('INSERT OR IGNORE INTO verifier_stats (user_id) VALUES (?)').run(userId);
766
+ stats = db.prepare('SELECT * FROM verifier_stats WHERE user_id = ?').get(userId);
767
+ }
768
+ return stats;
769
+ }
770
+ function isEligibleVerifier(userId, taskId) {
771
+ const task = db.prepare('SELECT * FROM verify_tasks WHERE id = ?').get(taskId);
772
+ if (!task)
773
+ return { ok: false, reason: '任务不存在' };
774
+ // 必须在白名单
775
+ const onWhitelist = db.prepare('SELECT user_id FROM verifier_whitelist WHERE user_id = ?').get(userId);
776
+ if (!onWhitelist)
777
+ return { ok: false, reason: '不在验证员白名单' };
778
+ // 不能是任务发布者(商品卖家)
779
+ const product = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(task.product_id);
780
+ if (product?.seller_id === userId)
781
+ return { ok: false, reason: '不能验证自己的商品链接' };
782
+ // 未已领取
783
+ const existing = db.prepare('SELECT id FROM verify_submissions WHERE task_id = ? AND verifier_id = ?').get(taskId, userId);
784
+ if (existing)
785
+ return { ok: false, reason: '已领取此任务' };
786
+ return { ok: true };
787
+ }
788
+ function assignVerifiers(taskId) {
789
+ const task = db.prepare('SELECT * FROM verify_tasks WHERE id = ?').get(taskId);
790
+ if (!task || task.status !== 'open')
791
+ return;
792
+ const needed = task.verifiers_needed;
793
+ const alreadyAssigned = db.prepare('SELECT COUNT(*) as n FROM verify_submissions WHERE task_id = ?').get(taskId).n;
794
+ const toAssign = needed - alreadyAssigned;
795
+ if (toAssign <= 0)
796
+ return;
797
+ // 仅从白名单用户中分配(排除商品卖家)
798
+ const pool = db.prepare(`
799
+ SELECT vw.user_id FROM verifier_whitelist vw
800
+ WHERE vw.user_id != (SELECT seller_id FROM products WHERE id = ?)
801
+ ORDER BY RANDOM()
802
+ `).all(task.product_id);
803
+ let assigned = 0;
804
+ for (const { user_id: uid } of pool) {
805
+ if (assigned >= toAssign)
806
+ break;
807
+ const check = isEligibleVerifier(uid, taskId);
808
+ if (!check.ok)
809
+ continue;
810
+ db.prepare(`INSERT OR IGNORE INTO verify_submissions (id, task_id, verifier_id) VALUES (?,?,?)`)
811
+ .run(generateId('vsb'), taskId, uid);
812
+ assigned++;
813
+ }
814
+ }
815
+ function settleTask(taskId) {
816
+ const task = db.prepare('SELECT * FROM verify_tasks WHERE id = ?').get(taskId);
817
+ const subs = db.prepare(`SELECT * FROM verify_submissions WHERE task_id = ? AND submitted_at IS NOT NULL`).all(taskId);
818
+ if (subs.length < task.verifiers_needed)
819
+ return; // 未满足
820
+ // 统计提交内容(忽略空白/null)
821
+ const freq = {};
822
+ for (const s of subs) {
823
+ const v = (s.submission ?? '').trim().toUpperCase();
824
+ if (v)
825
+ freq[v] = (freq[v] ?? 0) + 1;
826
+ }
827
+ // 找多数票(超过半数)
828
+ const majority = Object.entries(freq).find(([, n]) => n > subs.length / 2);
829
+ const expectedCode = task.code.toUpperCase();
830
+ const passed = majority && majority[0] === expectedCode;
831
+ const result = passed ? 'verified' : 'failed';
832
+ db.prepare(`UPDATE verify_tasks SET status='settled', result=?, settled_at=datetime('now') WHERE id=?`).run(result, taskId);
833
+ // 分发奖励 / 扣验证权
834
+ const rewardEach = task.reward_per_verifier;
835
+ const feeLocked = task.fee_locked;
836
+ if (passed) {
837
+ // 通过:全额发给多数验证者,少数验证权-2
838
+ for (const s of subs) {
839
+ const vid = s.verifier_id;
840
+ const sub = (s.submission ?? '').trim().toUpperCase();
841
+ const isCorrect = sub === expectedCode;
842
+ if (isCorrect) {
843
+ db.prepare(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`).run(rewardEach, vid);
844
+ db.prepare(`UPDATE verify_submissions SET verdict='correct' WHERE id=?`).run(s.id);
845
+ db.prepare(`UPDATE verifier_stats SET verify_rights = verify_rights + 1, tasks_done = tasks_done + 1, tasks_correct = tasks_correct + 1 WHERE user_id = ?`).run(vid);
846
+ }
847
+ else {
848
+ db.prepare(`UPDATE verify_submissions SET verdict='wrong' WHERE id=?`).run(s.id);
849
+ db.prepare(`UPDATE verifier_stats SET verify_rights = verify_rights - 2, tasks_done = tasks_done + 1, tasks_wrong = tasks_wrong + 1 WHERE user_id = ?`).run(vid);
850
+ // 验证权低于 -3 则暂停7天
851
+ const stats = getVerifierStats(vid);
852
+ if (stats.verify_rights < -3) {
853
+ const until = new Date(Date.now() + 7 * 86400_000).toISOString();
854
+ db.prepare(`UPDATE verifier_stats SET suspended_until = ? WHERE user_id = ?`).run(until, vid);
855
+ }
856
+ }
857
+ }
858
+ // 更新挑战者链接为已验证,商品自动上架
859
+ db.prepare(`UPDATE product_external_links SET verified=1, revoked=0, verify_note='众包验证通过', verified_at=datetime('now') WHERE product_id=? AND url=?`)
860
+ .run(task.product_id, task.url);
861
+ db.prepare(`UPDATE products SET status='active', updated_at=datetime('now') WHERE id=? AND status='warehouse'`)
862
+ .run(task.product_id);
863
+ // 原持有者链接标记为「主权失效」,并检查是否需要强制下架
864
+ const originalOwners = db.prepare(`
865
+ SELECT p.id as product_id, p.seller_id FROM product_external_links pel
866
+ JOIN products p ON pel.product_id = p.id
867
+ WHERE pel.url=? AND pel.product_id != ? AND pel.verified=1
868
+ `).all(task.url, task.product_id);
869
+ db.prepare(`
870
+ UPDATE product_external_links SET revoked=1, verified=0, verify_note='主权失效'
871
+ WHERE url=? AND product_id != ? AND verified=1
872
+ `).run(task.url, task.product_id);
873
+ for (const orig of originalOwners) {
874
+ const hasValidLink = db.prepare(`
875
+ SELECT id FROM product_external_links WHERE product_id=? AND verified=1 AND (revoked IS NULL OR revoked=0)
876
+ `).get(orig.product_id);
877
+ if (!hasValidLink) {
878
+ db.prepare(`UPDATE products SET status='warehouse', updated_at=datetime('now') WHERE id=? AND status='active'`)
879
+ .run(orig.product_id);
880
+ // 写入系统通知(降级处理,失败不影响主流程)
881
+ try {
882
+ db.prepare(`INSERT INTO notifications (id, user_id, type, entity_type, entity_id, message, created_at)
883
+ VALUES (?,?,'link_revoked','product',?,?,datetime('now'))`)
884
+ .run(generateId('ntf'), orig.seller_id, orig.product_id, `您的商品因链接「${task.url}」主权失效已被自动下架至仓库,如需重新上架请更换链接或重新发起认领验证。`);
885
+ }
886
+ catch { }
887
+ }
888
+ }
889
+ }
890
+ else {
891
+ // 失败:50% 发给参与验证者(补偿时间),50% 销毁
892
+ const compensateTotal = feeLocked * 0.5;
893
+ const compensateEach = subs.length > 0 ? compensateTotal / subs.length : 0;
894
+ for (const s of subs) {
895
+ if (compensateEach > 0)
896
+ db.prepare(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`).run(compensateEach, s.verifier_id);
897
+ db.prepare(`UPDATE verify_submissions SET verdict='abstain' WHERE id=?`).run(s.id);
898
+ db.prepare(`UPDATE verifier_stats SET tasks_done = tasks_done + 1 WHERE user_id = ?`).run(s.verifier_id);
899
+ }
900
+ // 验证失败:标记链接为 revoked,保留记录使商品无法直接上架
901
+ db.prepare(`UPDATE product_external_links SET revoked=1, verify_note='验证失败:验证码未在原链接中确认' WHERE product_id=? AND url=? AND verified=0`)
902
+ .run(task.product_id, task.url);
903
+ }
904
+ }
905
+ // ── 卖家确认:已在原平台添加验证码,任务进入分配池
906
+ app.post('/api/verify-tasks/:id/confirm', (req, res) => {
907
+ const user = auth(req, res);
908
+ if (!user)
909
+ return;
910
+ const task = db.prepare(`SELECT * FROM verify_tasks WHERE id = ? AND status IN ('code_issued','open')`).get(req.params.id);
911
+ if (!task)
912
+ return void res.json({ error: '任务不存在或已结束' });
913
+ const product = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(task.product_id);
914
+ if (!product || product.seller_id !== user.id)
915
+ return void res.status(403).json({ error: '无权限' });
916
+ if (task.status === 'open') {
917
+ return void res.json({ success: true, already_open: true, message: '任务已在验证中,无需重复确认' });
918
+ }
919
+ db.prepare(`UPDATE verify_tasks SET status='open' WHERE id=?`).run(req.params.id);
920
+ try {
921
+ assignVerifiers(req.params.id);
922
+ }
923
+ catch { }
924
+ res.json({ success: true, message: '任务已提交到验证池,等待审核员确认' });
925
+ });
926
+ // ── 验证者:查看分配给我的任务
927
+ // 卖家:查询某商品的进行中验证任务(供编辑页展示验证码)
928
+ app.get('/api/verify-tasks/by-product/:productId', (req, res) => {
929
+ const user = auth(req, res);
930
+ if (!user)
931
+ return;
932
+ const product = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(req.params.productId);
933
+ if (!product || product.seller_id !== user.id)
934
+ return void res.status(403).json({ error: '无权限' });
935
+ const tasks = db.prepare(`
936
+ SELECT id, type, url, code, status, expires_at, created_at,
937
+ (SELECT COUNT(*) FROM verify_submissions WHERE task_id = verify_tasks.id AND submitted_at IS NOT NULL) as submissions_done
938
+ FROM verify_tasks WHERE product_id = ? AND status IN ('code_issued','open') ORDER BY created_at DESC
939
+ `).all(req.params.productId);
940
+ res.json(tasks);
941
+ });
942
+ // 卖家:查询我发起的所有认领任务(用于"查看任务进度"页)
943
+ app.get('/api/verify-tasks/my-claims', (req, res) => {
944
+ const user = auth(req, res);
945
+ if (!user)
946
+ return;
947
+ const tasks = db.prepare(`
948
+ SELECT vt.id, vt.type, vt.url, vt.code, vt.status, vt.result,
949
+ vt.verifiers_needed, vt.expires_at, vt.created_at, vt.settled_at,
950
+ p.title as product_title, p.id as product_id,
951
+ (SELECT COUNT(*) FROM verify_submissions WHERE task_id=vt.id AND submitted_at IS NOT NULL) as submissions_done
952
+ FROM verify_tasks vt
953
+ JOIN products p ON vt.product_id = p.id
954
+ WHERE p.seller_id = ?
955
+ ORDER BY vt.created_at DESC
956
+ LIMIT 30
957
+ `).all(user.id);
958
+ res.json(tasks);
959
+ });
960
+ app.get('/api/verify-tasks/mine', (req, res) => {
961
+ const user = auth(req, res);
962
+ if (!user)
963
+ return;
964
+ const tasks = db.prepare(`
965
+ SELECT vt.id, vt.type, vt.url, vt.verifiers_needed, vt.reward_per_verifier, vt.expires_at,
966
+ vs.id as sub_id, vs.submitted_at, vs.verdict,
967
+ (SELECT COUNT(*) FROM verify_submissions WHERE task_id = vt.id AND submitted_at IS NOT NULL) as submissions_done
968
+ FROM verify_tasks vt
969
+ JOIN verify_submissions vs ON vs.task_id = vt.id AND vs.verifier_id = ?
970
+ WHERE vt.status = 'open'
971
+ ORDER BY vt.created_at DESC
972
+ `).all(user.id);
973
+ const stats = getVerifierStats(user.id);
974
+ res.json({ tasks, stats });
975
+ });
976
+ // ── 验证者:提交验证结果(填入式)
977
+ app.post('/api/verify-tasks/:id/submit', (req, res) => {
978
+ const user = auth(req, res);
979
+ if (!user)
980
+ return;
981
+ const { submission } = req.body; // 验证者填入的字符串(看到什么填什么)
982
+ const sub = db.prepare(`SELECT * FROM verify_submissions WHERE task_id = ? AND verifier_id = ?`)
983
+ .get(req.params.id, user.id);
984
+ if (!sub)
985
+ return void res.json({ error: '未分配到此任务' });
986
+ if (sub.submitted_at)
987
+ return void res.json({ error: '已提交过' });
988
+ const task = db.prepare('SELECT * FROM verify_tasks WHERE id = ? AND status = ?').get(req.params.id, 'open');
989
+ if (!task)
990
+ return void res.json({ error: '任务已结束或不存在' });
991
+ if (new Date(task.expires_at) < new Date())
992
+ return void res.json({ error: '任务已过期' });
993
+ // 保存提交(提交前不能看到 code,只存原始填入内容)
994
+ db.prepare(`UPDATE verify_submissions SET submission=?, submitted_at=datetime('now') WHERE task_id=? AND verifier_id=?`)
995
+ .run((submission ?? '').trim(), req.params.id, user.id);
996
+ // 检查是否已达到所需提交数 → 结算
997
+ const doneCount = db.prepare(`SELECT COUNT(*) as n FROM verify_submissions WHERE task_id = ? AND submitted_at IS NOT NULL`).get(req.params.id).n;
998
+ if (doneCount >= task.verifiers_needed)
999
+ settleTask(req.params.id);
1000
+ res.json({ success: true, message: '提交成功,等待其他验证者完成后自动结算' });
1001
+ });
1002
+ // ── 验证者:我的验证统计
1003
+ app.get('/api/verify-stats', (req, res) => {
1004
+ const user = auth(req, res);
1005
+ if (!user)
1006
+ return;
1007
+ res.json(getVerifierStats(user.id));
1008
+ });
1009
+ // ─── MCP 遥测:ingest + 管理员看板 ──────────────────────────────
1010
+ const TELEMETRY_RATE = new Map();
1011
+ function rateLimitOk(ip, max = 200, windowMs = 60_000) {
1012
+ const now = Date.now();
1013
+ const times = (TELEMETRY_RATE.get(ip) ?? []).filter((t) => now - t < windowMs);
1014
+ if (times.length >= max)
1015
+ return false;
1016
+ times.push(now);
1017
+ TELEMETRY_RATE.set(ip, times);
1018
+ return true;
1019
+ }
1020
+ app.post('/api/mcp-telemetry', (req, res) => {
1021
+ const ip = req.ip || 'unknown';
1022
+ if (!rateLimitOk(ip))
1023
+ return void res.status(429).json({ error: 'rate-limited' });
1024
+ const { tool_name, outcome, latency_ms, user_id_hash, server_version } = req.body ?? {};
1025
+ if (typeof tool_name !== 'string' || tool_name.length === 0 || tool_name.length > 64) {
1026
+ return void res.status(400).json({ error: 'bad tool_name' });
1027
+ }
1028
+ if (outcome !== 'success' && outcome !== 'error') {
1029
+ return void res.status(400).json({ error: 'bad outcome' });
1030
+ }
1031
+ const lat = Number(latency_ms);
1032
+ if (!Number.isFinite(lat) || lat < 0 || lat > 60_000) {
1033
+ return void res.status(400).json({ error: 'bad latency' });
1034
+ }
1035
+ const uih = typeof user_id_hash === 'string' && /^[0-9a-f]{1,32}$/.test(user_id_hash) ? user_id_hash : null;
1036
+ const sv = typeof server_version === 'string' && server_version.length <= 32 ? server_version : null;
1037
+ try {
1038
+ db.prepare(`
1039
+ INSERT INTO mcp_tool_calls (tool_name, user_id_hash, server_version, outcome, latency_ms)
1040
+ VALUES (?, ?, ?, ?, ?)
1041
+ `).run(tool_name, uih, sv, outcome, Math.round(lat));
1042
+ }
1043
+ catch { /* swallow — never fail telemetry */ }
1044
+ res.json({ ok: true });
1045
+ });
1046
+ app.get('/api/admin/usage', (req, res) => {
1047
+ if (!adminAuth(req, res))
1048
+ return;
1049
+ const total = db.prepare(`SELECT COUNT(*) as n FROM mcp_tool_calls`).get();
1050
+ const total24h = db.prepare(`SELECT COUNT(*) as n FROM mcp_tool_calls WHERE ts > datetime('now','-1 day')`).get();
1051
+ const total7d = db.prepare(`SELECT COUNT(*) as n FROM mcp_tool_calls WHERE ts > datetime('now','-7 day')`).get();
1052
+ const totalUsers = db.prepare(`SELECT COUNT(DISTINCT user_id_hash) as n FROM mcp_tool_calls WHERE user_id_hash IS NOT NULL`).get();
1053
+ const wau7d = db.prepare(`SELECT COUNT(DISTINCT user_id_hash) as n FROM mcp_tool_calls WHERE user_id_hash IS NOT NULL AND ts > datetime('now','-7 day')`).get();
1054
+ const dau24h = db.prepare(`SELECT COUNT(DISTINCT user_id_hash) as n FROM mcp_tool_calls WHERE user_id_hash IS NOT NULL AND ts > datetime('now','-1 day')`).get();
1055
+ const byTool = db.prepare(`
1056
+ SELECT tool_name,
1057
+ COUNT(*) AS calls,
1058
+ SUM(CASE WHEN outcome='error' THEN 1 ELSE 0 END) AS errors,
1059
+ ROUND(AVG(latency_ms), 0) AS avg_latency_ms
1060
+ FROM mcp_tool_calls WHERE ts > datetime('now','-7 day')
1061
+ GROUP BY tool_name ORDER BY calls DESC
1062
+ `).all();
1063
+ const byDay = db.prepare(`
1064
+ SELECT substr(ts, 1, 10) AS day,
1065
+ COUNT(*) AS calls,
1066
+ COUNT(DISTINCT user_id_hash) AS distinct_users
1067
+ FROM mcp_tool_calls WHERE ts > datetime('now','-14 day')
1068
+ GROUP BY day ORDER BY day
1069
+ `).all();
1070
+ const byVersion = db.prepare(`
1071
+ SELECT server_version,
1072
+ COUNT(*) AS calls,
1073
+ COUNT(DISTINCT user_id_hash) AS distinct_users
1074
+ FROM mcp_tool_calls WHERE ts > datetime('now','-7 day')
1075
+ GROUP BY server_version ORDER BY calls DESC
1076
+ `).all();
1077
+ res.json({
1078
+ summary: {
1079
+ total_calls: total.n,
1080
+ total_calls_24h: total24h.n,
1081
+ total_calls_7d: total7d.n,
1082
+ distinct_users_all: totalUsers.n,
1083
+ dau_24h: dau24h.n,
1084
+ wau_7d: wau7d.n,
1085
+ },
1086
+ by_tool_7d: byTool,
1087
+ by_day_14d: byDay,
1088
+ by_version_7d: byVersion,
1089
+ });
1090
+ });
1091
+ // ─── 管理端点(验证员白名单 & 内部审核账号)─────────────────────
1092
+ // 获取内部审核账号信息
1093
+ app.get('/api/admin/auditor', (req, res) => {
1094
+ const user = auth(req, res);
1095
+ if (!user)
1096
+ return;
1097
+ const auditor = db.prepare('SELECT id, name, api_key, created_at FROM users WHERE id = ?')
1098
+ .get(INTERNAL_AUDITOR_ID);
1099
+ if (!auditor)
1100
+ return void res.json({ error: '内部审核账号未初始化' });
1101
+ res.json({ id: auditor.id, name: auditor.name, api_key: auditor.api_key });
1102
+ });
1103
+ // 白名单列表
1104
+ app.get('/api/admin/verifier-whitelist', (req, res) => {
1105
+ const user = auth(req, res);
1106
+ if (!user)
1107
+ return;
1108
+ const list = db.prepare(`
1109
+ SELECT vw.user_id, vw.added_at, vw.note, u.name, u.role
1110
+ FROM verifier_whitelist vw
1111
+ JOIN users u ON u.id = vw.user_id
1112
+ ORDER BY vw.added_at ASC
1113
+ `).all();
1114
+ res.json(list);
1115
+ });
1116
+ // 添加到白名单(按 user_id 或 name 查找)
1117
+ app.post('/api/admin/verifier-whitelist', (req, res) => {
1118
+ const user = auth(req, res);
1119
+ if (!user)
1120
+ return;
1121
+ const { user_id, name, note } = req.body;
1122
+ let targetId = user_id;
1123
+ if (!targetId && name) {
1124
+ const found = db.prepare('SELECT id FROM users WHERE name = ?').get(name);
1125
+ if (!found)
1126
+ return void res.json({ error: `用户「${name}」不存在` });
1127
+ targetId = found.id;
1128
+ }
1129
+ if (!targetId)
1130
+ return void res.json({ error: '请提供 user_id 或 name' });
1131
+ const target = db.prepare('SELECT id, name FROM users WHERE id = ?').get(targetId);
1132
+ if (!target)
1133
+ return void res.json({ error: '用户不存在' });
1134
+ db.prepare('INSERT OR IGNORE INTO verifier_whitelist (user_id, note) VALUES (?, ?)').run(targetId, note ?? null);
1135
+ res.json({ success: true, user_id: targetId, name: target.name });
1136
+ });
1137
+ // 从白名单移除
1138
+ app.delete('/api/admin/verifier-whitelist/:userId', (req, res) => {
1139
+ const user = auth(req, res);
1140
+ if (!user)
1141
+ return;
1142
+ if (req.params.userId === INTERNAL_AUDITOR_ID)
1143
+ return void res.json({ error: '内部审核员不可移除' });
1144
+ db.prepare('DELETE FROM verifier_whitelist WHERE user_id = ?').run(req.params.userId);
1145
+ res.json({ success: true });
1146
+ });
1147
+ // ── 公开:验证任务大厅(对合格用户展示未满的任务,隐藏卖家信息)
1148
+ app.get('/api/verify-tasks/open', (req, res) => {
1149
+ const user = auth(req, res);
1150
+ if (!user)
1151
+ return;
1152
+ // 只看分配给我但未提交的
1153
+ const tasks = db.prepare(`
1154
+ SELECT vt.id, vt.type, vt.url, vt.reward_per_verifier, vt.expires_at,
1155
+ (SELECT COUNT(*) FROM verify_submissions WHERE task_id=vt.id AND submitted_at IS NOT NULL) as done,
1156
+ vt.verifiers_needed
1157
+ FROM verify_tasks vt
1158
+ JOIN verify_submissions vs ON vs.task_id = vt.id AND vs.verifier_id = ? AND vs.submitted_at IS NULL
1159
+ WHERE vt.status = 'open'
1160
+ ORDER BY vt.created_at ASC
1161
+ LIMIT 10
1162
+ `).all(user.id);
1163
+ res.json(tasks);
1164
+ });
1165
+ // 链接冲突验证 — 检查页面是否包含验证码
1166
+ app.post('/api/link-challenges/:id/verify', async (req, res) => {
1167
+ const user = auth(req, res);
1168
+ if (!user)
1169
+ return;
1170
+ const challenge = db.prepare(`SELECT * FROM link_challenges WHERE id = ? AND status = 'pending'`)
1171
+ .get(req.params.id);
1172
+ if (!challenge)
1173
+ return void res.json({ error: '验证码不存在或已失效' });
1174
+ if (challenge.product_id !== undefined) {
1175
+ const prod = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(challenge.product_id);
1176
+ if (!prod || prod.seller_id !== user.id)
1177
+ return void res.status(403).json({ error: '无权限' });
1178
+ }
1179
+ if (new Date(challenge.expires_at) < new Date()) {
1180
+ db.prepare(`UPDATE link_challenges SET status='expired' WHERE id = ?`).run(req.params.id);
1181
+ return void res.json({ error: '验证码已过期(48小时有效),请重新添加链接' });
1182
+ }
1183
+ const fullCode = `WebAZ-${challenge.code}`;
1184
+ try {
1185
+ const ctrl = new AbortController();
1186
+ setTimeout(() => ctrl.abort(), 10000);
1187
+ const resp = await fetch(challenge.url, { signal: ctrl.signal, headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'zh-CN,zh' } });
1188
+ const html = await resp.text();
1189
+ if (!html.includes(fullCode)) {
1190
+ return void res.json({ error: `页面中未找到验证码 "${fullCode}",请确认已保存到商品标题或描述中` });
1191
+ }
1192
+ }
1193
+ catch (e) {
1194
+ return void res.json({ error: `无法访问页面:${e.message}` });
1195
+ }
1196
+ // 验证通过:将旧链接转移到新商品
1197
+ db.prepare(`UPDATE product_external_links SET product_id = ?, verify_note = '通过挑战验证,从原商品转移', verified_at = datetime('now') WHERE url = ?`)
1198
+ .run(challenge.product_id, challenge.url);
1199
+ db.prepare(`UPDATE link_challenges SET status='verified', verified_at=datetime('now') WHERE id=?`).run(req.params.id);
1200
+ res.json({ success: true, message: `验证成功!链接已转移到此商品。` });
1201
+ });
1202
+ // 编辑商品
1203
+ app.put('/api/products/:id', (req, res) => {
1204
+ const user = auth(req, res);
1205
+ if (!user)
1206
+ return;
1207
+ const product = db.prepare('SELECT * FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
1208
+ if (!product)
1209
+ return void res.status(404).json({ error: '商品不存在或无权限' });
1210
+ const { title, description, price, stock, specs, brand, model, handling_hours, ship_regions, estimated_days, fragile, return_days, return_condition, warranty_days, } = req.body;
1211
+ const now = new Date().toISOString();
1212
+ const specsJson = specs != null ? (typeof specs === 'object' ? JSON.stringify(specs) : specs) : product.specs;
1213
+ const estJson = estimated_days != null ? (typeof estimated_days === 'object' ? JSON.stringify(estimated_days) : String(estimated_days)) : product.estimated_days;
1214
+ const newTitle = title ?? product.title;
1215
+ const newDesc = description ?? product.description;
1216
+ const newPrice = price != null ? Number(price) : product.price;
1217
+ const newHandling = handling_hours != null ? Number(handling_hours) : product.handling_hours;
1218
+ const newShipRegions = ship_regions ?? product.ship_regions;
1219
+ const newEstDays = estJson;
1220
+ const newReturnDays = return_days != null ? Number(return_days) : product.return_days;
1221
+ const newReturnCond = return_condition ?? product.return_condition;
1222
+ const newWarranty = warranty_days != null ? Number(warranty_days) : product.warranty_days;
1223
+ const newFragile = fragile != null ? (fragile ? 1 : 0) : product.fragile;
1224
+ const pFields = { ship_regions: newShipRegions, handling_hours: newHandling, estimated_days: newEstDays, return_days: newReturnDays, return_condition: newReturnCond, warranty_days: newWarranty };
1225
+ db.prepare(`UPDATE products SET
1226
+ title=?, description=?, price=?, stock=?,
1227
+ specs=?, brand=?, model=?, handling_hours=?, ship_regions=?,
1228
+ estimated_days=?, fragile=?, return_days=?, return_condition=?, warranty_days=?,
1229
+ commitment_hash=?, description_hash=?, price_hash=?, hashed_at=?,
1230
+ updated_at=datetime('now')
1231
+ WHERE id=?`).run(newTitle, newDesc, newPrice, stock != null ? Number(stock) : product.stock, specsJson, brand ?? product.brand, model ?? product.model, newHandling, newShipRegions, newEstDays, newFragile, newReturnDays, newReturnCond, newWarranty, makeCommitmentHash(pFields), makeDescriptionHash({ title: newTitle, description: newDesc, specs: specsJson }), makePriceHash(newPrice, now), now, req.params.id);
1232
+ res.json({ success: true });
1233
+ });
1234
+ // 卖家:修改商品状态(上架 / 下架到仓库 / 移入回收箱)
1235
+ app.patch('/api/products/:id/status', (req, res) => {
1236
+ const user = auth(req, res);
1237
+ if (!user)
1238
+ return;
1239
+ const { status } = req.body;
1240
+ if (!['active', 'warehouse', 'deleted'].includes(status))
1241
+ return void res.json({ error: '无效状态值' });
1242
+ const product = db.prepare('SELECT id FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
1243
+ if (!product)
1244
+ return void res.status(404).json({ error: '商品不存在或无权限' });
1245
+ if (status === 'active') {
1246
+ const pendingTask = db.prepare(`SELECT id FROM verify_tasks WHERE product_id=? AND status IN ('code_issued','open')`).get(req.params.id);
1247
+ if (pendingTask)
1248
+ return void res.json({ error: '链接核验进行中,请等待验证结果后再上架' });
1249
+ const hasRevoked = db.prepare(`SELECT id FROM product_external_links WHERE product_id=? AND revoked=1`).get(req.params.id);
1250
+ const hasValid = db.prepare(`SELECT id FROM product_external_links WHERE product_id=? AND verified=1 AND (revoked IS NULL OR revoked=0)`).get(req.params.id);
1251
+ if (hasRevoked && !hasValid)
1252
+ return void res.json({ error: '所有外部链接已失效(主权失效),请先添加新链接后再上架' });
1253
+ }
1254
+ db.prepare(`UPDATE products SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, req.params.id);
1255
+ res.json({ success: true });
1256
+ });
1257
+ // 卖家:彻底删除商品(仅限回收箱状态)
1258
+ app.delete('/api/products/:id', (req, res) => {
1259
+ const user = auth(req, res);
1260
+ if (!user)
1261
+ return;
1262
+ const product = db.prepare('SELECT * FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
1263
+ if (!product)
1264
+ return void res.status(404).json({ error: '商品不存在或无权限' });
1265
+ if (product.status !== 'deleted')
1266
+ return void res.json({ error: '请先将商品移入回收箱' });
1267
+ const activeOrders = db.prepare(`
1268
+ SELECT COUNT(*) as n FROM orders WHERE product_id = ? AND status NOT IN ('completed','cancelled','refunded','expired')
1269
+ `).get(req.params.id);
1270
+ if (activeOrders.n > 0)
1271
+ return void res.json({ error: '该商品有进行中的订单,暂无法删除' });
1272
+ db.prepare('DELETE FROM product_external_links WHERE product_id = ?').run(req.params.id);
1273
+ db.prepare('DELETE FROM products WHERE id = ?').run(req.params.id);
1274
+ res.json({ success: true });
1275
+ });
1276
+ // 初始化导入次数追踪表
1277
+ db.exec(`
1278
+ CREATE TABLE IF NOT EXISTS import_logs (
1279
+ id TEXT PRIMARY KEY,
1280
+ user_id TEXT NOT NULL,
1281
+ created_at TEXT DEFAULT (datetime('now'))
1282
+ )
1283
+ `);
1284
+ const FREE_IMPORT_LIMIT = 10;
1285
+ // 一键导入商品(smart_import)
1286
+ app.post('/api/import-product', async (req, res) => {
1287
+ const user = auth(req, res);
1288
+ if (!user)
1289
+ return;
1290
+ if (user.role !== 'seller')
1291
+ return void res.json({ error: '仅卖家可使用导入功能' });
1292
+ const { url, user_api_key } = req.body;
1293
+ if (!url)
1294
+ return void res.json({ error: '请提供商品链接' });
1295
+ // ── 链接认领检查(解析前,不浪费 AI 配额)─────────────────────
1296
+ // 同一卖家已上架此链接 → 直接拒绝
1297
+ const selfClaim = db.prepare(`
1298
+ SELECT p.id as product_id, p.title FROM product_external_links pel
1299
+ JOIN products p ON pel.product_id = p.id
1300
+ WHERE pel.url = ? AND p.seller_id = ?
1301
+ `).get(url, user.id);
1302
+ if (selfClaim) {
1303
+ return void res.json({ error: `您已上架过来自此链接的商品「${selfClaim.title}」,不能重复关联相同外部链接` });
1304
+ }
1305
+ // 其他卖家已认领(verified=1)→ 返回冲突,前端跳转认领流程
1306
+ const otherClaim = db.prepare(`
1307
+ SELECT p.id as product_id FROM product_external_links pel
1308
+ JOIN products p ON pel.product_id = p.id
1309
+ WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
1310
+ `).get(url, user.id);
1311
+ if (otherClaim) {
1312
+ return void res.json({
1313
+ conflict: true,
1314
+ url,
1315
+ message: '此链接已被其他商家认领上架。如需认领归属,请发起链接认领验证任务。',
1316
+ });
1317
+ }
1318
+ // 检查每日额度(用自己 Key 则跳过)
1319
+ const usingOwnKey = typeof user_api_key === 'string' && user_api_key.trim().startsWith('sk-ant-');
1320
+ if (!usingOwnKey) {
1321
+ const todayCount = db.prepare(`SELECT COUNT(*) as cnt FROM import_logs WHERE user_id = ? AND created_at >= datetime('now', '-1 day')`).get(user.id).cnt;
1322
+ if (todayCount >= FREE_IMPORT_LIMIT) {
1323
+ return void res.json({
1324
+ error: `今日免费导入次数已用完(${FREE_IMPORT_LIMIT} 次/天)。请在导入面板填入你自己的 Anthropic API Key 以继续使用。`,
1325
+ quota_exceeded: true,
1326
+ used: todayCount,
1327
+ limit: FREE_IMPORT_LIMIT,
1328
+ });
1329
+ }
1330
+ }
1331
+ // 抓取页面 HTML
1332
+ let html = '';
1333
+ try {
1334
+ const controller = new AbortController();
1335
+ const timer = setTimeout(() => controller.abort(), 10000);
1336
+ const resp = await fetch(url, {
1337
+ signal: controller.signal,
1338
+ headers: {
1339
+ 'User-Agent': 'Mozilla/5.0 (compatible; WebAZ/1.0; +https://webaz.xyz)',
1340
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
1341
+ },
1342
+ });
1343
+ clearTimeout(timer);
1344
+ const raw = await resp.text();
1345
+ html = raw.slice(0, 30000);
1346
+ }
1347
+ catch (e) {
1348
+ const msg = e instanceof Error ? e.message : String(e);
1349
+ return void res.json({ error: `无法访问该链接:${msg}` });
1350
+ }
1351
+ // 查询 WebAZ 同类商品均价(用于定价建议)
1352
+ const avgPrices = db.prepare(`
1353
+ SELECT category, AVG(price) as avg_price, MIN(price) as min_price, MAX(price) as max_price, COUNT(*) as cnt
1354
+ FROM products WHERE status = 'active' GROUP BY category
1355
+ `).all();
1356
+ const priceContext = avgPrices.map(r => `${r.category || '未分类'}:均价 ${r.avg_price?.toFixed(0)} WAZ,最低 ${r.min_price} WAZ,最高 ${r.max_price} WAZ(${r.cnt} 件商品)`).join('\n');
1357
+ // 调用 Claude 提取结构化商品数据
1358
+ const client = usingOwnKey
1359
+ ? new Anthropic({ apiKey: user_api_key.trim() })
1360
+ : anthropic;
1361
+ let extracted;
1362
+ try {
1363
+ const message = await client.messages.create({
1364
+ model: 'claude-haiku-4-5-20251001',
1365
+ max_tokens: 1024,
1366
+ messages: [{
1367
+ role: 'user',
1368
+ content: `你是一个电商商品信息提取助手,服务于 AI Agent 商业协议平台。从以下网页 HTML 中提取商品信息,返回精简结构化 JSON。
1369
+
1370
+ 网页来源 URL:${url}
1371
+
1372
+ WebAZ 平台各类目价格参考(WAZ ≈ CNY):
1373
+ ${priceContext || '暂无参考数据'}
1374
+
1375
+ 只返回 JSON,不要其他文字:
1376
+ {
1377
+ "title": "商品标题(简洁,50字以内)",
1378
+ "description": "面向 AI Agent 的商品描述:核心参数+适用场景,100字以内,无营销话术",
1379
+ "specs": {"规格名":"规格值"},
1380
+ "brand": "品牌(找不到填null)",
1381
+ "model": "型号或规格编号(找不到填null)",
1382
+ "original_price": 原平台价格数字(CNY,找不到填null),
1383
+ "suggested_price": 建议WAZ定价(参考原价和平台均价,有竞争力),
1384
+ "price_reasoning": "定价理由(1句)",
1385
+ "category": "茶具/家居/食品/服装/手工/电子(其他填空)",
1386
+ "stock": 建议库存(默认1),
1387
+ "weight_kg": 重量数字(找不到填null),
1388
+ "handling_hours": 备货时间小时数(默认24),
1389
+ "ship_regions": "全国",
1390
+ "estimated_days": {"华东":2,"全国":5},
1391
+ "return_days": 退货天数(默认7),
1392
+ "return_condition": "退货条件(如未拆封/任意原因)",
1393
+ "warranty_days": 质保天数(默认0),
1394
+ "fragile": false,
1395
+ "tags": ["标签1","标签2"]
1396
+ }
1397
+
1398
+ HTML(前30000字符):
1399
+ ${html}`,
1400
+ }],
1401
+ });
1402
+ const text = message.content[0].type === 'text' ? message.content[0].text : '';
1403
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
1404
+ if (!jsonMatch)
1405
+ throw new Error('未能提取 JSON');
1406
+ extracted = JSON.parse(jsonMatch[0]);
1407
+ }
1408
+ catch (e) {
1409
+ const msg = e instanceof Error ? e.message : String(e);
1410
+ return void res.json({ error: `AI 解析失败:${msg}` });
1411
+ }
1412
+ // 验证提取结果有效(防止页面需要登录导致返回空内容)
1413
+ const title = typeof extracted.title === 'string' ? extracted.title.trim() : '';
1414
+ const description = typeof extracted.description === 'string' ? extracted.description.trim() : '';
1415
+ if (!title || title.length < 2) {
1416
+ return void res.json({
1417
+ error: '该链接无法提取商品信息(可能需要登录、或为动态渲染页面)。建议使用京东/亚马逊/独立站链接,或改用手动上架。',
1418
+ suggestion: 'manual',
1419
+ });
1420
+ }
1421
+ if (!description || description.length < 5) {
1422
+ extracted.description = title; // 至少用标题填充描述
1423
+ }
1424
+ // 记录本次使用(仅平台 Key 计入额度)
1425
+ if (!usingOwnKey) {
1426
+ db.prepare(`INSERT INTO import_logs (id, user_id) VALUES (?, ?)`).run(generateId('iml'), user.id);
1427
+ }
1428
+ // 查询今日剩余次数
1429
+ const usedToday = usingOwnKey ? 0 : db.prepare(`SELECT COUNT(*) as cnt FROM import_logs WHERE user_id = ? AND created_at >= datetime('now', '-1 day')`).get(user.id).cnt;
1430
+ res.json({
1431
+ success: true,
1432
+ source_url: url,
1433
+ source_price: extracted.original_price ?? null,
1434
+ used_own_key: usingOwnKey,
1435
+ quota: usingOwnKey ? null : { used: usedToday, limit: FREE_IMPORT_LIMIT, remaining: FREE_IMPORT_LIMIT - usedToday },
1436
+ ...extracted,
1437
+ });
1438
+ });
1439
+ // 链接认领验证 — 卖家对已被他人认领的外部链接发起所有权验证
1440
+ app.post('/api/claim-url', (req, res) => {
1441
+ const user = auth(req, res);
1442
+ if (!user)
1443
+ return;
1444
+ if (user.role !== 'seller')
1445
+ return void res.json({ error: '仅卖家可发起认领' });
1446
+ const { url, title, description, price, stock = 1, category = '', specs, handling_hours = 24, return_days = 7, warranty_days = 0, } = req.body;
1447
+ if (!url || !title || !description || !price) {
1448
+ return void res.json({ error: '请填写链接、商品名、描述和价格' });
1449
+ }
1450
+ // 再次确认链接确实被他人认领(防止并发)
1451
+ const otherClaim = db.prepare(`
1452
+ SELECT p.id FROM product_external_links pel
1453
+ JOIN products p ON pel.product_id = p.id
1454
+ WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
1455
+ `).get(url, user.id);
1456
+ if (!otherClaim) {
1457
+ return void res.json({ error: '该链接当前没有其他商家认领,请直接使用导入上架功能' });
1458
+ }
1459
+ // 同卖家已有认领任务 → 不重复创建
1460
+ const existingClaim = db.prepare(`
1461
+ SELECT vt.id FROM verify_tasks vt
1462
+ JOIN products p ON vt.product_id = p.id
1463
+ WHERE vt.url = ? AND p.seller_id = ? AND vt.status IN ('code_issued','open')
1464
+ `).get(url, user.id);
1465
+ if (existingClaim) {
1466
+ return void res.json({ error: '您已有针对此链接的进行中认领任务,请在商品编辑页查看并确认', task_id: existingClaim.id });
1467
+ }
1468
+ // 检查钱包余额
1469
+ const VERIFIERS_NEEDED = 1;
1470
+ const REWARD_EACH = 0.1;
1471
+ const feeLocked = VERIFIERS_NEEDED * REWARD_EACH;
1472
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
1473
+ const priceNum = Number(price);
1474
+ const stakeDiscount = getStakeDiscount(db, user.id);
1475
+ const stakeRate = Math.max(0.05, 0.15 - stakeDiscount);
1476
+ const stakeAmount = Math.round(priceNum * stakeRate * 100) / 100;
1477
+ if (wallet.balance < stakeAmount + feeLocked) {
1478
+ return void res.json({ error: `余额不足:需要 ${stakeAmount} WAZ 质押 + ${feeLocked} WAZ 验证费,当前余额 ${wallet.balance} WAZ` });
1479
+ }
1480
+ // 创建商品
1481
+ const now = new Date().toISOString();
1482
+ const productId = generateId('prd');
1483
+ const specsJson = specs ? (typeof specs === 'string' ? specs : JSON.stringify(specs)) : null;
1484
+ const pFields = { ship_regions: '全国', handling_hours, estimated_days: null, return_days, return_condition: '', warranty_days };
1485
+ db.prepare(`INSERT INTO products (
1486
+ id, seller_id, title, description, price, stock, category, stake_amount,
1487
+ specs, source_url, handling_hours, return_days, warranty_days,
1488
+ commitment_hash, description_hash, price_hash, hashed_at, status
1489
+ ) 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);
1490
+ db.prepare(`UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?`)
1491
+ .run(stakeAmount, stakeAmount, user.id);
1492
+ // 插入未验证链接
1493
+ const linkId = generateId('lnk');
1494
+ db.prepare(`INSERT INTO product_external_links (id, product_id, url, source, verified, verify_note)
1495
+ VALUES (?,?,?,'claim',0,'认领验证进行中')`).run(linkId, productId, url);
1496
+ // 生成验证码 + 创建众包验证任务
1497
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
1498
+ const code = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
1499
+ const taskId = generateId('vtk');
1500
+ const expiresAt = new Date(Date.now() + 72 * 3600_000).toISOString();
1501
+ db.prepare(`INSERT INTO verify_tasks (id, type, product_id, url, code, verifiers_needed, reward_per_verifier, fee_locked, status, expires_at)
1502
+ VALUES (?,?,?,?,?,?,?,?,'code_issued',?)`).run(taskId, 'code_check', productId, url, code, VERIFIERS_NEEDED, REWARD_EACH, feeLocked, expiresAt);
1503
+ db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ?`).run(feeLocked, user.id);
1504
+ res.json({
1505
+ success: true,
1506
+ product_id: productId,
1507
+ task_id: taskId,
1508
+ code: `[${code}]`,
1509
+ expires_at: expiresAt,
1510
+ message: `商品已建立,认领任务已创建。请在原平台商品标题或描述中加入验证码 [${code}],完成后在商品编辑页点击「确认已添加」提交任务,审核通过后链接归属自动转移。`,
1511
+ });
1512
+ });
1513
+ // 智能下单 — agent 代用户比价后下单
1514
+ app.post('/api/agent-buy', async (req, res) => {
1515
+ const user = auth(req, res);
1516
+ if (!user)
1517
+ return;
1518
+ if (user.role !== 'buyer')
1519
+ return void res.json({ error: '仅买家可使用智能下单' });
1520
+ const { source_url, shipping_address, auto_buy = false, user_api_key } = req.body;
1521
+ if (!source_url)
1522
+ return void res.json({ error: '请提供商品链接' });
1523
+ if (auto_buy && !shipping_address)
1524
+ return void res.json({ error: '自动下单需提供收货地址' });
1525
+ // ① 抓取原商品页面
1526
+ let html = '';
1527
+ try {
1528
+ const ctrl = new AbortController();
1529
+ const timer = setTimeout(() => ctrl.abort(), 10000);
1530
+ const resp = await fetch(source_url, {
1531
+ signal: ctrl.signal,
1532
+ headers: {
1533
+ 'User-Agent': 'Mozilla/5.0 (compatible; WebAZ/1.0; +https://webaz.xyz)',
1534
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
1535
+ },
1536
+ });
1537
+ clearTimeout(timer);
1538
+ html = (await resp.text()).slice(0, 20000);
1539
+ }
1540
+ catch (e) {
1541
+ return void res.json({ error: `无法访问该链接:${e.message}` });
1542
+ }
1543
+ const client = (typeof user_api_key === 'string' && user_api_key.trim().startsWith('sk-ant-'))
1544
+ ? new Anthropic({ apiKey: user_api_key.trim() })
1545
+ : anthropic;
1546
+ // ② Claude 提取原商品关键信息(搜索词拆成独立短词数组)
1547
+ let source;
1548
+ try {
1549
+ const msg = await client.messages.create({
1550
+ model: 'claude-haiku-4-5-20251001',
1551
+ max_tokens: 512,
1552
+ messages: [{ role: 'user', content: `从以下网页提取商品关键信息,仅返回JSON:
1553
+ {
1554
+ "title": "商品全名",
1555
+ "price_cny": 数字或null,
1556
+ "category": "分类",
1557
+ "search_terms": ["独立短词1","独立短词2","独立短词3"]
1558
+ }
1559
+ search_terms 是3-5个独立的中文短词(每个2-4个汉字),用于在数据库里搜索同类商品。
1560
+ 例:九阳炒菜机器人 → ["炒菜机","九阳","炒菜机器人","自动炒菜"]
1561
+ HTML:${html}` }],
1562
+ });
1563
+ const text = msg.content[0].type === 'text' ? msg.content[0].text : '';
1564
+ const m = text.match(/\{[\s\S]*\}/);
1565
+ if (!m)
1566
+ throw new Error('no json');
1567
+ source = JSON.parse(m[0]);
1568
+ }
1569
+ catch {
1570
+ return void res.json({ error: '无法从链接提取商品信息,请尝试其他链接' });
1571
+ }
1572
+ if (!source.title)
1573
+ return void res.json({ error: '链接无法提取商品信息(可能需要登录或动态渲染)' });
1574
+ // ③ 搜索 WebAZ 同类商品
1575
+ // 优先:精确 URL 匹配(卖家已绑定该外部链接的商品,命中率 100%)
1576
+ const urlMatchIds = db.prepare(`
1577
+ SELECT DISTINCT product_id FROM product_external_links WHERE url = ? AND verified = 1
1578
+ `).all(source_url).map(r => r.product_id);
1579
+ const urlMatchProducts = urlMatchIds.length > 0
1580
+ ? db.prepare(`
1581
+ SELECT p.*, u.name as seller_name,
1582
+ COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
1583
+ FROM products p
1584
+ JOIN users u ON p.seller_id = u.id
1585
+ LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
1586
+ WHERE p.id IN (${urlMatchIds.map(() => '?').join(',')}) AND p.status = 'active' AND p.stock > 0
1587
+ `).all(...urlMatchIds)
1588
+ : [];
1589
+ // 兜底:关键词搜索(每个词独立 LIKE OR 连接)
1590
+ let keywordProducts = [];
1591
+ if (urlMatchProducts.length < 3) {
1592
+ const rawTerms = Array.isArray(source.search_terms) ? source.search_terms : [];
1593
+ if (rawTerms.length === 0) {
1594
+ const t = source.title;
1595
+ for (let i = 0; i + 2 <= t.length && rawTerms.length < 4; i += 2)
1596
+ rawTerms.push(t.slice(i, i + 4));
1597
+ }
1598
+ const terms = rawTerms.filter((t) => t && t.length >= 2).slice(0, 6);
1599
+ if (terms.length > 0) {
1600
+ const termClauses = terms.map(() => `p.title LIKE ? OR p.description LIKE ?`).join(' OR ');
1601
+ const termParams = terms.flatMap((t) => [`%${t}%`, `%${t}%`]);
1602
+ const catClause = source.category ? ` OR p.category = ?` : '';
1603
+ const catParam = source.category ? [source.category] : [];
1604
+ const alreadyIds = urlMatchProducts.map(p => p.id);
1605
+ const excludeClause = alreadyIds.length > 0 ? ` AND p.id NOT IN (${alreadyIds.map(() => '?').join(',')})` : '';
1606
+ keywordProducts = db.prepare(`
1607
+ SELECT p.*, u.name as seller_name,
1608
+ COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
1609
+ FROM products p
1610
+ JOIN users u ON p.seller_id = u.id
1611
+ LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
1612
+ WHERE p.status = 'active' AND p.stock > 0
1613
+ AND (${termClauses}${catClause})${excludeClause}
1614
+ ORDER BY rep_points DESC, p.price ASC LIMIT ${5 - urlMatchProducts.length}
1615
+ `).all(...termParams, ...catParam, ...alreadyIds);
1616
+ }
1617
+ }
1618
+ // URL 精确匹配排前,关键词结果补后
1619
+ const webazProducts = [...urlMatchProducts, ...keywordProducts];
1620
+ const webazFormatted = webazProducts.map(p => ({
1621
+ ...formatProductForAgent(p),
1622
+ url_match: urlMatchIds.includes(p.id), // 标记是否为精确匹配
1623
+ }));
1624
+ // ④ Claude 比价决策
1625
+ let decision;
1626
+ try {
1627
+ const msg = await client.messages.create({
1628
+ model: 'claude-haiku-4-5-20251001',
1629
+ max_tokens: 512,
1630
+ messages: [{ role: 'user', content: `你是一个购物助手。用户想买以下商品,我们找到了 WebAZ 平台上的替代选项,请做出购买建议。
1631
+
1632
+ 原商品:
1633
+ - 标题:${source.title}
1634
+ - 原平台价格:${source.price_cny ? `¥${source.price_cny} CNY` : '未知'}
1635
+ - 链接:${source_url}
1636
+
1637
+ WebAZ 平台替代方案(WAZ ≈ CNY):
1638
+ ${webazFormatted.length > 0 ? JSON.stringify(webazFormatted.map(p => ({
1639
+ id: p.id,
1640
+ title: p.title,
1641
+ price: p.price,
1642
+ agent_summary: p.agent_summary,
1643
+ seller: p.seller_name,
1644
+ rep: p.rep_level,
1645
+ })), null, 2) : '暂无匹配商品'}
1646
+
1647
+ 仅返回JSON(不要其他文字):
1648
+ {
1649
+ "recommendation": "buy_webaz" | "buy_source" | "no_match",
1650
+ "best_product_id": "WebAZ商品ID(recommendation=buy_webaz时填写,否则null)",
1651
+ "reason": "一句话购买建议,说明为什么选这个方案(包含价格对比、售后优势等)",
1652
+ "savings_note": "省了多少或更优在哪(简短,可null)"
1653
+ }` }],
1654
+ });
1655
+ const text = msg.content[0].type === 'text' ? msg.content[0].text : '';
1656
+ const m = text.match(/\{[\s\S]*\}/);
1657
+ if (!m)
1658
+ throw new Error('no json');
1659
+ decision = JSON.parse(m[0]);
1660
+ }
1661
+ catch {
1662
+ decision = { recommendation: 'no_match', reason: '无法完成比价分析,请手动选购' };
1663
+ }
1664
+ // ⑤ 自动下单(auto_buy=true 且推荐 WebAZ)
1665
+ let orderId = null;
1666
+ let sessionToken = null;
1667
+ let verifiedPrice = null;
1668
+ if (auto_buy && decision.recommendation === 'buy_webaz' && decision.best_product_id) {
1669
+ const product = db.prepare(`SELECT * FROM products WHERE id = ? AND status = 'active'`)
1670
+ .get(decision.best_product_id);
1671
+ if (product && product.stock > 0) {
1672
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
1673
+ if (wallet.balance >= product.price) {
1674
+ // verify price
1675
+ const now = new Date();
1676
+ const expiresAt = new Date(now.getTime() + 10 * 60_000);
1677
+ sessionToken = generateId('pst');
1678
+ db.prepare(`INSERT INTO price_sessions (token, product_id, user_id, price, quantity, created_at, expires_at) VALUES (?,?,?,?,1,?,?)`)
1679
+ .run(sessionToken, product.id, user.id, product.price, now.toISOString(), expiresAt.toISOString());
1680
+ verifiedPrice = product.price;
1681
+ // place order
1682
+ const oId = generateId('ord');
1683
+ const totalAmount = product.price;
1684
+ const seller = db.prepare('SELECT id FROM users WHERE id = ?').get(product.seller_id);
1685
+ db.prepare(`INSERT INTO orders (
1686
+ id, product_id, buyer_id, seller_id, quantity, unit_price, total_amount, escrow_amount,
1687
+ status, shipping_address, notes, pay_deadline, accept_deadline, ship_deadline,
1688
+ pickup_deadline, delivery_deadline, confirm_deadline
1689
+ ) VALUES (?,?,?,?,1,?,?,?,'created',?,?,?,?,?,?,?,?)`).run(oId, product.id, user.id, seller.id, totalAmount, totalAmount, totalAmount, shipping_address, `[智能下单] ${decision.reason}`, addHours(now, 24), addHours(now, 48), addHours(now, 120), addHours(now, 168), addHours(now, 336), addHours(now, 408));
1690
+ db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?')
1691
+ .run(totalAmount, totalAmount, user.id);
1692
+ db.prepare('UPDATE products SET stock = stock - 1 WHERE id = ?').run(product.id);
1693
+ db.prepare(`UPDATE price_sessions SET used_at = datetime('now') WHERE token = ?`).run(sessionToken);
1694
+ transition(db, oId, 'paid', user.id, [], '智能下单:模拟支付完成');
1695
+ notifyTransition(db, oId, 'created', 'paid');
1696
+ if (shouldAutoAccept(db, oId)) {
1697
+ const sys = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
1698
+ if (sys) {
1699
+ const ar = transition(db, oId, 'accepted', sys.id, [], '⚡ auto_accept Skill 自动接单');
1700
+ if (ar.success)
1701
+ notifyTransition(db, oId, 'paid', 'accepted');
1702
+ }
1703
+ }
1704
+ orderId = oId;
1705
+ }
1706
+ }
1707
+ }
1708
+ // 找到最佳 WebAZ 商品详情(用于展示)
1709
+ const bestProduct = decision.best_product_id
1710
+ ? webazFormatted.find(p => p.id === decision.best_product_id) ?? null
1711
+ : null;
1712
+ res.json({
1713
+ source: {
1714
+ title: source.title,
1715
+ price_cny: source.price_cny ?? null,
1716
+ url: source_url,
1717
+ },
1718
+ webaz_products: webazFormatted.slice(0, 3),
1719
+ recommendation: decision.recommendation,
1720
+ best_product: bestProduct,
1721
+ reason: decision.reason,
1722
+ savings_note: decision.savings_note ?? null,
1723
+ auto_bought: !!orderId,
1724
+ order_id: orderId,
1725
+ verified_price: verifiedPrice,
1726
+ });
184
1727
  });
185
1728
  // 我的订单(买家或卖家视角)
186
1729
  app.get('/api/orders', (req, res) => {
@@ -237,13 +1780,53 @@ app.get('/api/orders/:id', (req, res) => {
237
1780
  res.json({ ...statusInfo, history, product, dispute, trackingInfo });
238
1781
  });
239
1782
  // 下单
1783
+ // 价格验证 — agent 下单前锁定价格,返回 session_token(10分钟有效)
1784
+ app.post('/api/verify-price', (req, res) => {
1785
+ const user = auth(req, res);
1786
+ if (!user)
1787
+ return;
1788
+ const { product_id, quantity = 1 } = req.body;
1789
+ if (!product_id)
1790
+ return void res.json({ error: '请提供 product_id' });
1791
+ const product = db.prepare(`
1792
+ SELECT p.*, u.name as seller_name,
1793
+ COALESCE(rs.level, 'new') as rep_level
1794
+ FROM products p
1795
+ JOIN users u ON p.seller_id = u.id
1796
+ LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
1797
+ WHERE p.id = ? AND p.status = 'active'
1798
+ `).get(product_id);
1799
+ if (!product)
1800
+ return void res.json({ error: '商品不存在或已下架' });
1801
+ const qty = Number(quantity);
1802
+ if (product.stock < qty) {
1803
+ return void res.json({ error: `库存不足:当前库存 ${product.stock},请求数量 ${qty}` });
1804
+ }
1805
+ const now = new Date();
1806
+ const expiresAt = new Date(now.getTime() + 10 * 60_000); // 10分钟
1807
+ const token = generateId('pst'); // price session token
1808
+ db.prepare(`
1809
+ INSERT INTO price_sessions (token, product_id, user_id, price, quantity, created_at, expires_at)
1810
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1811
+ `).run(token, product_id, user.id, product.price, qty, now.toISOString(), expiresAt.toISOString());
1812
+ res.json({
1813
+ session_token: token,
1814
+ verified_price: product.price,
1815
+ quantity: qty,
1816
+ total: product.price * qty,
1817
+ product: formatProductForAgent(product),
1818
+ expires_at: expiresAt.toISOString(),
1819
+ expires_in_seconds: 600,
1820
+ note: '此价格在10分钟内有效。下单时传入 session_token 可保证此价格不变。',
1821
+ });
1822
+ });
240
1823
  app.post('/api/orders', (req, res) => {
241
1824
  const user = auth(req, res);
242
1825
  if (!user)
243
1826
  return;
244
1827
  if (user.role !== 'buyer')
245
1828
  return void res.json({ error: '仅买家可下单' });
246
- const { product_id, shipping_address, notes } = req.body;
1829
+ const { product_id, shipping_address, notes, session_token } = req.body;
247
1830
  if (!product_id || !shipping_address)
248
1831
  return void res.json({ error: '请提供商品ID和收货地址' });
249
1832
  const product = db.prepare(`SELECT p.*, u.id as seller_uid FROM products p
@@ -252,6 +1835,29 @@ app.post('/api/orders', (req, res) => {
252
1835
  return void res.json({ error: '商品不存在或已下架' });
253
1836
  if (product.stock < 1)
254
1837
  return void res.json({ error: '库存不足' });
1838
+ // 验证 session_token(如果提供)
1839
+ if (session_token) {
1840
+ const session = db.prepare(`
1841
+ SELECT * FROM price_sessions WHERE token = ? AND product_id = ? AND user_id = ?
1842
+ `).get(session_token, product_id, user.id);
1843
+ if (!session)
1844
+ return void res.json({ error: 'session_token 无效,请重新调用 verify-price' });
1845
+ if (session.used_at)
1846
+ return void res.json({ error: 'session_token 已使用,请重新调用 verify-price' });
1847
+ if (new Date(session.expires_at) < new Date()) {
1848
+ return void res.json({ error: 'session_token 已过期(10分钟有效),请重新调用 verify-price' });
1849
+ }
1850
+ // 价格变动检测
1851
+ if (session.price !== product.price) {
1852
+ return void res.json({
1853
+ error: 'price_changed',
1854
+ message: `商品价格已变动:验证时 ${session.price} WAZ,当前 ${product.price} WAZ`,
1855
+ new_price: product.price,
1856
+ hint: '请重新调用 verify-price 获取新价格',
1857
+ });
1858
+ }
1859
+ db.prepare(`UPDATE price_sessions SET used_at = datetime('now') WHERE token = ?`).run(session_token);
1860
+ }
255
1861
  const totalAmount = product.price;
256
1862
  const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
257
1863
  if (wallet.balance < totalAmount)
@@ -347,8 +1953,106 @@ app.get('/api/wallet', (req, res) => {
347
1953
  if (!user)
348
1954
  return;
349
1955
  const wallet = db.prepare('SELECT * FROM wallets WHERE user_id = ?').get(user.id);
1956
+ // 生成并缓存链上充值地址(首次调用时派生)
1957
+ if (!wallet.deposit_address) {
1958
+ const addr = deriveDepositAddress(user.id);
1959
+ db.prepare('UPDATE wallets SET deposit_address = ? WHERE user_id = ?').run(addr, user.id);
1960
+ wallet.deposit_address = addr;
1961
+ }
350
1962
  res.json(wallet);
351
1963
  });
1964
+ // 提现申请(Phase 1:记录申请,人工处理;Phase 2 自动链上转账)
1965
+ app.post('/api/wallet/withdraw', (req, res) => {
1966
+ const user = auth(req, res);
1967
+ if (!user)
1968
+ return;
1969
+ const { to_address, amount } = req.body;
1970
+ if (!/^0x[0-9a-fA-F]{40}$/.test(to_address ?? '')) {
1971
+ return void res.json({ error: '请输入有效的以太坊地址(0x 开头,42 位字符)' });
1972
+ }
1973
+ const amountNum = Number(amount);
1974
+ if (!amountNum || amountNum <= 0)
1975
+ return void res.json({ error: '请输入提现金额' });
1976
+ if (amountNum < 10)
1977
+ return void res.json({ error: '最低提现金额为 10 WAZ' });
1978
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
1979
+ if (wallet.balance < amountNum) {
1980
+ return void res.json({ error: `余额不足:当前可用 ${wallet.balance.toFixed(2)} WAZ` });
1981
+ }
1982
+ const wid = generateId('wdr');
1983
+ db.prepare(`INSERT INTO withdrawal_requests (id, user_id, to_address, amount) VALUES (?,?,?,?)`)
1984
+ .run(wid, user.id, to_address, amountNum);
1985
+ db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?').run(amountNum, user.id);
1986
+ res.json({
1987
+ success: true,
1988
+ request_id: wid,
1989
+ message: '提现申请已提交,将在 24 小时内到账。',
1990
+ });
1991
+ });
1992
+ // 我的提现记录
1993
+ app.get('/api/wallet/withdrawals', (req, res) => {
1994
+ const user = auth(req, res);
1995
+ if (!user)
1996
+ return;
1997
+ const list = db.prepare(`SELECT id, to_address, amount, status, created_at, tx_hash FROM withdrawal_requests WHERE user_id = ? ORDER BY created_at DESC LIMIT 10`).all(user.id);
1998
+ res.json(list);
1999
+ });
2000
+ // 我的充值记录
2001
+ app.get('/api/wallet/deposits', (req, res) => {
2002
+ const user = auth(req, res);
2003
+ if (!user)
2004
+ return;
2005
+ const list = db.prepare(`SELECT tx_hash, amount, block_number, swept, created_at FROM deposit_txns WHERE user_id = ? ORDER BY created_at DESC LIMIT 10`).all(user.id);
2006
+ res.json(list);
2007
+ });
2008
+ // ─── 管理员端点 ───────────────────────────────────────────────
2009
+ function adminAuth(req, res) {
2010
+ const adminKey = process.env.ADMIN_KEY;
2011
+ if (!adminKey) {
2012
+ res.status(503).json({ error: '管理功能未启用(未设置 ADMIN_KEY)' });
2013
+ return false;
2014
+ }
2015
+ if (req.headers['x-admin-key'] !== adminKey) {
2016
+ res.status(403).json({ error: '认证失败' });
2017
+ return false;
2018
+ }
2019
+ return true;
2020
+ }
2021
+ // 热钱包状态
2022
+ app.get('/api/admin/hot-wallet', async (req, res) => {
2023
+ if (!adminAuth(req, res))
2024
+ return;
2025
+ try {
2026
+ const balance = await publicClient.readContract({
2027
+ address: USDC_SEPOLIA, abi: USDC_ABI,
2028
+ functionName: 'balanceOf', args: [HOT_WALLET_ADDR],
2029
+ });
2030
+ res.json({ address: HOT_WALLET_ADDR, usdc_balance: Number(balance) / 1e6 });
2031
+ }
2032
+ catch (e) {
2033
+ res.json({ address: HOT_WALLET_ADDR, usdc_balance: null, error: e.message });
2034
+ }
2035
+ });
2036
+ // 待处理提现列表
2037
+ app.get('/api/admin/withdrawals', (req, res) => {
2038
+ if (!adminAuth(req, res))
2039
+ return;
2040
+ const list = db.prepare(`
2041
+ SELECT wr.*, u.name as user_name
2042
+ FROM withdrawal_requests wr JOIN users u ON wr.user_id = u.id
2043
+ WHERE wr.status = 'pending' ORDER BY wr.created_at ASC
2044
+ `).all();
2045
+ res.json(list);
2046
+ });
2047
+ // 批准并执行提现
2048
+ app.post('/api/admin/withdrawals/:id/approve', async (req, res) => {
2049
+ if (!adminAuth(req, res))
2050
+ return;
2051
+ const result = await executeWithdrawal(req.params.id).catch(e => ({ success: false, error: e.message, txHash: undefined }));
2052
+ if (!result.success)
2053
+ return void res.json({ error: result.error });
2054
+ res.json({ success: true, tx_hash: result.txHash });
2055
+ });
352
2056
  // 充值测试 WAZ(Phase 0 专用,最多单次 1000,余额上限 5000)
353
2057
  app.post('/api/wallet/topup', (req, res) => {
354
2058
  const user = auth(req, res);
@@ -798,8 +2502,133 @@ function runEnforcement() {
798
2502
  console.error('执法扫描出错:', err.message);
799
2503
  }
800
2504
  }
2505
+ // ─── 链上基础配置 ─────────────────────────────────────────────
2506
+ const USDC_SEPOLIA = '0x036CbD53842c5426634e7929541eC2318f3dCF7e';
2507
+ const USDC_DECIMALS = 6;
2508
+ const DEPOSIT_POLL_MS = 60_000;
2509
+ const USDC_ABI = parseAbi([
2510
+ 'function transfer(address to, uint256 value) returns (bool)',
2511
+ 'function balanceOf(address) view returns (uint256)',
2512
+ ]);
2513
+ const transferEvent = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)');
2514
+ const _rpcRaw = process.env.BASE_RPC_URL ?? 'sepolia.base.org';
2515
+ const rpcUrl = _rpcRaw.startsWith('http') ? _rpcRaw : `https://${_rpcRaw}`;
2516
+ const publicClient = createPublicClient({
2517
+ chain: baseSepolia,
2518
+ transport: http(rpcUrl),
2519
+ });
2520
+ // ─── 热钱包(归集 + 提现出账)────────────────────────────────────
2521
+ const HOT_WALLET_PRIV = derivePrivKey('platform-hot-wallet');
2522
+ const HOT_WALLET_ADDR = privateKeyToAddress(HOT_WALLET_PRIV);
2523
+ const hotWalletClient = createWalletClient({
2524
+ account: privateKeyToAccount(HOT_WALLET_PRIV),
2525
+ chain: baseSepolia,
2526
+ transport: http(rpcUrl),
2527
+ });
2528
+ // ─── 归集:充值地址 → 热钱包 ────────────────────────────────────
2529
+ async function sweepToHotWallet(userId, depositAddress) {
2530
+ // 检查链上 USDC 余额
2531
+ const onChain = await publicClient.readContract({
2532
+ address: USDC_SEPOLIA, abi: USDC_ABI,
2533
+ functionName: 'balanceOf',
2534
+ args: [depositAddress],
2535
+ });
2536
+ if (onChain === 0n)
2537
+ return;
2538
+ // 热钱包先打一点 ETH 给充值地址支付 Gas
2539
+ const ethHash = await hotWalletClient.sendTransaction({
2540
+ to: depositAddress,
2541
+ value: parseEther('0.0005'),
2542
+ });
2543
+ await publicClient.waitForTransactionReceipt({ hash: ethHash });
2544
+ // 充值地址把 USDC 转给热钱包
2545
+ const depClient = createWalletClient({
2546
+ account: privateKeyToAccount(derivePrivKey(userId)),
2547
+ chain: baseSepolia,
2548
+ transport: http(rpcUrl),
2549
+ });
2550
+ const usdcHash = await depClient.writeContract({
2551
+ address: USDC_SEPOLIA, abi: USDC_ABI,
2552
+ functionName: 'transfer',
2553
+ args: [HOT_WALLET_ADDR, onChain],
2554
+ });
2555
+ await publicClient.waitForTransactionReceipt({ hash: usdcHash });
2556
+ db.prepare('UPDATE deposit_txns SET swept = 1 WHERE user_id = ? AND swept = 0').run(userId);
2557
+ console.log(`🔄 归集:${Number(onChain) / 1e6} USDC → 热钱包 (${usdcHash.slice(0, 10)}...)`);
2558
+ }
2559
+ // ─── 提现执行:热钱包 → 用户地址 ────────────────────────────────
2560
+ async function executeWithdrawal(requestId) {
2561
+ const req = db.prepare("SELECT * FROM withdrawal_requests WHERE id = ? AND status = 'pending'")
2562
+ .get(requestId);
2563
+ if (!req)
2564
+ return { success: false, error: '申请不存在或已处理' };
2565
+ const amountRaw = BigInt(Math.round(req.amount * 10 ** USDC_DECIMALS));
2566
+ const hotBalance = await publicClient.readContract({
2567
+ address: USDC_SEPOLIA, abi: USDC_ABI,
2568
+ functionName: 'balanceOf', args: [HOT_WALLET_ADDR],
2569
+ });
2570
+ if (hotBalance < amountRaw) {
2571
+ return { success: false, error: `热钱包余额不足(需 ${req.amount} USDC,现有 ${Number(hotBalance) / 1e6} USDC)` };
2572
+ }
2573
+ const txHash = await hotWalletClient.writeContract({
2574
+ address: USDC_SEPOLIA, abi: USDC_ABI,
2575
+ functionName: 'transfer',
2576
+ args: [req.to_address, amountRaw],
2577
+ });
2578
+ await publicClient.waitForTransactionReceipt({ hash: txHash });
2579
+ db.prepare("UPDATE withdrawal_requests SET status='processed', tx_hash=?, processed_at=datetime('now') WHERE id=?")
2580
+ .run(txHash, requestId);
2581
+ console.log(`💸 提现完成:${req.amount} USDC → ${req.to_address.slice(0, 10)}... (${txHash.slice(0, 10)}...)`);
2582
+ return { success: true, txHash };
2583
+ }
2584
+ // ─── 充值监听 ─────────────────────────────────────────────────
2585
+ async function checkDeposits() {
2586
+ const rows = db.prepare('SELECT user_id, deposit_address FROM wallets WHERE deposit_address IS NOT NULL').all();
2587
+ if (rows.length === 0)
2588
+ return;
2589
+ const addrToUser = new Map(rows.map(r => [r.deposit_address.toLowerCase(), r.user_id]));
2590
+ const latestBlock = await publicClient.getBlockNumber();
2591
+ const savedRow = db.prepare("SELECT value FROM system_state WHERE key = 'last_deposit_block'").get();
2592
+ const fromBlock = savedRow ? BigInt(savedRow.value) + 1n : latestBlock - 50n;
2593
+ if (fromBlock > latestBlock)
2594
+ return;
2595
+ const logs = await publicClient.getLogs({
2596
+ address: USDC_SEPOLIA,
2597
+ event: transferEvent,
2598
+ args: { to: rows.map(r => r.deposit_address) },
2599
+ fromBlock,
2600
+ toBlock: latestBlock,
2601
+ });
2602
+ for (const log of logs) {
2603
+ const txHash = log.transactionHash;
2604
+ const toAddr = log.args.to?.toLowerCase();
2605
+ const userId = addrToUser.get(toAddr);
2606
+ if (!userId)
2607
+ continue;
2608
+ if (db.prepare('SELECT 1 FROM deposit_txns WHERE tx_hash = ?').get(txHash))
2609
+ continue;
2610
+ const amount = Number(log.args.value) / 10 ** USDC_DECIMALS;
2611
+ db.prepare('INSERT INTO deposit_txns (tx_hash, user_id, amount, block_number) VALUES (?,?,?,?)')
2612
+ .run(txHash, userId, amount, Number(log.blockNumber));
2613
+ db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(amount, userId);
2614
+ const name = db.prepare('SELECT name FROM users WHERE id = ?').get(userId)?.name ?? userId;
2615
+ console.log(`💰 充值到账:${name} +${amount} WAZ (${txHash.slice(0, 10)}...)`);
2616
+ // 异步归集,不阻塞充值到账
2617
+ sweepToHotWallet(userId, toAddr).catch(e => console.error(`归集失败 (${userId}):`, e.message));
2618
+ }
2619
+ db.prepare("INSERT OR REPLACE INTO system_state (key, value) VALUES ('last_deposit_block', ?)")
2620
+ .run(latestBlock.toString());
2621
+ }
2622
+ function startDepositWatcher() {
2623
+ checkDeposits().catch(e => console.error('充值扫描出错:', e.message));
2624
+ setInterval(() => {
2625
+ checkDeposits().catch(e => console.error('充值扫描出错:', e.message));
2626
+ }, DEPOSIT_POLL_MS);
2627
+ console.log(`⛓ 充值监听已启动(Base Sepolia,每 ${DEPOSIT_POLL_MS / 1000}s 扫描)`);
2628
+ console.log(`🏦 热钱包地址:${HOT_WALLET_ADDR}`);
2629
+ }
801
2630
  // ─── 启动 ─────────────────────────────────────────────────────
802
- const PORT = 3000;
2631
+ const PORT = Number(process.env.PORT) || 3000;
803
2632
  app.listen(PORT, () => {
804
2633
  console.log(`✅ WebAZ 已启动:http://localhost:${PORT}`);
805
2634
  console.log(` 手机访问:http://<本机IP>:${PORT}`);
@@ -807,4 +2636,6 @@ app.listen(PORT, () => {
807
2636
  runEnforcement();
808
2637
  setInterval(runEnforcement, ENFORCE_INTERVAL_MS);
809
2638
  console.log(`⚡ 自动执法已启动(每 ${ENFORCE_INTERVAL_MS / 60000} 分钟扫描)`);
2639
+ // 链上充值监听
2640
+ startDepositWatcher();
810
2641
  });