@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,443 @@
|
|
|
1
|
+
export function registerRfqsRoutes(app, deps) {
|
|
2
|
+
const { db, auth, generateId, VALID_RFQ_URGENCIES, VALID_AWARD_MODES, RFQ_MAX_QTY, RFQ_MAX_PRICE, RFQ_DAILY_CAP_PER_BUYER, RFQ_MAX_WINDOW_MIN, RFQ_DEFAULT_WINDOW_MIN, BID_DAILY_CAP_PER_SELLER, BID_STAKE_RATE, VALID_FULFILLMENT_TYPES, isListingCategoryKey, LISTING_CATEGORIES, awardBidAndCreateOrder, notifyMatchedSellers, evaluateAutoBidsForRfq, shouldAutoAccept, transition, notifyTransition } = deps;
|
|
3
|
+
// 内联押金 helper(小巧、仅 rfq 域用)
|
|
4
|
+
const buyerRfqDeposit = (maxPrice, qty) => {
|
|
5
|
+
const base = (maxPrice && qty) ? maxPrice * qty * 0.01 : 0.1;
|
|
6
|
+
return Math.max(0.1, Math.min(1, Math.round(base * 100) / 100));
|
|
7
|
+
};
|
|
8
|
+
const bidStakeFor = (price, qty) => Math.max(0.5, Math.round(price * qty * BID_STAKE_RATE * 100) / 100);
|
|
9
|
+
// 买家:创建 RFQ
|
|
10
|
+
app.post('/api/rfqs', (req, res) => {
|
|
11
|
+
const user = auth(req, res);
|
|
12
|
+
if (!user)
|
|
13
|
+
return;
|
|
14
|
+
if (user.role !== 'buyer')
|
|
15
|
+
return void res.json({ error: '仅买家可发求购单' });
|
|
16
|
+
const body = req.body;
|
|
17
|
+
const title = String(body.title || '').trim();
|
|
18
|
+
if (title.length < 2)
|
|
19
|
+
return void res.json({ error: '标题至少 2 字' });
|
|
20
|
+
const qty = Math.max(1, Math.floor(Number(body.qty) || 1));
|
|
21
|
+
if (qty > RFQ_MAX_QTY)
|
|
22
|
+
return void res.json({ error: `qty 超出上限 ${RFQ_MAX_QTY}` });
|
|
23
|
+
const urgency = String(body.urgency || 'flex');
|
|
24
|
+
if (!VALID_RFQ_URGENCIES.has(urgency))
|
|
25
|
+
return void res.json({ error: 'urgency 无效' });
|
|
26
|
+
const awardMode = String(body.award_mode || 'time_window');
|
|
27
|
+
if (!VALID_AWARD_MODES.has(awardMode))
|
|
28
|
+
return void res.json({ error: 'award_mode 无效' });
|
|
29
|
+
const maxPrice = body.max_price != null ? Number(body.max_price) : null;
|
|
30
|
+
if (maxPrice != null && (!Number.isFinite(maxPrice) || maxPrice <= 0))
|
|
31
|
+
return void res.json({ error: 'max_price 必须 > 0' });
|
|
32
|
+
if (maxPrice != null && maxPrice > RFQ_MAX_PRICE)
|
|
33
|
+
return void res.json({ error: `max_price 超出上限 ${RFQ_MAX_PRICE} WAZ` });
|
|
34
|
+
const category = String(body.category || 'general');
|
|
35
|
+
if (!isListingCategoryKey(category))
|
|
36
|
+
return void res.json({ error: '类目无效' });
|
|
37
|
+
const explicitWindow = body.award_window_min != null ? Math.max(5, Math.min(RFQ_MAX_WINDOW_MIN, Math.floor(Number(body.award_window_min)))) : null;
|
|
38
|
+
const windowMin = explicitWindow ?? RFQ_DEFAULT_WINDOW_MIN[urgency];
|
|
39
|
+
const todayCount = db.prepare("SELECT COUNT(1) as n FROM rfqs WHERE buyer_id = ? AND created_at > datetime('now','-1 day')").get(user.id).n;
|
|
40
|
+
if (todayCount >= RFQ_DAILY_CAP_PER_BUYER) {
|
|
41
|
+
return void res.json({ error: `今日已达上限 ${RFQ_DAILY_CAP_PER_BUYER} 单求购` });
|
|
42
|
+
}
|
|
43
|
+
const deposit = buyerRfqDeposit(maxPrice, qty);
|
|
44
|
+
const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
45
|
+
if (!wallet || Number(wallet.balance) < deposit) {
|
|
46
|
+
return void res.json({ error: `余额不足,发求购需 ${deposit} WAZ 押金(中标后释放,撤销扣 30%)` });
|
|
47
|
+
}
|
|
48
|
+
// P3c:award 自动建单需要收货地址。优先 body,否则 buyer 的默认地址
|
|
49
|
+
const buyerProfile = db.prepare('SELECT default_address_text, default_address_json FROM users WHERE id = ?').get(user.id);
|
|
50
|
+
let shippingAddress = body.shipping_address ? String(body.shipping_address).trim() : null;
|
|
51
|
+
if (!shippingAddress) {
|
|
52
|
+
if (buyerProfile?.default_address_text)
|
|
53
|
+
shippingAddress = buyerProfile.default_address_text;
|
|
54
|
+
else if (buyerProfile?.default_address_json) {
|
|
55
|
+
try {
|
|
56
|
+
const a = JSON.parse(buyerProfile.default_address_json);
|
|
57
|
+
const parts = [a.recipient, a.line1, a.line2, a.city, a.state, a.country, a.phone1].filter(Boolean);
|
|
58
|
+
if (parts.length)
|
|
59
|
+
shippingAddress = parts.join(' / ');
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!shippingAddress)
|
|
65
|
+
return void res.json({ error: '请先在个人主页设置默认收货地址,或在发求购时传 shipping_address' });
|
|
66
|
+
const id = generateId('rfq');
|
|
67
|
+
const regionRequired = body.region_required ? String(body.region_required) : user.region || null;
|
|
68
|
+
const fulfillmentRequired = body.fulfillment_required ? JSON.stringify(body.fulfillment_required) : null;
|
|
69
|
+
db.transaction(() => {
|
|
70
|
+
db.prepare(`
|
|
71
|
+
INSERT INTO rfqs (id, buyer_id, listing_id, title, spec_json, qty, category, region_required, urgency,
|
|
72
|
+
max_price, fulfillment_required, award_mode, award_window_min, deadline_at, buyer_stake_locked, notes, shipping_address)
|
|
73
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now', '+' || ? || ' minutes'),?,?,?)
|
|
74
|
+
`).run(id, user.id, body.listing_id ? String(body.listing_id) : null, title, body.spec_json ? JSON.stringify(body.spec_json) : null, qty, category, regionRequired, urgency, maxPrice, fulfillmentRequired, awardMode, windowMin, windowMin, deposit, body.notes ? String(body.notes) : null, shippingAddress);
|
|
75
|
+
db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?').run(deposit, deposit, user.id);
|
|
76
|
+
})();
|
|
77
|
+
try {
|
|
78
|
+
notifyMatchedSellers(id);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
console.error('[P3 notify]', e);
|
|
82
|
+
}
|
|
83
|
+
let autoBidCount = 0;
|
|
84
|
+
try {
|
|
85
|
+
autoBidCount = evaluateAutoBidsForRfq(id);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
console.error('[P3e auto_bid]', e);
|
|
89
|
+
}
|
|
90
|
+
res.json({ id, deposit, window_min: windowMin, deadline_at_minutes: windowMin, auto_bids: autoBidCount });
|
|
91
|
+
});
|
|
92
|
+
// 卖家 RFQ 看板
|
|
93
|
+
app.get('/api/rfqs', (req, res) => {
|
|
94
|
+
const user = auth(req, res);
|
|
95
|
+
if (!user)
|
|
96
|
+
return;
|
|
97
|
+
const where = ["r.status = 'open'", "r.deadline_at > datetime('now')"];
|
|
98
|
+
const args = [];
|
|
99
|
+
if (req.query.region) {
|
|
100
|
+
where.push('(r.region_required IS NULL OR r.region_required = ?)');
|
|
101
|
+
args.push(String(req.query.region));
|
|
102
|
+
}
|
|
103
|
+
if (req.query.category) {
|
|
104
|
+
where.push('r.category = ?');
|
|
105
|
+
args.push(String(req.query.category));
|
|
106
|
+
}
|
|
107
|
+
if (req.query.urgency && VALID_RFQ_URGENCIES.has(String(req.query.urgency))) {
|
|
108
|
+
where.push('r.urgency = ?');
|
|
109
|
+
args.push(String(req.query.urgency));
|
|
110
|
+
}
|
|
111
|
+
if (req.query.unbidded === '1') {
|
|
112
|
+
where.push('r.bid_count = 0');
|
|
113
|
+
}
|
|
114
|
+
// #974: q LIKE 搜索(title / notes)
|
|
115
|
+
if (req.query.q && typeof req.query.q === 'string' && req.query.q.trim()) {
|
|
116
|
+
const qE = req.query.q.trim().replace(/[\\%_]/g, '\\$&');
|
|
117
|
+
where.push("(r.title LIKE ? ESCAPE '\\' OR r.notes LIKE ? ESCAPE '\\')");
|
|
118
|
+
args.push('%' + qE + '%', '%' + qE + '%');
|
|
119
|
+
}
|
|
120
|
+
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
|
|
121
|
+
const rows = db.prepare(`
|
|
122
|
+
SELECT r.id, r.title, r.qty, r.category, r.region_required, r.urgency, r.max_price,
|
|
123
|
+
r.award_mode, r.deadline_at, r.bid_count, r.created_at,
|
|
124
|
+
(SELECT MIN(price) FROM bids b WHERE b.rfq_id = r.id AND b.status = 'active') as current_lowest_bid,
|
|
125
|
+
EXISTS(SELECT 1 FROM bids b WHERE b.rfq_id = r.id AND b.seller_id = ? AND b.status = 'active') as i_have_bid
|
|
126
|
+
FROM rfqs r
|
|
127
|
+
WHERE ${where.join(' AND ')}
|
|
128
|
+
ORDER BY r.created_at DESC
|
|
129
|
+
LIMIT ?
|
|
130
|
+
`).all(user.id, ...args, limit);
|
|
131
|
+
res.json({ items: rows, urgencies: ['now', 'today', 'flex'], categories: LISTING_CATEGORIES });
|
|
132
|
+
});
|
|
133
|
+
app.get('/api/rfqs/mine', (req, res) => {
|
|
134
|
+
const user = auth(req, res);
|
|
135
|
+
if (!user)
|
|
136
|
+
return;
|
|
137
|
+
const rows = db.prepare(`
|
|
138
|
+
SELECT r.*,
|
|
139
|
+
(SELECT MIN(price) FROM bids b WHERE b.rfq_id = r.id AND b.status = 'active') as current_lowest_bid
|
|
140
|
+
FROM rfqs r
|
|
141
|
+
WHERE r.buyer_id = ?
|
|
142
|
+
ORDER BY r.created_at DESC
|
|
143
|
+
LIMIT 100
|
|
144
|
+
`).all(user.id);
|
|
145
|
+
res.json({ items: rows });
|
|
146
|
+
});
|
|
147
|
+
app.get('/api/rfqs/:id', (req, res) => {
|
|
148
|
+
const user = auth(req, res);
|
|
149
|
+
if (!user)
|
|
150
|
+
return;
|
|
151
|
+
const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
|
|
152
|
+
if (!rfq)
|
|
153
|
+
return void res.status(404).json({ error: 'RFQ 不存在' });
|
|
154
|
+
const isOwner = rfq.buyer_id === user.id;
|
|
155
|
+
const bids = db.prepare(`
|
|
156
|
+
SELECT b.id, b.seller_id, b.price, b.qty_offered, b.eta_hours, b.fulfillment_type, b.note,
|
|
157
|
+
b.auto_bid_skill, b.status, b.submitted_at, b.offer_id,
|
|
158
|
+
u.handle as seller_handle, u.region as seller_region,
|
|
159
|
+
(SELECT COUNT(1) FROM orders WHERE seller_id = b.seller_id AND status = 'completed') as seller_sales
|
|
160
|
+
FROM bids b
|
|
161
|
+
LEFT JOIN users u ON u.id = b.seller_id
|
|
162
|
+
WHERE b.rfq_id = ?
|
|
163
|
+
ORDER BY b.price ASC, b.submitted_at ASC
|
|
164
|
+
`).all(req.params.id);
|
|
165
|
+
// 仅 owner 看全部;第三方只看自己 + 计数
|
|
166
|
+
const visibleBids = isOwner ? bids : bids.filter(b => b.seller_id === user.id);
|
|
167
|
+
// P1:非 owner 时 buyer 身份脱敏(防止私下交易)
|
|
168
|
+
const safeRfq = { ...rfq };
|
|
169
|
+
if (!isOwner) {
|
|
170
|
+
const bid = String(rfq.buyer_id || '');
|
|
171
|
+
safeRfq.buyer_id = '买家 #' + bid.slice(-6);
|
|
172
|
+
delete safeRfq.shipping_address; // 中标后从订单读
|
|
173
|
+
}
|
|
174
|
+
res.json({ rfq: safeRfq, bids: visibleBids, bid_count: bids.length, is_owner: isOwner });
|
|
175
|
+
});
|
|
176
|
+
app.delete('/api/rfqs/:id', (req, res) => {
|
|
177
|
+
const user = auth(req, res);
|
|
178
|
+
if (!user)
|
|
179
|
+
return;
|
|
180
|
+
const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
|
|
181
|
+
if (!rfq)
|
|
182
|
+
return void res.status(404).json({ error: 'RFQ 不存在' });
|
|
183
|
+
if (rfq.buyer_id !== user.id)
|
|
184
|
+
return void res.status(403).json({ error: '仅买家本人可取消' });
|
|
185
|
+
if (rfq.status !== 'open')
|
|
186
|
+
return void res.json({ error: `当前状态 ${rfq.status} 不可取消` });
|
|
187
|
+
const deposit = Number(rfq.buyer_stake_locked) || 0;
|
|
188
|
+
const forfeit = Math.round(deposit * 0.30 * 100) / 100;
|
|
189
|
+
const refund = Math.round((deposit - forfeit) * 100) / 100;
|
|
190
|
+
db.transaction(() => {
|
|
191
|
+
db.prepare("UPDATE rfqs SET status = 'cancelled', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
|
|
192
|
+
if (refund > 0)
|
|
193
|
+
db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(refund, deposit, user.id);
|
|
194
|
+
const activeBids = db.prepare("SELECT id, seller_id, stake_locked FROM bids WHERE rfq_id = ? AND status = 'active'").all(req.params.id);
|
|
195
|
+
for (const b of activeBids) {
|
|
196
|
+
db.prepare("UPDATE bids SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ?").run(b.id);
|
|
197
|
+
if (b.stake_locked > 0)
|
|
198
|
+
db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(b.stake_locked, b.stake_locked, b.seller_id);
|
|
199
|
+
}
|
|
200
|
+
})();
|
|
201
|
+
res.json({ success: true, refund, forfeit, active_bids_released: 0 });
|
|
202
|
+
});
|
|
203
|
+
app.post('/api/rfqs/:id/bids', (req, res) => {
|
|
204
|
+
const user = auth(req, res);
|
|
205
|
+
if (!user)
|
|
206
|
+
return;
|
|
207
|
+
if (user.role !== 'seller')
|
|
208
|
+
return void res.json({ error: '仅卖家可报价' });
|
|
209
|
+
const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
|
|
210
|
+
if (!rfq)
|
|
211
|
+
return void res.status(404).json({ error: 'RFQ 不存在' });
|
|
212
|
+
if (rfq.status !== 'open')
|
|
213
|
+
return void res.json({ error: `当前状态 ${rfq.status} 不接受报价` });
|
|
214
|
+
if (String(rfq.deadline_at) <= new Date().toISOString().replace('T', ' ').slice(0, 19)) {
|
|
215
|
+
return void res.json({ error: '该 RFQ 已过期' });
|
|
216
|
+
}
|
|
217
|
+
const body = req.body;
|
|
218
|
+
const price = Number(body.price);
|
|
219
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
220
|
+
return void res.json({ error: 'price 必须 > 0' });
|
|
221
|
+
if (price > RFQ_MAX_PRICE)
|
|
222
|
+
return void res.json({ error: `price 超出上限 ${RFQ_MAX_PRICE} WAZ` });
|
|
223
|
+
if (rfq.max_price && price > Number(rfq.max_price))
|
|
224
|
+
return void res.json({ error: `超出买家预算 ${rfq.max_price}` });
|
|
225
|
+
const qtyOffered = Math.max(1, Math.floor(Number(body.qty_offered) || Number(rfq.qty)));
|
|
226
|
+
if (qtyOffered > RFQ_MAX_QTY)
|
|
227
|
+
return void res.json({ error: `qty_offered 超出上限 ${RFQ_MAX_QTY}` });
|
|
228
|
+
const fulfillmentType = String(body.fulfillment_type || 'standard');
|
|
229
|
+
if (!VALID_FULFILLMENT_TYPES.has(fulfillmentType))
|
|
230
|
+
return void res.json({ error: 'fulfillment_type 无效' });
|
|
231
|
+
const today = db.prepare("SELECT COUNT(1) as n FROM bids WHERE seller_id = ? AND submitted_at > datetime('now','-1 day')").get(user.id).n;
|
|
232
|
+
if (today >= BID_DAILY_CAP_PER_SELLER) {
|
|
233
|
+
return void res.json({ error: `今日报价已达上限 ${BID_DAILY_CAP_PER_SELLER} 条` });
|
|
234
|
+
}
|
|
235
|
+
// 一卖家 × 一 RFQ = 一 bid(已有则用 PATCH)
|
|
236
|
+
const existing = db.prepare("SELECT id, status FROM bids WHERE rfq_id = ? AND seller_id = ?").get(req.params.id, user.id);
|
|
237
|
+
if (existing && existing.status === 'active')
|
|
238
|
+
return void res.json({ error: '已有进行中的 bid,请改用 PATCH 修改', bid_id: existing.id });
|
|
239
|
+
const stake = bidStakeFor(price, qtyOffered);
|
|
240
|
+
const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
241
|
+
if (!wallet || Number(wallet.balance) < stake) {
|
|
242
|
+
return void res.json({ error: `余额不足,bid 押金 ${stake} WAZ(落选/取消立即释放,中标后转 escrow)` });
|
|
243
|
+
}
|
|
244
|
+
const id = generateId('bid');
|
|
245
|
+
db.transaction(() => {
|
|
246
|
+
db.prepare(`
|
|
247
|
+
INSERT INTO bids (id, rfq_id, seller_id, offer_id, price, qty_offered, eta_hours, fulfillment_type, note, stake_locked, auto_bid_skill)
|
|
248
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
249
|
+
`).run(id, req.params.id, user.id, body.offer_id ? String(body.offer_id) : null, price, qtyOffered, body.eta_hours != null ? Number(body.eta_hours) : null, fulfillmentType, body.note ? String(body.note).slice(0, 500) : null, stake, body.auto_bid_skill ? 1 : 0);
|
|
250
|
+
db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?').run(stake, stake, user.id);
|
|
251
|
+
db.prepare(`UPDATE rfqs SET bid_count = bid_count + 1, status = 'open', updated_at = datetime('now') WHERE id = ?`).run(req.params.id);
|
|
252
|
+
})();
|
|
253
|
+
try {
|
|
254
|
+
db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
|
|
255
|
+
VALUES (?,?,'rfq_bid',?,?,datetime('now'))`)
|
|
256
|
+
.run(generateId('ntf'), rfq.buyer_id, `💰 新报价 ${price} WAZ`, `RFQ:${rfq.title} · #${req.params.id}`);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
console.error('[P3 notify bid]', e);
|
|
260
|
+
}
|
|
261
|
+
// P3c.4: first_match 模式 → 立即评估并自动 award
|
|
262
|
+
let autoAwardedOrder;
|
|
263
|
+
if (rfq.award_mode === 'first_match') {
|
|
264
|
+
const okForMatch = !rfq.max_price || price <= Number(rfq.max_price);
|
|
265
|
+
if (okForMatch) {
|
|
266
|
+
const newBid = db.prepare('SELECT * FROM bids WHERE id = ?').get(id);
|
|
267
|
+
let result = { ok: false };
|
|
268
|
+
try {
|
|
269
|
+
db.transaction(() => {
|
|
270
|
+
const rfqLatest = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
|
|
271
|
+
if (rfqLatest && rfqLatest.status === 'open') {
|
|
272
|
+
result = awardBidAndCreateOrder(rfqLatest, newBid);
|
|
273
|
+
if (!result.ok)
|
|
274
|
+
throw new Error(result.error || 'first_match award failed');
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
if (result.ok) {
|
|
278
|
+
autoAwardedOrder = result.order_id;
|
|
279
|
+
try {
|
|
280
|
+
db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
|
|
281
|
+
VALUES (?,?,'rfq_won',?,?,datetime('now'))`)
|
|
282
|
+
.run(generateId('ntf'), user.id, `🎉 中标(first_match 自动选)`, `订单 ${result.order_id}`);
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
console.error('[P3c notify first_match won]', e);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
console.error('[P3c first_match]', e.message);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
res.json({ id, stake_locked: stake, auto_awarded_order_id: autoAwardedOrder });
|
|
295
|
+
});
|
|
296
|
+
// 卖家:修改 bid(仅 active;stake 差额自动结算)
|
|
297
|
+
app.patch('/api/bids/:id', (req, res) => {
|
|
298
|
+
const user = auth(req, res);
|
|
299
|
+
if (!user)
|
|
300
|
+
return;
|
|
301
|
+
const bid = db.prepare('SELECT * FROM bids WHERE id = ?').get(req.params.id);
|
|
302
|
+
if (!bid)
|
|
303
|
+
return void res.status(404).json({ error: 'bid 不存在' });
|
|
304
|
+
if (bid.seller_id !== user.id)
|
|
305
|
+
return void res.status(403).json({ error: '仅本人可修改' });
|
|
306
|
+
if (bid.status !== 'active')
|
|
307
|
+
return void res.json({ error: `当前状态 ${bid.status} 不可修改` });
|
|
308
|
+
const rfq = db.prepare('SELECT max_price, status, deadline_at FROM rfqs WHERE id = ?').get(bid.rfq_id);
|
|
309
|
+
if (!rfq || rfq.status !== 'open')
|
|
310
|
+
return void res.json({ error: 'RFQ 已不接受改价' });
|
|
311
|
+
const body = req.body;
|
|
312
|
+
let newPrice = Number(bid.price);
|
|
313
|
+
let newQty = Number(bid.qty_offered);
|
|
314
|
+
let newEta = bid.eta_hours;
|
|
315
|
+
let newFt = String(bid.fulfillment_type);
|
|
316
|
+
let newNote = bid.note;
|
|
317
|
+
if (body.price != null) {
|
|
318
|
+
const p = Number(body.price);
|
|
319
|
+
if (!Number.isFinite(p) || p <= 0)
|
|
320
|
+
return void res.json({ error: 'price 必须 > 0' });
|
|
321
|
+
if (rfq.max_price && p > Number(rfq.max_price))
|
|
322
|
+
return void res.json({ error: `超出买家预算 ${rfq.max_price}` });
|
|
323
|
+
newPrice = p;
|
|
324
|
+
}
|
|
325
|
+
if (body.qty_offered != null) {
|
|
326
|
+
const q = Math.max(1, Math.floor(Number(body.qty_offered)));
|
|
327
|
+
if (!Number.isFinite(q))
|
|
328
|
+
return void res.json({ error: 'qty 无效' });
|
|
329
|
+
newQty = q;
|
|
330
|
+
}
|
|
331
|
+
if (body.eta_hours !== undefined)
|
|
332
|
+
newEta = body.eta_hours != null ? Number(body.eta_hours) : null;
|
|
333
|
+
if (body.fulfillment_type != null) {
|
|
334
|
+
const ft = String(body.fulfillment_type);
|
|
335
|
+
if (!VALID_FULFILLMENT_TYPES.has(ft))
|
|
336
|
+
return void res.json({ error: 'fulfillment_type 无效' });
|
|
337
|
+
newFt = ft;
|
|
338
|
+
}
|
|
339
|
+
if (body.note !== undefined)
|
|
340
|
+
newNote = body.note ? String(body.note).slice(0, 500) : null;
|
|
341
|
+
const oldStake = Number(bid.stake_locked) || 0;
|
|
342
|
+
const newStake = bidStakeFor(newPrice, newQty);
|
|
343
|
+
const delta = Math.round((newStake - oldStake) * 100) / 100;
|
|
344
|
+
if (delta > 0) {
|
|
345
|
+
const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
346
|
+
if (!wallet || Number(wallet.balance) < delta) {
|
|
347
|
+
return void res.json({ error: `余额不足补足 stake 差额 ${delta} WAZ` });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
db.transaction(() => {
|
|
351
|
+
db.prepare(`UPDATE bids SET price = ?, qty_offered = ?, eta_hours = ?, fulfillment_type = ?, note = ?, stake_locked = ?
|
|
352
|
+
WHERE id = ?`).run(newPrice, newQty, newEta, newFt, newNote, newStake, req.params.id);
|
|
353
|
+
if (delta > 0) {
|
|
354
|
+
db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?').run(delta, delta, user.id);
|
|
355
|
+
}
|
|
356
|
+
else if (delta < 0) {
|
|
357
|
+
const back = -delta;
|
|
358
|
+
db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(back, back, user.id);
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
res.json({ success: true, stake_locked: newStake, stake_delta: delta });
|
|
362
|
+
});
|
|
363
|
+
// 卖家:撤回 bid(释放 stake)
|
|
364
|
+
app.delete('/api/bids/:id', (req, res) => {
|
|
365
|
+
const user = auth(req, res);
|
|
366
|
+
if (!user)
|
|
367
|
+
return;
|
|
368
|
+
const bid = db.prepare('SELECT * FROM bids WHERE id = ?').get(req.params.id);
|
|
369
|
+
if (!bid)
|
|
370
|
+
return void res.status(404).json({ error: 'bid 不存在' });
|
|
371
|
+
if (bid.seller_id !== user.id)
|
|
372
|
+
return void res.status(403).json({ error: '仅本人可撤回' });
|
|
373
|
+
if (bid.status !== 'active')
|
|
374
|
+
return void res.json({ error: `当前状态 ${bid.status} 不可撤回` });
|
|
375
|
+
const stake = Number(bid.stake_locked) || 0;
|
|
376
|
+
db.transaction(() => {
|
|
377
|
+
db.prepare("UPDATE bids SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ?").run(req.params.id);
|
|
378
|
+
if (stake > 0)
|
|
379
|
+
db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(stake, stake, user.id);
|
|
380
|
+
db.prepare("UPDATE rfqs SET bid_count = MAX(0, bid_count - 1), updated_at = datetime('now') WHERE id = ?").run(String(bid.rfq_id));
|
|
381
|
+
})();
|
|
382
|
+
res.json({ success: true, stake_released: stake });
|
|
383
|
+
});
|
|
384
|
+
// 买家:选定 winning bid
|
|
385
|
+
app.post('/api/rfqs/:id/award', (req, res) => {
|
|
386
|
+
const user = auth(req, res);
|
|
387
|
+
if (!user)
|
|
388
|
+
return;
|
|
389
|
+
const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
|
|
390
|
+
if (!rfq)
|
|
391
|
+
return void res.status(404).json({ error: 'RFQ 不存在' });
|
|
392
|
+
if (rfq.buyer_id !== user.id)
|
|
393
|
+
return void res.status(403).json({ error: '仅买家本人可选定' });
|
|
394
|
+
if (rfq.status !== 'open')
|
|
395
|
+
return void res.json({ error: `当前状态 ${rfq.status} 不可选定` });
|
|
396
|
+
const bidId = String(req.body.bid_id || '');
|
|
397
|
+
let winner;
|
|
398
|
+
if (bidId) {
|
|
399
|
+
winner = db.prepare("SELECT * FROM bids WHERE id = ? AND rfq_id = ? AND status = 'active'").get(bidId, req.params.id);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
// P3c.3 提前结算:自动选当前最低价(同 cron 逻辑)
|
|
403
|
+
winner = db.prepare("SELECT * FROM bids WHERE rfq_id = ? AND status = 'active' ORDER BY price ASC, submitted_at ASC LIMIT 1").get(req.params.id);
|
|
404
|
+
}
|
|
405
|
+
if (!winner)
|
|
406
|
+
return void res.status(404).json({ error: bidId ? 'bid 无效或已失效' : '当前无有效报价可选' });
|
|
407
|
+
let result = { ok: false };
|
|
408
|
+
try {
|
|
409
|
+
db.transaction(() => {
|
|
410
|
+
result = awardBidAndCreateOrder(rfq, winner);
|
|
411
|
+
if (!result.ok)
|
|
412
|
+
throw new Error(result.error || 'award failed');
|
|
413
|
+
})();
|
|
414
|
+
}
|
|
415
|
+
catch (e) {
|
|
416
|
+
return void res.status(400).json({ error: result.error || String(e.message) });
|
|
417
|
+
}
|
|
418
|
+
// 通知(事务外):中标
|
|
419
|
+
try {
|
|
420
|
+
db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
|
|
421
|
+
VALUES (?,?,'rfq_won',?,?,datetime('now'))`)
|
|
422
|
+
.run(generateId('ntf'), winner.seller_id, `🎉 中标:${rfq.title}`, `订单 ${result.order_id}`);
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
console.error('[P3 notify won]', e);
|
|
426
|
+
}
|
|
427
|
+
// auto_accept Skill 触发
|
|
428
|
+
try {
|
|
429
|
+
if (shouldAutoAccept(db, result.order_id)) {
|
|
430
|
+
const sysUser = db.prepare("SELECT id FROM users WHERE id = 'sys_protocol'").get();
|
|
431
|
+
if (sysUser) {
|
|
432
|
+
const ar = transition(db, result.order_id, 'accepted', sysUser.id, [], '⚡ auto_accept Skill 自动接单');
|
|
433
|
+
if (ar.success)
|
|
434
|
+
notifyTransition(db, result.order_id, 'paid', 'accepted');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
console.error('[P3c auto_accept]', e);
|
|
440
|
+
}
|
|
441
|
+
res.json({ success: true, winning_bid_id: String(winner.id), order_id: result.order_id });
|
|
442
|
+
});
|
|
443
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export function registerSearchRoutes(app, deps) {
|
|
2
|
+
const { db, auth, applyCouponToOrder, extractUrlFromText, extractTitleFromText, parsePlatformUrl, searchByExternalLink, detectShareCommandFormat, formatProductForAgent } = deps;
|
|
3
|
+
app.get('/api/coupons/preview', (req, res) => {
|
|
4
|
+
const user = auth(req, res);
|
|
5
|
+
if (!user)
|
|
6
|
+
return;
|
|
7
|
+
const { code, product_id } = req.query;
|
|
8
|
+
if (!code || !product_id)
|
|
9
|
+
return void res.status(400).json({ error: '需提供 code + product_id' });
|
|
10
|
+
const p = db.prepare('SELECT seller_id, price FROM products WHERE id = ?').get(product_id);
|
|
11
|
+
if (!p)
|
|
12
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
13
|
+
const result = applyCouponToOrder(code, p.seller_id, product_id, Number(p.price));
|
|
14
|
+
if (!result.ok)
|
|
15
|
+
return void res.json({ ok: false, error: result.error });
|
|
16
|
+
res.json({ ok: true, discount: result.discount, final_price: Math.max(0, Number(p.price) - (result.discount || 0)) });
|
|
17
|
+
});
|
|
18
|
+
app.get('/api/my-products', (req, res) => {
|
|
19
|
+
const user = auth(req, res);
|
|
20
|
+
if (!user)
|
|
21
|
+
return;
|
|
22
|
+
const products = db.prepare(`
|
|
23
|
+
SELECT p.*,
|
|
24
|
+
CASE WHEN EXISTS (
|
|
25
|
+
SELECT 1 FROM verify_tasks WHERE product_id=p.id AND status IN ('code_issued','open')
|
|
26
|
+
) THEN 1 ELSE 0 END as has_pending_task,
|
|
27
|
+
CASE WHEN EXISTS (SELECT 1 FROM product_external_links WHERE product_id=p.id AND revoked=1)
|
|
28
|
+
AND NOT EXISTS (SELECT 1 FROM product_external_links WHERE product_id=p.id AND verified=1 AND (revoked IS NULL OR revoked=0))
|
|
29
|
+
THEN 1 ELSE 0 END as all_links_revoked
|
|
30
|
+
FROM products p WHERE p.seller_id = ? ORDER BY p.created_at DESC
|
|
31
|
+
`).all(user.id);
|
|
32
|
+
res.json(products);
|
|
33
|
+
});
|
|
34
|
+
app.post('/api/search-by-link', (req, res) => {
|
|
35
|
+
const text = (req.body?.text || '').toString();
|
|
36
|
+
const ext = (req.body?.external_link ?? null);
|
|
37
|
+
if (!text && !ext)
|
|
38
|
+
return void res.json({ error: '请提供 text 或 external_link' });
|
|
39
|
+
if (text && text.length > 2000)
|
|
40
|
+
return void res.json({ error: '文本过长(>2000)' });
|
|
41
|
+
let url = null;
|
|
42
|
+
let title = null;
|
|
43
|
+
let meta = null;
|
|
44
|
+
if (ext && typeof ext === 'object') {
|
|
45
|
+
if (ext.platform)
|
|
46
|
+
meta = { platform: ext.platform, external_id: ext.external_id ?? null };
|
|
47
|
+
if (ext.external_title)
|
|
48
|
+
title = ext.external_title;
|
|
49
|
+
if (ext.canonical_url)
|
|
50
|
+
url = ext.canonical_url;
|
|
51
|
+
}
|
|
52
|
+
if (text) {
|
|
53
|
+
if (!url)
|
|
54
|
+
url = extractUrlFromText(text);
|
|
55
|
+
if (!title)
|
|
56
|
+
title = extractTitleFromText(text);
|
|
57
|
+
if (!meta && url)
|
|
58
|
+
meta = parsePlatformUrl(url);
|
|
59
|
+
if (!url && !title)
|
|
60
|
+
title = text.trim();
|
|
61
|
+
}
|
|
62
|
+
const result = searchByExternalLink({
|
|
63
|
+
platform: meta?.platform,
|
|
64
|
+
external_id: meta?.external_id,
|
|
65
|
+
external_title: title,
|
|
66
|
+
});
|
|
67
|
+
let unsupportedHint = null;
|
|
68
|
+
if (result.matched_by === 'none' && !url && !title && text) {
|
|
69
|
+
const cmd = detectShareCommandFormat(text);
|
|
70
|
+
if (cmd) {
|
|
71
|
+
unsupportedHint = `检测到 ${cmd.hint},该格式经平台加密,无法直接解析。请改用包含 https:// 链接或「商品名」的分享文本。`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
res.json({
|
|
75
|
+
extracted: {
|
|
76
|
+
url,
|
|
77
|
+
title,
|
|
78
|
+
platform: meta?.platform ?? null,
|
|
79
|
+
external_id: meta?.external_id ?? null,
|
|
80
|
+
},
|
|
81
|
+
matched_by: result.matched_by,
|
|
82
|
+
products: result.products,
|
|
83
|
+
...(unsupportedHint ? { unsupported_format: true, hint: unsupportedHint } : {}),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
app.get('/api/search-fuzzy', (req, res) => {
|
|
87
|
+
const q = String(req.query.q ?? '').trim();
|
|
88
|
+
const threshold = 0.5;
|
|
89
|
+
if (!q)
|
|
90
|
+
return void res.json({ products: [], matched_by: 'none', score_threshold: threshold });
|
|
91
|
+
if (q.length > 200)
|
|
92
|
+
return void res.json({ error: '关键词过长(>200)' });
|
|
93
|
+
const norm = (s) => (s ?? '').normalize('NFKC').replace(/\s+/g, '').toLowerCase();
|
|
94
|
+
const qn = norm(q);
|
|
95
|
+
if (!qn)
|
|
96
|
+
return void res.json({ products: [], matched_by: 'none', score_threshold: threshold });
|
|
97
|
+
const grams = (s, n = 2) => {
|
|
98
|
+
if (s.length <= n)
|
|
99
|
+
return [s];
|
|
100
|
+
const out = [];
|
|
101
|
+
for (let i = 0; i + n <= s.length; i++)
|
|
102
|
+
out.push(s.slice(i, i + n));
|
|
103
|
+
return out;
|
|
104
|
+
};
|
|
105
|
+
const qg = grams(qn);
|
|
106
|
+
const rows = db.prepare(`
|
|
107
|
+
SELECT p.*, u.name as seller_name,
|
|
108
|
+
COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
|
|
109
|
+
FROM products p
|
|
110
|
+
JOIN users u ON p.seller_id = u.id
|
|
111
|
+
LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
|
|
112
|
+
WHERE p.status = 'active' AND p.stock > 0
|
|
113
|
+
`).all();
|
|
114
|
+
const scored = rows
|
|
115
|
+
.map((r) => {
|
|
116
|
+
const tn = norm(String(r.title ?? ''));
|
|
117
|
+
if (tn && tn.includes(qn))
|
|
118
|
+
return { row: r, score: 1 };
|
|
119
|
+
let titleScore = 0;
|
|
120
|
+
if (tn && qg.length) {
|
|
121
|
+
let hit = 0;
|
|
122
|
+
for (const g of qg)
|
|
123
|
+
if (tn.includes(g))
|
|
124
|
+
hit++;
|
|
125
|
+
titleScore = hit / qg.length;
|
|
126
|
+
}
|
|
127
|
+
let descScore = 0;
|
|
128
|
+
const dn = norm(String(r.description ?? ''));
|
|
129
|
+
if (dn && qg.length) {
|
|
130
|
+
let hit = 0;
|
|
131
|
+
for (const g of qg)
|
|
132
|
+
if (dn.includes(g))
|
|
133
|
+
hit++;
|
|
134
|
+
descScore = (hit / qg.length) * 0.6;
|
|
135
|
+
}
|
|
136
|
+
return { row: r, score: Math.max(titleScore, descScore) };
|
|
137
|
+
})
|
|
138
|
+
.filter((x) => x.score >= threshold)
|
|
139
|
+
.sort((a, b) => b.score - a.score)
|
|
140
|
+
.slice(0, 30);
|
|
141
|
+
res.json({
|
|
142
|
+
products: scored.map((x) => ({ ...formatProductForAgent(x.row), _score: Number(x.score.toFixed(2)) })),
|
|
143
|
+
matched_by: scored.length ? 'fuzzy' : 'none',
|
|
144
|
+
score_threshold: threshold,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
app.get('/api/check-url', (req, res) => {
|
|
148
|
+
const user = auth(req, res);
|
|
149
|
+
if (!user)
|
|
150
|
+
return;
|
|
151
|
+
const url = req.query.url;
|
|
152
|
+
if (!url)
|
|
153
|
+
return void res.json({ error: '请提供 url 参数' });
|
|
154
|
+
const selfClaim = db.prepare(`
|
|
155
|
+
SELECT p.id as product_id, p.title FROM product_external_links pel
|
|
156
|
+
JOIN products p ON pel.product_id = p.id
|
|
157
|
+
WHERE pel.url = ? AND p.seller_id = ?
|
|
158
|
+
`).get(url, user.id);
|
|
159
|
+
if (selfClaim) {
|
|
160
|
+
return void res.json({ claimed: true, self: true, product_title: selfClaim.title, message: `您已在商品「${selfClaim.title}」中关联了此链接` });
|
|
161
|
+
}
|
|
162
|
+
const otherClaim = db.prepare(`
|
|
163
|
+
SELECT p.title as product_title FROM product_external_links pel
|
|
164
|
+
JOIN products p ON pel.product_id = p.id
|
|
165
|
+
WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
|
|
166
|
+
`).get(url, user.id);
|
|
167
|
+
if (otherClaim) {
|
|
168
|
+
return void res.json({ claimed: true, self: false, message: `此链接已被其他商家认领,不能直接添加,上架后请在商品编辑页发起认领验证任务` });
|
|
169
|
+
}
|
|
170
|
+
res.json({ claimed: false });
|
|
171
|
+
});
|
|
172
|
+
}
|