@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,119 @@
|
|
|
1
|
+
export function registerProductsAliasesRoutes(app, deps) {
|
|
2
|
+
const { db, auth, generateId, extractCandidateAliases } = deps;
|
|
3
|
+
// M7.2-5: 从外部原文提取候选 alias
|
|
4
|
+
app.post('/api/products/extract-aliases', (req, res) => {
|
|
5
|
+
const user = auth(req, res);
|
|
6
|
+
if (!user)
|
|
7
|
+
return;
|
|
8
|
+
const text = String(req.body?.text || '').trim();
|
|
9
|
+
if (!text || text.length < 6)
|
|
10
|
+
return void res.json({ error: '文本至少 6 字符' });
|
|
11
|
+
if (text.length > 5000)
|
|
12
|
+
return void res.json({ error: '文本过长(≤ 5000 字符)' });
|
|
13
|
+
const candidates = extractCandidateAliases(text);
|
|
14
|
+
res.json({ candidates, hint: '勾选要声明为该商品 alias 的项目(≥ 6 字符;过短或过通用的项会被反作弊机制挑战)' });
|
|
15
|
+
});
|
|
16
|
+
// M7.2-7: alias CRUD(仅商品 owner)
|
|
17
|
+
app.get('/api/products/:id/aliases', (req, res) => {
|
|
18
|
+
const user = auth(req, res);
|
|
19
|
+
if (!user)
|
|
20
|
+
return;
|
|
21
|
+
const p = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(req.params.id);
|
|
22
|
+
if (!p)
|
|
23
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
24
|
+
if (p.seller_id !== user.id)
|
|
25
|
+
return void res.status(403).json({ error: '仅商品 owner 可查看 alias' });
|
|
26
|
+
const rows = db.prepare(`SELECT id, alias_type, alias_value, min_match_chars, status, challenged_at, created_at
|
|
27
|
+
FROM product_aliases WHERE product_id = ? ORDER BY created_at DESC`).all(req.params.id);
|
|
28
|
+
res.json({ aliases: rows });
|
|
29
|
+
});
|
|
30
|
+
app.post('/api/products/:id/aliases', (req, res) => {
|
|
31
|
+
const user = auth(req, res);
|
|
32
|
+
if (!user)
|
|
33
|
+
return;
|
|
34
|
+
const p = db.prepare('SELECT seller_id, title FROM products WHERE id = ?').get(req.params.id);
|
|
35
|
+
if (!p)
|
|
36
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
37
|
+
if (p.seller_id !== user.id)
|
|
38
|
+
return void res.status(403).json({ error: '仅商品 owner 可添加 alias' });
|
|
39
|
+
const inputs = Array.isArray(req.body?.aliases) ? req.body.aliases : [];
|
|
40
|
+
if (!inputs.length)
|
|
41
|
+
return void res.json({ error: '请提供 aliases 数组' });
|
|
42
|
+
const ALLOWED_TYPES = new Set(['external_id', 'external_title', 'short_url', 'kouling_token', 'title_substring']);
|
|
43
|
+
const ALIAS_LIMIT_PER_PRODUCT = 20;
|
|
44
|
+
const countActive = db.prepare(`SELECT COUNT(*) as n FROM product_aliases WHERE product_id = ? AND status = 'active'`);
|
|
45
|
+
const insertAlias = db.prepare(`INSERT INTO product_aliases (id, product_id, alias_type, alias_value, min_match_chars) VALUES (?,?,?,?,?)`);
|
|
46
|
+
const rollbackAlias = db.prepare(`DELETE FROM product_aliases WHERE id = ?`);
|
|
47
|
+
// M-3 fix:事务 + 每次 INSERT 后立即 SELECT COUNT 校验
|
|
48
|
+
// 防止并发 / 多 INSERT 突破上限 → 单事务内 TOCTOU 不复存在
|
|
49
|
+
const inserted = [];
|
|
50
|
+
const skipped = [];
|
|
51
|
+
const tx = db.transaction(() => {
|
|
52
|
+
const startCount = countActive.get(req.params.id).n;
|
|
53
|
+
if (startCount >= ALIAS_LIMIT_PER_PRODUCT) {
|
|
54
|
+
throw new Error(`LIMIT_REACHED:${startCount}`);
|
|
55
|
+
}
|
|
56
|
+
for (const a of inputs) {
|
|
57
|
+
const type = String(a?.type || '').trim();
|
|
58
|
+
const value = String(a?.value || '').trim();
|
|
59
|
+
if (!ALLOWED_TYPES.has(type)) {
|
|
60
|
+
skipped.push({ value, reason: `unknown type: ${type}` });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (value.length < 6) {
|
|
64
|
+
skipped.push({ value, reason: '< 6 字符' });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (value.length > 200) {
|
|
68
|
+
skipped.push({ value, reason: '> 200 字符' });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (type === 'title_substring' && !p.title.includes(value)) {
|
|
72
|
+
skipped.push({ value, reason: 'title_substring 必须是商品标题的真子串' });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const minChars = Math.max(6, Number(a?.min_chars) || 6);
|
|
76
|
+
const id = generateId('pal');
|
|
77
|
+
try {
|
|
78
|
+
insertAlias.run(id, req.params.id, type, value, minChars);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
skipped.push({ value, reason: 'duplicate or constraint' });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// 立即校验:超 limit 立刻回滚这条
|
|
85
|
+
const afterCount = countActive.get(req.params.id).n;
|
|
86
|
+
if (afterCount > ALIAS_LIMIT_PER_PRODUCT) {
|
|
87
|
+
rollbackAlias.run(id);
|
|
88
|
+
skipped.push({ value, reason: `reached ${ALIAS_LIMIT_PER_PRODUCT} cap` });
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
inserted.push(id);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
tx();
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
const msg = String(e.message || '');
|
|
99
|
+
if (msg.startsWith('LIMIT_REACHED:')) {
|
|
100
|
+
return void res.json({ error: `每个商品最多 ${ALIAS_LIMIT_PER_PRODUCT} 个 active alias` });
|
|
101
|
+
}
|
|
102
|
+
return void res.status(500).json({ error: msg });
|
|
103
|
+
}
|
|
104
|
+
res.json({ inserted: inserted.length, skipped });
|
|
105
|
+
});
|
|
106
|
+
app.delete('/api/products/:id/aliases/:aliasId', (req, res) => {
|
|
107
|
+
const user = auth(req, res);
|
|
108
|
+
if (!user)
|
|
109
|
+
return;
|
|
110
|
+
const p = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(req.params.id);
|
|
111
|
+
if (!p)
|
|
112
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
113
|
+
if (p.seller_id !== user.id)
|
|
114
|
+
return void res.status(403).json({ error: '仅商品 owner 可删除 alias' });
|
|
115
|
+
const r = db.prepare(`UPDATE product_aliases SET status = 'revoked' WHERE id = ? AND product_id = ?`)
|
|
116
|
+
.run(req.params.aliasId, req.params.id);
|
|
117
|
+
res.json({ success: r.changes > 0 });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function registerProductsClaimsRoutes(app, deps) {
|
|
2
|
+
const { db, auth, isTrustedRole, errorRes, generateId, PRODUCT_CLAIM_TARGETS, PRODUCT_CLAIM_STAKE_DEFAULT, PRODUCT_CLAIM_DEADLINE_HOURS, PRODUCT_CLAIM_VERIFIERS_NEEDED } = deps;
|
|
3
|
+
app.post('/api/products/:id/claim', (req, res) => {
|
|
4
|
+
const user = auth(req, res);
|
|
5
|
+
if (!user)
|
|
6
|
+
return;
|
|
7
|
+
// 受信角色不可参与(管理员中立)
|
|
8
|
+
if (isTrustedRole(user)) {
|
|
9
|
+
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_CLAIM', '受信角色不可发起商品声明');
|
|
10
|
+
}
|
|
11
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
|
|
12
|
+
if (!product)
|
|
13
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
14
|
+
if (product.seller_id === user.id)
|
|
15
|
+
return void errorRes(res, 403, 'CANNOT_CLAIM_OWN', '不可对自己的商品发起声明');
|
|
16
|
+
if (product.status !== 'active')
|
|
17
|
+
return void res.status(400).json({ error: '仅在售商品可发起声明' });
|
|
18
|
+
const claim_target = String(req.body?.claim_target || '').trim();
|
|
19
|
+
if (!PRODUCT_CLAIM_TARGETS.has(claim_target)) {
|
|
20
|
+
return void res.status(400).json({ error: `claim_target 须为 ${[...PRODUCT_CLAIM_TARGETS].join(' / ')}` });
|
|
21
|
+
}
|
|
22
|
+
const claim_text = String(req.body?.claim_text || '').trim();
|
|
23
|
+
if (claim_text.length < 6 || claim_text.length > 500) {
|
|
24
|
+
return void res.status(400).json({ error: 'claim_text 长度需 6-500 字' });
|
|
25
|
+
}
|
|
26
|
+
const evidence_uri = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
|
|
27
|
+
const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
28
|
+
const stake = PRODUCT_CLAIM_STAKE_DEFAULT;
|
|
29
|
+
if (!wallet || wallet.balance < stake) {
|
|
30
|
+
return void res.status(400).json({ error: `余额不足:发起需锁 ${stake} WAZ,当前 ${wallet?.balance ?? 0} WAZ` });
|
|
31
|
+
}
|
|
32
|
+
// 同用户对同商品同 target 只能挂一个 open claim
|
|
33
|
+
const dup = db.prepare(`SELECT id FROM product_claim_tasks WHERE product_id = ? AND claimant_id = ? AND claim_target = ? AND status = 'open'`)
|
|
34
|
+
.get(req.params.id, user.id, claim_target);
|
|
35
|
+
if (dup)
|
|
36
|
+
return void res.status(409).json({ error: '你已对此商品的同一项发起过 open 声明' });
|
|
37
|
+
const id = generateId('pct');
|
|
38
|
+
const deadline = new Date(Date.now() + PRODUCT_CLAIM_DEADLINE_HOURS * 3600_000).toISOString();
|
|
39
|
+
db.prepare(`INSERT INTO product_claim_tasks
|
|
40
|
+
(id, product_id, claimant_id, seller_id, claim_target, claim_text, evidence_uri, stake_claimant, deadline_at, status)
|
|
41
|
+
VALUES (?,?,?,?,?,?,?,?,?,'open')`)
|
|
42
|
+
.run(id, req.params.id, user.id, product.seller_id, claim_target, claim_text, evidence_uri, stake, deadline);
|
|
43
|
+
db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?')
|
|
44
|
+
.run(stake, stake, user.id);
|
|
45
|
+
res.json({ success: true, claim_id: id, deadline_at: deadline, stake_locked: stake });
|
|
46
|
+
});
|
|
47
|
+
// 公开:列出某商品的全部声明(含已结算)
|
|
48
|
+
app.get('/api/products/:id/claims', (req, res) => {
|
|
49
|
+
const rows = db.prepare(`
|
|
50
|
+
SELECT pct.id, pct.claim_target, pct.claim_text, pct.evidence_uri, pct.status, pct.ruling, pct.deadline_at, pct.resolved_at, pct.created_at,
|
|
51
|
+
u.name as claimant_name,
|
|
52
|
+
(SELECT COUNT(*) FROM product_claim_votes WHERE claim_id = pct.id) as votes_count
|
|
53
|
+
FROM product_claim_tasks pct
|
|
54
|
+
JOIN users u ON u.id = pct.claimant_id
|
|
55
|
+
WHERE pct.product_id = ?
|
|
56
|
+
ORDER BY pct.created_at DESC LIMIT 50
|
|
57
|
+
`).all(req.params.id);
|
|
58
|
+
res.json({ claims: rows, votes_needed: PRODUCT_CLAIM_VERIFIERS_NEEDED });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
export function registerProductsCreateRoutes(app, deps) {
|
|
2
|
+
const { db, auth, generateId, checkSellerCanList, getStakeDiscount, VALID_PRODUCT_TYPES, parsePlatformUrl, makeCommitmentHash, makeDescriptionHash, makePriceHash } = deps;
|
|
3
|
+
app.post('/api/products', (req, res) => {
|
|
4
|
+
const user = auth(req, res);
|
|
5
|
+
if (!user)
|
|
6
|
+
return;
|
|
7
|
+
if (user.role !== 'seller')
|
|
8
|
+
return void res.json({ error: '仅卖家可上架商品' });
|
|
9
|
+
// 里程碑 3-D:1h 上架限速(防 spam 批量上架)
|
|
10
|
+
const LISTING_RATE_LIMIT = 5;
|
|
11
|
+
const recentListings = db.prepare(`
|
|
12
|
+
SELECT COUNT(1) as n FROM products
|
|
13
|
+
WHERE seller_id = ? AND created_at > datetime('now', '-1 hour')
|
|
14
|
+
`).get(user.id).n;
|
|
15
|
+
if (recentListings >= LISTING_RATE_LIMIT) {
|
|
16
|
+
return void res.status(429).json({
|
|
17
|
+
error: `上架过于频繁:1 小时内最多 ${LISTING_RATE_LIMIT} 个商品(当前已 ${recentListings} 个)`,
|
|
18
|
+
retry_after_seconds: 3600,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// 发新品配额检查(模块 A)
|
|
22
|
+
const quotaCheck = checkSellerCanList(user);
|
|
23
|
+
if (!quotaCheck.ok)
|
|
24
|
+
return void res.json({ error: quotaCheck.reason });
|
|
25
|
+
const { title, description, price, stock = 1, category = '', specs, brand, model, source_url, source_price, external_title, weight_kg, ship_regions = '全国', handling_hours = 24, estimated_days, fragile = 0, return_days = 7, return_condition = '', warranty_days = 0, commission_rate, product_type = 'retail', // 里程碑 6
|
|
26
|
+
aliases = [], // 里程碑 7.2:上架时同步声明的 alias 集合
|
|
27
|
+
image_hashes = [], // 商品图片 — 只存 hash(64 hex),实际 blob 在卖家节点 IDB
|
|
28
|
+
} = req.body;
|
|
29
|
+
// 协议化原则:服务端只存 hash 引用,不存图片字节。校验仅做格式 + 数量。
|
|
30
|
+
let imagesJsonForInsert = null;
|
|
31
|
+
if (Array.isArray(image_hashes) && image_hashes.length > 0) {
|
|
32
|
+
if (image_hashes.length > 9)
|
|
33
|
+
return void res.json({ error: '图片最多 9 张' });
|
|
34
|
+
for (const h of image_hashes) {
|
|
35
|
+
if (typeof h !== 'string' || !/^[a-f0-9]{64}$/.test(h)) {
|
|
36
|
+
return void res.json({ error: 'image_hashes 必须为 64 字符十六进制' });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
imagesJsonForInsert = JSON.stringify(image_hashes);
|
|
40
|
+
}
|
|
41
|
+
// product_type 校验
|
|
42
|
+
if (typeof product_type !== 'string' || !VALID_PRODUCT_TYPES.has(product_type)) {
|
|
43
|
+
return void res.json({ error: `product_type 必须是 ${[...VALID_PRODUCT_TYPES].join(' / ')} 之一` });
|
|
44
|
+
}
|
|
45
|
+
const sourceMeta = parsePlatformUrl(source_url);
|
|
46
|
+
// 精准匹配原则:external_title 必须显式提供,不 fallback 到店铺 title
|
|
47
|
+
const externalTitleVal = typeof external_title === 'string' && external_title.trim() ? external_title.trim() : null;
|
|
48
|
+
if (!title || !description || !price)
|
|
49
|
+
return void res.json({ error: '请填写商品名、描述、价格' });
|
|
50
|
+
// 推土机 commission_rate(1%-50%)
|
|
51
|
+
const commissionRateNum = Number(commission_rate ?? 0.10);
|
|
52
|
+
if (!(commissionRateNum >= 0.01 && commissionRateNum <= 0.50)) {
|
|
53
|
+
return void res.json({ error: 'commission_rate 必须在 1% - 50% 之间(小数 0.01-0.50)' });
|
|
54
|
+
}
|
|
55
|
+
// 上架前检查:同一卖家不能重复关联相同外部链接
|
|
56
|
+
if (source_url) {
|
|
57
|
+
const sameSellerDupe = db.prepare(`
|
|
58
|
+
SELECT COUNT(*) as n FROM product_external_links pel
|
|
59
|
+
JOIN products p ON pel.product_id = p.id
|
|
60
|
+
WHERE pel.url = ? AND p.seller_id = ?
|
|
61
|
+
`).get(source_url, user.id);
|
|
62
|
+
if (sameSellerDupe.n > 0) {
|
|
63
|
+
return void res.json({ error: '您已上架过来自此链接的商品,不能重复关联相同外部链接' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// M7.2.6 / 方案 3:上架免质押 — 零门槛入驻
|
|
67
|
+
// stake_amount 字段记录"预期 stake"(首单成交时从订单 escrow 锁定)
|
|
68
|
+
const priceNum = Number(price);
|
|
69
|
+
const stakeDiscount = getStakeDiscount(db, user.id);
|
|
70
|
+
const stakeRate = Math.max(0.05, 0.15 - stakeDiscount);
|
|
71
|
+
const stakeAmount = Math.round(priceNum * stakeRate * 100) / 100;
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
const id = generateId('prd');
|
|
74
|
+
const specsJson = specs ? (typeof specs === 'string' ? specs : JSON.stringify(specs)) : null;
|
|
75
|
+
const estJson = estimated_days ? (typeof estimated_days === 'string' ? estimated_days : JSON.stringify(estimated_days)) : null;
|
|
76
|
+
const pFields = { ship_regions, handling_hours, estimated_days: estJson, return_days, return_condition, warranty_days };
|
|
77
|
+
db.prepare(`INSERT INTO products (
|
|
78
|
+
id, seller_id, title, description, price, stock, category, stake_amount,
|
|
79
|
+
specs, brand, model, source_url, source_price, source_price_at,
|
|
80
|
+
weight_kg, ship_regions, handling_hours, estimated_days, fragile,
|
|
81
|
+
return_days, return_condition, warranty_days,
|
|
82
|
+
commitment_hash, description_hash, price_hash, hashed_at,
|
|
83
|
+
commission_rate, product_type, images
|
|
84
|
+
) 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, commissionRateNum, product_type, imagesJsonForInsert);
|
|
85
|
+
// M7.2.6:免质押上架 — 不再扣 stake;首单成交时 settleOrder 自动从订单 escrow 锁定
|
|
86
|
+
// M7.2-6: 上架时同步入 aliases(卖家已勾选的 candidates)
|
|
87
|
+
if (Array.isArray(aliases) && aliases.length > 0) {
|
|
88
|
+
const ALLOWED_TYPES = new Set(['external_id', 'external_title', 'short_url', 'kouling_token', 'title_substring']);
|
|
89
|
+
const ALIAS_LIMIT = 20;
|
|
90
|
+
let n = 0;
|
|
91
|
+
for (const a of aliases) {
|
|
92
|
+
if (n >= ALIAS_LIMIT)
|
|
93
|
+
break;
|
|
94
|
+
const type = String(a?.type || '').trim();
|
|
95
|
+
const value = String(a?.value || '').trim();
|
|
96
|
+
if (!ALLOWED_TYPES.has(type) || value.length < 6 || value.length > 200)
|
|
97
|
+
continue;
|
|
98
|
+
// M-2 fix: title_substring 必须是 product.title 真子串
|
|
99
|
+
if (type === 'title_substring' && !String(title).includes(value))
|
|
100
|
+
continue;
|
|
101
|
+
try {
|
|
102
|
+
db.prepare(`INSERT INTO product_aliases (id, product_id, alias_type, alias_value, min_match_chars) VALUES (?,?,?,?,6)`)
|
|
103
|
+
.run(generateId('pal'), id, type, value);
|
|
104
|
+
n++;
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 来源链接:冲突检测
|
|
110
|
+
let linkConflict = null;
|
|
111
|
+
if (source_url) {
|
|
112
|
+
// 另一家卖家已认领此链接(verified=1)
|
|
113
|
+
const otherClaim = db.prepare(`
|
|
114
|
+
SELECT pel.product_id FROM product_external_links pel
|
|
115
|
+
JOIN products p ON pel.product_id = p.id
|
|
116
|
+
WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
|
|
117
|
+
`).get(source_url, user.id);
|
|
118
|
+
if (otherClaim) {
|
|
119
|
+
// 插入为未验证状态
|
|
120
|
+
db.prepare(`INSERT OR IGNORE INTO product_external_links
|
|
121
|
+
(id, product_id, url, source, verified, verify_note, platform, external_id, external_title)
|
|
122
|
+
VALUES (?, ?, ?, 'import', 0, '链接冲突:等待众包验证确认归属', ?, ?, ?)`).run(generateId('lnk'), id, source_url, sourceMeta?.platform ?? null, sourceMeta?.external_id ?? null, externalTitleVal);
|
|
123
|
+
// 创建认领验证任务(扣锁定费)
|
|
124
|
+
const VERIFIERS_NEEDED = 1;
|
|
125
|
+
const REWARD_EACH = 0.1;
|
|
126
|
+
const feeLocked = VERIFIERS_NEEDED * REWARD_EACH;
|
|
127
|
+
const walletNow = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
128
|
+
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
129
|
+
const code = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
|
130
|
+
const taskId = generateId('vtk');
|
|
131
|
+
const expiresAt = new Date(Date.now() + 72 * 3600_000).toISOString();
|
|
132
|
+
const baseMsg = `此商品来源链接已被其他商家认领。请将验证码 [${code}] 放入该平台商品标题或描述,等待人工审核确认后归属自动转移。`;
|
|
133
|
+
if (walletNow.balance >= feeLocked) {
|
|
134
|
+
try {
|
|
135
|
+
db.prepare(`INSERT INTO verify_tasks (id, type, product_id, url, code, verifiers_needed, reward_per_verifier, fee_locked, status, expires_at)
|
|
136
|
+
VALUES (?,?,?,?,?,?,?,?,'code_issued',?)`).run(taskId, 'code_check', id, source_url, code, VERIFIERS_NEEDED, REWARD_EACH, feeLocked, expiresAt);
|
|
137
|
+
db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ?`).run(feeLocked, user.id);
|
|
138
|
+
linkConflict = { task_id: taskId, code: `[${code}]`, expires_at: expiresAt, message: baseMsg };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
linkConflict = { message: `${baseMsg}(余额不足以锁定验证费 ${feeLocked} WAZ,请前往商品外部链接手动发起验证)` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
linkConflict = { message: `${baseMsg}(当前余额不足以锁定验证费 ${feeLocked} WAZ,请充值后前往商品编辑页手动发起验证)` };
|
|
146
|
+
}
|
|
147
|
+
// 有冲突:商品进入仓库,等待验证结果后再上架
|
|
148
|
+
db.prepare(`UPDATE products SET status='warehouse', updated_at=datetime('now') WHERE id=?`).run(id);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// 无冲突 — 直接 verified=1
|
|
152
|
+
db.prepare(`INSERT OR IGNORE INTO product_external_links
|
|
153
|
+
(id, product_id, url, source, verified, verified_at, platform, external_id, external_title)
|
|
154
|
+
VALUES (?, ?, ?, 'import', 1, datetime('now'), ?, ?, ?)`).run(generateId('lnk'), id, source_url, sourceMeta?.platform ?? null, sourceMeta?.external_id ?? null, externalTitleVal);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// 额外链接:同步冲突检查(最多 5 个)
|
|
158
|
+
const additionalLinks = req.body.additional_links;
|
|
159
|
+
const blockedLinks = [];
|
|
160
|
+
if (Array.isArray(additionalLinks) && additionalLinks.length > 0) {
|
|
161
|
+
for (const extraUrl of additionalLinks.slice(0, 5)) {
|
|
162
|
+
if (typeof extraUrl !== 'string' || !extraUrl.startsWith('http'))
|
|
163
|
+
continue;
|
|
164
|
+
const alreadyLinked = db.prepare('SELECT id FROM product_external_links WHERE product_id = ? AND url = ?').get(id, extraUrl);
|
|
165
|
+
if (alreadyLinked)
|
|
166
|
+
continue;
|
|
167
|
+
// 同卖家已在其他商品关联过此链接
|
|
168
|
+
const selfConflict = db.prepare(`
|
|
169
|
+
SELECT p.title FROM product_external_links pel
|
|
170
|
+
JOIN products p ON pel.product_id = p.id
|
|
171
|
+
WHERE pel.url = ? AND p.seller_id = ? AND p.id != ?
|
|
172
|
+
`).get(extraUrl, user.id, id);
|
|
173
|
+
if (selfConflict) {
|
|
174
|
+
blockedLinks.push({ url: extraUrl, message: `您已在商品「${selfConflict.title}」中关联了此链接` });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// 他人已认领(verified=1)
|
|
178
|
+
const otherConflict = db.prepare(`
|
|
179
|
+
SELECT p.title FROM product_external_links pel
|
|
180
|
+
JOIN products p ON pel.product_id = p.id
|
|
181
|
+
WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
|
|
182
|
+
`).get(extraUrl, user.id);
|
|
183
|
+
if (otherConflict) {
|
|
184
|
+
blockedLinks.push({ url: extraUrl, message: `此链接已被其他商家认领,上架后可在商品编辑页发起验证任务` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// 无冲突 — 直接 verified=1
|
|
188
|
+
try {
|
|
189
|
+
const extraMeta = parsePlatformUrl(extraUrl);
|
|
190
|
+
db.prepare(`INSERT OR IGNORE INTO product_external_links
|
|
191
|
+
(id, product_id, url, source, verified, verified_at, platform, external_id)
|
|
192
|
+
VALUES (?, ?, ?, 'import_extra', 1, datetime('now'), ?, ?)`).run(generateId('lnk'), id, extraUrl, extraMeta?.platform ?? null, extraMeta?.external_id ?? null);
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
res.json({
|
|
198
|
+
success: true,
|
|
199
|
+
product_id: id,
|
|
200
|
+
stake_locked: 0, // 免质押上架(M7.2.6 方案 3)
|
|
201
|
+
stake_deferred: stakeAmount, // 首单成交时自动锁定(trusted+ 跳过)
|
|
202
|
+
...(linkConflict ? { link_conflict: linkConflict } : {}),
|
|
203
|
+
...(blockedLinks.length > 0 ? { blocked_links: blockedLinks } : {}),
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function registerProductsCrudRoutes(app, deps) {
|
|
2
|
+
const { db, auth, errorRes, formatProductForAgent, retireAnchorsByTarget } = deps;
|
|
3
|
+
// 单品详情(agent verify price 时使用)
|
|
4
|
+
// 卖家可查看自己的非上架商品(编辑页用),其他人只能看 active
|
|
5
|
+
app.get('/api/products/:id', (req, res) => {
|
|
6
|
+
const token = (req.headers.authorization || '').replace('Bearer ', '');
|
|
7
|
+
const selfUser = token ? db.prepare('SELECT id FROM users WHERE api_key = ?').get(token) : undefined;
|
|
8
|
+
const row = db.prepare(`
|
|
9
|
+
SELECT p.*, u.name as seller_name,
|
|
10
|
+
COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
|
|
11
|
+
FROM products p
|
|
12
|
+
JOIN users u ON p.seller_id = u.id
|
|
13
|
+
LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
|
|
14
|
+
WHERE p.id = ? AND (p.status = 'active' OR p.seller_id = ?)
|
|
15
|
+
`).get(req.params.id, selfUser?.id ?? '');
|
|
16
|
+
if (!row)
|
|
17
|
+
return void res.status(404).json({ error: 'not_found' });
|
|
18
|
+
res.json(formatProductForAgent(row, req));
|
|
19
|
+
});
|
|
20
|
+
// 状态切换(active / warehouse / deleted)
|
|
21
|
+
app.patch('/api/products/:id/status', (req, res) => {
|
|
22
|
+
const user = auth(req, res);
|
|
23
|
+
if (!user)
|
|
24
|
+
return;
|
|
25
|
+
const { status } = req.body;
|
|
26
|
+
if (!['active', 'warehouse', 'deleted'].includes(status))
|
|
27
|
+
return void res.json({ error: '无效状态值' });
|
|
28
|
+
const product = db.prepare('SELECT id, claim_loss_count FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
|
|
29
|
+
if (!product)
|
|
30
|
+
return void res.status(404).json({ error: '商品不存在或无权限' });
|
|
31
|
+
if (status === 'active') {
|
|
32
|
+
// Sprint 5 audit fix: claim_loss_count >= 3 禁 seller 自助再上架,必须 admin 干预(content 权限)
|
|
33
|
+
if ((product.claim_loss_count || 0) >= 3) {
|
|
34
|
+
return void errorRes(res, 403, 'CLAIM_THRESHOLD_REACHED', `该商品累计 ${product.claim_loss_count} 次声明被验证不实,已达自动下架阈值。需 admin 干预方可重新上架,请联系管理员。`);
|
|
35
|
+
}
|
|
36
|
+
const pendingTask = db.prepare(`SELECT id FROM verify_tasks WHERE product_id=? AND status IN ('code_issued','open')`).get(req.params.id);
|
|
37
|
+
if (pendingTask)
|
|
38
|
+
return void res.json({ error: '链接核验进行中,请等待验证结果后再上架' });
|
|
39
|
+
const hasRevoked = db.prepare(`SELECT id FROM product_external_links WHERE product_id=? AND revoked=1`).get(req.params.id);
|
|
40
|
+
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);
|
|
41
|
+
if (hasRevoked && !hasValid)
|
|
42
|
+
return void res.json({ error: '所有外部链接已失效(主权失效),请先添加新链接后再上架' });
|
|
43
|
+
}
|
|
44
|
+
db.prepare(`UPDATE products SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, req.params.id);
|
|
45
|
+
res.json({ success: true });
|
|
46
|
+
});
|
|
47
|
+
// 硬删(仅 deleted 状态 + 无进行中订单)
|
|
48
|
+
app.delete('/api/products/:id', (req, res) => {
|
|
49
|
+
const user = auth(req, res);
|
|
50
|
+
if (!user)
|
|
51
|
+
return;
|
|
52
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
|
|
53
|
+
if (!product)
|
|
54
|
+
return void res.status(404).json({ error: '商品不存在或无权限' });
|
|
55
|
+
if (product.status !== 'deleted')
|
|
56
|
+
return void res.json({ error: '请先将商品移入回收箱' });
|
|
57
|
+
const activeOrders = db.prepare(`
|
|
58
|
+
SELECT COUNT(*) as n FROM orders WHERE product_id = ? AND status NOT IN ('completed','cancelled','refunded','expired')
|
|
59
|
+
`).get(req.params.id);
|
|
60
|
+
if (activeOrders.n > 0)
|
|
61
|
+
return void res.json({ error: '该商品有进行中的订单,暂无法删除' });
|
|
62
|
+
db.prepare('DELETE FROM product_external_links WHERE product_id = ?').run(req.params.id);
|
|
63
|
+
// E1 anchor GC: 指向该 product 的 active anchor → retired
|
|
64
|
+
try {
|
|
65
|
+
retireAnchorsByTarget(db, 'product', String(req.params.id));
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
console.warn('[anchor-gc product]', e.message);
|
|
69
|
+
}
|
|
70
|
+
db.prepare('DELETE FROM products WHERE id = ?').run(req.params.id);
|
|
71
|
+
res.json({ success: true });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export function registerProductsLinksRoutes(app, deps) {
|
|
2
|
+
const { db, auth, generateId, extractUrlFromText, extractTitleFromText, parsePlatformUrl } = deps;
|
|
3
|
+
app.get('/api/products/:id/links', (req, res) => {
|
|
4
|
+
const user = auth(req, res);
|
|
5
|
+
if (!user)
|
|
6
|
+
return;
|
|
7
|
+
const product = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(req.params.id);
|
|
8
|
+
if (!product)
|
|
9
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
10
|
+
if (product.seller_id !== user.id)
|
|
11
|
+
return void res.status(403).json({ error: '无权限' });
|
|
12
|
+
const links = db.prepare(`SELECT id, url, source, verified, revoked, verify_note, added_at, platform, external_id, external_title FROM product_external_links WHERE product_id = ? ORDER BY added_at ASC`).all(req.params.id);
|
|
13
|
+
res.json(links);
|
|
14
|
+
});
|
|
15
|
+
// 新链接(无人认领)直接 verified=1;已被他人认领则发起众包验证任务
|
|
16
|
+
app.post('/api/products/:id/links', (req, res) => {
|
|
17
|
+
const user = auth(req, res);
|
|
18
|
+
if (!user)
|
|
19
|
+
return;
|
|
20
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
|
|
21
|
+
if (!product)
|
|
22
|
+
return void res.status(404).json({ error: '商品不存在或无权限' });
|
|
23
|
+
// 支持两种 body:(a) {url, external_title?} (b) {text}
|
|
24
|
+
const rawText = req.body?.text;
|
|
25
|
+
let url = req.body?.url;
|
|
26
|
+
let bodyExternalTitle = req.body?.external_title;
|
|
27
|
+
if (!url && rawText) {
|
|
28
|
+
url = extractUrlFromText(rawText) ?? undefined;
|
|
29
|
+
if (!bodyExternalTitle)
|
|
30
|
+
bodyExternalTitle = extractTitleFromText(rawText) ?? undefined;
|
|
31
|
+
}
|
|
32
|
+
if (!url || !url.startsWith('http'))
|
|
33
|
+
return void res.json({ error: '请提供有效链接(URL 或包含 URL 的分享文本)' });
|
|
34
|
+
// 精准匹配原则:external_title 必须显式输入,不 fallback 到 product.title
|
|
35
|
+
const linkExternalTitle = bodyExternalTitle && bodyExternalTitle.trim() ? bodyExternalTitle.trim() : null;
|
|
36
|
+
// 已关联此商品
|
|
37
|
+
const existing = db.prepare('SELECT id, verified, revoked FROM product_external_links WHERE product_id = ? AND url = ?')
|
|
38
|
+
.get(req.params.id, url);
|
|
39
|
+
if (existing) {
|
|
40
|
+
// 主权失效的旧记录:删除后允许重新发起认领
|
|
41
|
+
if (existing.revoked) {
|
|
42
|
+
db.prepare('DELETE FROM product_external_links WHERE id = ?').run(existing.id);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
return void res.json({ error: '该链接已关联到此商品' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// 同卖家的其他商品已关联此链接
|
|
49
|
+
const sameSellerOther = db.prepare(`
|
|
50
|
+
SELECT p.title FROM product_external_links pel
|
|
51
|
+
JOIN products p ON pel.product_id = p.id
|
|
52
|
+
WHERE pel.url = ? AND p.seller_id = ? AND pel.product_id != ?
|
|
53
|
+
`).get(url, user.id, req.params.id);
|
|
54
|
+
if (sameSellerOther) {
|
|
55
|
+
return void res.json({ error: `此链接已在您的商品「${sameSellerOther.title}」中关联,一个链接不能关联多个商品` });
|
|
56
|
+
}
|
|
57
|
+
// 是否已被其他卖家 verified 认领
|
|
58
|
+
const otherClaim = db.prepare(`
|
|
59
|
+
SELECT p.title as product_title FROM product_external_links pel
|
|
60
|
+
JOIN products p ON pel.product_id = p.id
|
|
61
|
+
WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
|
|
62
|
+
`).get(url, user.id);
|
|
63
|
+
if (!otherClaim) {
|
|
64
|
+
// 新链接,无冲突:直接 verified=1
|
|
65
|
+
const linkId = generateId('lnk');
|
|
66
|
+
const meta = parsePlatformUrl(url);
|
|
67
|
+
db.prepare(`INSERT INTO product_external_links
|
|
68
|
+
(id, product_id, url, source, verified, verified_at, platform, external_id, external_title)
|
|
69
|
+
VALUES (?, ?, ?, 'manual', 1, datetime('now'), ?, ?, ?)`).run(linkId, req.params.id, url, meta?.platform ?? null, meta?.external_id ?? null, linkExternalTitle);
|
|
70
|
+
return void res.json({ link_id: linkId, verified: 1, external_title: linkExternalTitle, message: '链接已关联' });
|
|
71
|
+
}
|
|
72
|
+
// 已被他人认领:发起众包验证任务
|
|
73
|
+
const existingTask = db.prepare(`SELECT id, code, status, expires_at FROM verify_tasks WHERE product_id = ? AND url = ? AND status IN ('code_issued','open')`)
|
|
74
|
+
.get(req.params.id, url);
|
|
75
|
+
if (existingTask) {
|
|
76
|
+
const isPending = existingTask.status === 'code_issued';
|
|
77
|
+
return void res.json({
|
|
78
|
+
task_id: existingTask.id,
|
|
79
|
+
code: `[${existingTask.code}]`,
|
|
80
|
+
status: existingTask.status,
|
|
81
|
+
expires_at: existingTask.expires_at,
|
|
82
|
+
already_pending: true,
|
|
83
|
+
conflict: true,
|
|
84
|
+
instructions: isPending
|
|
85
|
+
? `此链接已有认领任务,请将验证码 [${existingTask.code}] 放入原平台商品标题或描述,完成后回来点击「确认已添加」提交任务。`
|
|
86
|
+
: `此链接已有进行中的认领任务,等待验证者确认。`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const VERIFIERS_NEEDED = 1;
|
|
90
|
+
const REWARD_EACH = 0.1;
|
|
91
|
+
const feeLocked = VERIFIERS_NEEDED * REWARD_EACH;
|
|
92
|
+
const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
93
|
+
if (wallet.balance < feeLocked) {
|
|
94
|
+
return void res.json({ error: `余额不足:认领验证需锁定 ${feeLocked} WAZ,当前余额 ${wallet.balance} WAZ` });
|
|
95
|
+
}
|
|
96
|
+
// 8 位验证码(排除易混 IOOLZ)
|
|
97
|
+
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
98
|
+
const code = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
|
99
|
+
const linkId = generateId('lnk');
|
|
100
|
+
const taskId = generateId('vtk');
|
|
101
|
+
const expiresAt = new Date(Date.now() + 72 * 3600_000).toISOString();
|
|
102
|
+
const claimMeta = parsePlatformUrl(url);
|
|
103
|
+
db.prepare(`INSERT INTO product_external_links
|
|
104
|
+
(id, product_id, url, source, verified, verify_note, platform, external_id, external_title)
|
|
105
|
+
VALUES (?, ?, ?, 'manual', 0, '认领验证进行中', ?, ?, ?)`).run(linkId, req.params.id, url, claimMeta?.platform ?? null, claimMeta?.external_id ?? null, linkExternalTitle);
|
|
106
|
+
db.prepare(`INSERT INTO verify_tasks (id, type, product_id, url, code, verifiers_needed, reward_per_verifier, fee_locked, status, expires_at)
|
|
107
|
+
VALUES (?,?,?,?,?,?,?,?,'code_issued',?)`).run(taskId, 'claim', req.params.id, url, code, VERIFIERS_NEEDED, REWARD_EACH, feeLocked, expiresAt);
|
|
108
|
+
db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ?`).run(feeLocked, user.id);
|
|
109
|
+
res.json({
|
|
110
|
+
link_id: linkId,
|
|
111
|
+
task_id: taskId,
|
|
112
|
+
verified: 0,
|
|
113
|
+
conflict: true,
|
|
114
|
+
code: `[${code}]`,
|
|
115
|
+
instructions: `此链接已被其他商家的商品「${otherClaim.product_title}」认领。请将验证码 [${code}] 放入该平台商品标题或描述,完成后在商品编辑页点击「确认已添加」提交验证任务,经审核确认后,链接归属将转移到您的商品。`,
|
|
116
|
+
expires_at: expiresAt,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
app.delete('/api/products/:id/links/:linkId', (req, res) => {
|
|
120
|
+
const user = auth(req, res);
|
|
121
|
+
if (!user)
|
|
122
|
+
return;
|
|
123
|
+
const product = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(req.params.id);
|
|
124
|
+
if (!product || product.seller_id !== user.id)
|
|
125
|
+
return void res.status(403).json({ error: '无权限' });
|
|
126
|
+
db.prepare('DELETE FROM product_external_links WHERE id = ? AND product_id = ?').run(req.params.linkId, req.params.id);
|
|
127
|
+
res.json({ success: true });
|
|
128
|
+
});
|
|
129
|
+
}
|