@seasonkoh/webaz 0.1.8 → 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 +3543 -852
- 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 +31230 -2345
- package/dist/pwa/public/i18n.js +5282 -111
- package/dist/pwa/public/icon.svg +11 -0
- package/dist/pwa/public/index.html +4 -1
- package/dist/pwa/public/manifest.json +39 -4
- package/dist/pwa/public/openapi.json +5946 -0
- package/dist/pwa/public/style.css +278 -5
- package/dist/pwa/public/sw.js +41 -2
- 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 +9247 -2097
- package/package.json +8 -3
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const VALID_GB_DISCOUNT_MIN = 0.05;
|
|
2
|
+
const VALID_GB_DISCOUNT_MAX = 0.50;
|
|
3
|
+
/** 结算团购 — 成团创建订单 + 退差价;未达成全员退款。export 仅供 cron。 */
|
|
4
|
+
export function settleGroupBuy(db, generateId, broadcastSystemEvent, gbId) {
|
|
5
|
+
const gb = db.prepare('SELECT * FROM group_buys WHERE id = ? AND status = \'active\'').get(gbId);
|
|
6
|
+
if (!gb)
|
|
7
|
+
return;
|
|
8
|
+
const participants = db.prepare(`SELECT * FROM group_buy_participants WHERE group_buy_id = ? AND status = 'pending'`).all(gbId);
|
|
9
|
+
const joined = participants.length;
|
|
10
|
+
const targetMet = joined >= Number(gb.target_count);
|
|
11
|
+
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(gb.product_id);
|
|
12
|
+
if (!product)
|
|
13
|
+
return;
|
|
14
|
+
const originalPrice = Number(product.price);
|
|
15
|
+
const finalPrice = Math.round(originalPrice * (1 - Number(gb.discount_pct)) * 100) / 100;
|
|
16
|
+
db.transaction(() => {
|
|
17
|
+
if (targetMet) {
|
|
18
|
+
// 为每位 participant 创建 order(简化:status=paid,escrow=finalPrice,差价退回 balance)
|
|
19
|
+
for (const p of participants) {
|
|
20
|
+
const orderId = generateId('ord');
|
|
21
|
+
const refund = originalPrice - finalPrice;
|
|
22
|
+
const now = new Date();
|
|
23
|
+
db.prepare(`INSERT INTO orders (id, product_id, buyer_id, seller_id, quantity, unit_price, total_amount, escrow_amount,
|
|
24
|
+
status, shipping_address, notes, pay_deadline, accept_deadline, ship_deadline,
|
|
25
|
+
pickup_deadline, delivery_deadline, confirm_deadline, source, variant_id)
|
|
26
|
+
VALUES (?,?,?,?,1,?,?,?, 'paid', ?, ?, ?, ?, ?, ?, ?, ?, 'group_buy', ?)`)
|
|
27
|
+
.run(orderId, gb.product_id, p.buyer_id, gb.seller_id, finalPrice, finalPrice, finalPrice, p.shipping_address, `[团购 ${gbId}] -${(Number(gb.discount_pct) * 100).toFixed(0)}%`, new Date(now).toISOString(), new Date(now.getTime() + 48 * 3600000).toISOString(), new Date(now.getTime() + 120 * 3600000).toISOString(), new Date(now.getTime() + 168 * 3600000).toISOString(), new Date(now.getTime() + 336 * 3600000).toISOString(), new Date(now.getTime() + 408 * 3600000).toISOString(), gb.variant_id || null);
|
|
28
|
+
// escrow 调整:buyer 已锁原价 → 释放差价,留 finalPrice
|
|
29
|
+
db.prepare('UPDATE wallets SET balance = balance + ?, escrowed = escrowed - ? WHERE user_id = ?').run(refund, refund, p.buyer_id);
|
|
30
|
+
db.prepare(`UPDATE group_buy_participants SET status = 'fulfilled', order_id = ? WHERE id = ?`).run(orderId, p.id);
|
|
31
|
+
}
|
|
32
|
+
db.prepare(`UPDATE group_buys SET status = 'succeeded', settled_at = datetime('now') WHERE id = ?`).run(gbId);
|
|
33
|
+
try {
|
|
34
|
+
broadcastSystemEvent('group_buy_success', '🎉', `团购 ${gbId} 成团 ${joined} 人,-${(Number(gb.discount_pct) * 100).toFixed(0)}%`, String(gb.product_id));
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// 失败:全员退款
|
|
40
|
+
for (const p of participants) {
|
|
41
|
+
db.prepare('UPDATE wallets SET balance = balance + ?, escrowed = escrowed - ? WHERE user_id = ?').run(p.escrow_amount, p.escrow_amount, p.buyer_id);
|
|
42
|
+
db.prepare(`UPDATE group_buy_participants SET status = 'refunded' WHERE id = ?`).run(p.id);
|
|
43
|
+
}
|
|
44
|
+
db.prepare(`UPDATE group_buys SET status = 'failed', settled_at = datetime('now') WHERE id = ?`).run(gbId);
|
|
45
|
+
try {
|
|
46
|
+
broadcastSystemEvent('group_buy_failed', '⏰', `团购 ${gbId} 未达成(${joined}/${gb.target_count})`, String(gb.product_id));
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
}
|
|
52
|
+
/** Cron 扫描:过期未成团 → 失败结算。server.ts setInterval 调用。 */
|
|
53
|
+
export function sweepExpiredGroupBuys(db, generateId, broadcastSystemEvent) {
|
|
54
|
+
const expired = db.prepare(`SELECT id FROM group_buys WHERE status = 'active' AND ends_at <= datetime('now')`).all();
|
|
55
|
+
for (const e of expired) {
|
|
56
|
+
try {
|
|
57
|
+
settleGroupBuy(db, generateId, broadcastSystemEvent, e.id);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error('[gb sweep]', err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function registerGroupBuysRoutes(app, deps) {
|
|
65
|
+
const { db, generateId, auth, isTrustedRole, errorRes, broadcastSystemEvent } = deps;
|
|
66
|
+
// 卖家开团
|
|
67
|
+
app.post('/api/group-buys', (req, res) => {
|
|
68
|
+
const user = auth(req, res);
|
|
69
|
+
if (!user)
|
|
70
|
+
return;
|
|
71
|
+
const { product_id, variant_id, target_count, discount_pct, duration_hours } = req.body || {};
|
|
72
|
+
if (!product_id)
|
|
73
|
+
return void res.status(400).json({ error: 'product_id 必填' });
|
|
74
|
+
const p = db.prepare('SELECT id, seller_id, price, has_variants FROM products WHERE id = ? AND status = \'active\'').get(product_id);
|
|
75
|
+
if (!p)
|
|
76
|
+
return void res.status(404).json({ error: '商品不存在或已下架' });
|
|
77
|
+
if (p.seller_id !== user.id)
|
|
78
|
+
return void res.status(403).json({ error: '仅自己商品可开团' });
|
|
79
|
+
const target = Math.max(2, Math.min(50, Number(target_count) || 3));
|
|
80
|
+
const disc = Number(discount_pct);
|
|
81
|
+
if (!Number.isFinite(disc) || disc < VALID_GB_DISCOUNT_MIN || disc > VALID_GB_DISCOUNT_MAX) {
|
|
82
|
+
return void res.status(400).json({ error: `discount_pct 必须在 ${VALID_GB_DISCOUNT_MIN} ~ ${VALID_GB_DISCOUNT_MAX} 之间` });
|
|
83
|
+
}
|
|
84
|
+
const hours = Math.max(1, Math.min(168, Number(duration_hours) || 24));
|
|
85
|
+
const endsAt = new Date(Date.now() + hours * 3600 * 1000).toISOString();
|
|
86
|
+
if (Number(p.has_variants) === 1 && !variant_id)
|
|
87
|
+
return void res.status(400).json({ error: '该商品有规格,请指定 variant_id' });
|
|
88
|
+
if (variant_id) {
|
|
89
|
+
const v = db.prepare('SELECT id FROM product_variants WHERE id = ? AND product_id = ? AND is_active = 1').get(variant_id, p.id);
|
|
90
|
+
if (!v)
|
|
91
|
+
return void res.status(400).json({ error: 'variant 不存在' });
|
|
92
|
+
}
|
|
93
|
+
const id = generateId('gb');
|
|
94
|
+
db.prepare(`INSERT INTO group_buys (id, seller_id, product_id, variant_id, target_count, discount_pct, ends_at) VALUES (?,?,?,?,?,?,?)`)
|
|
95
|
+
.run(id, user.id, p.id, variant_id || null, target, disc, endsAt);
|
|
96
|
+
try {
|
|
97
|
+
broadcastSystemEvent('group_buy_created', '👥', `团购创建 ${id} · 目标 ${target} 人 · ${(disc * 100).toFixed(0)}% off`, p.id);
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
res.json({ success: true, id, ends_at: endsAt });
|
|
101
|
+
});
|
|
102
|
+
// 公开列表
|
|
103
|
+
app.get('/api/group-buys/live', (_req, res) => {
|
|
104
|
+
const rows = db.prepare(`
|
|
105
|
+
SELECT gb.*, p.title as product_title, p.price as original_price, p.images, p.category,
|
|
106
|
+
u.handle as seller_handle, u.name as seller_name,
|
|
107
|
+
(SELECT COUNT(*) FROM group_buy_participants WHERE group_buy_id = gb.id AND status != 'refunded') as joined_count
|
|
108
|
+
FROM group_buys gb
|
|
109
|
+
JOIN products p ON p.id = gb.product_id
|
|
110
|
+
JOIN users u ON u.id = gb.seller_id
|
|
111
|
+
WHERE gb.status = 'active' AND gb.ends_at > datetime('now')
|
|
112
|
+
ORDER BY gb.ends_at ASC LIMIT 100
|
|
113
|
+
`).all();
|
|
114
|
+
res.json({ items: rows });
|
|
115
|
+
});
|
|
116
|
+
// 详情 + participants
|
|
117
|
+
app.get('/api/group-buys/:id', (req, res) => {
|
|
118
|
+
const gb = db.prepare(`
|
|
119
|
+
SELECT gb.*, p.title as product_title, p.price as original_price, p.images, p.category,
|
|
120
|
+
u.handle as seller_handle, u.name as seller_name
|
|
121
|
+
FROM group_buys gb
|
|
122
|
+
JOIN products p ON p.id = gb.product_id
|
|
123
|
+
JOIN users u ON u.id = gb.seller_id
|
|
124
|
+
WHERE gb.id = ?
|
|
125
|
+
`).get(req.params.id);
|
|
126
|
+
if (!gb)
|
|
127
|
+
return void res.status(404).json({ error: '团购不存在' });
|
|
128
|
+
const participants = db.prepare(`
|
|
129
|
+
SELECT p.id, p.buyer_id, p.status, p.created_at, u.handle as buyer_handle
|
|
130
|
+
FROM group_buy_participants p JOIN users u ON u.id = p.buyer_id
|
|
131
|
+
WHERE p.group_buy_id = ? AND p.status != 'refunded'
|
|
132
|
+
ORDER BY p.created_at ASC
|
|
133
|
+
`).all(req.params.id);
|
|
134
|
+
res.json({ ...gb, participants });
|
|
135
|
+
});
|
|
136
|
+
// 加入团购
|
|
137
|
+
app.post('/api/group-buys/:id/join', (req, res) => {
|
|
138
|
+
const user = auth(req, res);
|
|
139
|
+
if (!user)
|
|
140
|
+
return;
|
|
141
|
+
if (isTrustedRole(user))
|
|
142
|
+
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
|
|
143
|
+
const { shipping_address } = req.body || {};
|
|
144
|
+
if (!shipping_address)
|
|
145
|
+
return void res.status(400).json({ error: '请填写收货地址' });
|
|
146
|
+
const gb = db.prepare('SELECT id, seller_id, product_id, status, target_count, ends_at, discount_pct FROM group_buys WHERE id = ?').get(req.params.id);
|
|
147
|
+
if (!gb)
|
|
148
|
+
return void res.status(404).json({ error: '团购不存在' });
|
|
149
|
+
if (gb.status !== 'active')
|
|
150
|
+
return void res.status(400).json({ error: '团购非活跃状态' });
|
|
151
|
+
if (new Date(gb.ends_at) <= new Date())
|
|
152
|
+
return void res.status(400).json({ error: '团购已结束' });
|
|
153
|
+
if (gb.seller_id === user.id)
|
|
154
|
+
return void res.status(400).json({ error: '不可加入自己的团购' });
|
|
155
|
+
const existing = db.prepare('SELECT id FROM group_buy_participants WHERE group_buy_id = ? AND buyer_id = ? AND status != \'refunded\'').get(gb.id, user.id);
|
|
156
|
+
if (existing)
|
|
157
|
+
return void res.status(400).json({ error: '已加入此团购' });
|
|
158
|
+
const product = db.prepare('SELECT price FROM products WHERE id = ?').get(gb.product_id);
|
|
159
|
+
if (!product)
|
|
160
|
+
return void res.status(500).json({ error: '商品记录缺失' });
|
|
161
|
+
const escrow = Number(product.price);
|
|
162
|
+
const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
|
|
163
|
+
if (!wallet || wallet.balance < escrow)
|
|
164
|
+
return void res.status(400).json({ error: `余额不足:需 ${escrow} WAZ` });
|
|
165
|
+
const id = generateId('gbp');
|
|
166
|
+
db.transaction(() => {
|
|
167
|
+
db.prepare(`INSERT INTO group_buy_participants (id, group_buy_id, buyer_id, shipping_address, escrow_amount) VALUES (?,?,?,?,?)`)
|
|
168
|
+
.run(id, gb.id, user.id, String(shipping_address).slice(0, 200), escrow);
|
|
169
|
+
db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ?').run(escrow, escrow, user.id);
|
|
170
|
+
})();
|
|
171
|
+
const joined = db.prepare(`SELECT COUNT(*) as n FROM group_buy_participants WHERE group_buy_id = ? AND status != 'refunded'`).get(gb.id).n;
|
|
172
|
+
if (joined >= gb.target_count) {
|
|
173
|
+
try {
|
|
174
|
+
settleGroupBuy(db, generateId, broadcastSystemEvent, gb.id);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
console.error('[gb settle]', e);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
broadcastSystemEvent('group_buy_join', '👥', `团购 ${gb.id} 新成员 (${joined}/${gb.target_count})`, gb.id);
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
res.json({ success: true, id, joined_count: joined, target_count: gb.target_count });
|
|
185
|
+
});
|
|
186
|
+
// 离开团购
|
|
187
|
+
app.delete('/api/group-buys/:id/leave', (req, res) => {
|
|
188
|
+
const user = auth(req, res);
|
|
189
|
+
if (!user)
|
|
190
|
+
return;
|
|
191
|
+
const p = db.prepare(`SELECT p.id, p.escrow_amount, p.status, gb.status as gb_status FROM group_buy_participants p
|
|
192
|
+
JOIN group_buys gb ON gb.id = p.group_buy_id
|
|
193
|
+
WHERE p.group_buy_id = ? AND p.buyer_id = ?`).get(req.params.id, user.id);
|
|
194
|
+
if (!p)
|
|
195
|
+
return void res.status(404).json({ error: '未加入此团购' });
|
|
196
|
+
if (p.status === 'fulfilled')
|
|
197
|
+
return void res.status(400).json({ error: '团购已成团,无法退出' });
|
|
198
|
+
if (p.status === 'refunded')
|
|
199
|
+
return void res.status(400).json({ error: '已退款' });
|
|
200
|
+
if (p.gb_status !== 'active')
|
|
201
|
+
return void res.status(400).json({ error: '团购已结算,无法退出' });
|
|
202
|
+
db.transaction(() => {
|
|
203
|
+
db.prepare(`UPDATE group_buy_participants SET status = 'refunded' WHERE id = ?`).run(p.id);
|
|
204
|
+
db.prepare('UPDATE wallets SET balance = balance + ?, escrowed = escrowed - ? WHERE user_id = ?').run(p.escrow_amount, p.escrow_amount, user.id);
|
|
205
|
+
})();
|
|
206
|
+
res.json({ success: true });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
const GROWTH_TASK_CATALOG = [
|
|
2
|
+
// 第 1 关:新手起步
|
|
3
|
+
{ id: 'first_purchase', chapter: 1,
|
|
4
|
+
title_zh: '完成首笔购买', title_en: 'Complete first purchase',
|
|
5
|
+
desc_zh: '解锁分享奖励资格', desc_en: 'Unlocks share-commission eligibility',
|
|
6
|
+
cta: { label_zh: '去发现', label_en: 'Browse', href: '#buy' },
|
|
7
|
+
evaluate: c => c.completed_orders >= 1 },
|
|
8
|
+
{ id: 'default_address', chapter: 1,
|
|
9
|
+
title_zh: '设置默认配送地址', title_en: 'Set default shipping address',
|
|
10
|
+
desc_zh: '智能下单将按地址过滤可派送商品', desc_en: 'Smart-order filters by your address',
|
|
11
|
+
cta: { label_zh: '去填写', label_en: 'Set up', href: '#profile' },
|
|
12
|
+
evaluate: c => !!c.default_address_line1 },
|
|
13
|
+
{ id: 'profile_bio', chapter: 1,
|
|
14
|
+
title_zh: '写一句话简介', title_en: 'Write a one-line bio',
|
|
15
|
+
desc_zh: '让买家知道你是谁', desc_en: 'Let buyers know who you are',
|
|
16
|
+
cta: { label_zh: '去设置', label_en: 'Set up', href: '#profile' },
|
|
17
|
+
evaluate: c => !!(c.bio && c.bio.trim()) },
|
|
18
|
+
{ id: 'profile_anchor', chapter: 1,
|
|
19
|
+
title_zh: '设置流量口令', title_en: 'Set your search anchor',
|
|
20
|
+
desc_zh: '从抖音 / 小红书引流到 WebAZ', desc_en: 'Funnel traffic from TikTok / Xiaohongshu',
|
|
21
|
+
cta: { label_zh: '去设置', label_en: 'Set up', href: '#profile' },
|
|
22
|
+
evaluate: c => !!(c.search_anchor && c.search_anchor.trim()) },
|
|
23
|
+
// 第 2 关:开始分享
|
|
24
|
+
{ id: 'first_l1', chapter: 2,
|
|
25
|
+
title_zh: '你推荐的第一个人完成注册', title_en: 'Your first referral signs up',
|
|
26
|
+
desc_zh: '通过你的邀请链让一人注册', desc_en: 'One sign-up via your referral link',
|
|
27
|
+
cta: { label_zh: '复制邀请链', label_en: 'Copy invite link', action: 'scrollToShareTools' },
|
|
28
|
+
evaluate: c => c.team_l1 >= 1 },
|
|
29
|
+
{ id: 'first_shareable', chapter: 2,
|
|
30
|
+
title_zh: '添加首个外部分享链', title_en: 'Add first external share link',
|
|
31
|
+
desc_zh: 'YouTube / TikTok / 小红书 链接绑商品', desc_en: 'Link YouTube / TikTok / etc. to a product',
|
|
32
|
+
cta: { label_zh: '添加', label_en: 'Add', action: 'openAddExternalModal' },
|
|
33
|
+
evaluate: c => c.shareables_count >= 1 },
|
|
34
|
+
{ id: 'first_commission', chapter: 2,
|
|
35
|
+
title_zh: '拿到第一笔分享佣金', title_en: 'Earn first share commission',
|
|
36
|
+
desc_zh: '分享链产生第一笔成交', desc_en: 'A share link generates its first sale',
|
|
37
|
+
evaluate: c => c.earnings_grand > 0 },
|
|
38
|
+
// 第 3 关:团队建设
|
|
39
|
+
{ id: 'first_manifest', chapter: 3,
|
|
40
|
+
title_zh: '创作首个原生内容', title_en: 'Create first native content',
|
|
41
|
+
desc_zh: 'P2P 流转 + pin 收益', desc_en: 'P2P-distributed + pin rewards',
|
|
42
|
+
cta: { label_zh: '创作', label_en: 'Create', action: 'openCreateNativeModal' },
|
|
43
|
+
evaluate: c => c.manifests_count >= 1 },
|
|
44
|
+
{ id: 'team_5', chapter: 3,
|
|
45
|
+
title_zh: '推荐网络达到 5 人', title_en: 'Referral network reaches 5',
|
|
46
|
+
desc_zh: '稳定贡献从这里开始', desc_en: 'Steady contribution starts here',
|
|
47
|
+
evaluate: c => c.team_total >= 5 },
|
|
48
|
+
{ id: 'tier1_match', chapter: 3,
|
|
49
|
+
title_zh: '持续贡献阶段 1', title_en: 'Contribution stage 1',
|
|
50
|
+
desc_zh: '推荐网络贡献达到第一阶段标准', desc_en: 'Referral-network contribution reaches stage-1 threshold',
|
|
51
|
+
evaluate: c => c.weak_leg_pv >= 30000 },
|
|
52
|
+
// 第 4 关:分享达人
|
|
53
|
+
{ id: 'monthly_100', chapter: 4,
|
|
54
|
+
title_zh: '月度推荐收益 100 WAZ', title_en: 'Monthly referral income 100 WAZ',
|
|
55
|
+
desc_zh: '近 30 日累计推荐返利 ≥ 100 WAZ', desc_en: 'Last-30-day referral rewards ≥ 100 WAZ',
|
|
56
|
+
evaluate: c => c.last_30_total >= 100 },
|
|
57
|
+
{ id: 'team_50', chapter: 4,
|
|
58
|
+
title_zh: '推荐网络达到 50 人', title_en: 'Referral network reaches 50',
|
|
59
|
+
desc_zh: '正式跻身分享达人', desc_en: 'Officially a share pro',
|
|
60
|
+
evaluate: c => c.team_total >= 50 },
|
|
61
|
+
];
|
|
62
|
+
function buildGrowthTaskCtx(db, userId) {
|
|
63
|
+
const u = db.prepare("SELECT bio, search_anchor, default_address_json, default_address_text FROM users WHERE id = ?").get(userId);
|
|
64
|
+
let line1 = null;
|
|
65
|
+
try {
|
|
66
|
+
const a = JSON.parse(u?.default_address_json || 'null');
|
|
67
|
+
line1 = a?.line1 || null;
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
if (!line1 && u?.default_address_text)
|
|
71
|
+
line1 = u.default_address_text; // legacy 兜底
|
|
72
|
+
const completed = db.prepare("SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND status = 'completed'").get(userId).n;
|
|
73
|
+
const l1 = db.prepare("SELECT COUNT(*) AS n FROM users WHERE sponsor_id = ?").get(userId).n;
|
|
74
|
+
const teamTotal = db.prepare(`
|
|
75
|
+
SELECT COUNT(*) AS n FROM users
|
|
76
|
+
WHERE sponsor_id = ?
|
|
77
|
+
OR sponsor_id IN (SELECT id FROM users WHERE sponsor_id = ?)
|
|
78
|
+
OR sponsor_id IN (SELECT id FROM users WHERE sponsor_id IN (SELECT id FROM users WHERE sponsor_id = ?))
|
|
79
|
+
`).get(userId, userId, userId).n;
|
|
80
|
+
const grand = db.prepare("SELECT COALESCE(SUM(amount),0) AS s FROM commission_records WHERE beneficiary_id = ?").get(userId).s;
|
|
81
|
+
const sCount = db.prepare("SELECT COUNT(*) AS n FROM shareables WHERE owner_id = ? AND status = 'active'").get(userId).n;
|
|
82
|
+
const mCount = db.prepare("SELECT COUNT(*) AS n FROM manifest_registry WHERE owner_id = ? AND status = 'active'").get(userId).n;
|
|
83
|
+
const pv = db.prepare("SELECT total_left_pv, total_right_pv FROM users WHERE id = ?").get(userId);
|
|
84
|
+
const weakLeg = Math.min(Number(pv?.total_left_pv || 0), Number(pv?.total_right_pv || 0));
|
|
85
|
+
const comm30 = db.prepare(`SELECT COALESCE(SUM(amount),0) AS s FROM commission_records WHERE beneficiary_id = ? AND created_at >= datetime('now','-30 days')`).get(userId).s;
|
|
86
|
+
const waz30 = db.prepare(`SELECT COALESCE(SUM(waz_amount),0) AS s FROM binary_score_records WHERE user_id = ? AND settled_at >= datetime('now','-30 days')`).get(userId).s;
|
|
87
|
+
return {
|
|
88
|
+
userId,
|
|
89
|
+
bio: u?.bio || null,
|
|
90
|
+
search_anchor: u?.search_anchor || null,
|
|
91
|
+
default_address_line1: line1,
|
|
92
|
+
completed_orders: completed,
|
|
93
|
+
team_l1: l1,
|
|
94
|
+
team_total: teamTotal,
|
|
95
|
+
earnings_grand: grand,
|
|
96
|
+
shareables_count: sCount,
|
|
97
|
+
manifests_count: mCount,
|
|
98
|
+
weak_leg_pv: weakLeg,
|
|
99
|
+
last_30_total: comm30 + waz30,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function evaluateGrowthTasks(db, userId, lang = 'zh') {
|
|
103
|
+
const ctx = buildGrowthTaskCtx(db, userId);
|
|
104
|
+
const logs = db.prepare("SELECT task_id, status, claimed_at, completed_at FROM growth_task_log WHERE user_id = ?").all(userId);
|
|
105
|
+
const logMap = new Map(logs.map(l => [l.task_id, l]));
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const t of GROWTH_TASK_CATALOG) {
|
|
108
|
+
const done = t.evaluate(ctx);
|
|
109
|
+
const log = logMap.get(t.id);
|
|
110
|
+
let status;
|
|
111
|
+
let completed_at = log?.completed_at || null;
|
|
112
|
+
if (done) {
|
|
113
|
+
if (!log || log.status !== 'completed') {
|
|
114
|
+
db.prepare(`INSERT OR REPLACE INTO growth_task_log (user_id, task_id, status, claimed_at, completed_at)
|
|
115
|
+
VALUES (?,?,?,?,datetime('now'))`).run(userId, t.id, 'completed', log?.claimed_at || null);
|
|
116
|
+
completed_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
117
|
+
}
|
|
118
|
+
status = 'completed';
|
|
119
|
+
}
|
|
120
|
+
else if (log?.status === 'skipped')
|
|
121
|
+
status = 'skipped';
|
|
122
|
+
else if (log?.status === 'claimed')
|
|
123
|
+
status = 'claimed';
|
|
124
|
+
else
|
|
125
|
+
status = 'available';
|
|
126
|
+
out.push({
|
|
127
|
+
id: t.id, chapter: t.chapter,
|
|
128
|
+
title: lang === 'en' ? t.title_en : t.title_zh,
|
|
129
|
+
desc: lang === 'en' ? t.desc_en : t.desc_zh,
|
|
130
|
+
status,
|
|
131
|
+
cta: t.cta ? { label: lang === 'en' ? t.cta.label_en : t.cta.label_zh, href: t.cta.href, action: t.cta.action } : undefined,
|
|
132
|
+
claimed_at: log?.claimed_at || null,
|
|
133
|
+
completed_at,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const summary = {
|
|
137
|
+
available: out.filter(t => t.status === 'available').length,
|
|
138
|
+
claimed: out.filter(t => t.status === 'claimed').length,
|
|
139
|
+
completed: out.filter(t => t.status === 'completed').length,
|
|
140
|
+
skipped: out.filter(t => t.status === 'skipped').length,
|
|
141
|
+
chapter_progress: [1, 2, 3, 4].map(ch => {
|
|
142
|
+
const chTasks = out.filter(t => t.chapter === ch);
|
|
143
|
+
const chDone = chTasks.filter(t => t.status === 'completed').length;
|
|
144
|
+
return { chapter: ch, total: chTasks.length, completed: chDone };
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
return { tasks: out, summary };
|
|
148
|
+
}
|
|
149
|
+
export function registerGrowthRoutes(app, deps) {
|
|
150
|
+
const { db, auth } = deps;
|
|
151
|
+
app.get('/api/growth/tasks', (req, res) => {
|
|
152
|
+
const user = auth(req, res);
|
|
153
|
+
if (!user)
|
|
154
|
+
return;
|
|
155
|
+
const lang = String(req.headers['accept-language'] || '').startsWith('en') ? 'en' : 'zh';
|
|
156
|
+
res.json(evaluateGrowthTasks(db, user.id, lang));
|
|
157
|
+
});
|
|
158
|
+
app.post('/api/growth/tasks/:id/claim', (req, res) => {
|
|
159
|
+
const user = auth(req, res);
|
|
160
|
+
if (!user)
|
|
161
|
+
return;
|
|
162
|
+
const taskId = req.params.id;
|
|
163
|
+
if (!GROWTH_TASK_CATALOG.find(t => t.id === taskId))
|
|
164
|
+
return void res.json({ error: 'unknown task' });
|
|
165
|
+
const existing = db.prepare("SELECT status, completed_at FROM growth_task_log WHERE user_id = ? AND task_id = ?").get(user.id, taskId);
|
|
166
|
+
if (existing?.status === 'completed')
|
|
167
|
+
return void res.json({ error: '该任务已完成' });
|
|
168
|
+
db.prepare(`INSERT OR REPLACE INTO growth_task_log (user_id, task_id, status, claimed_at, completed_at)
|
|
169
|
+
VALUES (?,?,?,datetime('now'),NULL)`).run(user.id, taskId, 'claimed');
|
|
170
|
+
res.json({ success: true, status: 'claimed' });
|
|
171
|
+
});
|
|
172
|
+
app.post('/api/growth/tasks/:id/skip', (req, res) => {
|
|
173
|
+
const user = auth(req, res);
|
|
174
|
+
if (!user)
|
|
175
|
+
return;
|
|
176
|
+
const taskId = req.params.id;
|
|
177
|
+
if (!GROWTH_TASK_CATALOG.find(t => t.id === taskId))
|
|
178
|
+
return void res.json({ error: 'unknown task' });
|
|
179
|
+
const existing = db.prepare("SELECT status FROM growth_task_log WHERE user_id = ? AND task_id = ?").get(user.id, taskId);
|
|
180
|
+
if (existing?.status === 'completed')
|
|
181
|
+
return void res.json({ error: '该任务已完成,无法跳过' });
|
|
182
|
+
db.prepare(`INSERT OR REPLACE INTO growth_task_log (user_id, task_id, status, claimed_at, completed_at)
|
|
183
|
+
VALUES (?,?,?,NULL,NULL)`).run(user.id, taskId, 'skipped');
|
|
184
|
+
res.json({ success: true, status: 'skipped' });
|
|
185
|
+
});
|
|
186
|
+
app.post('/api/growth/tasks/:id/reset', (req, res) => {
|
|
187
|
+
const user = auth(req, res);
|
|
188
|
+
if (!user)
|
|
189
|
+
return;
|
|
190
|
+
const taskId = req.params.id;
|
|
191
|
+
if (!GROWTH_TASK_CATALOG.find(t => t.id === taskId))
|
|
192
|
+
return void res.json({ error: 'unknown task' });
|
|
193
|
+
const existing = db.prepare("SELECT status FROM growth_task_log WHERE user_id = ? AND task_id = ?").get(user.id, taskId);
|
|
194
|
+
if (existing?.status === 'completed')
|
|
195
|
+
return void res.json({ error: '已完成任务无法重置' });
|
|
196
|
+
db.prepare("DELETE FROM growth_task_log WHERE user_id = ? AND task_id = ?").run(user.id, taskId);
|
|
197
|
+
res.json({ success: true, status: 'available' });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export function registerImportProductRoutes(app, deps) {
|
|
2
|
+
const { db, auth, safeFetch, rateLimitOk, generateId, checkSellerCanList, anthropic, AnthropicCtor, FREE_IMPORT_LIMIT } = deps;
|
|
3
|
+
app.post('/api/import-product', async (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
|
+
const quotaCheck = checkSellerCanList(user);
|
|
10
|
+
if (!quotaCheck.ok)
|
|
11
|
+
return void res.json({ error: quotaCheck.reason });
|
|
12
|
+
const { url, user_api_key } = req.body;
|
|
13
|
+
if (!url)
|
|
14
|
+
return void res.json({ error: '请提供商品链接' });
|
|
15
|
+
if (!rateLimitOk(req.ip || 'unknown', 6, 60_000))
|
|
16
|
+
return void res.status(429).json({ error: '请求过于频繁,请稍后再试' });
|
|
17
|
+
const selfClaim = db.prepare(`
|
|
18
|
+
SELECT p.id as product_id, p.title FROM product_external_links pel
|
|
19
|
+
JOIN products p ON pel.product_id = p.id
|
|
20
|
+
WHERE pel.url = ? AND p.seller_id = ?
|
|
21
|
+
`).get(url, user.id);
|
|
22
|
+
if (selfClaim) {
|
|
23
|
+
return void res.json({ error: `您已上架过来自此链接的商品「${selfClaim.title}」,不能重复关联相同外部链接` });
|
|
24
|
+
}
|
|
25
|
+
const otherClaim = db.prepare(`
|
|
26
|
+
SELECT p.id as product_id FROM product_external_links pel
|
|
27
|
+
JOIN products p ON pel.product_id = p.id
|
|
28
|
+
WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
|
|
29
|
+
`).get(url, user.id);
|
|
30
|
+
if (otherClaim) {
|
|
31
|
+
return void res.json({
|
|
32
|
+
conflict: true,
|
|
33
|
+
url,
|
|
34
|
+
message: '此链接已被其他商家认领上架。如需认领归属,请发起链接认领验证任务。',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const usingOwnKey = typeof user_api_key === 'string' && user_api_key.trim().startsWith('sk-ant-');
|
|
38
|
+
if (!usingOwnKey) {
|
|
39
|
+
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;
|
|
40
|
+
if (todayCount >= FREE_IMPORT_LIMIT) {
|
|
41
|
+
return void res.json({
|
|
42
|
+
error: `今日免费导入次数已用完(${FREE_IMPORT_LIMIT} 次/天)。请在导入面板填入你自己的 Anthropic API Key 以继续使用。`,
|
|
43
|
+
quota_exceeded: true,
|
|
44
|
+
used: todayCount,
|
|
45
|
+
limit: FREE_IMPORT_LIMIT,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let html = '';
|
|
50
|
+
try {
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timer = setTimeout(() => controller.abort(), 10000);
|
|
53
|
+
const resp = await safeFetch(String(url), {
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
headers: {
|
|
56
|
+
'User-Agent': 'Mozilla/5.0 (compatible; WebAZ/1.0; +https://webaz.xyz)',
|
|
57
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
const raw = await resp.text();
|
|
62
|
+
html = raw.slice(0, 30000);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
66
|
+
if (msg.startsWith('ssrf_'))
|
|
67
|
+
return void res.json({ error: '链接指向私网/localhost 或经 redirect 触达内部地址,已拦截' });
|
|
68
|
+
return void res.json({ error: `无法访问该链接:${msg}` });
|
|
69
|
+
}
|
|
70
|
+
const avgPrices = db.prepare(`
|
|
71
|
+
SELECT category, AVG(price) as avg_price, MIN(price) as min_price, MAX(price) as max_price, COUNT(*) as cnt
|
|
72
|
+
FROM products WHERE status = 'active' GROUP BY category
|
|
73
|
+
`).all();
|
|
74
|
+
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');
|
|
75
|
+
const client = usingOwnKey
|
|
76
|
+
? new AnthropicCtor({ apiKey: user_api_key.trim() })
|
|
77
|
+
: anthropic;
|
|
78
|
+
let extracted;
|
|
79
|
+
try {
|
|
80
|
+
const message = await client.messages.create({
|
|
81
|
+
model: 'claude-haiku-4-5-20251001',
|
|
82
|
+
max_tokens: 1024,
|
|
83
|
+
messages: [{
|
|
84
|
+
role: 'user',
|
|
85
|
+
content: `你是一个电商商品信息提取助手,服务于 AI Agent 商业协议平台。从以下网页 HTML 中提取商品信息,返回精简结构化 JSON。
|
|
86
|
+
|
|
87
|
+
网页来源 URL:${url}
|
|
88
|
+
|
|
89
|
+
WebAZ 平台各类目价格参考(WAZ ≈ CNY):
|
|
90
|
+
${priceContext || '暂无参考数据'}
|
|
91
|
+
|
|
92
|
+
只返回 JSON,不要其他文字:
|
|
93
|
+
{
|
|
94
|
+
"title": "商品标题(简洁,50字以内)",
|
|
95
|
+
"description": "面向 AI Agent 的商品描述:核心参数+适用场景,100字以内,无营销话术",
|
|
96
|
+
"specs": {"规格名":"规格值"},
|
|
97
|
+
"brand": "品牌(找不到填null)",
|
|
98
|
+
"model": "型号或规格编号(找不到填null)",
|
|
99
|
+
"original_price": 原平台价格数字(CNY,找不到填null),
|
|
100
|
+
"suggested_price": 建议WAZ定价(参考原价和平台均价,有竞争力),
|
|
101
|
+
"price_reasoning": "定价理由(1句)",
|
|
102
|
+
"category": "茶具/家居/食品/服装/手工/电子(其他填空)",
|
|
103
|
+
"stock": 建议库存(默认1),
|
|
104
|
+
"weight_kg": 重量数字(找不到填null),
|
|
105
|
+
"handling_hours": 备货时间小时数(默认24),
|
|
106
|
+
"ship_regions": "全国",
|
|
107
|
+
"estimated_days": {"华东":2,"全国":5},
|
|
108
|
+
"return_days": 退货天数(默认7),
|
|
109
|
+
"return_condition": "退货条件(如未拆封/任意原因)",
|
|
110
|
+
"warranty_days": 质保天数(默认0),
|
|
111
|
+
"fragile": false,
|
|
112
|
+
"tags": ["标签1","标签2"]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
HTML(前30000字符):
|
|
116
|
+
${html}`,
|
|
117
|
+
}],
|
|
118
|
+
});
|
|
119
|
+
const text = message.content[0].type === 'text' ? message.content[0].text : '';
|
|
120
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
121
|
+
if (!jsonMatch)
|
|
122
|
+
throw new Error('未能提取 JSON');
|
|
123
|
+
extracted = JSON.parse(jsonMatch[0]);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
127
|
+
return void res.json({ error: `AI 解析失败:${msg}` });
|
|
128
|
+
}
|
|
129
|
+
const title = typeof extracted.title === 'string' ? extracted.title.trim() : '';
|
|
130
|
+
const description = typeof extracted.description === 'string' ? extracted.description.trim() : '';
|
|
131
|
+
if (!title || title.length < 2) {
|
|
132
|
+
return void res.json({
|
|
133
|
+
error: '该链接无法提取商品信息(可能需要登录、或为动态渲染页面)。建议使用京东/亚马逊/独立站链接,或改用手动上架。',
|
|
134
|
+
suggestion: 'manual',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (!description || description.length < 5) {
|
|
138
|
+
extracted.description = title;
|
|
139
|
+
}
|
|
140
|
+
if (!usingOwnKey) {
|
|
141
|
+
db.prepare(`INSERT INTO import_logs (id, user_id) VALUES (?, ?)`).run(generateId('iml'), user.id);
|
|
142
|
+
}
|
|
143
|
+
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;
|
|
144
|
+
res.json({
|
|
145
|
+
success: true,
|
|
146
|
+
source_url: url,
|
|
147
|
+
source_price: extracted.original_price ?? null,
|
|
148
|
+
used_own_key: usingOwnKey,
|
|
149
|
+
quota: usingOwnKey ? null : { used: usedToday, limit: FREE_IMPORT_LIMIT, remaining: FREE_IMPORT_LIMIT - usedToday },
|
|
150
|
+
...extracted,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
export function registerKycRoutes(app, deps) {
|
|
3
|
+
const { db, auth, MASTER_SEED } = deps;
|
|
4
|
+
const VALID_KYC_ID_TYPES = new Set(['passport', 'national_id', 'driver_license', 'other']);
|
|
5
|
+
app.post('/api/kyc/submit', (req, res) => {
|
|
6
|
+
const user = auth(req, res);
|
|
7
|
+
if (!user)
|
|
8
|
+
return;
|
|
9
|
+
const { real_name, id_type, id_number } = req.body || {};
|
|
10
|
+
if (!real_name || String(real_name).trim().length < 2)
|
|
11
|
+
return void res.status(400).json({ error: '真实姓名不能为空' });
|
|
12
|
+
if (!VALID_KYC_ID_TYPES.has(String(id_type)))
|
|
13
|
+
return void res.status(400).json({ error: 'id_type 无效' });
|
|
14
|
+
if (!id_number || String(id_number).trim().length < 6)
|
|
15
|
+
return void res.status(400).json({ error: '证件号无效' });
|
|
16
|
+
const idStr = String(id_number).trim();
|
|
17
|
+
const idHash = createHash('sha256').update(idStr + MASTER_SEED).digest('hex');
|
|
18
|
+
const idLast4 = idStr.slice(-4);
|
|
19
|
+
// 已存在?必须先 admin reject 或允许重新提交
|
|
20
|
+
const existing = db.prepare('SELECT status FROM kyc_records WHERE user_id = ?').get(user.id);
|
|
21
|
+
if (existing && existing.status === 'approved')
|
|
22
|
+
return void res.status(400).json({ error: '已通过认证,无需重复提交' });
|
|
23
|
+
if (existing && existing.status === 'pending')
|
|
24
|
+
return void res.status(400).json({ error: '审核中,请耐心等待' });
|
|
25
|
+
db.prepare(`INSERT INTO kyc_records (user_id, real_name, id_type, id_number_hash, id_number_last4, status, submitted_at)
|
|
26
|
+
VALUES (?,?,?,?,?,'pending', datetime('now'))
|
|
27
|
+
ON CONFLICT(user_id) DO UPDATE SET real_name = excluded.real_name, id_type = excluded.id_type,
|
|
28
|
+
id_number_hash = excluded.id_number_hash, id_number_last4 = excluded.id_number_last4,
|
|
29
|
+
status = 'pending', reject_reason = NULL, submitted_at = datetime('now')`)
|
|
30
|
+
.run(user.id, String(real_name).trim().slice(0, 60), String(id_type), idHash, idLast4);
|
|
31
|
+
res.json({ success: true, status: 'pending' });
|
|
32
|
+
});
|
|
33
|
+
app.get('/api/kyc/me', (req, res) => {
|
|
34
|
+
const user = auth(req, res);
|
|
35
|
+
if (!user)
|
|
36
|
+
return;
|
|
37
|
+
const row = db.prepare('SELECT status, id_type, id_number_last4, reject_reason, submitted_at, reviewed_at FROM kyc_records WHERE user_id = ?').get(user.id);
|
|
38
|
+
res.json({ kyc: row || null });
|
|
39
|
+
});
|
|
40
|
+
}
|